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
661 changes: 661 additions & 0 deletions cli/LICENSE

Large diffs are not rendered by default.

45 changes: 37 additions & 8 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,53 @@ the command-line surface.

## Run it

From the repo root:
From the repo root, straight from TypeScript source (useful while developing):

```sh
node --import tsx cli/src/main.ts install <module-slug>
node --import tsx cli/src/main.ts verify <tarball> --manifest <path> --signature <path>
```

Or via the package's own bin, once its dependencies are installed:
Or build the package and run its own bin, which is what a real install runs:

```sh
./cli/bin/helpthread-module.js install <module-slug>
npm run build -w cli
./cli/dist/main.js install <module-slug>
```

This package has no build step yet: `bin/helpthread-module.js` runs
`src/main.ts` straight from TypeScript source via a `node --import tsx`
shebang. A published, globally-installed CLI would need a bundling step
(`tsup`/`esbuild`) so `tsx` isn't a runtime dependency of every install β€”
out of scope for HT-116.
Once published, an operator installs `@helpthread/module` but still runs
the `helpthread-module` command:

```sh
npm install -g @helpthread/module
helpthread-module install <module-slug>

# or without a global install:
npx @helpthread/module install <module-slug>
```

## Building and packing

`npm run build -w cli` bundles `cli/src/main.ts` β€” and everything it
imports, including the shared verifier at `src/modules/artifact/**` β€” into
a single, package-contained `cli/dist/main.js` via esbuild (see
`cli/scripts/build.mjs`). That bundle is what `bin.helpthread-module`
points at, and it runs on plain Node with no dev tooling: no `tsx`, no
TypeScript, no repo-relative imports. `files` in `package.json` limits a
pack/publish to exactly `dist/`, `README.md`, and `LICENSE`.

`prepack`/`prepublishOnly` both run the build, so a stale bundle can never
ship. This package is still `"private": true` β€” nothing here publishes it,
it only makes publishing possible.

The package name is the scoped `@helpthread/module` (npm org: `helpthread`);
the command it installs stays the unscoped `helpthread-module` β€” package
name and bin name are independent, so `npm i -g @helpthread/module` still
gives you a `helpthread-module` command, and `npx @helpthread/module install
<slug>` works without a global install. A scoped package defaults to
*restricted* on npm, which would silently fail (or silently go private on a
paid account) on a first publish; `publishConfig.access: "public"` in
`package.json` opts it into a public publish instead.

## `install`'s download contract

Expand Down
4 changes: 0 additions & 4 deletions cli/bin/helpthread-module.js

This file was deleted.

21 changes: 17 additions & 4 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
{
"name": "helpthread-module",
"name": "@helpthread/module",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "AGPL-3.0-only",
"description": "Install and offline-verify Helpthread module release artifacts.",
"bin": {
"helpthread-module": "./bin/helpthread-module.js"
"helpthread-module": "./dist/main.js"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20"
},
"dependencies": {
"tsx": "^4.23.1"
"scripts": {
"build": "node scripts/build.mjs",
"prepack": "npm run build",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"esbuild": "0.28.1"
}
}
55 changes: 55 additions & 0 deletions cli/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env node
import { chmodSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
// Bundles `cli/src/main.ts` β€” and everything it imports, including the
// shared verifier at `src/modules/artifact/**` β€” into a single
// package-contained `cli/dist/main.js` (HT-121).
//
// This is what makes the package installable outside the monorepo: the
// source imports the verifier by repo-relative path (`../../src/modules/
// artifact/index.js`), which only resolves inside this checkout. Bundling
// copies those bytes into the artifact at publish time, so a published
// tarball containing only `cli/**` is self-contained. The CLI's compiled-in
// trust store (`cli/src/trust-store.ts`) travels the same way β€” see the
// comment there, and the test at `tests/cli/build.test.ts`, for why copying
// bytes here is safe while forking the verifier's *source* would not be.
//
// Run via `npm run build` (from `cli/`) or `npm run build -w cli` (from the
// repo root); `prepack`/`prepublishOnly` call this automatically so the
// published bundle can never go stale relative to source.
import { build } from 'esbuild'

const cliRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const outfile = join(cliRoot, 'dist', 'main.js')

// Wipe `dist/` before rebuilding rather than overwriting `main.js` in
// place. `files` in package.json admits the whole directory, so anything
// that ever lands here β€” a stale bundle from an older entry point, a
// sourcemap from a debugging session, a file dropped by hand β€” would be
// published silently. `npm publish` does not run the test suite, so the
// allowlist test cannot catch it either. Starting from an empty directory
// makes the published contents a function of this script alone.
rmSync(dirname(outfile), { recursive: true, force: true })
mkdirSync(dirname(outfile), { recursive: true })

await build({
entryPoints: [join(cliRoot, 'src', 'main.ts')],
outfile,
bundle: true,
platform: 'node',
target: 'node20',
format: 'esm',
// Readable output is a trust property here, not a nicety: this tool's
// whole job is verifying signed artifacts, and an operator (or a
// reviewer of a fork) auditing what they installed should be able to
// read the bundle directly rather than take minified output on faith.
minify: false,
sourcemap: false,
banner: { js: '#!/usr/bin/env node' },
logLevel: 'info',
})

// The bin entry (`cli/package.json`'s `bin.helpthread-module`) points
// straight at this file β€” npm requires the target to be executable.
chmodSync(outfile, 0o755)
6 changes: 4 additions & 2 deletions cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/usr/bin/env node
/**
* `helpthread-module` CLI entrypoint (HT-116). Dispatches to `install` and
* `verify`; everything else lives in the other files in this directory so
* it can be unit-tested without a process/TTY.
* it can be unit-tested without a process/TTY. No shebang here β€” the built
* `cli/dist/main.js` (HT-121, `cli/scripts/build.mjs`) gets one from
* esbuild's `banner` option, which is the only copy that actually needs to
* be executable.
*/
import { ArgsError, INSTALL_HELP, parseInstallArgs, parseVerifyArgs, VERIFY_HELP } from './args.js'
import { InstallError, runInstall } from './install.js'
Expand Down
Loading