Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
25 changes: 15 additions & 10 deletions bin/cli-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -133,9 +139,9 @@ function runLinks(root: string, args: readonly string[]): number {
function installCompanions({ latest = false, quiet = false } = {}): ReturnType<typeof ensureCompanions> {
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,
Expand All @@ -144,15 +150,14 @@ function installCompanions({ latest = false, quiet = false } = {}): ReturnType<t
if (quiet) return results;
for (const entry of results) {
if (entry.action === 'present') continue;
const from = companionSource(entry);
if (entry.action === 'installed') {
process.stderr.write(
`${entry.name}: ${entry.message ? `${entry.package} ${entry.message}` : `installed ${entry.package}`}\n`,
);
process.stderr.write(`${entry.name}: ${entry.message ? `${from} ${entry.message}` : `installed ${from}`}\n`);
continue;
}
process.stderr.write(
`${entry.name}: could not install ${entry.package} — ${entry.message}\n` +
` install it yourself with: npm install -g ${entry.package}\n`,
`${entry.name}: could not install ${from} — ${entry.message}\n` +
` install it yourself with: ${installCommand(entry, { latest }).display}\n`,
);
}
return results;
Expand Down Expand Up @@ -753,7 +758,7 @@ export async function run(argv: readonly string[]): Promise<number> {
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;
Expand Down
78 changes: 65 additions & 13 deletions src/companions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
];

Expand All @@ -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 <pkg>` 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 <pkg>` 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';
Expand Down Expand Up @@ -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[];
},
Expand All @@ -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({
Expand All @@ -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;
Expand Down
Loading
Loading