diff --git a/README.md b/README.md index 8ad297f..8ef26a9 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,14 @@ downloads the release tarball and checks the published sha256, and if any of that fails it warns and moves on rather than failing an install that otherwise worked. Authenticate it once with `stripe login`. -It also installs the **companions** — two commands this set ships but does not -implement, because they are published on npm in their own right: +It also installs the **companions** — commands this set ships but does not +implement, because they are distributed in their own right: | | | | --- | --- | | `timer` | [`@profullstack/timer`](https://github.com/profullstack/timer) — track time against projects, for people and for agents | | `billing` | [`@profullstack/billing`](https://github.com/profullstack/billing) — clients, rates and invoices from the hours the timer tracked | +| `diskpush` | [diskpush.com](https://diskpush.com) — browse servers like FileZilla, transfer with rsync; incremental, resumable, server-to-server | They are not `bin/*.ts` like everything else here for a reason: they run on Windows, which this install cannot (it is symlinks into a git checkout executed @@ -95,9 +96,15 @@ under any agentic CLI, from a Dockerfile, on a box that has never heard of this repository. Vendoring them to make one list tidier would cost them all of that. So `cli-tools` is their front door, not their implementation. -`npm install -g` is idempotent, which is what lets install, re-install and -update be the same command. `CLI_TOOLS_NO_COMPANIONS=1` skips them, and an npm -failure warns rather than failing the install. +The first two come from npm. `diskpush` comes from its own installer, which is +not a lesser arrangement: one command places a desktop application and a CLI +together and decides between them by what the machine can actually run. Here it +is installed with `--cli-only`, because a command-line toolbelt asking for a +server should not be answered with 100MB of Electron. + +Both kinds are idempotent, which is what lets install, re-install and update be +the same command. `CLI_TOOLS_NO_COMPANIONS=1` skips them, and a failure warns +rather than failing the install. With moshcode on the box, the same thing: diff --git a/bin/cli-tools.ts b/bin/cli-tools.ts index 6261032..93236df 100755 --- a/bin/cli-tools.ts +++ b/bin/cli-tools.ts @@ -51,7 +51,13 @@ import { resolveCommand, whichOnPath, } from '../src/registry.ts'; -import { COMPANIONS, ensure as ensureCompanions, statuses as companionStatuses } from '../src/companions.ts'; +import { + COMPANIONS, + ensure as ensureCompanions, + installCommand, + source as companionSource, + statuses as companionStatuses, +} from '../src/companions.ts'; export const USAGE = `Usage: cli-tools list @@ -133,9 +139,9 @@ function runLinks(root: string, args: readonly string[]): number { function installCompanions({ latest = false, quiet = false } = {}): ReturnType { const results = ensureCompanions({ onPath: (name) => whichOnPath(name), - run: (args) => { - const out = spawnSync('npm', args, { encoding: 'utf8' }); - if (out.error) return { status: 1, stderr: `npm is not available: ${out.error.message}` }; + run: ({ command, args }) => { + const out = spawnSync(command, args, { encoding: 'utf8' }); + if (out.error) return { status: 1, stderr: `${command} is not available: ${out.error.message}` }; return { status: out.status, stderr: out.stderr }; }, latest, @@ -144,15 +150,14 @@ function installCompanions({ latest = false, quiet = false } = {}): ReturnType { for (const entry of companionStatuses((name) => whichOnPath(name))) { const mark = entry.state === 'installed' ? '*' : ' '; process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}\n`); - process.stdout.write(`${' '.repeat(19)}${entry.package}\n`); + process.stdout.write(`${' '.repeat(19)}${companionSource(entry)}\n`); } process.stdout.write('\nInstall or update them with `cli-tools companions --install`.\n'); return 0; diff --git a/src/companions.ts b/src/companions.ts index 71ea0b4..0af8091 100644 --- a/src/companions.ts +++ b/src/companions.ts @@ -18,24 +18,52 @@ * what lets install, re-install and update all be the same command. */ +/** + * How a companion gets onto the machine. + * + * `npm` covers anything published to the registry. `script` covers the ones + * distributed as an installer instead, which is not a lesser choice: an + * installer can place a desktop application and a command together, decide + * between them by what the machine can actually run, and needs no Node on the + * box at all. Both are idempotent, which is what lets install, re-install and + * update stay the same command. + */ +export type InstallMethod = + | { kind: 'npm'; package: string } + | { kind: 'script'; url: string; args?: readonly string[] }; + export interface Companion { /** The binary the package puts on PATH. */ name: string; - /** What to hand `npm install -g`. */ - package: string; + install: InstallMethod; summary: string; + /** Where to read about it, for the message printed when installing fails. */ + home: string; } export const COMPANIONS: readonly Companion[] = [ { name: 'timer', - package: '@profullstack/timer', + install: { kind: 'npm', package: '@profullstack/timer' }, summary: 'Track time against projects, for people and for agents', + home: 'https://github.com/profullstack/timer', }, { name: 'billing', - package: '@profullstack/billing', + install: { kind: 'npm', package: '@profullstack/billing' }, summary: 'Clients, rates and invoices from the hours the timer tracked', + home: 'https://github.com/profullstack/billing', + }, + { + name: 'diskpush', + // Not on npm, and not only a Node package: the installer places the + // desktop app too when the machine has a desktop to run it on, and the CLI + // it installs runs on the Node inside that app, so a desktop install needs + // no system Node. `--cli-only` is what makes it a companion here rather + // than a 100MB surprise on a server. + install: { kind: 'script', url: 'https://diskpush.com/install.sh', args: ['--cli-only'] }, + summary: 'Browse servers like FileZilla, transfer with rsync — incremental, resumable, server-to-server', + home: 'https://diskpush.com', }, ]; @@ -44,15 +72,37 @@ export function findCompanion(name: string): Companion | null { return COMPANIONS.find((entry) => entry.name === key) ?? null; } +export interface InstallCommand { + command: string; + args: string[]; + /** How a person would run it, for the message when it fails. */ + display: string; +} + /** - * What `npm install -g` should be handed. + * The command that installs a companion. * - * `@latest` is explicit on an update because a bare `npm install -g ` will - * happily leave an already-satisfied version in place; naming the tag is what - * makes "update" mean it. + * For npm, `@latest` is explicit on an update because a bare + * `npm install -g ` will happily leave an already-satisfied version in + * place; naming the tag is what makes "update" mean it. A script installer is + * already idempotent and upgrades in place, so there is nothing to add. */ -export function installArgs(companion: Companion, { latest = false } = {}): string[] { - return ['install', '-g', latest ? `${companion.package}@latest` : companion.package]; +export function installCommand(companion: Companion, { latest = false } = {}): InstallCommand { + if (companion.install.kind === 'npm') { + const spec = latest ? `${companion.install.package}@latest` : companion.install.package; + return { command: 'npm', args: ['install', '-g', spec], display: `npm install -g ${spec}` }; + } + + const { url, args = [] } = companion.install; + // Piped into sh the same way the project documents it, so this and a manual + // install take the same path and cannot drift apart. + const line = args.length > 0 ? `curl -fsSL ${url} | sh -s -- ${args.join(' ')}` : `curl -fsSL ${url} | sh`; + return { command: 'sh', args: ['-c', line], display: line }; +} + +/** The package or url a companion comes from, for display. */ +export function source(companion: Companion): string { + return companion.install.kind === 'npm' ? companion.install.package : companion.install.url; } export type CompanionState = 'installed' | 'missing'; @@ -107,7 +157,7 @@ export function ensure( list = COMPANIONS, }: { onPath: (name: string) => string | null; - run: (args: string[]) => { status: number | null; stderr?: string }; + run: (command: InstallCommand) => { status: number | null; stderr?: string }; latest?: boolean; list?: readonly Companion[]; }, @@ -119,7 +169,7 @@ export function ensure( results.push({ ...companion, state: 'installed', path: found, action: 'present' }); continue; } - const outcome = run(installArgs(companion, { latest })); + const outcome = run(installCommand(companion, { latest })); if (outcome.status === 0) { const after = onPath(companion.name); results.push({ @@ -142,7 +192,9 @@ export function ensure( state: found ? 'installed' : 'missing', path: found, action: 'failed', - message: (outcome.stderr ?? '').trim().split('\n').at(-1) || `npm exited ${outcome.status}`, + message: + (outcome.stderr ?? '').trim().split('\n').at(-1) || + `${installCommand(companion, { latest }).command} exited ${outcome.status}`, }); } return results; diff --git a/test/companions.test.ts b/test/companions.test.ts index 879a464..16e350a 100644 --- a/test/companions.test.ts +++ b/test/companions.test.ts @@ -4,7 +4,8 @@ import { COMPANIONS, ensure, findCompanion, - installArgs, + installCommand, + source, statuses, type Companion, } from '../src/companions.ts'; @@ -14,48 +15,85 @@ const present = (...names: string[]) => (name: string) => const nothing = () => null; describe('the companion list', () => { - it('names the timer and billing packages', () => { - expect(COMPANIONS.map((c) => c.name)).toEqual(['timer', 'billing']); - expect(COMPANIONS.map((c) => c.package)).toEqual([ - '@profullstack/timer', - '@profullstack/billing', - ]); + it('names the commands that come from elsewhere', () => { + expect(COMPANIONS.map((c) => c.name)).toEqual(['timer', 'billing', 'diskpush']); }); - it('gives every companion a scoped package and a summary', () => { + it('gives every companion a summary and somewhere to read about it', () => { for (const companion of COMPANIONS) { - expect(companion.package.startsWith('@profullstack/'), companion.name).toBe(true); expect(companion.summary.length, companion.name).toBeGreaterThan(0); + expect(companion.home.startsWith('https://'), companion.name).toBe(true); + } + }); + + it('names an npm companion by a scoped package matching its binary', () => { + for (const companion of COMPANIONS) { + if (companion.install.kind !== 'npm') continue; + expect(companion.install.package.startsWith('@profullstack/'), companion.name).toBe(true); // The binary name is not derivable from the package name in general, so - // it is stated; this holds it to the one case we actually ship. - expect(companion.package.endsWith(`/${companion.name}`), companion.name).toBe(true); + // it is stated; this holds it to the cases we actually ship. + expect(companion.install.package.endsWith(`/${companion.name}`), companion.name).toBe(true); + } + }); + + it('gives a script companion an https installer', () => { + for (const companion of COMPANIONS) { + if (companion.install.kind !== 'script') continue; + expect(companion.install.url.startsWith('https://'), companion.name).toBe(true); } }); it('resolves a companion by name, case-insensitively', () => { - expect(findCompanion('timer')?.package).toBe('@profullstack/timer'); - expect(findCompanion('BILLING')?.package).toBe('@profullstack/billing'); + expect(source(findCompanion('timer')!)).toBe('@profullstack/timer'); + expect(source(findCompanion('BILLING')!)).toBe('@profullstack/billing'); + expect(source(findCompanion('DiskPush')!)).toBe('https://diskpush.com/install.sh'); expect(findCompanion('nonsense')).toBeNull(); expect(findCompanion('')).toBeNull(); }); }); -describe('installArgs', () => { - const timer = COMPANIONS[0] as Companion; +describe('installCommand', () => { + const timer = findCompanion('timer') as Companion; + const diskpush = findCompanion('diskpush') as Companion; - it('installs the package globally', () => { - expect(installArgs(timer)).toEqual(['install', '-g', '@profullstack/timer']); + it('installs an npm companion globally', () => { + expect(installCommand(timer)).toMatchObject({ + command: 'npm', + args: ['install', '-g', '@profullstack/timer'], + }); }); it('names @latest on an update, because a bare install would be a no-op', () => { // `npm install -g ` leaves an already-satisfied version alone, so // without the tag `cli-tools update` would silently never move them. - expect(installArgs(timer, { latest: true })).toEqual([ + expect(installCommand(timer, { latest: true }).args).toEqual([ 'install', '-g', '@profullstack/timer@latest', ]); }); + + it('pipes a script companion into sh, the way its project documents it', () => { + const command = installCommand(diskpush); + expect(command.command).toBe('sh'); + expect(command.args[0]).toBe('-c'); + expect(command.args[1]).toBe('curl -fsSL https://diskpush.com/install.sh | sh -s -- --cli-only'); + }); + + it('installs the CLI only, so a server does not get a desktop app', () => { + // The installer would otherwise place ~100MB of Electron wherever it finds + // a desktop session, which is not what a command-line toolbelt asked for. + expect(installCommand(diskpush).display).toContain('--cli-only'); + }); + + it('adds nothing for a script companion on update: its installer upgrades in place', () => { + expect(installCommand(diskpush, { latest: true })).toEqual(installCommand(diskpush)); + }); + + it('shows the command a person would run', () => { + expect(installCommand(timer).display).toBe('npm install -g @profullstack/timer'); + expect(installCommand(diskpush).display.startsWith('curl -fsSL ')).toBe(true); + }); }); describe('statuses', () => { @@ -64,6 +102,7 @@ describe('statuses', () => { expect(rows.map((r) => [r.name, r.state])).toEqual([ ['timer', 'installed'], ['billing', 'missing'], + ['diskpush', 'missing'], ]); expect(rows[0]?.path).toBe('/usr/local/bin/timer'); expect(rows[1]?.path).toBeNull(); @@ -74,11 +113,11 @@ describe('ensure', () => { it('leaves an installed companion alone', () => { // It may be a newer version, a local build, or a fork somebody is testing. // Reinstalling over it is the surprise `link` refuses for symlinks. - const calls: string[][] = []; + const calls: string[] = []; const results = ensure({ - onPath: present('timer', 'billing'), - run: (args) => { - calls.push(args); + onPath: present('timer', 'billing', 'diskpush'), + run: ({ display }) => { + calls.push(display); return { status: 0 }; }, }); @@ -87,47 +126,49 @@ describe('ensure', () => { }); it('installs only what is missing', () => { - const calls: string[][] = []; - const installed = new Set(['timer']); + const calls: string[] = []; + const installed = new Set(['timer', 'diskpush']); ensure({ onPath: (name) => (installed.has(name) ? `/usr/local/bin/${name}` : null), - run: (args) => { - calls.push(args); + run: ({ display }) => { + calls.push(display); installed.add('billing'); return { status: 0 }; }, }); - expect(calls).toEqual([['install', '-g', '@profullstack/billing']]); + expect(calls).toEqual(['npm install -g @profullstack/billing']); }); it('reinstalls everything at @latest when asked', () => { - const calls: string[][] = []; + const calls: string[] = []; ensure({ - onPath: present('timer', 'billing'), - run: (args) => { - calls.push(args); + onPath: present('timer', 'billing', 'diskpush'), + run: ({ display }) => { + calls.push(display); return { status: 0 }; }, latest: true, }); expect(calls).toEqual([ - ['install', '-g', '@profullstack/timer@latest'], - ['install', '-g', '@profullstack/billing@latest'], + 'npm install -g @profullstack/timer@latest', + 'npm install -g @profullstack/billing@latest', + // A script installer upgrades in place, so there is no @latest to add. + 'curl -fsSL https://diskpush.com/install.sh | sh -s -- --cli-only', ]); }); it('keeps going after a failure, and says which package and why', () => { // npm fails for ordinary reasons — no npm, a read-only prefix, no network — // and none of them are a reason for the rest of `cli-tools link` to stop. - const attempted: string[][] = []; + const attempted: string[] = []; const results = ensure({ onPath: nothing, - run: (args) => { - attempted.push(args); + run: ({ display }) => { + attempted.push(display); return { status: 1, stderr: 'npm ERR! code EACCES\nnpm ERR! permission denied' }; }, }); - expect(attempted).toHaveLength(2); + expect(attempted).toHaveLength(COMPANIONS.length); expect(results.every((r) => r.action === 'failed')).toBe(true); expect(results[0]?.message).toBe('npm ERR! permission denied'); expect(results[0]?.state).toBe('missing');