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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,22 +70,50 @@ 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:

| | |
| --- | --- |
| `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 |

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
through an `npx tsx` shebang), and they are useful with no checkout at all —
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.

With moshcode on the box, the same thing:

```sh
moshcode install cli-tools # then /cli-tools … in the pit
```

That is now the one-liner that also gets you `/timer` and `/billing` in the
pit: moshcode hands both straight to these CLIs once they are installed.

Check what landed, and wire up the pit aliases:

```sh
cli-tools list # * runs from here, ! is shadowed by another copy
cli-tools companions # the two from npm, and whether they are on PATH
cli-tools companions --install # install the missing ones (--force updates all)
cli-tools aliases --install # /aff /blog /free /merge /prs /speak /web /whois
cli-tools config # API keys: what is set, and where it came from
cli-tools update # git pull, reinstall, relink
cli-tools update # git pull, reinstall, relink, update companions
cli-tools autoupdate --install # …or have a timer do that daily
```

`cli-tools unlink` deliberately leaves the companions installed: they are
ordinary global npm packages that work without this checkout, so unlinking the
repository is no reason to take them off the machine.

### Keeping it current

`cli-tools autoupdate --install` writes a systemd **user** timer that runs
Expand Down
124 changes: 114 additions & 10 deletions bin/cli-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,17 @@ import {
PIT_ALIASES,
repoRoot,
resolveCommand,
whichOnPath,
} from '../src/registry.ts';
import { COMPANIONS, ensure as ensureCompanions, statuses as companionStatuses } from '../src/companions.ts';

const USAGE = `Usage:
export const USAGE = `Usage:
cli-tools list
cli-tools update [--auto]
cli-tools autoupdate [--install [--hours N] | --remove]
cli-tools link [--force]
cli-tools unlink
cli-tools companions [--install [--force]]
cli-tools aliases [--install]
cli-tools config [pull | set <key> [value] | unset <key>]
cli-tools <command> [args…]
Expand All @@ -67,8 +70,10 @@ Commands:
"--auto" is the unattended form: at most once a day, and only on a
clean checkout of the default branch with nothing unpushed
autoupdate A systemd user timer that runs "update --auto" for you
link Symlink the commands into ~/.local/bin
unlink Remove the symlinks we own
link Symlink the commands into ~/.local/bin, and install the companions
unlink Remove the symlinks we own (companions are left installed)
companions The commands that come from npm rather than this checkout
"--install" installs the missing ones, "--force" updates them all
aliases Print the moshcode pit aliases, or write them with --install
config API keys: what is set, where it came from, and how to change it
"config pull" imports them from the logicsrc team vault
Expand Down Expand Up @@ -98,11 +103,61 @@ const SPEC = {
string: ['--hours'],
} as const;

/**
* The dispatcher's own verbs. Anything else is one of the commands.
*
* Exported so a test can hold it against USAGE. This list going stale is a real
* failure mode with a quiet symptom: a verb documented in the usage text but
* missing here falls through to the passthrough and answers "unknown command"
* while the help says it exists. That is exactly what happened to `help`, and
* then again to `companions`.
*/
export const KNOWN_VERBS = new Set([
'help', 'list', 'update', 'autoupdate', 'link', 'unlink', 'companions', 'aliases', 'config',
'where',
]);

function runLinks(root: string, args: readonly string[]): number {
const script = join(root, 'scripts', 'install-links.mjs');
return spawnSync(process.execPath, [script, ...args], { cwd: root, stdio: 'inherit' }).status ?? 1;
}

/**
* Install the npm-backed companions, and say what happened to each.
*
* Never fails the caller. `npm install -g` fails for ordinary reasons — no npm
* on the box, a read-only prefix, no network — and none of them are a reason
* for `cli-tools link` to report that the linking did not happen. The line is
* printed to stderr so it stays out of anything reading stdout.
*/
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}` };
return { status: out.status, stderr: out.stderr };
},
latest,
});

if (quiet) return results;
for (const entry of results) {
if (entry.action === 'present') continue;
if (entry.action === 'installed') {
process.stderr.write(
`${entry.name}: ${entry.message ? `${entry.package} ${entry.message}` : `installed ${entry.package}`}\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`,
);
}
return results;
}

/** Pull and relink. Dependencies come first so a new one is present before use. */
function update(root: string): number {
const git = spawnSync('git', ['pull', '--ff-only'], { cwd: root, stdio: 'inherit' });
Expand All @@ -125,7 +180,12 @@ function update(root: string): number {
return pnpm.status ?? 1;
}

return runLinks(root, []);
const linked = runLinks(root, []);
// After the links, so a failed npm never hides a failed relink. `--latest`
// here is what makes `update` mean update for the companions too: a bare
// `npm install -g <pkg>` leaves an already-satisfied version in place.
installCompanions({ latest: true });
return linked;
}

/** Where the last automatic check is remembered. */
Expand Down Expand Up @@ -549,9 +609,7 @@ export async function run(argv: readonly string[]): Promise<number> {
// `help` is here because it is what people type. Without it the word fell
// through to the passthrough below, which reported "unknown command: help"
// and exited 1 — before printing the usage that answers the question.
const known = new Set([
'help', 'list', 'update', 'autoupdate', 'link', 'unlink', 'aliases', 'config', 'where',
]);
const known = KNOWN_VERBS;
if (!known.has(command)) {
const match = commands(root).find((entry) => entry.name === command);
if (!match) {
Expand Down Expand Up @@ -591,8 +649,10 @@ export async function run(argv: readonly string[]): Promise<number> {
...resolveCommand(entry.name, binDir),
}));

const companions = companionStatuses((name) => whichOnPath(name));

if (options.flags.has('--json')) {
process.stdout.write(`${JSON.stringify({ root, commands: all }, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ root, commands: all, companions }, null, 2)}\n`);
return 0;
}

Expand All @@ -608,10 +668,25 @@ export async function run(argv: readonly string[]): Promise<number> {
}
}

// A separate block, because they are a different kind of thing: these
// come from npm and run with no checkout, so the *-ours / !-shadowed
// marks above would be answering a question that does not apply.
process.stdout.write('\nFrom npm:\n');
for (const entry of companions) {
const mark = entry.state === 'installed' ? '*' : ' ';
process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}\n`);
}

const other = all.filter((entry) => entry.status === 'other');
const missing = all.filter((entry) => entry.status === 'missing');
const absent = companions.filter((entry) => entry.state === 'missing');

process.stdout.write('\n');
if (absent.length > 0) {
process.stdout.write(
`${absent.length} companion${absent.length === 1 ? '' : 's'} not installed — run \`cli-tools companions --install\`.\n`,
);
}
if (other.length === 0 && missing.length === 0) {
process.stdout.write('All running from this checkout.\n');
return 0;
Expand Down Expand Up @@ -649,12 +724,41 @@ export async function run(argv: readonly string[]): Promise<number> {
integer(options.values, '--hours', 24, { min: 1, max: 24 * 30 }),
);

case 'link':
return runLinks(root, options.flags.has('--force') ? ['--force'] : []);
case 'link': {
const linked = runLinks(root, options.flags.has('--force') ? ['--force'] : []);
installCompanions();
return linked;
}

case 'unlink':
// Deliberately not uninstalling the companions. They are ordinary global
// npm packages that work with no checkout at all, so unlinking this
// repository is no reason to take them off the machine — and `npm rm -g`
// is not a decision to make on somebody's behalf.
return runLinks(root, ['--remove']);

case 'companions': {
if (options.flags.has('--json')) {
const rows = options.flags.has('--install')
? installCompanions({ latest: options.flags.has('--force'), quiet: true })
: companionStatuses((name) => whichOnPath(name));
process.stdout.write(`${JSON.stringify({ companions: rows }, null, 2)}\n`);
return 0;
}
if (options.flags.has('--install')) {
installCompanions({ latest: options.flags.has('--force') });
return 0;
}
process.stdout.write('Published separately, installed from npm:\n\n');
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('\nInstall or update them with `cli-tools companions --install`.\n');
return 0;
}

case 'aliases': {
if (options.flags.has('--install')) return writeAliases();
if (options.flags.has('--json')) {
Expand Down
20 changes: 20 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ LINK_ARGS=""
# shellcheck disable=SC2086
CLI_TOOLS_PREFIX="$PREFIX" node "$HOME_DIR/scripts/install-links.mjs" $LINK_ARGS

# ── Companions ───────────────────────────────────────────────────────────────
#
# Commands this set ships but does not implement: published npm packages that
# bring their own binary. The list lives in src/companions.ts and is read from
# there rather than repeated here, so adding one is a single-file change.
#
# Run through the checkout's own dispatcher rather than $PREFIX/cli-tools: the
# link above is refused when another checkout already owns that name, and this
# should still work on such a box.
#
# Warns rather than dying, like the Stripe block below. npm being absent or a
# prefix being read-only should not fail an install that has otherwise
# succeeded — and CLI_TOOLS_NO_COMPANIONS=1 skips it entirely for anyone who
# would rather manage those packages themselves.
if [ "${CLI_TOOLS_NO_COMPANIONS:-0}" != "1" ]; then
say "Installing npm companions (timer, billing)"
"$HOME_DIR/bin/cli-tools.ts" companions --install ||
printf 'cli-tools: companions skipped. Install them later with: cli-tools companions --install\n' >&2
fi

# ── Stripe CLI ───────────────────────────────────────────────────────────────
#
# Not one of this repo's commands: it is the official binary from
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.11.0",
"version": "0.12.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
Loading
Loading