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
61 changes: 56 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ One thing here is not a `PATH` command and does not need Node:
[torlnk](https://www.npmjs.com/package/torlnk) running
- **ImageMagick** (`magick`) — `img` only, and only for what sharp cannot do
(PDF, PSD, animated GIF); sharp ships with this repo as an optional dependency
- **`unzip` or `bsdtar`** — the `adb` companion only, and only while installing
it: the archive Google publishes is a zip, and Node cannot read one
- **Network on first use** — `favicon` only: the generation is
[`@profullstack/favicon-generator`](https://github.com/profullstack/favicon-generator),
fetched by `npx` rather than installed here (about seven seconds the first
Expand Down Expand Up @@ -99,16 +101,27 @@ implement, because they are distributed in their own right:
| `devdb` | [terrablue/devdb](https://github.com/terrablue/devdb) — spin up a throwaway local database for development or testing |
| `kali` | [`@profullstack/kali`](https://github.com/profullstack/kali) — install a Kali-style web pentesting toolbelt on Debian/Ubuntu |

And one group that is installed only when you ask for it, with
`cli-tools companions --install mobile`:

| | |
| --- | --- |
| `adb` | [platform-tools](https://developer.android.com/tools/adb) — talk to Android devices and emulators: install, log, shell, port-forward. Brings `fastboot` |
| `expo` | [Expo CLI](https://docs.expo.dev/more/expo-cli/) — create and run Expo apps, with no project to hand |
| `eas` | [EAS CLI](https://docs.expo.dev/eas/) — build, sign and submit iOS and Android apps in the cloud, and ship OTA updates |

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 —
through an `npx tsx` shebang — `adb` is the one exception, and says so below),
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.

Four come from npm. `bw` is the only companion that is nobody's but its
vendor's; it earns the place on the same terms as the rest, and covers the
secrets `cli-tools` deliberately does not — the `vault` helpers read a logicsrc
Six come from npm. `bw` was the first companion that is nobody's but its
vendor's — `adb`, `expo` and `eas` are the others — and it earns the place on
the same terms as the rest: published, self-installing, useful on a box with no
checkout. It covers the secrets `cli-tools` deliberately does not — the `vault` helpers read a logicsrc
team vault of shared API keys, which is a different thing from one person's
passwords. Note that its binary is `bw` while its package is `@bitwarden/cli`:
the two need not match, which is why the binary name is stated rather than
Expand Down Expand Up @@ -144,7 +157,45 @@ tool, so there is no need to run Kali itself. The tools are dual-use, which is
the point of keeping it a front door: it equips a box you are authorized to
test, and nothing about a target lives in this repository.

All three kinds are idempotent, which is what lets install, re-install and update be
`adb` is the odd one, and the reason there is a fourth kind of install. It is
not on npm — what is published under those names is either a Node
reimplementation of the wire protocol or somebody's mirror of Google's zip, and
a debugging bridge that gets a root shell on every attached device is the last
thing to take from a mirror. Google publishes no installer either, only
`platform-tools-latest-<os>.zip`. So `scripts/install-archive.ts` fetches it,
unpacks it under `vendor/platform-tools` and links `adb` and `fastboot` — the
same arrangement `install.sh` already has with the Stripe CLI, moved somewhere
`cli-tools update` can reach. An `adb` already on PATH, apt's or an Android
Studio SDK's, is left alone rather than shadowed; `--force` is how you say
otherwise, and it names what it is now competing with. There is no checksum to
check, which is worth saying rather than quietly skipping: Google publishes none
beside that alias, so the guarantee is TLS to dl.google.com and the sha256 of
what landed is printed. It needs `unzip` (or `bsdtar`) on the box, and there is
no Windows entry on purpose — this install is symlinks into a vendor directory,
which is not how a command reaches PATH there. Use Android Studio's SDK Manager.

`expo` and `eas` are why groups exist at all. The Expo CLI has no package of its
own: it ships inside `expo`, and Expo's own advice is `npx expo` from inside a
project so the CLI matches that project's SDK — this global copy is for the
other half, creating an app before a project exists. `eas` is the half that is
meant to be global, since builds, signing, store submission and OTA updates all
happen on their infrastructure; like `myna` it is a front door and `eas login`
does the credential handling. Between them they are about half a gigabyte, which
a web server has no use for, so they are installed on request rather than by
`link` — the same judgement `diskpush --cli-only` gets. `cli-tools update`
follows the same rule: it updates the mobile commands you have and never adopts
the ones you do not.

Deliberately not here, for native mobile work: `scrcpy` (mirror and control an
Android screen) is a distro package — `apt install scrcpy` or `snap install
scrcpy`; the Android `cmdline-tools` that carry `sdkmanager`, `avdmanager` and
the emulator want a JDK and a licence-acceptance flow, which is Android
Studio's job; and [Maestro](https://maestro.mobile.dev)'s installer is a bash
script that appends to your shell rc files and needs Java, which is not a thing
this should do to a box on your behalf — `curl -Ls https://get.maestro.mobile.dev
| bash` if you want it.

All four 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.

Expand Down
84 changes: 73 additions & 11 deletions bin/cli-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,15 @@ import {
} from '../src/registry.ts';
import {
COMPANIONS,
core as coreCompanions,
ensure as ensureCompanions,
forUpdate as companionsToUpdate,
groups as companionGroups,
installCommand,
select as selectCompanions,
source as companionSource,
statuses as companionStatuses,
type Companion,
} from '../src/companions.ts';

export const USAGE = `Usage:
Expand All @@ -66,7 +71,7 @@ export const USAGE = `Usage:
cli-tools autoupdate [--install [--hours N] | --remove]
cli-tools link [--force]
cli-tools unlink
cli-tools companions [--install [--force]]
cli-tools companions [<name|group>…] [--install [--force]]
cli-tools aliases [--install]
cli-tools config [pull | set <key> [value] | unset <key>]
cli-tools <command> [args…]
Expand All @@ -79,8 +84,11 @@ Commands:
autoupdate A systemd user timer that runs "update --auto" for you
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
companions The commands that come from elsewhere rather than this checkout
"--install" installs the missing ones, "--force" updates them all
Name a companion or a group to reach past the default set:
"companions --install mobile" adds adb, expo and eas; "all" adds
every one of them
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 @@ -137,7 +145,13 @@ function runLinks(root: string, args: readonly string[]): number {
* 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> {
function installCompanions(
{ latest = false, quiet = false, list = coreCompanions() }: {
latest?: boolean;
quiet?: boolean;
list?: readonly Companion[];
} = {},
): ReturnType<typeof ensureCompanions> {
const results = ensureCompanions({
onPath: (name) => whichOnPath(name),
run: ({ command, args }) => {
Expand All @@ -146,6 +160,7 @@ function installCompanions({ latest = false, quiet = false } = {}): ReturnType<t
return { status: out.status, stderr: out.stderr };
},
latest,
list,
});

if (quiet) return results;
Expand All @@ -164,6 +179,27 @@ function installCompanions({ latest = false, quiet = false } = {}): ReturnType<t
return results;
}

/**
* One line about a set this box has not asked for.
*
* Printed rather than acted on. A group exists because installing it by
* default would be a surprise -- the mobile one is half a gigabyte of Expo --
* and a toolbelt that then nags about it every run has only moved the
* surprise. So: named once, where someone is already reading companion output.
*/
function groupHint(): string {
const lines: string[] = [];
for (const group of companionGroups()) {
const members = COMPANIONS.filter((entry) => entry.group === group);
if (members.every((entry) => whichOnPath(entry.name))) continue;
lines.push(
`${group}: ${members.map((entry) => entry.name).join(', ')} — ` +
`\`cli-tools companions --install ${group}\``,
);
}
return lines.join('\n');
}

/** 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 @@ -190,7 +226,11 @@ function update(root: string): number {
// 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 });
//
// Only what this box actually has, though: update means update, not adopt.
// Someone who never asked for the mobile set should not find `eas` on their
// machine because they ran `cli-tools update`.
installCompanions({ latest: true, list: companionsToUpdate((name) => whichOnPath(name)) });
return linked;
}

Expand Down Expand Up @@ -625,15 +665,20 @@ 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');
process.stdout.write('\nCompanions:\n');
for (const entry of companions) {
const mark = entry.state === 'installed' ? '*' : ' ';
process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}\n`);
const tag = entry.group ? ` [${entry.group}]` : '';
process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}${tag}\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');
// Only the default set counts as missing. A grouped companion nobody
// asked for is not a gap in the install, and `--install` would not
// install it anyway -- saying "3 not installed" and then not installing
// them is the kind of lie a status line only gets to tell once.
const absent = companions.filter((entry) => entry.state === 'missing' && !entry.group);

process.stdout.write('\n');
if (absent.length > 0) {
Expand Down Expand Up @@ -681,6 +726,8 @@ export async function run(argv: readonly string[]): Promise<number> {
case 'link': {
const linked = runLinks(root, options.flags.has('--force') ? ['--force'] : []);
installCompanions();
const hint = groupHint();
if (hint) process.stdout.write(`\nInstalled only when asked for:\n ${hint}\n`);
return linked;
}

Expand All @@ -692,24 +739,39 @@ export async function run(argv: readonly string[]): Promise<number> {
return runLinks(root, ['--remove']);

case 'companions': {
// Positional, so `cli-tools companions --install mobile` reads as the
// sentence it is. Nothing named is the default set, which is what the
// installer and `link` ask for.
const { list: selected, unknown } = selectCompanions(options.positional);
if (unknown.length > 0) {
process.stderr.write(
`companions: no companion or group called ${unknown.join(', ')}.\n` +
` groups: ${companionGroups().join(', ')}, or "all"\n`,
);
return 1;
}

if (options.flags.has('--json')) {
const rows = options.flags.has('--install')
? installCompanions({ latest: options.flags.has('--force'), quiet: true })
? installCompanions({ latest: options.flags.has('--force'), quiet: true, list: selected })
: 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') });
installCompanions({ latest: options.flags.has('--force'), list: selected });
return 0;
}
process.stdout.write('Published separately, installed from npm:\n\n');
process.stdout.write('Published separately, installed alongside the commands here:\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`);
const tag = entry.group ? ` [${entry.group}]` : '';
process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}${tag}\n`);
process.stdout.write(`${' '.repeat(19)}${companionSource(entry)}\n`);
}
process.stdout.write('\nInstall or update them with `cli-tools companions --install`.\n');
const hint = groupHint();
if (hint) process.stdout.write(`Installed only when asked for:\n ${hint}\n`);
return 0;
}

Expand Down
11 changes: 7 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ 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.
# Commands this set ships but does not implement: published 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
Expand All @@ -118,7 +118,10 @@ CLI_TOOLS_PREFIX="$PREFIX" node "$HOME_DIR/scripts/install-links.mjs" $LINK_ARGS
# 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)"
say "Installing companions (timer, billing, bw, …)"
# The default set only. The grouped ones -- `mobile`, which is adb plus half
# a gigabyte of Expo -- are installed when somebody asks for them and not
# because they ran an installer. `cli-tools companions` names them.
"$HOME_DIR/bin/cli-tools.ts" companions --install ||
printf 'cli-tools: companions skipped. Install them later with: cli-tools companions --install\n' >&2
fi
Expand Down
Loading
Loading