@amritk/nish 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The `nish` command: run the native compiler this machine installed, or say
3
+ * why there is none and stop.
4
+ *
5
+ * This is what `bin.nish` points at, and it exists because npm cannot point a
6
+ * `bin` entry at a dependency that may or may not be installed. The native
7
+ * compiler ships as `@amritk/nish-<asset>` packages declared as
8
+ * `optionalDependencies` with `os` and `cpu` set, so npm installs exactly the
9
+ * one that matches and silently skips the rest (docs/wp12-release.md,
10
+ * "Which compiler the package ships"). Something still has to look at what
11
+ * landed and hand over to it, and that is this file.
12
+ *
13
+ * **Nothing is ever compiled on a user's machine.** The binary is built,
14
+ * `--verify`d and smoke-tested per platform by `release.yml` before the
15
+ * release that carries it exists; installing is a download and an unpack.
16
+ *
17
+ * **A platform with no prebuilt binary is now an error rather than a fallback,
18
+ * and that is a decision rather than an oversight.** Until 0.6.0 this file
19
+ * imported `dist/index.js` -- the TypeScript compiler built from `src/` -- so
20
+ * musl, FreeBSD and 32-bit anything got a working compiler that happened to be
21
+ * slower. `src/` was deleted in R6 (`docs/wp19-stage0-retirement.md`), so there
22
+ * is no second compiler in the package to reach for, and the honest answer is
23
+ * the one below: name the platforms a release carries, say there is nothing to
24
+ * fall back to, and exit non-zero. The alternative -- shipping `self/` and
25
+ * bootstrapping on the user's machine -- was priced in wp12 and turned down,
26
+ * and quietly doing nothing was never on the table: a command that exits 0
27
+ * having compiled nothing is worse than one that refuses.
28
+ *
29
+ * `bin/` rather than the old `dist/` for the same reason: the command may not
30
+ * be a build artifact of the compiler it installs.
31
+ */
32
+ import { spawnSync } from "node:child_process";
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import { createRequire } from "node:module";
36
+ import { assetFor, noCompilerMessage, platformPackageName } from "./packaging.js";
37
+
38
+ /** Package root: bin/launcher.js -> `..`, the directory `package.json` sits in. */
39
+ const PKG_ROOT = path.resolve(import.meta.dirname, "..");
40
+
41
+ /**
42
+ * Exit 3, the toolchain code.
43
+ *
44
+ * **It is deliberately the same 3 a missing `clang` answers, and that is worth
45
+ * defending because a wrapper cannot tell the two apart by status alone.** The
46
+ * table in docs/wp12-release.md's "Exit codes and failure modes" reads
47
+ * "toolchain: the C toolchain, or the prebuilt compiler itself, could not be
48
+ * run", which is one band for one shape of failure: nothing is wrong with the
49
+ * program, there is no source position, and what could not be run is a
50
+ * *program* rather than the input. A fourth code would put the launcher in the
51
+ * exit-status contract that `tests/nish/cli.ts` pins for the compiler, for a
52
+ * situation the compiler cannot be in. What tells them apart is the `code`
53
+ * field under `--json` and the first line otherwise, which is why the refusal
54
+ * below is machine-readable. `scripts/postinstall.mjs`'s generated shim answers
55
+ * 3 for its own version of this, so all three spell one situation the same way.
56
+ */
57
+ const NO_COMPILER = 3;
58
+
59
+ /** This package's own name, or `null` when its `package.json` cannot be read. */
60
+ const packageName = () => {
61
+ try {
62
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
63
+ return typeof pkg.name === "string" ? pkg.name : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ };
68
+
69
+ /**
70
+ * Whether this invocation asked for machine-readable output.
71
+ *
72
+ * Read off the raw argv rather than parsed, because the launcher does not parse
73
+ * the command line and must not start: every other flag is the compiler's
74
+ * business. `--json` is the one thing it has to notice, since a refusal is a
75
+ * failure the `--json` contract covers (AGENTS.md, "Machine-readable
76
+ * surfaces").
77
+ */
78
+ const wantsJson = () => process.argv.slice(2).includes("--json");
79
+
80
+ /**
81
+ * Write and exit, without losing what was written.
82
+ *
83
+ * `process.exit` does not wait for a pending write, and stdout/stderr are
84
+ * asynchronous when they are pipes -- which is what they are whenever anything
85
+ * captures this command's output, and the case where the message matters most.
86
+ * `process.exitCode` plus a natural exit flushes, so that is what this does;
87
+ * there is nothing after it to run.
88
+ */
89
+ const writeAndExit = (stream, text, status) => {
90
+ stream.write(text);
91
+ process.exitCode = status;
92
+ };
93
+
94
+ /**
95
+ * Where the native compiler for this machine is, or `null` when this machine
96
+ * has none installed.
97
+ *
98
+ * Resolution goes through the platform package's `package.json` rather than
99
+ * its binary, because a package may expose its own files however it likes but
100
+ * `./package.json` is the one export every package in this project declares.
101
+ * From there the layout is the release tarball's, unchanged: `bin/nish` beside
102
+ * `runtime/`, `scripts/` and `std/`, which is what lets the binary find
103
+ * `build.sh`, the C runtime and the standard library one level up from itself
104
+ * exactly as it does when unpacked by hand.
105
+ */
106
+ const nativeCompiler = (asset) => {
107
+ const name = packageName();
108
+ if (name === null) return null;
109
+ try {
110
+ const manifest = createRequire(import.meta.url).resolve(`${platformPackageName(name, asset)}/package.json`);
111
+ const binary = path.join(path.dirname(manifest), "bin", "nish");
112
+ return fs.existsSync(binary) ? binary : null;
113
+ } catch {
114
+ // Not installed. On a supported platform that is a partial install; on an
115
+ // unsupported one npm skipped the entry on purpose. `refuse` tells the two
116
+ // apart, because the advice differs.
117
+ return null;
118
+ }
119
+ };
120
+
121
+ /**
122
+ * Say why there is no compiler to run, and stop.
123
+ *
124
+ * Under `--json` that is one object on stdout and **nothing on stderr**, which
125
+ * is the shape `self/compile.ts`'s own `reportToolchainFailure` uses for the
126
+ * same code: stdout carries objects and nothing else, so a tool reading it does
127
+ * not have to strip a human report out of the stream. Otherwise it is the
128
+ * report on stderr. Either way the status is the same.
129
+ */
130
+ const refuse = (unstartable) => {
131
+ const why = noCompilerMessage({
132
+ platform: process.platform,
133
+ arch: process.arch,
134
+ // The name comes from this package's own `package.json` or not at all. A
135
+ // literal here would be the scope spelled a second time, against
136
+ // packaging.js's own rule, and a *wrong* package name is worse advice than
137
+ // none: it sends the user to install something that does not exist.
138
+ packageName: packageName(),
139
+ unstartable,
140
+ });
141
+ if (wantsJson()) {
142
+ writeAndExit(
143
+ process.stdout,
144
+ `${JSON.stringify({ severity: "error", code: why.code, message: why.summary })}\n`,
145
+ NO_COMPILER
146
+ );
147
+ } else {
148
+ writeAndExit(process.stderr, why.report, NO_COMPILER);
149
+ }
150
+ };
151
+
152
+ const asset = assetFor(process.platform, process.arch);
153
+ // No binary for this machine, and none coming: musl, FreeBSD, 32-bit anything.
154
+ // `refuse` sets an exit code rather than exiting, so each of these returns
155
+ // before the next line runs: the sequence below is written as a chain for that
156
+ // reason and `process.exitCode` is what carries the status out.
157
+ if (asset === null) {
158
+ refuse(null);
159
+ } else {
160
+ const binary = nativeCompiler(asset);
161
+ if (binary === null) {
162
+ // A binary exists for this platform and this install does not have it.
163
+ refuse(null);
164
+ } else {
165
+ handOver(binary);
166
+ }
167
+ }
168
+
169
+ /** Run the native compiler and give the caller back exactly what it answered. */
170
+ function handOver(binary) {
171
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
172
+ if (result.error !== undefined && result.error !== null) {
173
+ // Installed and will not start -- a broken or partial install rather than an
174
+ // unsupported platform. There is nothing to fall back to, so this is a
175
+ // refusal with the reason in it rather than a warning above a slower compile.
176
+ refuse({ binary, reason: result.error.message });
177
+ } else if (result.signal !== null) {
178
+ // Re-raise rather than translating to an exit code, so that a crash or an
179
+ // interrupt reaches the shell as the signal it was. `process.exitCode`
180
+ // cannot express one, and a wrapper that turned SIGINT into exit 130 would
181
+ // make `nish` the one command in a pipeline that did.
182
+ process.kill(process.pid, result.signal);
183
+ } else {
184
+ process.exitCode = result.status ?? 0;
185
+ }
186
+ }
package/bin/nish ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ // The `nish` command, before `scripts/postinstall.mjs` has had a look at it.
3
+ //
4
+ // This file is deliberately tiny and deliberately not the whole story. On a
5
+ // machine that installed one of the prebuilt `@amritk/nish-<asset>` packages,
6
+ // postinstall replaces this file with a one-line `/bin/sh` exec of that
7
+ // package's native compiler, so nothing node-shaped is left in the path --
8
+ // measured at 3.2 ms against 94 ms for this shim, which is almost entirely
9
+ // node's own startup (docs/wp12-release.md, "What the launcher costs").
10
+ //
11
+ // It execs the binary in its own package rather than copying it here, because
12
+ // npm links the command through a symlink and the native compiler resolves
13
+ // `build.sh` and `runtime/` from argv[0] without following one. That section
14
+ // has the failure a copy produces.
15
+ //
16
+ // It has to keep working anyway, because the swap is a postinstall and
17
+ // `npm ci --ignore-scripts` is a normal thing for CI to do, as is a registry
18
+ // proxy or a sandbox that disables scripts outright. So this is the path that
19
+ // is always correct and sometimes slower, and the swap is the optimisation on
20
+ // top of it. Everything it delegates to is in `./launcher.js`, beside this
21
+ // file rather than in `dist/`: the command may not be a build artifact of the
22
+ // compiler it installs, and since 0.6.0 there is no `dist/` to build it into.
23
+ import "./launcher.js";
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Which prebuilt binary this machine wants, and what the package holding it is
3
+ * called.
4
+ *
5
+ * `npm install -g @amritk/nish` is a download, not a build: the native
6
+ * compiler is already built, verified and smoke-tested per platform by
7
+ * `release.yml`, and it reaches a user as an `optionalDependencies` entry that
8
+ * npm installs only where `os` and `cpu` match -- the esbuild pattern. This
9
+ * module is the one place the three spellings of a platform are converted
10
+ * between, so the launcher, the package generator and the tests cannot drift:
11
+ *
12
+ * node `process.platform` / `process.arch` darwin / arm64
13
+ * this project the `asset` in .github/seed-targets.json aarch64-darwin
14
+ * npm `os` / `cpu` in a package.json darwin / arm64
15
+ *
16
+ * The middle one is the odd spelling and it is not this module's to change:
17
+ * `asset` is the target triple with the vendor and the ABI dropped, which is
18
+ * what the release assets, the seed protocol and `bootstrap.sh` already call a
19
+ * platform. So the rule is a translation between node's two-word name and that
20
+ * one, and `tests/run.js` checks it against `seed-targets.json` rather than
21
+ * trusting the two lists to stay equal.
22
+ *
23
+ * **This is plain JavaScript under `bin/`, and that is deliberate.** It used
24
+ * to be `src/packaging.ts`, compiled into `dist/` by `tsc` -- which made the
25
+ * command a build artifact of the compiler it is supposed to install. `bin/`
26
+ * is what the tarball carries and what `bin.nish` points into, so the launcher
27
+ * and its platform table now live where they ship, need no build step, and
28
+ * survived the deletion of `src/` (`docs/wp19-stage0-retirement.md` R6). The
29
+ * types they lose are not much of a loss for two string maps; what they gain is
30
+ * that `npm pack` ships the same bytes this repository runs.
31
+ */
32
+
33
+ /**
34
+ * The architecture half of an `asset`, keyed by node's `process.arch`.
35
+ *
36
+ * Only the architectures a release actually attaches are here. An arch that is
37
+ * missing is not an error -- it means no prebuilt binary exists for this
38
+ * machine, which the launcher answers by saying so and stopping.
39
+ */
40
+ const ARCH_BY_CPU = {
41
+ x64: "x86_64",
42
+ arm64: "aarch64",
43
+ };
44
+
45
+ /**
46
+ * The operating-system half. It is an identity map today, because node's
47
+ * `linux` and `darwin` are already what the triple calls them, and it exists
48
+ * so that the one platform where that stops being true -- a `win32` whose
49
+ * triple says `windows` -- is a line here rather than a special case at the
50
+ * call site.
51
+ */
52
+ const OS_BY_PLATFORM = {
53
+ linux: "linux",
54
+ darwin: "darwin",
55
+ };
56
+
57
+ /**
58
+ * Every asset a release actually attaches, sorted, in `seed-targets.json`'s
59
+ * spelling. This is the list the refusal *prints*.
60
+ *
61
+ * **A literal list rather than the cross product of the two maps above**, which
62
+ * is what it was first written as and which is wrong in a way that only shows up
63
+ * later: the maps multiply out to this list only because the support matrix
64
+ * happens to be full. Add `riscv64: "riscv64"` for linux alone -- one platform,
65
+ * not four -- and a cross product advertises `riscv64-darwin` to a user, and
66
+ * fails the check that compares this against `.github/seed-targets.json` for a
67
+ * launcher that was behaving correctly. The authority is that file; `tests/run.js`
68
+ * compares the two, so a literal is exactly as gated as a derivation and says
69
+ * only what is true.
70
+ */
71
+ export const SUPPORTED_ASSETS = ["aarch64-darwin", "aarch64-linux", "x86_64-darwin", "x86_64-linux"];
72
+
73
+ /**
74
+ * The `asset` for a node platform/arch pair, or `null` when this project
75
+ * attaches no binary for it.
76
+ *
77
+ * `null` is a supported answer rather than a crash: Alpine on musl, a FreeBSD,
78
+ * a linux/riscv64 all land here. What it is no longer is a *quiet* answer --
79
+ * there is no compiler in the package to fall back to since 0.6.0, so the
80
+ * launcher turns this `null` into a diagnostic naming the four platforms and
81
+ * exits non-zero (`docs/wp12-release.md`, "Which compiler the package ships").
82
+ */
83
+ export const assetFor = (platform, arch) => {
84
+ const os = OS_BY_PLATFORM[platform];
85
+ const cpu = ARCH_BY_CPU[arch];
86
+ if (os === undefined || cpu === undefined) return null;
87
+ return `${cpu}-${os}`;
88
+ };
89
+
90
+ /** The inverse, for the generator and the tests: an `asset` back to npm's pair. */
91
+ export const targetForAsset = (asset) => {
92
+ const dash = asset.indexOf("-");
93
+ if (dash < 0) return null;
94
+ const arch = asset.slice(0, dash);
95
+ const os = asset.slice(dash + 1);
96
+ const cpu = Object.keys(ARCH_BY_CPU).find((key) => ARCH_BY_CPU[key] === arch);
97
+ const platform = Object.keys(OS_BY_PLATFORM).find((key) => OS_BY_PLATFORM[key] === os);
98
+ if (cpu === undefined || platform === undefined) return null;
99
+ return { asset, os: platform, cpu };
100
+ };
101
+
102
+ /**
103
+ * The package holding the binary for one asset, derived from the main
104
+ * package's own name: `@amritk/nish` + `x86_64-linux` -> `@amritk/nish-x86_64-linux`.
105
+ *
106
+ * Derived rather than written out so that the scope is spelled once. The
107
+ * registry name was settled late (`nish` belongs to somebody else, so this one
108
+ * is scoped -- docs/wp12-release.md "The npm name"), and a list of five literal
109
+ * names would be five places to edit if it ever moves again.
110
+ */
111
+ export const platformPackageName = (packageName, asset) => `${packageName}-${asset}`;
112
+
113
+ /**
114
+ * The diagnostic code every refusal here carries: `NL0002`, the toolchain code.
115
+ *
116
+ * Not a new code, and that is the point. `self/codes.ts` defines `NL0002` as
117
+ * "the toolchain `--link` needs could not be used (exit 3)", and a prebuilt
118
+ * compiler that is absent or will not start is the same class of failure seen
119
+ * one step earlier: no source position, nothing wrong with the program, and the
120
+ * thing that could not be run is a binary rather than the input. Reusing it
121
+ * keeps the launcher out of the diagnostic registry entirely -- a code minted
122
+ * here would be one `self/codes.ts` mirrors for a message the compiler can
123
+ * never print, and `tests/diagnostic_coverage.js` would then want a case
124
+ * provoking a rule that does not exist.
125
+ */
126
+ export const NO_COMPILER_CODE = "NL0002";
127
+
128
+ /**
129
+ * Why there is no compiler to run, and what to do about it: the one-line
130
+ * `message` a `--json` object carries, and the report a person reads.
131
+ *
132
+ * **Both, from one function, because the contract is that they say the same
133
+ * thing.** `AGENTS.md` promises that a failure with no source position is still
134
+ * one JSON line under `--json` and that "you never have to read stderr to find
135
+ * out why a run failed" -- so the refusal cannot be prose only, and the first
136
+ * version of this file made it prose only, which broke that contract on a path
137
+ * a user reaches with `--no-optional` on a fully supported platform.
138
+ *
139
+ * A pure function rather than `process.stderr.write` calls in the launcher, for
140
+ * one reason: the branch a user on musl or FreeBSD hits is the branch no machine
141
+ * in CI can reach, because every runner this project uses is one of the four
142
+ * supported platforms. Written like this, `tests/run.js` can ask for that exact
143
+ * message on any host and assert what it says, while the branch the harness
144
+ * *can* reach is asserted end to end on the installed command. A diagnostic
145
+ * nothing can test is how the advice in it goes stale.
146
+ *
147
+ * Three situations, and they get different advice because the remedy differs:
148
+ *
149
+ * - `unsupported`: this project publishes no binary for the machine. Nothing
150
+ * about the install is wrong and reinstalling will not help, so the advice
151
+ * is the bootstrap, and INSTALL.md carries the part that needs more than
152
+ * four lines -- including that on musl no released binary may run at all.
153
+ * - `missing`: a binary exists for this platform and the install does not
154
+ * have it. Reinstalling is the remedy.
155
+ * - `unstartable`: it is installed and will not run. A broken or partly
156
+ * written install, named as one rather than reported as an unsupported
157
+ * platform.
158
+ *
159
+ * Every branch names the four platforms, because a user who has just been
160
+ * refused wants to know whether the list is the whole list. It is.
161
+ */
162
+ export const noCompilerMessage = ({ platform, arch, packageName, unstartable = null }) => {
163
+ const asset = assetFor(platform, arch);
164
+ const published = SUPPORTED_ASSETS.join(" ");
165
+ const preamble =
166
+ " This package installs a prebuilt native compiler. One is published for:\n" +
167
+ ` ${published}\n\n` +
168
+ " There is no compiler inside the package to fall back to, and nothing is\n" +
169
+ " compiled on your machine on any path.\n";
170
+ if (asset === null) {
171
+ return {
172
+ code: NO_COMPILER_CODE,
173
+ summary:
174
+ `no prebuilt compiler for ${platform}/${arch}; one is published for ${published}, ` +
175
+ "and there is none inside the package to fall back to. Build one from a released " +
176
+ "`nish` that runs here: NISH_BOOTSTRAP=<nish> scripts/bootstrap.sh in a checkout " +
177
+ "(docs/INSTALL.md)",
178
+ report:
179
+ `nish: no prebuilt compiler for ${platform}/${arch}\n\n${preamble}\n` +
180
+ " To get a compiler for this machine, build one from a released `nish` in a\n" +
181
+ " checkout of the repository:\n" +
182
+ " NISH_BOOTSTRAP=<a released nish that runs here> scripts/bootstrap.sh\n" +
183
+ " docs/INSTALL.md has the detail, including what to do when no released\n" +
184
+ " binary runs on this platform at all.\n",
185
+ };
186
+ }
187
+ if (unstartable !== null) {
188
+ return {
189
+ code: NO_COMPILER_CODE,
190
+ summary:
191
+ `${unstartable.binary} could not be started (${unstartable.reason}); that is a broken ` +
192
+ "or partly written install rather than an unsupported platform. Reinstall the " +
193
+ "package, or run `npm rebuild` if the tree moved",
194
+ report:
195
+ `nish: ${unstartable.binary} could not be started (${unstartable.reason})\n\n${preamble}\n` +
196
+ " That is a broken or partly written install rather than an unsupported\n" +
197
+ " platform. Reinstall the package, or run `npm rebuild` if the tree moved.\n",
198
+ };
199
+ }
200
+ // A `packageName` of `null` means this package's own `package.json` could not
201
+ // be read, which is a broken install and not the moment to guess: naming the
202
+ // wrong package sends the user to install something that does not exist, so
203
+ // the sentence loses the name instead of inventing one.
204
+ const named = packageName === null ? null : platformPackageName(packageName, asset);
205
+ const pkg = named === null ? `the platform package for ${asset}` : named;
206
+ return {
207
+ code: NO_COMPILER_CODE,
208
+ summary:
209
+ `the prebuilt compiler for ${asset} is not installed; its package is ${pkg}, which npm ` +
210
+ "installs automatically for this platform. Reinstall the package, or install the pair " +
211
+ "by hand (docs/INSTALL.md)",
212
+ report:
213
+ `nish: the prebuilt compiler for ${asset} is not installed\n\n${preamble}\n` +
214
+ ` Its package is ${pkg}, which npm installs\n` +
215
+ " automatically for this platform. An install that skipped it is usually\n" +
216
+ " `--no-optional`, a lockfile without the platform packages, or a registry that\n" +
217
+ " does not carry them yet. Reinstall the package, or install the pair by hand as\n" +
218
+ " docs/INSTALL.md shows.\n",
219
+ };
220
+ };