diff --git a/README.md b/README.md index c5fe9de..d7fb4a5 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,6 @@ diskpush ./data/ prod:/data/ -- --checksum # your own rsync flags ``` - **Local → server**, **server → local**, and **server → server** directly. -- **Sync a whole directory, or just what you picked.** Tick files and folders - in the pane, or `--only NAME` from the CLI. - **Archive metadata by default.** Permissions, timestamps, symlinks. - **Resumable by default.** An interrupted transfer keeps its partial data. - **Skips unchanged files.** Re-running a job moves almost nothing. diff --git a/apps/cli/package.json b/apps/cli/package.json index 5b25f3a..c6bb7a5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/cli", - "version": "0.2.11", + "version": "0.2.10", "type": "module", "bin": { "diskpush": "./dist/bin.js" diff --git a/apps/cli/src/commands/fleet.ts b/apps/cli/src/commands/fleet.ts index 92084ca..c290b49 100644 --- a/apps/cli/src/commands/fleet.ts +++ b/apps/cli/src/commands/fleet.ts @@ -10,12 +10,15 @@ import { describeHazards, inspectScript, needsAttention, + parseSelector, runFleet, selectConnections, SelectionError, type SudoMode, } from '@diskpush/fleet-core' import { + isListTerm, + listTermName, FLEET_DEFAULT_CONCURRENCY, FLEET_DEFAULT_TIMEOUT_SECONDS, FleetInterpreterSchema, @@ -72,6 +75,9 @@ async function dispatch(parsed: ParsedArgv, store: DiskPushStore, output: Output return fleetServers(parsed, store, output) case 'commands': return fleetCommands(parsed, store, output) + case 'lists': + case 'list': + return fleetLists(parsed, store, output) case 'runs': return fleetRuns(parsed, store, output) case 'show': @@ -80,7 +86,7 @@ async function dispatch(parsed: ParsedArgv, store: DiskPushStore, output: Output return failure( output, `Unknown subcommand ${JSON.stringify(subcommand)}. ` + - 'Try: run, script, upgrade, check, servers, commands, runs, show.', + 'Try: run, script, upgrade, check, servers, lists, commands, runs, show.', EXIT.usage, ) } @@ -116,7 +122,7 @@ async function resolveTargets(parsed: ParsedArgv, store: DiskPushStore, fallback } const available = await availableConnections(store) - const selection = selectConnections(available, terms) + const selection = await resolveSelector(terms, store, available) if (selection.unmatched.length > 0) { // A typo'd host is not a smaller fleet. Refusing here is the difference @@ -133,6 +139,70 @@ async function resolveTargets(parsed: ParsedArgv, store: DiskPushStore, fallback return { connections: selection.matched, selector: terms } } +/** + * Parse, expand any `list:` terms, then select. + * + * The order matters and got it wrong once: `parseSelector` is what splits + * `--on 'all,!list:web'` into terms, so expanding before that leaves + * `all,!list:web` as one unrecognised string and the exclusion silently does + * nothing. Everything that resolves a selector goes through here so there is + * one order rather than one per caller. + */ +async function resolveSelector( + terms: readonly string[], + store: DiskPushStore, + available: readonly Connection[], +): Promise> { + return selectConnections(available, await expandLists(parseSelector(terms), store, available)) +} + +/** + * Turns `list:production` into the servers that list holds. + * + * Expanded here rather than inside `selectConnections`, which is a pure + * function over connections and has no business reaching for a database. + * + * A member whose connection has since been deleted is named and refused + * rather than skipped: a list that quietly shrinks is how a command misses + * the one server it most needed to reach. + */ +async function expandLists( + terms: readonly string[], + store: DiskPushStore, + available: readonly Connection[], +): Promise { + const byId = new Map(available.map((connection) => [connection.id, connection])) + const expandedTerms: string[] = [] + + for (const term of terms) { + const negated = term.startsWith('!') + const bare = negated ? term.slice(1) : term + if (!isListTerm(bare)) { + expandedTerms.push(term) + continue + } + + const name = listTermName(bare) + const list = await store.findFleetList(name) + if (!list) throw new SelectionError(`No saved list named ${JSON.stringify(name)}. Run \`diskpush fleet lists\`.`) + if (list.members.length === 0) throw new SelectionError(`The list ${JSON.stringify(name)} has no servers in it.`) + + const missing = list.members.filter((member) => !byId.has(member.connectionId)) + if (missing.length > 0) { + throw new SelectionError( + `The list ${JSON.stringify(name)} names ${missing.length} server(s) that no longer exist: ` + + `${missing.map((member) => member.connectionName).join(', ')}. ` + + 'Save the list again to drop them.', + ) + } + // Ids, not names: a list resolves to exactly the servers it was saved with, + // even if one has been renamed since. + for (const member of list.members) expandedTerms.push(`${negated ? '!' : ''}${member.connectionId}`) + } + + return expandedTerms +} + // --- prompts --------------------------------------------------------------- /** @@ -680,7 +750,7 @@ function formatUptime(seconds: number): string { async function fleetServers(parsed: ParsedArgv, store: DiskPushStore, output: Output): Promise { const available = await availableConnections(store) const selector = flagValues(parsed, '--on') - const shown = selector.length > 0 ? selectConnections(available, selector).matched : available + const shown = selector.length > 0 ? (await resolveSelector(selector, store, available)).matched : available if (output.isJson) { output.json({ status: 'ok', servers: shown }) @@ -814,6 +884,125 @@ async function fleetCommands(parsed: ParsedArgv, store: DiskPushStore, output: O return failure(output, `Unknown action ${JSON.stringify(action)}. Try: list, show, save, copy, remove.`, EXIT.usage) } +/** + * Saved sets of servers. + * + * Tags say what a server *is*; a list is a set someone assembled by hand and + * wants back. Used as `--on list:NAME`, prefixed so a list and a server may + * share a name without either shadowing the other. + */ +async function fleetLists(parsed: ParsedArgv, store: DiskPushStore, output: Output): Promise { + const action = parsed.positionals[1] ?? 'list' + + if (action === 'list') { + const lists = await store.listFleetLists() + if (output.isJson) { + output.json({ status: 'ok', lists }) + return EXIT.ok + } + if (lists.length === 0) { + output.line('No saved lists. Make one with: diskpush fleet lists save NAME --on tag:production') + return EXIT.ok + } + output.line( + table( + lists.map((list) => [ + list.name, + String(list.members.length), + list.members.slice(0, 4).map((member) => member.connectionName).join(', ') + + (list.members.length > 4 ? ', ...' : ''), + list.description.slice(0, 40), + ]), + ['LIST', 'SERVERS', 'MEMBERS', 'DESCRIPTION'], + ), + ) + return EXIT.ok + } + + if (action === 'show') { + const name = parsed.positionals[2] + if (!name) return failure(output, 'Usage: diskpush fleet lists show NAME', EXIT.usage) + const list = await store.findFleetList(name) + if (!list) return failure(output, `No saved list named ${JSON.stringify(name)}.`, EXIT.configuration) + + if (output.isJson) { + output.json({ status: 'ok', list }) + return EXIT.ok + } + + // A member whose connection has gone is shown as missing rather than + // dropped: that is the difference between a list you can trust and one + // that quietly got smaller. + const available = await availableConnections(store) + const byId = new Map(available.map((connection) => [connection.id, connection])) + output.line(`${list.name}${list.description ? ` ${list.description}` : ''}`) + output.line() + output.line( + table( + list.members.map((member) => { + const live = byId.get(member.connectionId) + return [ + member.connectionName, + live ? `${live.username}@${live.host}:${live.port}` : '-', + live ? 'ok' : 'MISSING', + ] + }), + ['SERVER', 'TARGET', 'STATE'], + ), + ) + return EXIT.ok + } + + if (action === 'save') { + const name = parsed.positionals[2] + if (!name) return failure(output, 'Usage: diskpush fleet lists save NAME --on SELECTOR', EXIT.usage) + if (isListTerm(name)) { + return failure(output, `A list is named without the ${JSON.stringify('list:')} prefix.`, EXIT.usage) + } + + // Resolved now, and stored as members. A list is a set someone chose, not + // a query that might mean something different next week. + const targets = await resolveTargets(parsed, store) + const saved = await store.saveFleetList({ + name, + description: flagValue(parsed, '--description') ?? '', + members: targets.connections.map((connection) => ({ + connectionId: connection.id, + connectionName: connection.name, + })), + }) + + if (output.isJson) output.json({ status: 'ok', list: saved }) + else { + output.line(`Saved list ${saved.name} with ${saved.members.length} server(s).`) + output.line(`Use it with: diskpush fleet run "uptime" --on list:${saved.name}`) + } + return EXIT.ok + } + + if (action === 'rename') { + const [, , from, to] = parsed.positionals + if (!from || !to) return failure(output, 'Usage: diskpush fleet lists rename NAME NEW-NAME', EXIT.usage) + const renamed = await store.renameFleetList(from, to) + if (!renamed) return failure(output, `No saved list named ${JSON.stringify(from)}.`, EXIT.configuration) + if (output.isJson) output.json({ status: 'ok', list: renamed }) + else output.line(`Renamed list ${from} to ${renamed.name}.`) + return EXIT.ok + } + + if (action === 'remove' || action === 'rm') { + const name = parsed.positionals[2] + if (!name) return failure(output, 'Usage: diskpush fleet lists remove NAME', EXIT.usage) + const removed = await store.deleteFleetList(name) + if (!removed) return failure(output, `No saved list named ${JSON.stringify(name)}.`, EXIT.configuration) + if (output.isJson) output.json({ status: 'ok', removed: name }) + else output.line(`Removed list ${name}. The servers themselves are untouched.`) + return EXIT.ok + } + + return failure(output, `Unknown action ${JSON.stringify(action)}. Try: list, show, save, rename, remove.`, EXIT.usage) +} + async function fleetRuns(parsed: ParsedArgv, store: DiskPushStore, output: Output): Promise { const runs = await store.listFleetRuns(numberFlag(parsed, '--limit') ?? 25) if (output.isJson) { diff --git a/apps/cli/src/commands/transfer-helpers.ts b/apps/cli/src/commands/transfer-helpers.ts index 6d43862..58fe29e 100644 --- a/apps/cli/src/commands/transfer-helpers.ts +++ b/apps/cli/src/commands/transfer-helpers.ts @@ -8,10 +8,7 @@ export { planTransfer, runPlan, runToCompletion, - writeSelectionList, - describeSelection, type ExecutionPlan, - type SelectionList, } from '@diskpush/rsync-core' import { runToCompletion, type ExecutionPlan } from '@diskpush/rsync-core' diff --git a/apps/cli/src/commands/transfer.ts b/apps/cli/src/commands/transfer.ts index abda98e..0e7aabb 100644 --- a/apps/cli/src/commands/transfer.ts +++ b/apps/cli/src/commands/transfer.ts @@ -7,18 +7,15 @@ import { intersectCapabilities, planTransfer, runPlan, - writeSelectionList, - describeSelection, summarizeChangesFrom, type ExecutionPlan, - type SelectionList, } from './transfer-helpers.js' import type { Change, RsyncOptions } from '@diskpush/schemas' import { summarizeChanges, topologyOf } from '@diskpush/schemas' import { EXIT } from '../exit-codes.js' import { estimateRemaining, formatBytes, formatDuration, formatRate, pluralize, table } from '../format.js' import { failure, type Output } from '../output.js' -import { flagValue, flagValues, hasFlag, type ParsedArgv } from '../parse-argv.js' +import { flagValue, hasFlag, type ParsedArgv } from '../parse-argv.js' import { detectLocalCapabilities, optionsFromFlags, resolveEndpoint } from '../resolve.js' import type { RsyncCapabilities } from '@diskpush/rsync-core' @@ -64,61 +61,6 @@ export async function runTransfer( if (alias.deleteMode !== 'off') options.deleteMode = alias.deleteMode - /* - * `--only NAME` transfers just the entries named, rather than everything in - * the source directory — the thing an SFTP client makes trivial and a bare - * rsync does not. - * - * The names go to rsync as a NUL-separated `--files-from` list, which is not - * bounded by the command-line length limit and can express any name a - * filesystem allows. `writeSelectionList` refuses `..` and absolute paths: - * a selection is a choice among what the source directory holds, so a name - * is the only thing it can be. - */ - const only = flagValues(parsed, '--only') - let selection: SelectionList | null = null - if (only.length > 0) { - if (options.filesFrom) { - return failure(output, '--only and --files-from both choose what to send; use one.', EXIT.usage) - } - try { - selection = writeSelectionList(only) - } catch (error) { - return failure(output, (error as Error).message, EXIT.usage) - } - options.filesFrom = selection.path - options.from0 = true - } - - try { - return await runResolvedTransfer( - command, - parsed, - store, - output, - alias, - sourceInput, - destinationInput, - options, - only, - ) - } finally { - // rsync reads the list at startup, but it is not gone until the run is. - selection?.cleanup() - } -} - -async function runResolvedTransfer( - command: string, - parsed: ParsedArgv, - store: DiskPushStore, - output: Output, - alias: (typeof TRANSFER_ALIASES)[string], - sourceInput: string, - destinationInput: string, - options: RsyncOptions, - only: readonly string[], -): Promise { const source = await resolveEndpoint(store, sourceInput) const destination = await resolveEndpoint(store, destinationInput) const topology = topologyOf(source.endpoint, destination.endpoint) @@ -238,9 +180,6 @@ async function runResolvedTransfer( output.line(`DiskPush: ${alias.label} ${describe(sourceInput)} -> ${describe(destinationInput)}`) output.line(`Source: ${sourceInput}`) output.line(`Destination: ${destinationInput}`) - // Named before the transfer runs, for the same reason a mirror shows its - // delete list: what is about to move is worth stating. - if (only.length > 0) output.line(`Only: ${describeSelection(only)}`) if (topology === 'remote-to-remote') { output.line('') output.line(`Direct path: ${source.endpoint.type === 'ssh' ? source.endpoint.host : '?'} -> ${destination.endpoint.type === 'ssh' ? destination.endpoint.host : '?'}`) diff --git a/apps/cli/src/parse-argv.ts b/apps/cli/src/parse-argv.ts index 03c2579..0e1bd29 100644 --- a/apps/cli/src/parse-argv.ts +++ b/apps/cli/src/parse-argv.ts @@ -33,7 +33,6 @@ export const VALUE_FLAGS = new Set([ '--exclude-from', '--include-from', '--files-from', - '--only', '--bwlimit', '--max-size', '--min-size', diff --git a/apps/desktop/electron/main/ipc.ts b/apps/desktop/electron/main/ipc.ts index 12d440a..71b7530 100644 --- a/apps/desktop/electron/main/ipc.ts +++ b/apps/desktop/electron/main/ipc.ts @@ -10,6 +10,8 @@ import { DeleteEntryRequestSchema, ExternalUrlSchema, FleetCheckRequestSchema, + FleetListRenameSchema, + FleetListSaveSchema, FleetRequestSchema, FleetRunIdSchema, IPC, @@ -25,8 +27,12 @@ import { cancelFleet, checkFleetServers, fleetCommands, + fleetLists, fleetRunDetail, fleetServers, + removeFleetList, + renameFleetList, + saveFleetList, previewFleet, startFleet, } from './services/fleet.js' @@ -325,6 +331,16 @@ export function registerIpc(): void { handle(IPC.fleetRunDetail, z.object({ runId: FleetRunIdSchema }), async ({ runId }) => fleetRunDetail(runId)) + handle(IPC.fleetLists, z.undefined(), async () => fleetLists()) + + handle(IPC.fleetListSave, FleetListSaveSchema, async (input) => saveFleetList(input)) + + handle(IPC.fleetListRename, FleetListRenameSchema, async ({ from, to }) => renameFleetList(from, to)) + + handle(IPC.fleetListRemove, z.object({ name: z.string().min(1).max(128) }), async ({ name }) => + removeFleetList(name), + ) + // --- shell --------------------------------------------------------------- handle(IPC.shellOpenExternal, z.object({ url: ExternalUrlSchema }), async ({ url }) => { diff --git a/apps/desktop/electron/main/services/fleet.ts b/apps/desktop/electron/main/services/fleet.ts index 68a6627..b3045a2 100644 --- a/apps/desktop/electron/main/services/fleet.ts +++ b/apps/desktop/electron/main/services/fleet.ts @@ -8,7 +8,7 @@ import { type Hazard, type SudoMode, } from '@diskpush/fleet-core' -import type { Connection, FleetCommand, FleetHostResult, HostUpdateReport } from '@diskpush/schemas' +import type { Connection, FleetCommand, FleetHostResult, FleetList, HostUpdateReport } from '@diskpush/schemas' import { sshConfigConnections } from '@diskpush/ssh-core' import { IPC, type FleetRequest } from '../../shared/contract.js' import { dropSession, sessionFor } from './sessions.js' @@ -173,6 +173,44 @@ export async function startFleet(request: FleetRequest, sender: WebContents): Pr } } +// --- saved lists ------------------------------------------------------------ + +export async function fleetLists(): Promise { + return (await store()).listFleetLists() +} + +/** + * Saves the ticked servers as a named list. + * + * The ids are resolved here and each member's *current* name stored beside it, + * so the list can still name a member after that connection is gone. + */ +export async function saveFleetList(input: { + name: string + description: string + connectionIds: readonly string[] +}): Promise { + const connections = await connectionsFor(input.connectionIds) + return (await store()).saveFleetList({ + name: input.name, + description: input.description, + members: connections.map((connection) => ({ + connectionId: connection.id, + connectionName: connection.name, + })), + }) +} + +export async function renameFleetList(from: string, to: string): Promise { + const renamed = await (await store()).renameFleetList(from, to) + if (!renamed) throw new Error(`No saved list named ${JSON.stringify(from)}.`) + return renamed +} + +export async function removeFleetList(name: string): Promise { + return (await store()).deleteFleetList(name) +} + export function cancelFleet(runId: string): boolean { const run = running.get(runId) if (!run) return false diff --git a/apps/desktop/electron/main/services/transfers.ts b/apps/desktop/electron/main/services/transfers.ts index 485c9e5..df86f66 100644 --- a/apps/desktop/electron/main/services/transfers.ts +++ b/apps/desktop/electron/main/services/transfers.ts @@ -2,8 +2,6 @@ import { randomUUID } from 'node:crypto' import type { WebContents } from 'electron' import { intersectCapabilities, - writeSelectionList, - type SelectionList, parseRsyncCapabilities, planTransfer, runPlan, @@ -104,32 +102,11 @@ function optionsFrom(input: TransferOptions): RsyncOptions { }) } -/** - * Turns the renderer's selection into a `--files-from` list. - * - * Returns null when nothing is selected, which means the whole directory — - * the behaviour the two-pane view has always had. The caller removes the list - * once rsync has exited. - */ -function selectionFor(request: TransferRequest): SelectionList | null { - return request.selection.length > 0 ? writeSelectionList(request.selection) : null -} - -async function buildPlan( - request: TransferRequest, - overrides: Partial = {}, - selection: SelectionList | null = null, -): Promise { +async function buildPlan(request: TransferRequest, overrides: Partial = {}): Promise { const source = await resolveEndpoint(request.source) const destination = await resolveEndpoint(request.destination) const capabilities = await capabilitiesFor([source.connectionId, destination.connectionId]) const options = { ...optionsFrom(request.options), ...overrides } - if (selection) { - options.filesFrom = selection.path - // NUL-separated: a newline is legal in a filename, so a newline-separated - // list cannot express every name a directory can hold. - options.from0 = true - } const isServerToServer = source.endpoint.type === 'ssh' && destination.endpoint.type === 'ssh' const sourceConnection = source.connectionId ? await resolveConnection(source.connectionId) : null @@ -165,15 +142,7 @@ export type PreviewResult = { /** The dry run behind Preview Changes and behind every mirror. */ export async function previewTransfer(request: TransferRequest): Promise { - const selection = selectionFor(request) - try { - return await previewWithPlan(await buildPlan(request, { dryRun: true }, selection)) - } finally { - selection?.cleanup() - } -} - -async function previewWithPlan(plan: ExecutionPlan): Promise { + const plan = await buildPlan(request, { dryRun: true }) const result = await runToCompletion(plan) return { changes: result.changes, @@ -190,10 +159,7 @@ async function previewWithPlan(plan: ExecutionPlan): Promise { export type StartedJob = { jobId: string; command: string; control: string | null; warnings: string[] } export async function startTransfer(request: TransferRequest, sender: WebContents): Promise { - // rsync reads the list at startup, but the run owns it until it exits: the - // cleanup below is in the event loop's `finally`, not this function's. - const selection = selectionFor(request) - const plan = await buildPlan(request, {}, selection) + const plan = await buildPlan(request) const jobId = randomUUID() const db = await store() @@ -255,7 +221,6 @@ export async function startTransfer(request: TransferRequest, sender: WebContent } } running.delete(jobId) - selection?.cleanup() })() return { jobId, command: plan.display, control: plan.controlDisplay ?? null, warnings: plan.warnings } diff --git a/apps/desktop/electron/preload/index.ts b/apps/desktop/electron/preload/index.ts index d9fe464..fcfae96 100644 --- a/apps/desktop/electron/preload/index.ts +++ b/apps/desktop/electron/preload/index.ts @@ -58,6 +58,11 @@ const api = { check: (connectionIds: string[], concurrency = 4, timeoutSeconds = 180) => call(IPC.fleetCheck, { connectionIds, concurrency, timeoutSeconds }), runs: (limit = 25) => call(IPC.fleetRuns, { limit }), + lists: () => call(IPC.fleetLists), + saveList: (name: string, connectionIds: string[], description = '') => + call(IPC.fleetListSave, { name, connectionIds, description }), + renameList: (from: string, to: string) => call(IPC.fleetListRename, { from, to }), + removeList: (name: string) => call(IPC.fleetListRemove, { name }), runDetail: (runId: string) => call(IPC.fleetRunDetail, { runId }), }, shell: { diff --git a/apps/desktop/electron/shared/contract.ts b/apps/desktop/electron/shared/contract.ts index 4d5be0c..9ae8706 100644 --- a/apps/desktop/electron/shared/contract.ts +++ b/apps/desktop/electron/shared/contract.ts @@ -45,6 +45,10 @@ export const IPC = { fleetCancel: 'fleet:cancel', fleetCheck: 'fleet:check', fleetRuns: 'fleet:runs', + fleetLists: 'fleet:lists', + fleetListSave: 'fleet:list-save', + fleetListRename: 'fleet:list-rename', + fleetListRemove: 'fleet:list-remove', fleetRunDetail: 'fleet:run-detail', shellOpenExternal: 'shell:open-external', @@ -113,39 +117,12 @@ export const TransferOptionsSchema = z.object({ }) export type TransferOptions = z.infer -/** - * A single entry name inside a directory — never a path. - * - * Every mutating operation takes a directory plus one of these and joins them - * in the main process, so the renderer cannot walk out of the folder it is - * showing. `..`, a separator or a NUL would each be a way to do exactly that. - */ -export const EntryNameSchema = z - .string() - .min(1) - .max(255) - .refine((name) => !name.includes('/') && !name.includes('\\') && !name.includes('\0'), { - message: 'A name cannot contain a path separator.', - }) - .refine((name) => name !== '.' && name !== '..', { message: 'That name is reserved.' }) - .refine((name) => name.trim() === name, { message: 'A name cannot begin or end with a space.' }) - export const TransferRequestSchema = z.object({ source: EndpointRefSchema, destination: EndpointRefSchema, options: TransferOptionsSchema, /** Only meaningful for a delete-enabled job, and only after a preview. */ deletesConfirmed: z.boolean().default(false), - /** - * Send only these entries, rather than everything in the source directory. - * - * Entry names, not paths: each is one item the source pane is showing. The - * main process turns them into a `--files-from` list; the renderer never - * builds a path, so a selection cannot address anything the pane is not - * already looking at. Empty means the whole directory, which is what the - * two-pane view has always done. - */ - selection: z.array(EntryNameSchema).max(10_000).default([]), }) export type TransferRequest = z.infer @@ -162,6 +139,23 @@ export const RenameRequestSchema = z.object({ to: PathSchema, }) +/** + * A single entry name inside a directory — never a path. + * + * Every mutating operation takes a directory plus one of these and joins them + * in the main process, so the renderer cannot walk out of the folder it is + * showing. `..`, a separator or a NUL would each be a way to do exactly that. + */ +export const EntryNameSchema = z + .string() + .min(1) + .max(255) + .refine((name) => !name.includes('/') && !name.includes('\\') && !name.includes('\0'), { + message: 'A name cannot contain a path separator.', + }) + .refine((name) => name !== '.' && name !== '..', { message: 'That name is reserved.' }) + .refine((name) => name.trim() === name, { message: 'A name cannot begin or end with a space.' }) + /** Create a directory or an empty file: `name` inside `directory`. */ export const CreateEntryRequestSchema = z.object({ connectionId: ConnectionIdSchema.optional(), @@ -239,6 +233,28 @@ export const FleetCheckRequestSchema = z.object({ export const FleetRunIdSchema = z.string().min(1).max(128) +/** + * A saved set of servers. + * + * The renderer sends the ids it has ticked; the main process resolves them and + * stores each member's current name alongside its id, so a list stays readable + * after a connection is deleted. + */ +export const FleetListNameSchema = z.string().min(1).max(128).refine((name) => name.trim() === name, { + message: 'A name cannot begin or end with a space.', +}) + +export const FleetListSaveSchema = z.object({ + name: FleetListNameSchema, + description: z.string().max(500).default(''), + connectionIds: z.array(ConnectionIdSchema).min(1).max(500), +}) + +export const FleetListRenameSchema = z.object({ + from: FleetListNameSchema, + to: FleetListNameSchema, +}) + /** Only http(s) may be handed to the system browser. */ export const ExternalUrlSchema = z.string().url().refine((value) => /^https?:\/\//i.test(value), { message: 'Only http and https URLs can be opened externally.', diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 07be1db..e137584 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/desktop", - "version": "0.2.11", + "version": "0.2.10", "private": true, "type": "module", "main": "dist-electron/main/index.js", diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index 26a199b..fdefa83 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -191,10 +191,6 @@ export default function Workspace() { destination: refFor(destination, withTrailingSlash(destination.path)), options: { deleteMode: mirror ? ('delay' as const) : ('off' as const) }, deletesConfirmed: false, - // Ticked entries in the source pane mean "just these", the way an SFTP - // client behaves. Nothing ticked keeps the old meaning: the whole - // directory. - selection: [...source.selected], }), [source, destination, mirror], ) @@ -413,7 +409,6 @@ export default function Workspace() { /> void }) { const [servers, setServers] = useState([]) const [commands, setCommands] = useState([]) + const [lists, setLists] = useState([]) + const [savingList, setSavingList] = useState(false) + const [newListName, setNewListName] = useState('') const [selected, setSelected] = useState>(new Set()) const [tagFilter, setTagFilter] = useState(null) @@ -92,12 +98,14 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) { const refresh = useCallback(async () => { try { - const [serverList, commandList] = await Promise.all([ + const [serverList, commandList, listList] = await Promise.all([ unwrap(api()?.fleet.servers()), unwrap(api()?.fleet.commands()), + unwrap(api()?.fleet.lists()), ]) setServers(serverList) setCommands(commandList) + setLists(listList) } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) } @@ -158,6 +166,54 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) { return next }) + /** + * Ticks exactly the servers a list holds. + * + * Replaces the selection rather than adding to it: picking a list is saying + * "these", and a list that quietly unioned with whatever was already ticked + * would run on servers nobody chose. + * + * A member whose connection has gone is reported rather than skipped, the + * same rule the CLI selector follows. + */ + const pickList = (list: FleetList) => { + const known = new Set(servers.map((server) => server.id)) + const missing = list.members.filter((member) => !known.has(member.connectionId)) + if (missing.length > 0) { + setError( + `The list "${list.name}" names ${missing.length} server(s) that no longer exist: ` + + `${missing.map((member) => member.connectionName).join(', ')}. Save it again to drop them.`, + ) + return + } + setError(null) + setSelected(new Set(list.members.map((member) => member.connectionId))) + } + + const saveList = useCallback(async () => { + const name = newListName.trim() + if (!name || selected.size === 0) return + setError(null) + try { + await unwrap(api()?.fleet.saveList(name, [...selected])) + setNewListName('') + setSavingList(false) + setLists(await unwrap(api()?.fleet.lists())) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } + }, [newListName, selected]) + + const removeList = useCallback(async (name: string) => { + setError(null) + try { + await unwrap(api()?.fleet.removeList(name)) + setLists(await unwrap(api()?.fleet.lists())) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } + }, []) + const pickCommand = (command: FleetCommand) => { setScript(command.script) setLabel(command.name) @@ -270,6 +326,82 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) { ) : null} + {/* + Saved lists first, then tags. A tag says what a server *is*; a list + is a set someone assembled by hand and wants back, so it is the + more deliberate of the two and sits above. + */} + {lists.length > 0 || selected.size > 0 ? ( +
+
+ {lists.map((list) => ( + + + + + ))} + + {selected.size > 0 && !savingList ? ( + + ) : null} +
+ + {savingList ? ( +
+ setNewListName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveList() + if (event.key === 'Escape') { + setSavingList(false) + setNewListName('') + } + }} + placeholder="list name" + className="h-6 flex-1 text-[11.5px]" + /> + +
+ ) : null} +
+ ) : null} + {tags.length > 0 ? (
{tags.map((tag) => ( diff --git a/apps/desktop/src/components/transfer-rail.tsx b/apps/desktop/src/components/transfer-rail.tsx index 6d7fa84..4ba5f28 100644 --- a/apps/desktop/src/components/transfer-rail.tsx +++ b/apps/desktop/src/components/transfer-rail.tsx @@ -27,15 +27,12 @@ function Direction({ label, armed, busy, - selectedCount, onClick, }: { side: 'left' | 'right' label: string armed: boolean busy: boolean - /** Ticked entries in the source pane. 0 means the whole directory. */ - selectedCount: number onClick: () => void }) { const Arrow = side === 'right' ? ArrowRight : ArrowLeft @@ -63,17 +60,11 @@ function Direction({ armed ? 'text-primary-foreground/70' : 'text-faint', )} > - {armed && selectedCount > 0 ? `Send ${selectedCount}` : 'Sync to'} + Sync to {label} - - {/* Only on the armed button: the count is the *source* pane's, and - the other button would make the other pane the source. */} - {armed && selectedCount > 0 - ? `Copy the ${selectedCount} selected ${selectedCount === 1 ? 'entry' : 'entries'} into ${label}` - : `Copy into ${label}, overwriting what differs`} - + Copy into {label}, overwriting what differs ) } @@ -127,7 +118,6 @@ export function TransferRail({ direction, mirror, busy, - selectedCount, leftLabel, rightLabel, onDirection, @@ -138,8 +128,6 @@ export function TransferRail({ direction: 'ltr' | 'rtl' mirror: boolean busy: boolean - /** Ticked entries in the source pane. 0 means the whole directory. */ - selectedCount: number leftLabel: string rightLabel: string onDirection: (direction: 'ltr' | 'rtl') => void @@ -155,7 +143,6 @@ export function TransferRail({
> runs(limit?: number): Promise> runDetail(runId: string): Promise> + lists(): Promise> + saveList(name: string, connectionIds: string[], description?: string): Promise> + renameList(from: string, to: string): Promise> + removeList(name: string): Promise> } shell: { openExternal(url: string): Promise> } events: { diff --git a/apps/web/package.json b/apps/web/package.json index b8d557b..82834a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/web", - "version": "0.2.11", + "version": "0.2.10", "private": true, "type": "module", "scripts": { diff --git a/docs/cli.md b/docs/cli.md index a5b37da..07ccdca 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -121,13 +121,14 @@ diskpush fleet upgrade --on SELECTOR --sudo # install them diskpush fleet run "COMMAND" --on SELECTOR diskpush fleet run --command NAME --on SELECTOR diskpush fleet script FILE --on SELECTOR +diskpush fleet lists [list|show|save|rename|remove] diskpush fleet commands [list|show|save|copy|remove] diskpush fleet runs [--limit N] diskpush fleet show RUN-ID [--all] ``` -`--on` takes names, globs, `tag:NAME`, `host:GLOB`, `all`, and `!TERM` to -exclude. Terms may be repeated or comma-separated. A term that matches nothing +`--on` takes names, globs, `tag:NAME`, `host:GLOB`, `list:NAME`, `all`, and +`!TERM` to exclude. Terms may be repeated or comma-separated. A term that matches nothing is an error rather than a smaller fleet. | Option | Effect | @@ -171,7 +172,6 @@ job would need a background daemon, which does not exist yet. | Option | Effect | | --- | --- | -| `--only NAME` | Send only this entry from the source directory, rather than all of it. Repeatable. | | `-n`, `--dry-run` | Show the change set; transfer nothing. | | `--print-args` | Print the exact rsync command and exit. | | `--preset NAME` | `fast-sync`, `exact-mirror`, `maximum-metadata`, `slow-wan`, `verify-everything`. | @@ -198,36 +198,6 @@ job would need a background daemon, which does not exist yet. flag it is a boolean, so that `diskpush sync --compress ./a/ ./b/` cannot mistake an endpoint for its value. -## Sending only some of a directory - -`--only` transfers just the entries named, rather than everything in the -source — the thing an SFTP client makes trivial and a bare rsync does not. - -```bash -diskpush ./site/ prod:/var/www/ --only index.html --only assets -diskpush pull prod:/var/log/ ./logs/ --only 'app.log' -``` - -Each name is one entry **inside the source directory**. A folder comes across -whole, with its contents. Names with spaces are fine. `..` and absolute paths -are refused: a selection is a choice among what the source holds, so a name is -the only thing it can be. - -The names reach rsync as a NUL-separated `--files-from` list, which has two -consequences worth knowing: - -- **`--files-from` turns recursion off, and `--archive` does not turn it back - on.** DiskPush restates `--recursive` so a selected folder arrives with its - contents rather than as an empty directory. Verified against rsync 3.4.1 — - it is not what the flag summary suggests. -- **`--delete` stays scoped to the selection.** `diskpush mirror SRC DST --only - cache` removes destination files inside `cache/` and leaves the rest of the - destination alone. - -In the desktop app this is the pane selection: tick entries in the source pane -and the transfer button changes from *Sync to web-01* to *Send 2 → web-01*. -Nothing ticked means the whole directory, as before. - ## Pass-through ```bash diff --git a/docs/desktop.md b/docs/desktop.md index 1ab79fa..dd9920c 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -37,19 +37,6 @@ Skip unchanged files: On Delete destination-only files: Off ``` -## Sending only what is selected - -Tick entries in the source pane and the transfer button changes from -**Sync to web-01** to **Send 2 → web-01**: only those entries move, the way an -SFTP client behaves. Nothing ticked means the whole directory, which is what -the two panes have always meant. - -A selected folder comes across whole, with its contents. The selection travels -as entry names — the renderer never builds a path, so it cannot address -anything the pane is not already showing. - -`--only` is the same thing from the CLI. See [cli.md](cli.md). - ## Mirror The destination pane stays browsable right up to the confirmation: you can @@ -108,6 +95,11 @@ script editor, and the results — and **the action bar is pinned outside all of them**. Run is on screen at the 960×600 minimum window size just as it is maximised. +Saved **lists** sit at the top of the sidebar, above the tag chips. Clicking +one ticks exactly its members; ticking servers by hand offers **Save these 3**; +the × on a chip deletes the list and leaves the servers alone. A member whose +connection has been deleted is named rather than silently skipped. + Ticking servers is the same thing `--on` does in the CLI, and tag chips filter the list. The two share one local database, so a command saved in one runs from the other. diff --git a/docs/fleet.md b/docs/fleet.md index a376d4d..5f94b2c 100644 --- a/docs/fleet.md +++ b/docs/fleet.md @@ -35,6 +35,49 @@ host:10.0.0.* a glob over hostnames Includes are unioned, then exclusions are subtracted, so order does not matter: `tag:web !web-03` and `!web-03 tag:web` select the same servers. +### Saved lists + +A tag says what a server *is*. A list is a set you assembled by hand and want +back — "the four boxes behind the EU load balancer" is not a property of any +one of them. + +```bash +diskpush fleet lists save eu-edge --on 'web-*,cache-01' --description "behind the EU LB" +diskpush fleet lists # read +diskpush fleet lists show eu-edge +diskpush fleet lists rename eu-edge edge +diskpush fleet lists remove edge # the servers themselves are untouched + +diskpush fleet run "uptime" --on list:eu-edge +diskpush fleet upgrade --on 'list:eu-edge,!web-03' --sudo +``` + +`list:` is a prefix so a list and a server may share a name without either +shadowing the other: `--on production` is the server, `--on list:production` +is the list. + +A list stores each member's **connection id and the name it had when saved**. +The id is what resolves, so a renamed server stays in the list. The name is +what keeps the list readable afterwards — and a member whose connection has +been deleted is **named and refused**, not silently skipped: + +```text +The list "pair" names 1 server(s) that no longer exist: web-02. +Save the list again to drop them. +``` + +That is the same rule a selector term follows. A list that quietly got smaller +is how a command misses the one server it most needed to reach. + +Saving resolves the selector **now** and stores the result. A list is a set +someone chose, not a query that might mean something different next week — use +a tag when you want the dynamic behaviour. + +In the desktop app the lists appear as chips at the top of the server sidebar, +above the tags. Clicking one ticks exactly its members; ticking servers by hand +offers **Save these 3**; the × on a chip deletes the list and leaves the +servers alone. + Both saved connections and `~/.ssh/config` hosts are selectable. A saved connection wins a name clash. `diskpush fleet servers` lists what is available and where each entry came from. diff --git a/packages/database/package.json b/packages/database/package.json index 8bc1f5a..416adbc 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/database", - "version": "0.2.11", + "version": "0.2.10", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/database/src/fleet-list-store.test.ts b/packages/database/src/fleet-list-store.test.ts new file mode 100644 index 0000000..df6e7a5 --- /dev/null +++ b/packages/database/src/fleet-list-store.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { isListTerm, listTermName, FLEET_LIST_PREFIX } from '@diskpush/schemas' +import { DiskPushStore } from './store.js' + +async function store() { + return DiskPushStore.open({ path: ':memory:' }) +} + +const members = [ + { connectionId: 'id-web-01', connectionName: 'web-01' }, + { connectionId: 'id-web-02', connectionName: 'web-02' }, +] + +describe('fleet lists', () => { + it('saves a set of servers and reads it back', async () => { + const db = await store() + const saved = await db.saveFleetList({ name: 'web', description: 'the web tier', members }) + + expect(saved.members).toHaveLength(2) + const found = await db.findFleetList('web') + expect(found?.description).toBe('the web tier') + expect(found?.members.map((m) => m.connectionName)).toEqual(['web-01', 'web-02']) + }) + + it('updates in place rather than creating a second list of the same name', async () => { + const db = await store() + const first = await db.saveFleetList({ name: 'web', description: '', members }) + const second = await db.saveFleetList({ name: 'web', description: '', members: [members[0]!] }) + + expect(second.id).toBe(first.id) + expect(second.members).toHaveLength(1) + expect(await db.listFleetLists()).toHaveLength(1) + }) + + it('keeps the name each member had, so a list stays readable after a deletion', async () => { + // Nothing joins back to `connections`: a member that has gone away can be + // named rather than silently dropped, which is the whole point. + const db = await store() + await db.saveFleetList({ name: 'web', description: '', members }) + expect((await db.findFleetList('web'))?.members[1]?.connectionName).toBe('web-02') + }) + + it('renames without losing its members or its identity', async () => { + const db = await store() + const before = await db.saveFleetList({ name: 'web', description: '', members }) + const after = await db.renameFleetList('web', 'web-tier') + + expect(after?.id).toBe(before.id) + expect(after?.name).toBe('web-tier') + expect(after?.members).toHaveLength(2) + expect(await db.findFleetList('web')).toBeNull() + }) + + it('has nothing to rename when the list does not exist', async () => { + expect(await (await store()).renameFleetList('nope', 'x')).toBeNull() + }) + + it('deletes', async () => { + const db = await store() + await db.saveFleetList({ name: 'web', description: '', members }) + expect(await db.deleteFleetList('web')).toBe(true) + expect(await db.findFleetList('web')).toBeNull() + expect(await db.deleteFleetList('web')).toBe(false) + }) + + it('lists alphabetically', async () => { + const db = await store() + await db.saveFleetList({ name: 'zeta', description: '', members }) + await db.saveFleetList({ name: 'alpha', description: '', members }) + expect((await db.listFleetLists()).map((list) => list.name)).toEqual(['alpha', 'zeta']) + }) + + it('accepts an empty list, which the selector refuses to run on', async () => { + // Storing it is fine; using it is what has to complain, and does. + const db = await store() + expect((await db.saveFleetList({ name: 'empty', description: '', members: [] })).members).toEqual([]) + }) +}) + +describe('list selector terms', () => { + it('is prefixed, so a list and a server may share a name', () => { + expect(FLEET_LIST_PREFIX).toBe('list:') + expect(isListTerm('list:production')).toBe(true) + expect(isListTerm('production')).toBe(false) + expect(listTermName('list:production')).toBe('production') + }) +}) diff --git a/packages/database/src/migrations.ts b/packages/database/src/migrations.ts index 9959c5e..722e9a3 100644 --- a/packages/database/src/migrations.ts +++ b/packages/database/src/migrations.ts @@ -148,4 +148,29 @@ export const MIGRATIONS: Migration[] = [ `CREATE INDEX IF NOT EXISTS idx_fleet_run_hosts_state ON fleet_run_hosts(run_id, state)`, ], }, + { + name: '003-fleet-lists', + statements: [ + /* + * A saved set of servers, assembled by hand. + * + * Members are JSON rather than a join table, and carry the name each + * connection had when the list was saved as well as its id. The id is + * what resolves; the name is what lets a list stay readable after a + * connection is deleted, so a member that has gone away can be named + * rather than silently dropped. + * + * No foreign key to `connections` for the same reason, and because a + * member may be a `~/.ssh/config` host, which has no row there at all. + */ + `CREATE TABLE IF NOT EXISTS fleet_lists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + members_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + ], + }, ] diff --git a/packages/database/src/store.ts b/packages/database/src/store.ts index d6c8d88..b92da69 100644 --- a/packages/database/src/store.ts +++ b/packages/database/src/store.ts @@ -6,12 +6,14 @@ import { ConnectionSchema, FleetCommandSchema, FleetHostResultSchema, + FleetListSchema, FleetRunSchema, SyncProfileSchema, TransferJobSchema, type Connection, type FleetCommand, type FleetHostResult, + type FleetList, type FleetRun, type JobState, type SyncProfile, @@ -364,6 +366,58 @@ export class DiskPushStore { return result.rowsAffected > 0 } + // --- fleet lists --------------------------------------------------------- + + async listFleetLists(): Promise { + const result = await this.client.execute('SELECT * FROM fleet_lists ORDER BY name') + return result.rows.map(rowToFleetList) + } + + async findFleetList(nameOrId: string): Promise { + const result = await this.client.execute({ + sql: 'SELECT * FROM fleet_lists WHERE name = ? OR id = ? LIMIT 1', + args: [nameOrId, nameOrId], + }) + const row = result.rows[0] + return row ? rowToFleetList(row) : null + } + + /** Create or replace. Saving an existing name updates it in place. */ + async saveFleetList(input: Omit & { id?: string }): Promise { + const now = new Date().toISOString() + const existing = input.id ? await this.findFleetList(input.id) : await this.findFleetList(input.name) + const list = FleetListSchema.parse({ + ...input, + id: existing?.id ?? input.id ?? randomUUID(), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + + await this.client.execute({ + sql: `INSERT INTO fleet_lists (id, name, description, members_json, created_at, updated_at) + VALUES (?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, description=excluded.description, + members_json=excluded.members_json, updated_at=excluded.updated_at`, + args: [list.id, list.name, list.description, JSON.stringify(list.members), list.createdAt, list.updatedAt], + }) + return list + } + + async renameFleetList(nameOrId: string, newName: string): Promise { + const existing = await this.findFleetList(nameOrId) + if (!existing) return null + return this.saveFleetList({ ...existing, name: newName }) + } + + async deleteFleetList(nameOrId: string): Promise { + const result = await this.client.execute({ + sql: 'DELETE FROM fleet_lists WHERE name = ? OR id = ?', + args: [nameOrId, nameOrId], + }) + return result.rowsAffected > 0 + } + // --- fleet runs ---------------------------------------------------------- async createFleetRun(run: Omit & { createdAt?: string }): Promise { @@ -542,6 +596,17 @@ function rowToFleetCommand(row: Row): FleetCommand { }) } +function rowToFleetList(row: Row): FleetList { + return FleetListSchema.parse({ + id: String(row.id), + name: String(row.name), + description: String(row.description), + members: JSON.parse(String(row.members_json)), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }) +} + function rowToFleetRun(row: Row): FleetRun { return FleetRunSchema.parse({ id: String(row.id), diff --git a/packages/fleet-core/package.json b/packages/fleet-core/package.json index 9145028..dc4de3a 100644 --- a/packages/fleet-core/package.json +++ b/packages/fleet-core/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/fleet-core", - "version": "0.2.11", + "version": "0.2.10", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/rsync-core/package.json b/packages/rsync-core/package.json index f95bd83..0df66d5 100644 --- a/packages/rsync-core/package.json +++ b/packages/rsync-core/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/rsync-core", - "version": "0.2.11", + "version": "0.2.10", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/rsync-core/src/args.ts b/packages/rsync-core/src/args.ts index 24cf68e..fe35a5b 100644 --- a/packages/rsync-core/src/args.ts +++ b/packages/rsync-core/src/args.ts @@ -140,18 +140,7 @@ export function buildRsyncArgs(input: BuildArgsInput): BuildArgsResult { if (options.includeFrom) args.push(`--include-from=${options.includeFrom}`) for (const exclude of options.excludes) args.push(`--exclude=${exclude}`) if (options.excludeFrom) args.push(`--exclude-from=${options.excludeFrom}`) - if (options.filesFrom) { - if (options.from0) args.push('--from0') - args.push(`--files-from=${options.filesFrom}`) - /* - * `--files-from` turns recursion OFF, and `--archive` does not turn it - * back on. A selected folder then arrives as an empty directory — verified - * against rsync 3.4.1: with `-a --files-from` only `keep/` is created, - * with `-a -r --files-from` its whole tree comes across. So the flag is - * re-stated explicitly here rather than assumed from `--archive`. - */ - args.push('--recursive') - } + if (options.filesFrom) args.push(`--files-from=${options.filesFrom}`) if (options.pruneEmptyDirs) args.push('--prune-empty-dirs') if (options.relative) args.push('--relative') if (options.maxSize) args.push(`--max-size=${options.maxSize}`) diff --git a/packages/rsync-core/src/index.ts b/packages/rsync-core/src/index.ts index 6d1fc02..fdc6c76 100644 --- a/packages/rsync-core/src/index.ts +++ b/packages/rsync-core/src/index.ts @@ -7,6 +7,5 @@ export * from './presets.js' export * from './raw-args.js' export * from './remote-shell.js' export * from './runner.js' -export * from './selection.js' export * from './shell-quote.js' export * from './version.js' diff --git a/packages/rsync-core/src/selection.test.ts b/packages/rsync-core/src/selection.test.ts deleted file mode 100644 index 44af178..0000000 --- a/packages/rsync-core/src/selection.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { assertSelectable, describeSelection, SelectionError, writeSelectionList } from './selection.js' -import { buildRsyncArgs } from './args.js' -import { defaultRsyncOptions } from '@diskpush/schemas' - -describe('assertSelectable', () => { - it('accepts an ordinary entry name', () => { - expect(() => assertSelectable('one.txt')).not.toThrow() - expect(() => assertSelectable('a folder')).not.toThrow() - expect(() => assertSelectable('nested/deep.txt')).not.toThrow() - }) - - it('refuses anything that climbs out of the source directory', () => { - expect(() => assertSelectable('..')).toThrow(SelectionError) - expect(() => assertSelectable('../etc/passwd')).toThrow(/climb out/) - expect(() => assertSelectable('a/../../b')).toThrow(/climb out/) - }) - - it('refuses an absolute path, which would ignore the source entirely', () => { - expect(() => assertSelectable('/etc/passwd')).toThrow(/relative to the source/) - }) - - it('refuses names that are not names', () => { - expect(() => assertSelectable('')).toThrow(SelectionError) - expect(() => assertSelectable('.')).toThrow(SelectionError) - expect(() => assertSelectable('a//b')).toThrow(SelectionError) - expect(() => assertSelectable('a\0b')).toThrow(/NUL/) - }) -}) - -describe('writeSelectionList', () => { - it('writes a NUL-separated, NUL-terminated list', () => { - const list = writeSelectionList(['one.txt', 'keep']) - try { - expect(readFileSync(list.path, 'utf8')).toBe('one.txt\0keep\0') - } finally { - list.cleanup() - } - }) - - it('can express a name containing a newline, which a line-based list cannot', () => { - // rsync splits such a name across two entries in a newline-separated list - // and fails both halves with "No such file or directory". - const list = writeSelectionList(['new\nline.txt']) - try { - expect(readFileSync(list.path, 'utf8')).toBe('new\nline.txt\0') - } finally { - list.cleanup() - } - }) - - it('removes the list, and does not mind being told twice', () => { - const list = writeSelectionList(['a']) - expect(existsSync(list.path)).toBe(true) - list.cleanup() - expect(existsSync(list.path)).toBe(false) - expect(() => list.cleanup()).not.toThrow() - }) - - it('refuses an empty selection rather than silently sending everything', () => { - expect(() => writeSelectionList([])).toThrow(/Nothing was selected/) - }) - - it('validates every name, not just the first', () => { - expect(() => writeSelectionList(['fine.txt', '../escape'])).toThrow(/climb out/) - }) -}) - -describe('buildRsyncArgs with a selection', () => { - const endpoints = { - source: { type: 'local' as const, path: '/src/' }, - destination: { type: 'local' as const, path: '/dst/' }, - } - - it('restates --recursive, which --files-from turns off', () => { - /* - * Verified against rsync 3.4.1: with `-a --files-from` a selected folder - * arrives as an EMPTY directory, and `--archive` does not save you. Only - * an explicit `-r` brings its contents. Without this the feature looks - * like it works and silently transfers nothing inside a folder. - */ - const built = buildRsyncArgs({ - ...endpoints, - options: { ...defaultRsyncOptions(), filesFrom: '/tmp/list', from0: true }, - }) - expect(built.args).toContain('--recursive') - expect(built.args).toContain('--from0') - expect(built.args).toContain('--files-from=/tmp/list') - }) - - it('does not add --from0 unless asked, so a hand-written list still works', () => { - const built = buildRsyncArgs({ - ...endpoints, - options: { ...defaultRsyncOptions(), filesFrom: '/tmp/list' }, - }) - expect(built.args).not.toContain('--from0') - expect(built.args).toContain('--files-from=/tmp/list') - }) - - it('leaves an ordinary transfer alone', () => { - const built = buildRsyncArgs({ ...endpoints, options: defaultRsyncOptions() }) - expect(built.args).not.toContain('--from0') - expect(built.args.some((arg) => arg.startsWith('--files-from'))).toBe(false) - }) -}) - -describe('describeSelection', () => { - it('names a single entry, and counts the rest', () => { - expect(describeSelection(['one.txt'])).toBe('one.txt') - expect(describeSelection(['a', 'b', 'c'])).toBe('3 selected entries') - }) -}) diff --git a/packages/rsync-core/src/selection.ts b/packages/rsync-core/src/selection.ts deleted file mode 100644 index 96b407f..0000000 --- a/packages/rsync-core/src/selection.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -/** - * Transferring only the entries someone picked, rather than a whole directory. - * - * The selection is a list of names relative to the source directory, and it - * reaches rsync as a `--files-from` list rather than as extra source - * arguments. Both work; the list wins on two counts. It is not bounded by the - * command-line length limit, so selecting four hundred files is the same shape - * as selecting one. And with `--from0` it can express every name a filesystem - * allows, including the ones with a newline in them, which no argument list - * assembled from a newline-separated source can. - * - * Two behaviours of `--files-from` are worth knowing, both verified against - * rsync 3.4.1 rather than taken from the manual: - * - * - It turns recursion **off**, and `--archive` does not turn it back on. A - * selected folder arrives as an empty directory unless `--recursive` is - * restated. `buildRsyncArgs` restates it. - * - `--delete` stays scoped to the listed entries. Mirroring a selection - * removes destination files inside those entries and leaves the rest of the - * destination alone, which is the behaviour you would want and not the one - * you would fear. - */ - -export class SelectionError extends Error { - constructor(message: string) { - super(message) - this.name = 'SelectionError' - } -} - -/** - * Rejects anything that is not a plain entry name inside the source directory. - * - * A selection comes from a file listing, so a name is all it should ever be. - * `..` would reach outside the directory being transferred, and an absolute - * path would ignore it entirely — neither is a thing a selection can mean. - */ -export function assertSelectable(name: string): void { - if (name.length === 0) throw new SelectionError('An empty name cannot be selected.') - if (name.startsWith('/')) throw new SelectionError(`A selection must be relative to the source: ${JSON.stringify(name)}.`) - if (name.includes('\0')) throw new SelectionError('A name cannot contain a NUL byte.') - const segments = name.split('/') - if (segments.some((segment) => segment === '..')) { - throw new SelectionError(`A selection cannot climb out of the source directory: ${JSON.stringify(name)}.`) - } - if (segments.some((segment) => segment === '.' || segment === '')) { - throw new SelectionError(`${JSON.stringify(name)} is not a usable entry name.`) - } -} - -export type SelectionList = { - /** Path to the NUL-separated list, for `--files-from`. */ - path: string - /** Removes the list and its directory. Safe to call more than once. */ - cleanup: () => void -} - -/** - * Writes a selection to a NUL-separated list file in a private temp directory. - * - * The caller removes it with `cleanup()` once rsync has exited — rsync reads - * the list at startup, but deleting it out from under a run that has not begun - * would be a race, so it is not tied to anything cleverer than the caller's - * `finally`. - */ -export function writeSelectionList(names: readonly string[]): SelectionList { - if (names.length === 0) throw new SelectionError('Nothing was selected.') - for (const name of names) assertSelectable(name) - - const directory = mkdtempSync(join(tmpdir(), 'diskpush-selection-')) - const path = join(directory, 'files-from') - // NUL-separated, with a trailing NUL: `--from0` reads it as a list of - // NUL-terminated names. - writeFileSync(path, `${names.join('\0')}\0`, { mode: 0o600 }) - - let removed = false - return { - path, - cleanup: () => { - if (removed) return - removed = true - try { - rmSync(directory, { recursive: true, force: true }) - } catch { - // A temp file that outlives the run is untidy, not a failure of the - // transfer, and the transfer's outcome is what the caller reports. - } - }, - } -} - -/** One line describing the selection, for a preview or a summary. */ -export function describeSelection(names: readonly string[]): string { - if (names.length === 1) return names[0]! - return `${names.length} selected entries` -} diff --git a/packages/schemas/package.json b/packages/schemas/package.json index ba3816e..fe7171c 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/schemas", - "version": "0.2.11", + "version": "0.2.10", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/schemas/src/fleet-list.ts b/packages/schemas/src/fleet-list.ts new file mode 100644 index 0000000..978eb56 --- /dev/null +++ b/packages/schemas/src/fleet-list.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' + +/** + * A saved set of servers. + * + * Tags describe what a server *is*; a list is a set someone assembled by hand + * and wants back. "the four boxes behind the EU load balancer" is not a + * property of any one of them, and re-ticking it every time is the friction + * this removes. + * + * Members are stored by connection id **and** by the name they had when the + * list was saved. The id is what resolves; the name is what makes the list + * readable after a connection is deleted, so a member that has gone away can + * be named rather than silently dropped — the same rule the selector follows, + * where a term matching nothing is an error rather than a smaller fleet. + */ + +export const FleetListMemberSchema = z.object({ + connectionId: z.string().min(1), + /** The name at save time. Refreshed whenever the list is saved again. */ + connectionName: z.string().min(1), +}) +export type FleetListMember = z.infer + +export const FleetListSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + members: z.array(FleetListMemberSchema).default([]), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type FleetList = z.infer + +/** + * How a list is named in a selector: `list:production`. + * + * Prefixed rather than bare, so a list can share a name with a server without + * either shadowing the other. `--on production` is the server; `--on + * list:production` is the list. + */ +export const FLEET_LIST_PREFIX = 'list:' + +export function isListTerm(term: string): boolean { + return term.startsWith(FLEET_LIST_PREFIX) +} + +export function listTermName(term: string): string { + return term.slice(FLEET_LIST_PREFIX.length) +} diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 52552c2..bdf0b2a 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -5,3 +5,4 @@ export * from './profile.js' export * from './job.js' export * from './events.js' export * from './fleet.js' +export * from './fleet-list.js' diff --git a/packages/schemas/src/rsync-options.ts b/packages/schemas/src/rsync-options.ts index f878514..7f6aff6 100644 --- a/packages/schemas/src/rsync-options.ts +++ b/packages/schemas/src/rsync-options.ts @@ -52,15 +52,6 @@ export const RsyncOptionsSchema = z.object({ excludeFrom: z.string().min(1).nullable().default(null), includeFrom: z.string().min(1).nullable().default(null), filesFrom: z.string().min(1).nullable().default(null), - /** - * Read `--files-from` as NUL-separated rather than newline-separated. - * - * A newline is legal in a filename, so a newline-separated list cannot - * express every name a directory can hold — rsync splits such a name in two - * and fails both halves with "No such file or directory". Anything DiskPush - * generates uses this. - */ - from0: z.boolean().default(false), maxSize: z.string().min(1).nullable().default(null), minSize: z.string().min(1).nullable().default(null), pruneEmptyDirs: z.boolean().default(false), diff --git a/packages/ssh-core/package.json b/packages/ssh-core/package.json index 000723b..95e4037 100644 --- a/packages/ssh-core/package.json +++ b/packages/ssh-core/package.json @@ -1,6 +1,6 @@ { "name": "@diskpush/ssh-core", - "version": "0.2.11", + "version": "0.2.10", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts",