@gjsify/cli 0.59.0 → 0.60.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.
@@ -21,6 +21,16 @@ interface InstallOptions {
21
21
  force?: boolean;
22
22
  }
23
23
  export declare const installCommand: Command<unknown, InstallOptions>;
24
+ /**
25
+ * Version of the `@gjsify/cli` the workspace at `cwd` pins: the one DECLARED in
26
+ * its package.json (`^`/`~`/`=` prefix stripped; only a plain version counts).
27
+ * The installed copy is consulted only when package.json declares no
28
+ * `@gjsify/cli` at all — `install` replaces a stale `node_modules`, so it must
29
+ * not override a declared pin (that flagged the very state being repaired).
30
+ * A declared non-version spec (`workspace:^`, a range, a tag) falls back to the in-repo
31
+ * source so the check still fires on a fresh checkout of the gjsify monorepo.
32
+ */
33
+ export declare function readWorkspaceCliVersion(cwd: string): string | null;
24
34
  /**
25
35
  * Which dependency NAMES are optional across the given manifests.
26
36
  *
@@ -350,13 +350,31 @@ function readRunningCliVersion() {
350
350
  return null;
351
351
  }
352
352
  /**
353
- * Version of the `@gjsify/cli` the workspace at `cwd` pins. Prefers the resolved
354
- * install, falling back to the in-repo source so the check also fires on a fresh,
355
- * not-yet-installed checkout of the gjsify monorepo itself.
353
+ * Version of the `@gjsify/cli` the workspace at `cwd` pins: the one DECLARED in
354
+ * its package.json (`^`/`~`/`=` prefix stripped; only a plain version counts).
355
+ * The installed copy is consulted only when package.json declares no
356
+ * `@gjsify/cli` at all — `install` replaces a stale `node_modules`, so it must
357
+ * not override a declared pin (that flagged the very state being repaired).
358
+ * A declared non-version spec (`workspace:^`, a range, a tag) falls back to the in-repo
359
+ * source so the check still fires on a fresh checkout of the gjsify monorepo.
356
360
  */
357
- function readWorkspaceCliVersion(cwd) {
358
- return (readCliVersionFrom(join(cwd, 'node_modules', CLI_PACKAGE_NAME, 'package.json')) ??
359
- readCliVersionFrom(join(cwd, 'packages', 'infra', 'cli', 'package.json')));
361
+ export function readWorkspaceCliVersion(cwd) {
362
+ const pkg = readPackageJson(join(cwd, 'package.json'));
363
+ let declared = false;
364
+ for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
365
+ const spec = pkg?.[kind]?.[CLI_PACKAGE_NAME];
366
+ if (typeof spec === 'string')
367
+ declared = true;
368
+ const m = typeof spec === 'string' ? /^[\^~=v]*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(spec.trim()) : null;
369
+ if (m)
370
+ return m[1];
371
+ }
372
+ if (!declared) {
373
+ const installed = readCliVersionFrom(join(cwd, 'node_modules', CLI_PACKAGE_NAME, 'package.json'));
374
+ if (installed)
375
+ return installed;
376
+ }
377
+ return readCliVersionFrom(join(cwd, 'packages', 'infra', 'cli', 'package.json'));
360
378
  }
361
379
  /**
362
380
  * Warn (never block) when the running gjsify CLI differs from the `@gjsify/cli`
@@ -2,6 +2,8 @@ import type { Command } from '../types/index.js';
2
2
  import { type Diagnose404Result } from '../utils/publish-diagnose.js';
3
3
  import { type ReadbackResult } from '../utils/publish-readback.js';
4
4
  import { type WorkspacePin } from '../utils/publish-pins.js';
5
+ import { type HoldReason } from '../utils/publish-hold.js';
6
+ import { type Classification } from '../utils/publish-status.js';
5
7
  import { OtpProvider } from '../utils/npm-otp.js';
6
8
  interface PublishOptions {
7
9
  path?: string;
@@ -12,6 +14,9 @@ interface PublishOptions {
12
14
  'tolerate-untrusted-new'?: boolean;
13
15
  'verify-timeout'?: number;
14
16
  'verify-defer'?: boolean;
17
+ 'hold-timeout'?: number;
18
+ 'report-ledger'?: boolean;
19
+ ref?: string;
15
20
  provenance?: boolean;
16
21
  'dry-run'?: boolean;
17
22
  json?: boolean;
@@ -60,6 +65,11 @@ export interface PublishWorkspaceInput {
60
65
  * success, which is the pre-v0.46.0 behaviour and why this defaults on.
61
66
  */
62
67
  verifyBudgetMs?: number;
68
+ /**
69
+ * How long to wait for an intra-repo pin the registry does not serve before
70
+ * holding this package. Default {@link DEFAULT_HOLD_WAIT_MS}.
71
+ */
72
+ holdWaitMs?: number;
63
73
  }
64
74
  /** Discriminated result of {@link publishWorkspace}; the caller owns presentation. */
65
75
  export type PublishOutcome = {
@@ -101,6 +111,21 @@ export type PublishOutcome = {
101
111
  putUrl: string;
102
112
  payloadBytes: number;
103
113
  readback: ReadbackResult;
114
+ /** What the unserved version IS (validating / staged / blocked / …). */
115
+ classification: Classification;
116
+ }
117
+ /**
118
+ * The tarball was NOT sent, because an intra-repo pin in it names a version
119
+ * the registry does not serve — an accepted-but-"validating" upload, say.
120
+ * `ok` is the tolerance, as for `blocked-missing-dependency`.
121
+ */
122
+ | {
123
+ ok: false;
124
+ action: 'held-unpublished-dependency';
125
+ name: string;
126
+ version: string;
127
+ registry: string;
128
+ reasons: HoldReason[];
104
129
  } | {
105
130
  ok: true;
106
131
  action: 'republish-tolerated';
@@ -50,6 +50,8 @@ import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailabl
50
50
  import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
51
51
  import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, probePackageName, verifyPublishedVersion, } from '../utils/publish-readback.js';
52
52
  import { collectWorkspacePins, REQUIRED_BLOCK } from '../utils/publish-pins.js';
53
+ import { appendLedger, DEFAULT_HOLD_WAIT_MS, findUnavailablePins, holdsPublish, readLedger, recordUnserved, renderLedgerSummary, summariseReasons, } from '../utils/publish-hold.js';
54
+ import { describeClass, statusTokenFromEnv } from '../utils/publish-status.js';
53
55
  import { loadNpmrc } from '../utils/load-npmrc.js';
54
56
  import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
55
57
  import { promptLine } from '../utils/prompt.js';
@@ -90,6 +92,20 @@ export const publishCommand = {
90
92
  description: 'Report an unconfirmed read-back and exit 0 instead of 1. ONLY for a caller that re-checks the same set afterwards — `gjsify run npm:publish` passes it because `scripts/verify-published-closure.mjs` sweeps the whole roster at the end of release.yml. Everywhere else an unconfirmed publish must fail the job that made it.',
91
93
  type: 'boolean',
92
94
  default: false,
95
+ })
96
+ .option('hold-timeout', {
97
+ description: 'Seconds to wait for an intra-repo dependency pin whose exact version the registry does not serve (yet) before HOLDING this package instead of publishing it. A version npm accepted but never serves (status "validating") would make every dependent uninstallable; the write lag measured on v0.46.0 reaches 252s, hence the default. `0` probes once and holds without waiting. A hold exits 1, or 0 under `--verify-defer`.',
98
+ type: 'number',
99
+ default: DEFAULT_HOLD_WAIT_MS / 1000,
100
+ })
101
+ .option('report-ledger', {
102
+ description: 'Print the job-summary markdown for the publish ledger named by $GJSIFY_PUBLISH_LEDGER (versions npm accepted but does not serve, packages held because of them, the recovery command) and exit. Pair with `--ref <tag>`.',
103
+ type: 'boolean',
104
+ default: false,
105
+ })
106
+ .option('ref', {
107
+ description: 'With --report-ledger: the git ref to put in the printed recovery command.',
108
+ type: 'string',
93
109
  })
94
110
  .option('provenance', {
95
111
  description: "Pass-through flag — recorded in the payload but no signing happens (gjsify doesn't ship a sigstore signer yet).",
@@ -142,6 +158,17 @@ export const publishCommand = {
142
158
  const checkTrustedOnly = args['check-trusted'] === true;
143
159
  const trustedFlag = args.trusted;
144
160
  const verbose = Boolean(process.env.GJSIFY_PUBLISH_DEBUG);
161
+ if (args['report-ledger'] === true) {
162
+ const summary = renderLedgerSummary(readLedger(process.env.GJSIFY_PUBLISH_LEDGER), args.ref ?? '<tag>');
163
+ if (summary)
164
+ process.stdout.write(`${summary}\n`);
165
+ return;
166
+ }
167
+ const holdSeconds = args['hold-timeout'] ?? DEFAULT_HOLD_WAIT_MS / 1000;
168
+ if (!Number.isFinite(holdSeconds) || holdSeconds < 0) {
169
+ console.error(`gjsify publish: --hold-timeout must be a non-negative number of seconds, got ${JSON.stringify(args['hold-timeout'])}`);
170
+ return process.exit(2);
171
+ }
145
172
  if (provenance) {
146
173
  console.warn('gjsify publish: --provenance recorded but not signed (no sigstore integration yet).');
147
174
  }
@@ -250,6 +277,7 @@ export const publishCommand = {
250
277
  otpProvider,
251
278
  seedOtpFirst: otp !== undefined,
252
279
  verifyBudgetMs,
280
+ holdWaitMs: Math.round(holdSeconds * 1000),
253
281
  });
254
282
  reportPublishOutcome(outcome, args.json === true, deferUnconfirmed);
255
283
  },
@@ -355,6 +383,43 @@ export async function publishWorkspace(input) {
355
383
  };
356
384
  }
357
385
  }
386
+ // 5b. Do not PUT a tarball whose pin names a VERSION the registry does not
387
+ // serve (v0.59.x: adwaita-fonts "Validating" while its dependents shipped).
388
+ // Unlike 5 this waits, because a version is late for minutes after its own
389
+ // 2xx; see publish-hold.ts.
390
+ const ledgerPath = process.env.GJSIFY_PUBLISH_LEDGER;
391
+ if (pins.length > 0) {
392
+ const reasons = await findUnavailablePins({
393
+ registry: registryClean,
394
+ pins,
395
+ ledger: readLedger(ledgerPath),
396
+ ledgerPath,
397
+ authorization: headers['authorization'],
398
+ statusToken: statusTokenFromEnv(process.env),
399
+ waitBudgetMs: input.holdWaitMs ?? DEFAULT_HOLD_WAIT_MS,
400
+ log: verbose ? (m) => console.error(m) : undefined,
401
+ });
402
+ for (const r of reasons.filter((x) => !holdsPublish(x))) {
403
+ console.error(`gjsify publish: ${r.block}.${r.name}@${r.version} is not served (${r.detail}) — optional edge, publishing anyway`);
404
+ }
405
+ const holding = reasons.filter(holdsPublish);
406
+ if (holding.length > 0) {
407
+ appendLedger(ledgerPath, {
408
+ ev: 'held',
409
+ name: packed.name,
410
+ version: packed.version,
411
+ reasons: holding,
412
+ });
413
+ return {
414
+ ok: false,
415
+ action: 'held-unpublished-dependency',
416
+ name: packed.name,
417
+ version: packed.version,
418
+ registry: registryClean,
419
+ reasons: holding,
420
+ };
421
+ }
422
+ }
358
423
  // Trusted Publishing (OIDC) path — `--trusted` forces it, `undefined`
359
424
  // auto-detects from the GitHub OIDC env. Unused by `gjsify onboard` (local
360
425
  // token auth), preserved verbatim for `gjsify publish` in CI.
@@ -529,6 +594,14 @@ export async function publishWorkspace(input) {
529
594
  putUrl: url,
530
595
  payloadBytes: bodyStr.length,
531
596
  readback,
597
+ classification: await recordUnserved({
598
+ ledgerPath,
599
+ registry: registryClean,
600
+ name: packed.name,
601
+ version: packed.version,
602
+ verdict: readback.verdict,
603
+ statusToken: statusTokenFromEnv(process.env),
604
+ }),
532
605
  };
533
606
  }
534
607
  return {
@@ -576,6 +649,14 @@ export async function publishWorkspace(input) {
576
649
  putUrl: url,
577
650
  payloadBytes: bodyStr.length,
578
651
  readback,
652
+ classification: await recordUnserved({
653
+ ledgerPath,
654
+ registry: registryClean,
655
+ name: packed.name,
656
+ version: packed.version,
657
+ verdict: readback.verdict,
658
+ statusToken: statusTokenFromEnv(process.env),
659
+ }),
579
660
  };
580
661
  }
581
662
  return {
@@ -701,8 +782,31 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
701
782
  warnUnverified(outcome.name, outcome.version, outcome.registry);
702
783
  return;
703
784
  }
785
+ case 'held-unpublished-dependency': {
786
+ const because = summariseReasons(outcome.reasons);
787
+ if (asJson) {
788
+ process.stdout.write(`${JSON.stringify({
789
+ ok: false,
790
+ action: 'held-unpublished-dependency',
791
+ name: outcome.name,
792
+ version: outcome.version,
793
+ registry: outcome.registry,
794
+ reasons: outcome.reasons,
795
+ deferred: deferUnconfirmed,
796
+ }, null, 2)}\n`);
797
+ }
798
+ else {
799
+ process.stderr.write(`~ ${outcome.name}@${outcome.version} (HELD — not published: ${because})\n`);
800
+ }
801
+ if (process.env.GITHUB_ACTIONS) {
802
+ process.stderr.write(`::warning title=Package held::${outcome.name}@${outcome.version} was NOT published: ${because}\n`);
803
+ }
804
+ // The sweep defers like an unconfirmed publish: the closure job goes red
805
+ // on the missing package, and the job summary carries the recovery.
806
+ return deferUnconfirmed ? undefined : process.exit(1);
807
+ }
704
808
  case 'publish-unconfirmed': {
705
- const message = formatUnconfirmedPublish(outcome);
809
+ const message = `${formatUnconfirmedPublish(outcome)}\n state: ${describeClass(outcome.classification.cls)} — ${outcome.classification.detail}`;
706
810
  if (asJson) {
707
811
  process.stdout.write(`${JSON.stringify({
708
812
  ok: false,
@@ -720,6 +824,8 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
720
824
  verdictDetail: outcome.readback.verdictDetail,
721
825
  recordedAt: outcome.readback.recordedAt ?? null,
722
826
  deferred: deferUnconfirmed,
827
+ class: outcome.classification.cls,
828
+ classDetail: outcome.classification.detail,
723
829
  }, null, 2)}\n`);
724
830
  }
725
831
  else {
@@ -736,7 +842,7 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
736
842
  const claimed = outcome.claim === 'already-published' ? 'was reported already published' : 'was accepted';
737
843
  process.stderr.write(`::warning title=Publish unconfirmed::${outcome.name}@${outcome.version} ${claimed} ` +
738
844
  `(HTTP ${outcome.putStatus}) but did not resolve on ${outcome.registry} within ` +
739
- `${(outcome.readback.elapsedMs / 1000).toFixed(1)}s [${outcome.readback.verdict}] — ` +
845
+ `${(outcome.readback.elapsedMs / 1000).toFixed(1)}s [${outcome.readback.verdict}; ${describeClass(outcome.classification.cls)}] — ` +
740
846
  'the release-closure job must confirm it\n');
741
847
  }
742
848
  return;
@@ -7,6 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  function manifestPath() {
10
+ // Test seam: e2e points this at a fixture catalog (a gjs-only workspace showcase),
11
+ // since the curated list no longer holds one.
12
+ const override = process.env.GJSIFY_SHOWCASES_MANIFEST;
13
+ if (override)
14
+ return override;
10
15
  const here = dirname(fileURLToPath(import.meta.url));
11
16
  // `showcases.json` sits at the CLI package root, but its depth below this
12
17
  // module depends on how the CLI was loaded: two levels from
@@ -41,6 +41,24 @@ export declare function needsSelfShim(opts: {
41
41
  * reuse it instead of creating their own.
42
42
  */
43
43
  export declare function ensureGjsifyShimOnPath(): void;
44
+ /**
45
+ * Write `files` (relative path → content) into a directory NAMED BY THEIR HASH and
46
+ * return it. Same content, same directory: a run finds what the last one wrote and
47
+ * writes nothing, so shim dirs no longer pile up one per invocation (5930 in a
48
+ * `/tmp`, none ever removed).
49
+ *
50
+ * A reused dir, rather than one removed on exit, is what lets a child outlive its
51
+ * parent: a detached grandchild keeps resolving `gjsify` through the inherited PATH
52
+ * after the CLI that made the shim is gone, and a dir that is deleted at exit
53
+ * breaks it. Content-addressed means a file in it never changes, so a running
54
+ * process can never see a different shim than the one it was started with; each
55
+ * member lands by tmp+`rename`, so a concurrent run sees it whole or not at all.
56
+ *
57
+ * Lives under `$XDG_CACHE_HOME/gjsify/shim` — a `/tmp` path is world-writable, and
58
+ * a PATH entry there lets another local user plant a `gjsify`. A cache dir that
59
+ * cannot be written (read-only HOME) falls back to a private `mkdtemp` dir.
60
+ */
61
+ export declare function materializeShimDir(files: Record<string, string>, root?: string): string;
44
62
  /**
45
63
  * The tree whose `node_modules` holds the running CLI and its prebuilds: the
46
64
  * install prefix above the first `node_modules` segment, or the workspace root
@@ -39,13 +39,15 @@
39
39
  // came through the workspace's own `node_modules/.bin/gjsify`, that shim IS the CLI
40
40
  // the tree wants. Widening this to every Node invocation would silently move in-repo
41
41
  // nested builds off the GJS bundle.
42
- import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
42
+ import { createHash } from 'node:crypto';
43
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, existsSync, readFileSync, renameSync, rmSync, } from 'node:fs';
43
44
  import { tmpdir } from 'node:os';
44
45
  import { join, delimiter, relative, isAbsolute, dirname } from 'node:path';
45
46
  import { fileURLToPath } from 'node:url';
46
47
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
47
48
  import { buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, shQuoteArg } from './bin-shim.js';
48
49
  import { detectNativePackages } from './detect-native-packages.js';
50
+ import { gjsifyCacheRoot } from './install-cache-fs.js';
49
51
  import { resolveBinOnPath } from './install-global.js';
50
52
  import { findWorkspaceRoot } from './workspace-root.js';
51
53
  let _ensured = false;
@@ -128,8 +130,6 @@ export function ensureGjsifyShimOnPath() {
128
130
  const workspaceRoot = findWorkspaceRoot(process.cwd()) ?? process.cwd();
129
131
  if (!needsSelfShim({ gjs, selfEntry, workspaceRoot }))
130
132
  return;
131
- const dir = mkdtempSync(join(tmpdir(), 'gjsify-shim-'));
132
- const shim = join(dir, 'gjsify');
133
133
  // The interpreter is NAMED, not pathed, matching `buildShLauncher` and
134
134
  // `buildLauncherShims`, so a missing one fails the same way on every OS. Not
135
135
  // `process.execPath`: the batch member cannot quote an interpreter argument
@@ -143,23 +143,78 @@ export function ensureGjsifyShimOnPath() {
143
143
  // beifahrer consumer on macOS: `gjsify workspace <pkg> build` died in
144
144
  // `get columns` with "Unsupported type void".
145
145
  const envPreamble = gjs ? selfShimPreamble(selfEntry) : '';
146
- writeFileSync(shim, gjs
147
- ? buildShLauncher(selfEntry, { isGjsBundle: true, envPreamble, gjs: interpreter })
148
- : `#!/bin/sh\nexec "${interpreter}" "${selfEntry}" "$@"\n`, { mode: 0o755 });
149
- chmodSync(shim, 0o755);
146
+ const files = {
147
+ gjsify: gjs
148
+ ? buildShLauncher(selfEntry, { isGjsBundle: true, envPreamble, gjs: interpreter })
149
+ : `#!/bin/sh\nexec "${interpreter}" "${selfEntry}" "$@"\n`,
150
+ };
150
151
  // cmd.exe and pwsh cannot run the extension-less member: not on PATHEXT, and
151
152
  // Windows has no shebang handling. `buildLauncherShims` ports npm's
152
153
  // three-sibling answer, including the IF/ELSE ERRORLEVEL handling that
153
154
  // `&&`/`||` chaining gets wrong.
154
155
  if (!gjs && process.platform === 'win32') {
155
156
  const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs: [], target: selfEntry });
156
- writeFileSync(`${shim}.cmd`, cmd);
157
- writeFileSync(`${shim}.ps1`, ps1);
157
+ files['gjsify.cmd'] = cmd;
158
+ files['gjsify.ps1'] = ps1;
158
159
  }
160
+ const nodeShim = buildNodeShim(gjs, selfEntry, envPreamble);
161
+ if (nodeShim)
162
+ files[`${NODE_SHIM_SUBDIR}/node`] = nodeShim;
163
+ const dir = materializeShimDir(files);
159
164
  _selfShimActive = true;
160
165
  process.env.GJSIFY_SHIM_DIR = dir;
161
166
  process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
162
- writeNodeShim(dir, gjs, selfEntry, envPreamble);
167
+ }
168
+ /**
169
+ * Write `files` (relative path → content) into a directory NAMED BY THEIR HASH and
170
+ * return it. Same content, same directory: a run finds what the last one wrote and
171
+ * writes nothing, so shim dirs no longer pile up one per invocation (5930 in a
172
+ * `/tmp`, none ever removed).
173
+ *
174
+ * A reused dir, rather than one removed on exit, is what lets a child outlive its
175
+ * parent: a detached grandchild keeps resolving `gjsify` through the inherited PATH
176
+ * after the CLI that made the shim is gone, and a dir that is deleted at exit
177
+ * breaks it. Content-addressed means a file in it never changes, so a running
178
+ * process can never see a different shim than the one it was started with; each
179
+ * member lands by tmp+`rename`, so a concurrent run sees it whole or not at all.
180
+ *
181
+ * Lives under `$XDG_CACHE_HOME/gjsify/shim` — a `/tmp` path is world-writable, and
182
+ * a PATH entry there lets another local user plant a `gjsify`. A cache dir that
183
+ * cannot be written (read-only HOME) falls back to a private `mkdtemp` dir.
184
+ */
185
+ export function materializeShimDir(files, root = gjsifyCacheRoot('shim', 'v1')) {
186
+ const names = Object.keys(files).sort();
187
+ const hash = createHash('sha256');
188
+ for (const name of names)
189
+ hash.update(`${name}\0${files[name]}\0`);
190
+ const key = hash.digest('hex').slice(0, 16);
191
+ try {
192
+ return writeShimDir(join(root, key), files, names);
193
+ }
194
+ catch {
195
+ // Unwritable cache (read-only HOME, full disk): the shim is still needed
196
+ // for this run, so take the per-run dir this function exists to avoid.
197
+ return writeShimDir(mkdtempSync(join(tmpdir(), 'gjsify-shim-')), files, names);
198
+ }
199
+ }
200
+ function writeShimDir(dir, files, names) {
201
+ for (const name of names) {
202
+ const path = join(dir, name);
203
+ if (existsSync(path) && readFileSync(path, 'utf8') === files[name])
204
+ continue;
205
+ mkdirSync(dirname(path), { recursive: true });
206
+ const tmp = `${path}.tmp.${process.pid}`;
207
+ try {
208
+ writeFileSync(tmp, files[name], { mode: 0o755 });
209
+ chmodSync(tmp, 0o755);
210
+ renameSync(tmp, path);
211
+ }
212
+ catch (err) {
213
+ rmSync(tmp, { force: true });
214
+ throw err;
215
+ }
216
+ }
217
+ return dir;
163
218
  }
164
219
  /**
165
220
  * The tree whose `node_modules` holds the running CLI and its prebuilds: the
@@ -268,22 +323,17 @@ export function nodeShimDir() {
268
323
  * resolves NOWHERE, and it is reachable only from a package script's PATH (see
269
324
  * {@link nodeShimDir}).
270
325
  */
271
- function writeNodeShim(dir, gjs, selfEntry, envPreamble) {
326
+ function buildNodeShim(gjs, selfEntry, envPreamble) {
272
327
  if (!gjs)
273
- return;
274
- const sub = join(dir, NODE_SHIM_SUBDIR);
275
- if (existsSync(join(sub, 'node')))
276
- return; // inherited from a parent gjsify
328
+ return null;
277
329
  if (resolveBinOnPath('node'))
278
- return; // a real Node is present — leave it alone
330
+ return null; // a real Node is present — leave it alone
279
331
  const interpreter = process.env.GJS_CONSOLE || 'gjs';
280
- mkdirSync(sub, { recursive: true });
281
- const shim = join(sub, 'node');
282
332
  // A leading FLAG is refused rather than forwarded: `node --test x.mjs` wants
283
333
  // Node's own test runner and `node -e '…'` an eval, neither of which
284
334
  // `--node-script` can honour, and yargs would take `--test` FOR the script
285
335
  // path (`unknown-options-as-args` turns an unknown flag into a positional).
286
- writeFileSync(shim, '#!/bin/sh\n' +
336
+ return ('#!/bin/sh\n' +
287
337
  // Same SIP hop as the self-shim — see `buildSelfShimScript`.
288
338
  dyldCarryPreamble(process.platform, process.env) +
289
339
  'case "$1" in\n' +
@@ -293,6 +343,5 @@ function writeNodeShim(dir, gjs, selfEntry, envPreamble) {
293
343
  'esac\n' +
294
344
  // The same hop as the `gjsify` shim, so the same preamble.
295
345
  envPreamble +
296
- `exec "${interpreter}" -m "${selfEntry}" run --node-script "$@"\n`, { mode: 0o755 });
297
- chmodSync(shim, 0o755);
346
+ `exec "${interpreter}" -m "${selfEntry}" run --node-script "$@"\n`);
298
347
  }
@@ -0,0 +1,89 @@
1
+ import { type DependencyBlock, type WorkspacePin } from './publish-pins.js';
2
+ import { classifyUnserved, type Classification, type UnservedClass } from './publish-status.js';
3
+ /** Wait for a pin this long before holding. Covers the measured 252 s write lag (publish-readback.ts). */
4
+ export declare const DEFAULT_HOLD_WAIT_MS = 300000;
5
+ export interface HoldReason {
6
+ /** The dependency that is not installable. */
7
+ name: string;
8
+ version: string;
9
+ block: DependencyBlock;
10
+ /** `held` = the dependency was itself held in this run, so nothing of it was published. */
11
+ cls: UnservedClass | 'held';
12
+ detail: string;
13
+ }
14
+ export type LedgerEntry =
15
+ /** This run PUT the version, npm accepted it, and the packument does not serve it. */
16
+ {
17
+ ev: 'unserved';
18
+ name: string;
19
+ version: string;
20
+ cls: UnservedClass;
21
+ detail: string;
22
+ }
23
+ /** This run did NOT publish the package, because of the listed dependencies. */
24
+ | {
25
+ ev: 'held';
26
+ name: string;
27
+ version: string;
28
+ reasons: HoldReason[];
29
+ }
30
+ /** A later probe found a version an earlier `unserved` entry recorded now served; it supersedes that entry. */
31
+ | {
32
+ ev: 'served';
33
+ name: string;
34
+ version: string;
35
+ };
36
+ export declare function appendLedger(path: string | undefined, entry: LedgerEntry): void;
37
+ /** Tolerates a missing file (no entries yet) and skips unparseable lines rather than losing the rest. */
38
+ export declare function readLedger(path: string | undefined): LedgerEntry[];
39
+ /**
40
+ * The version a rewritten pin asks for, when the spec names exactly one:
41
+ * `0.59.1`, `^0.59.1`, `~0.59.1`. `undefined` for a range, tag or URL — those are
42
+ * not decidable against one version and the name-level guard covers them.
43
+ */
44
+ export declare function pinVersion(spec: string): string | undefined;
45
+ export interface FindUnavailablePinsInput {
46
+ registry: string;
47
+ pins: readonly WorkspacePin[];
48
+ ledger: readonly LedgerEntry[];
49
+ /** Where a re-probe that finds a ledger-`unserved` version served records it, so later dependents skip the probe. */
50
+ ledgerPath?: string;
51
+ /** Forwarded to the packument GETs, as the publish's own read-back does. */
52
+ authorization?: string;
53
+ statusToken?: string;
54
+ /** How long to wait for a pin that may merely be late. 0 = one probe, no waiting. */
55
+ waitBudgetMs: number;
56
+ fetchImpl?: typeof fetch;
57
+ sleep?: (ms: number) => Promise<void>;
58
+ now?: () => number;
59
+ log?: (msg: string) => void;
60
+ }
61
+ /**
62
+ * Every pin whose exact version the registry does not serve (after waiting for
63
+ * one that may be late), with the reason. Empty = safe to publish.
64
+ *
65
+ * Three cheap exits come first, in order: the ledger already knows this version
66
+ * is unserved or held; one packument probe finds it served (nine in ten); the
67
+ * status endpoint says it can never be served. Only a pin that is none of those —
68
+ * possibly just late — pays the wait.
69
+ */
70
+ export declare function findUnavailablePins(input: FindUnavailablePinsInput): Promise<HoldReason[]>;
71
+ /** Only a `dependencies` edge holds a publish, as in the name-level guard; the rest are reported. */
72
+ export declare const holdsPublish: (reason: HoldReason) => boolean;
73
+ export declare function summariseReasons(reasons: readonly HoldReason[]): string;
74
+ /** Classify a version THIS process PUT and could not read back, and write it to the ledger. */
75
+ export declare function recordUnserved(input: {
76
+ ledgerPath: string | undefined;
77
+ registry: string;
78
+ name: string;
79
+ version: string;
80
+ verdict: Parameters<typeof classifyUnserved>[0];
81
+ statusToken?: string;
82
+ fetchImpl?: typeof fetch;
83
+ }): Promise<Classification>;
84
+ /**
85
+ * The job-summary markdown for a ledger: what is unserved and why, what was held
86
+ * and why, and the exact command that publishes the held set once their
87
+ * dependencies are installable. `undefined` when nothing needs saying.
88
+ */
89
+ export declare function renderLedgerSummary(entries: readonly LedgerEntry[], ref: string): string | undefined;