@gjsify/cli 0.36.0 → 0.38.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.
Files changed (122) hide show
  1. package/dist/cli.gjs.mjs +169 -169
  2. package/lib/actions/build.d.ts +7 -0
  3. package/lib/actions/build.js +18 -3
  4. package/lib/bundler-pick.js +61 -87
  5. package/lib/cli-fail.d.ts +4 -7
  6. package/lib/cli-fail.js +29 -55
  7. package/lib/commands/affected.js +161 -286
  8. package/lib/commands/install.d.ts +63 -66
  9. package/lib/commands/install.js +372 -536
  10. package/lib/config.d.ts +14 -0
  11. package/lib/config.js +18 -1
  12. package/lib/templates/install.mjs.tmpl +164 -75
  13. package/lib/types/cli-build-options.d.ts +33 -67
  14. package/lib/types/command.d.ts +9 -12
  15. package/lib/types/config-data.d.ts +137 -221
  16. package/lib/utils/auth-npmrc.d.ts +5 -10
  17. package/lib/utils/auth-npmrc.js +11 -21
  18. package/lib/utils/bin-shim.d.ts +40 -25
  19. package/lib/utils/bin-shim.js +51 -26
  20. package/lib/utils/clear-targets.d.ts +8 -11
  21. package/lib/utils/clear-targets.js +22 -29
  22. package/lib/utils/copy-targets.d.ts +13 -19
  23. package/lib/utils/copy-targets.js +31 -44
  24. package/lib/utils/dep-aggregation.d.ts +7 -15
  25. package/lib/utils/dep-aggregation.js +9 -22
  26. package/lib/utils/detect-native-packages.d.ts +89 -122
  27. package/lib/utils/detect-native-packages.js +119 -169
  28. package/lib/utils/dir-link.d.ts +29 -48
  29. package/lib/utils/dir-link.js +33 -56
  30. package/lib/utils/discover-showcases.d.ts +6 -7
  31. package/lib/utils/discover-showcases.js +11 -15
  32. package/lib/utils/dlx-cache.d.ts +29 -38
  33. package/lib/utils/dlx-cache.js +53 -63
  34. package/lib/utils/force-exit.d.ts +0 -3
  35. package/lib/utils/force-exit.js +13 -24
  36. package/lib/utils/gi-search-path.d.ts +16 -24
  37. package/lib/utils/gi-search-path.js +54 -82
  38. package/lib/utils/gjs-bundle-guard.d.ts +5 -13
  39. package/lib/utils/gjs-bundle-guard.js +35 -57
  40. package/lib/utils/gjs-source-escape.d.ts +2 -2
  41. package/lib/utils/gjs-source-escape.js +21 -39
  42. package/lib/utils/gjsify-shim.d.ts +17 -20
  43. package/lib/utils/gjsify-shim.js +80 -120
  44. package/lib/utils/html-entry.d.ts +8 -12
  45. package/lib/utils/html-entry.js +19 -28
  46. package/lib/utils/install-backend-native.d.ts +123 -161
  47. package/lib/utils/install-backend-native.js +536 -737
  48. package/lib/utils/install-backend.d.ts +42 -57
  49. package/lib/utils/install-backend.js +16 -21
  50. package/lib/utils/install-cache-fs.d.ts +10 -15
  51. package/lib/utils/install-cache-fs.js +18 -26
  52. package/lib/utils/install-lock.d.ts +5 -6
  53. package/lib/utils/install-lock.js +46 -65
  54. package/lib/utils/install-packument-cache.d.ts +27 -33
  55. package/lib/utils/install-packument-cache.js +44 -57
  56. package/lib/utils/install-progress.d.ts +2 -9
  57. package/lib/utils/install-progress.js +14 -39
  58. package/lib/utils/install-tarball-cache.d.ts +14 -20
  59. package/lib/utils/install-tarball-cache.js +41 -59
  60. package/lib/utils/library-output.d.ts +9 -22
  61. package/lib/utils/library-output.js +19 -37
  62. package/lib/utils/load-npmrc.d.ts +5 -6
  63. package/lib/utils/load-npmrc.js +18 -27
  64. package/lib/utils/node-bundle-guard.d.ts +4 -8
  65. package/lib/utils/node-bundle-guard.js +21 -35
  66. package/lib/utils/node-script.js +40 -53
  67. package/lib/utils/node-version.d.ts +5 -14
  68. package/lib/utils/node-version.js +15 -24
  69. package/lib/utils/normalize-bundler-options.d.ts +7 -11
  70. package/lib/utils/normalize-bundler-options.js +24 -42
  71. package/lib/utils/npm-oidc.d.ts +23 -37
  72. package/lib/utils/npm-oidc.js +28 -53
  73. package/lib/utils/npm-otp-cache.d.ts +4 -4
  74. package/lib/utils/npm-otp-cache.js +20 -25
  75. package/lib/utils/npm-otp.d.ts +20 -30
  76. package/lib/utils/npm-otp.js +32 -51
  77. package/lib/utils/onboard-probe.d.ts +7 -13
  78. package/lib/utils/onboard-probe.js +16 -27
  79. package/lib/utils/parse-spec.d.ts +3 -3
  80. package/lib/utils/parse-spec.js +4 -5
  81. package/lib/utils/pkg-json-edit.d.ts +7 -12
  82. package/lib/utils/pkg-json-edit.js +13 -26
  83. package/lib/utils/pkg-json.d.ts +11 -18
  84. package/lib/utils/pkg-json.js +19 -28
  85. package/lib/utils/prefixed-output.d.ts +3 -4
  86. package/lib/utils/prefixed-output.js +13 -19
  87. package/lib/utils/prompt.d.ts +3 -4
  88. package/lib/utils/prompt.js +14 -18
  89. package/lib/utils/publish-diagnose.d.ts +11 -16
  90. package/lib/utils/publish-diagnose.js +15 -28
  91. package/lib/utils/publish-headers.d.ts +7 -11
  92. package/lib/utils/publish-headers.js +36 -65
  93. package/lib/utils/publishable-packages.d.ts +4 -6
  94. package/lib/utils/publishable-packages.js +14 -18
  95. package/lib/utils/resolve-gjs-entry.d.ts +5 -8
  96. package/lib/utils/resolve-gjs-entry.js +10 -27
  97. package/lib/utils/resolve-npm-package.d.ts +6 -11
  98. package/lib/utils/resolve-npm-package.js +29 -63
  99. package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
  100. package/lib/utils/resolve-plugin-by-name.js +9 -14
  101. package/lib/utils/run-gjs.d.ts +16 -21
  102. package/lib/utils/run-gjs.js +31 -56
  103. package/lib/utils/run-lifecycle-script.d.ts +5 -9
  104. package/lib/utils/run-lifecycle-script.js +23 -42
  105. package/lib/utils/run-node.d.ts +21 -33
  106. package/lib/utils/run-node.js +43 -64
  107. package/lib/utils/runtimes.d.ts +25 -35
  108. package/lib/utils/runtimes.js +34 -46
  109. package/lib/utils/spawn.d.ts +32 -48
  110. package/lib/utils/spawn.js +51 -84
  111. package/lib/utils/stdin.d.ts +2 -2
  112. package/lib/utils/stdin.js +10 -25
  113. package/lib/utils/suggest.d.ts +6 -7
  114. package/lib/utils/suggest.js +8 -10
  115. package/lib/utils/system-gi.d.ts +36 -27
  116. package/lib/utils/system-gi.js +85 -114
  117. package/lib/utils/trust-registry.d.ts +4 -8
  118. package/lib/utils/trust-registry.js +7 -15
  119. package/lib/utils/win32-command.d.ts +8 -14
  120. package/lib/utils/win32-command.js +29 -43
  121. package/lib/utils/workspace-root.js +7 -12
  122. package/package.json +18 -18
@@ -19,86 +19,71 @@ export interface InstallOptions {
19
19
  /** Use `<prefix>/gjsify-lock.json` as the source of truth — fail if missing. */
20
20
  frozen?: boolean;
21
21
  /**
22
- * Native backend only: force a fresh re-resolution that bumps every spec to
23
- * the newest version satisfying its range, rewriting the lockfile — the
24
- * pre-0.7.x behaviour. By default (this flag off) a resolve that has to run
25
- * (a new/changed/removed dep) PRESERVES the versions already pinned in the
26
- * existing lockfile and only resolves the genuinely new/changed deps, the
27
- * way `npm install` / `yarn install` / `pnpm install` do. Set this to
28
- * intentionally pick up in-range updates (≈ `yarn install
29
- * --mode=update-lockfile` / `npm update` / `pnpm update`).
22
+ * Native backend only: force a fresh re-resolution that bumps every spec to the
23
+ * newest version satisfying its range, rewriting the lockfile. Off, a resolve
24
+ * that has to run PRESERVES the versions already pinned and only resolves the
25
+ * genuinely new/changed deps, as `npm`/`yarn`/`pnpm install` do. Set it to
26
+ * intentionally pick up in-range updates (≈ `npm update`).
30
27
  */
31
28
  refreshLockfile?: boolean;
32
29
  /**
33
- * Per-package version overrides — `<name> → <range>`. Applied to every
34
- * edge during dependency resolution, irrespective of the requester.
35
- * Mirrors npm's top-level `overrides` field and yarn's `resolutions`
36
- * (the simple, name-only flavour; pattern keys like `typescript@*` are
37
- * normalised to bare `typescript` by the caller before passing in).
38
- *
39
- * Lets a workspace root pin a transitive dep version when the
40
- * deduplicated tree would otherwise pick a different one — e.g. for
41
- * forcing `typescript@~5.9` across every `typescript@*` devDep.
30
+ * Per-package version overrides — `<name> → <range>`, applied to every edge
31
+ * during resolution irrespective of the requester. Mirrors npm's top-level
32
+ * `overrides` and yarn's `resolutions` (name-only flavour; pattern keys like
33
+ * `typescript@*` are normalised to bare `typescript` by the caller). Lets a
34
+ * workspace root pin a transitive dep the deduplicated tree would otherwise
35
+ * resolve differently.
42
36
  */
43
37
  overrides?: Record<string, string>;
44
38
  /**
45
- * Native backend only: skip transitive dependency resolution and only
46
- * install the top-level requested packages. Use this when the packages
47
- * are self-contained bundles whose declared `dependencies` are either
48
- * bundled into the artifact (e.g. `@gjsify/cli`'s GJS bundle) or
49
- * workspace-only packages not published to npm separately. Setting this
50
- * avoids spurious packument fetches for workspace-internal packages.
39
+ * Native backend only: install only the top-level requested packages, skipping
40
+ * transitive resolution. For self-contained bundles whose declared
41
+ * `dependencies` are either bundled into the artifact (e.g. `@gjsify/cli`'s GJS
42
+ * bundle) or workspace-only and never published, where resolution would only
43
+ * produce spurious packument fetches.
51
44
  *
52
- * Has no effect when `frozen: true` (the lockfile already contains the
53
- * full resolved tree and is used verbatim) or when `GJSIFY_INSTALL_BACKEND=npm`
54
- * (npm does its own resolution and does not consult this flag).
45
+ * No effect under `frozen: true` (the lockfile holds the full resolved tree and
46
+ * is used verbatim) or `GJSIFY_INSTALL_BACKEND=npm` (npm resolves for itself).
55
47
  */
56
48
  skipDeps?: boolean;
57
49
  /**
58
- * Native backend only: overall wall-clock budget for the install. When
59
- * fired, in-flight packument + tarball fetches are aborted via their
60
- * AbortSignals (the resolver/extractor surface the abort as a normal
61
- * AbortError), so the user gets a clean failure instead of a silent
62
- * hang. Zero or undefined disables the overall budget — per-request
63
- * timeouts in @gjsify/npm-registry still apply.
50
+ * Native backend only: overall wall-clock budget. On firing, in-flight packument
51
+ * + tarball fetches abort via their AbortSignals (surfaced as a normal
52
+ * AbortError), so a stall fails cleanly instead of hanging. Zero/undefined
53
+ * disables the overall budget; per-request timeouts in @gjsify/npm-registry
54
+ * still apply.
64
55
  */
65
56
  signal?: AbortSignal;
66
57
  /**
67
- * Native backend only: progress reporter for resolve / download / extract /
68
- * link phases. The CLI auto-creates a TTY-aware reporter by default; pass
69
- * a custom reporter (or the `NOOP` from `makeProgressReporter({enabled:false})`)
70
- * to override.
58
+ * Native backend only: progress reporter for the resolve / download / extract /
59
+ * link phases. Defaults to a TTY-aware reporter; pass a custom one (or the
60
+ * `NOOP` from `makeProgressReporter({enabled:false})`) to override.
71
61
  */
72
62
  progress?: ProgressReporter;
73
63
  /**
74
- * Native backend only: the names of the monorepo's own workspace packages.
75
- * A package whose name is in this set is materialised as a symlink to its
76
- * workspace source by `workspaceInstall`, never fetched from the registry —
77
- * even when the lockfile or a transitive edge references a same-named
78
- * published version (e.g. a `types-dev/<lib>` workspace that ALSO exists on
79
- * npm). The native backend drops such names from `resolveDeps` AND from the
80
- * fetch/extract set, so `extractOne` never tries to `rm` + overwrite a
81
- * workspace source symlink (its data-loss guard would otherwise abort the
82
- * whole install). The `npm` backend ignores this — npm resolves workspaces
83
- * itself.
64
+ * Native backend only: the monorepo's own workspace package names. A name in
65
+ * this set is materialised as a symlink to its workspace source by
66
+ * `workspaceInstall` and never fetched, even when the lockfile or a transitive
67
+ * edge references a same-named PUBLISHED version (e.g. a `types-dev/<lib>`
68
+ * workspace that also exists on npm). Such names are dropped from `resolveDeps`
69
+ * AND from the fetch/extract set, so `extractOne` never tries to `rm` +
70
+ * overwrite a workspace source symlink — its data-loss guard would abort the
71
+ * whole install. The `npm` backend ignores this and resolves workspaces itself.
84
72
  */
85
73
  workspaceNames?: Set<string>;
86
74
  /**
87
- * Native backend only: requester labels for top-level specs, keyed by the
88
- * exact `"<name>@<range>"` spec string. `workspaceInstall` records which
89
- * workspace(s) declared each aggregated external spec so the resolver's
90
- * version-conflict warning can name BOTH sides of a conflict ("^1 requested
91
- * by @scope/a, ^2 requested by @scope/b"). Optional — flows without
92
- * per-requester attribution (single-project installs, dlx) omit it and the
93
- * warning falls back to the bare ranges.
75
+ * Native backend only: requester labels for top-level specs, keyed by the exact
76
+ * `"<name>@<range>"` string, so the resolver's version-conflict warning can name
77
+ * BOTH sides ("^1 requested by @scope/a, ^2 requested by @scope/b"). Optional —
78
+ * flows without per-requester attribution (single-project installs, dlx) omit it
79
+ * and the warning falls back to bare ranges.
94
80
  */
95
81
  specOrigins?: Map<string, string[]>;
96
82
  }
97
83
  export interface InstallResult {
98
- /** Top-level packages that were requested, with the version each
99
- * resolved to. Empty for the npm backend (parsing npm's stdout would
100
- * be unreliable; callers that need this should set
101
- * GJSIFY_INSTALL_BACKEND=native). */
84
+ /** Requested top-level packages with the version each resolved to. Empty for
85
+ * the npm backend — parsing npm's stdout would be unreliable, so a caller that
86
+ * needs this must use the native backend. */
102
87
  installed: Array<{
103
88
  name: string;
104
89
  version: string;
@@ -1,17 +1,15 @@
1
1
  // Install backend abstraction.
2
2
  //
3
- // Default: native backend (resolves packuments via @gjsify/npm-registry,
4
- // extracts tarballs via @gjsify/tar — no Node, no npm required at runtime).
5
- // Fallback: `npm install --no-package-lock --no-audit --no-fund --prefix <dir> <specs...>`,
6
- // for parity with the legacy code path. Switched via
7
- // `GJSIFY_INSTALL_BACKEND=native|npm`.
3
+ // Default: native backend (resolves packuments via @gjsify/npm-registry, extracts
4
+ // tarballs via @gjsify/tar — no Node, no npm required at runtime). Fallback:
5
+ // `npm install`. Switched via `GJSIFY_INSTALL_BACKEND=native|npm`.
8
6
  //
9
7
  // `gjsify dlx` uses this seam — installing under a cache prefix, with no
10
- // package.json update to the user's project. The native backend matches that
11
- // workflow without ever shelling out to Node.
8
+ // package.json update to the user's project.
12
9
  //
13
- // `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
14
- // already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
10
+ // On the npm path, `--no-package-lock` keeps the cache prepare dir hermetic (the
11
+ // cache key already covers reproducibility) and `--no-audit --no-fund` cuts ~5s
12
+ // off cold runs.
15
13
  import { writeFileSync } from 'node:fs';
16
14
  import { join } from 'node:path';
17
15
  import { spawnToCompletion } from './spawn.js';
@@ -30,8 +28,8 @@ async function installViaNpm({ prefix, specs, verbose, registry }) {
30
28
  if (specs.length === 0) {
31
29
  throw new Error('installPackages: empty specs list');
32
30
  }
33
- // Seed an empty package.json so npm doesn't walk up from prefix and pick
34
- // up the user's project metadata. Cosmetic name/version only.
31
+ // Seed an empty package.json so npm doesn't walk up from prefix and pick up the
32
+ // user's project metadata. Cosmetic name/version only.
35
33
  writeFileSync(join(prefix, 'package.json'), JSON.stringify({ name: 'gjsify-dlx-cache', version: '0.0.0', private: true }, null, 2));
36
34
  if (registry) {
37
35
  writeFileSync(join(prefix, '.npmrc'), `registry=${registry}\n`);
@@ -46,17 +44,14 @@ async function installViaNpm({ prefix, specs, verbose, registry }) {
46
44
  ...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
47
45
  ...specs,
48
46
  ];
49
- // Through `spawnToCompletion` so `npm` is resolved the way every other
50
- // spawn in the CLI resolves it. A bare `spawn('npm', …)` is ENOENT on
51
- // Windows — `CreateProcess` appends only `.exe` and npm ships a `.cmd`
52
- // shim (see utils/win32-command.ts). That made this backend unusable
53
- // there, and the ENOENT hint below actively misdiagnosed it: npm IS on
54
- // PATH on such a host, and it told the user to select the backend that
55
- // was already the default while calling it unsupported.
47
+ // Through `spawnToCompletion` because a bare `spawn('npm', …)` is ENOENT on
48
+ // Windows — `CreateProcess` appends only `.exe` and npm ships a `.cmd` shim
49
+ // (see utils/win32-command.ts). That made this backend unusable there while the
50
+ // ENOENT hint below misdiagnosed it as npm being absent from PATH.
56
51
  //
57
- // `completion: 'return'` — `installPackages` resolves a promise to its
58
- // caller instead of exiting, so under GJS this must not leave a main loop
59
- // armed. (Moot in practice: this backend needs npm, hence Node.)
52
+ // `completion: 'return'` — this resolves a promise to its caller instead of
53
+ // exiting, so under GJS it must not leave a main loop armed. (Moot in practice:
54
+ // this backend needs npm, hence Node.)
60
55
  const { code } = await spawnToCompletion('npm', args, {
61
56
  completion: 'return',
62
57
  notFound: (err) => new Error(`npm not found on PATH — install Node.js, or unset GJSIFY_INSTALL_BACKEND to use the default native backend (${err.message})`),
@@ -6,26 +6,21 @@
6
6
  */
7
7
  export declare function gjsifyCacheRoot(kind: string, layoutVersion: string): string;
8
8
  /**
9
- * Write `bytes` to `path` atomically: write a `<path>.tmp.<pid>` sibling, then
10
- * `rename` it into place, so a concurrent reader never observes a half-written
11
- * file. Creates the parent directory. Best-effort — any failure (read-only /
12
- * out-of-disk cache volume) is swallowed so a cache hiccup never breaks the
13
- * install; the caller proceeds with its in-memory copy.
9
+ * Write `bytes` to `path` atomically via a `<path>.tmp.<pid>` sibling + `rename`. Best-effort:
10
+ * any failure (read-only / out-of-disk cache volume) is swallowed so a cache hiccup never
11
+ * breaks the install — the caller proceeds with its in-memory copy.
14
12
  */
15
13
  export declare function atomicWrite(path: string, bytes: Uint8Array | string): void;
16
14
  /**
17
- * Strict sibling of {@link atomicWrite}: identical tmp+`rename` pattern, but
18
- * failures PROPAGATE to the caller. Use for files whose corruption/loss must
19
- * abort the operation (the `gjsify-lock.json` writer) rather than degrade
20
- * silently like a cache entry. `rename` over the destination is atomic on
21
- * POSIX, so a concurrent reader (or a crash mid-write) sees either the old
22
- * complete file or the new complete file — never a torn one.
15
+ * Strict sibling of {@link atomicWrite}: same tmp+`rename` pattern, but failures PROPAGATE.
16
+ * Use for files whose loss must abort the operation (the `gjsify-lock.json` writer) rather
17
+ * than degrade silently like a cache entry. `rename` over the destination is atomic on POSIX,
18
+ * so a concurrent reader — or a crash mid-write — sees the old or the new file, never a torn one.
23
19
  */
24
20
  export declare function atomicWriteStrict(path: string, bytes: Uint8Array | string): void;
25
21
  /**
26
- * Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
27
- * missing file, a zero-byte file (an interrupted previous write), or any read
28
- * error are all treated as a MISS — the file is left untouched so a follow-up
29
- * writer's atomic rename isn't disturbed.
22
+ * Read a cache file: bytes on a HIT, `null` on a MISS. Missing, zero-byte (an interrupted
23
+ * previous write) and unreadable all count as a MISS, and the file is left untouched so a
24
+ * follow-up writer's atomic rename isn't disturbed.
30
25
  */
31
26
  export declare function readCacheFile(path: string): Buffer | null;
@@ -2,18 +2,15 @@
2
2
  // Shared filesystem primitives for `gjsify install`'s on-disk caches and its
3
3
  // lockfile writer.
4
4
  //
5
- // Both the content-addressable tarball cache (install-tarball-cache.ts) and the
6
- // packument metadata cache (install-packument-cache.ts) need the same three
7
- // things: an `XDG_CACHE_HOME`-honouring cache root, an atomic write (so a
8
- // concurrent install never observes a half-written entry), and a read that
9
- // treats a missing / zero-byte / unreadable file as a MISS. Centralising them
10
- // here keeps the two caches byte-for-byte consistent. The lockfile writer
11
- // (`gjsify-lock.json`) shares the same tmp+rename pattern via the strict
12
- // variant so an interrupted install can never leave a torn lockfile behind.
5
+ // The tarball cache (install-tarball-cache.ts) and the packument metadata cache
6
+ // (install-packument-cache.ts) need the same three things: an `XDG_CACHE_HOME`-honouring
7
+ // root, an atomic write (so a concurrent install never observes a half-written entry), and a
8
+ // read treating missing / zero-byte / unreadable as a MISS. The `gjsify-lock.json` writer
9
+ // shares the tmp+rename pattern via the strict variant, so an interrupted install cannot
10
+ // leave a torn lockfile.
13
11
  //
14
- // Out of scope: the dlx cache (`dlx-cache.ts`) has a different layout
15
- // (`gjsify/dlx`, sha256 + symlink-swap) and its own TTL/prepare-dir concerns —
16
- // it is a precedent for the XDG helper, not a reuse target.
12
+ // Out of scope: the dlx cache (`dlx-cache.ts`) has a different layout (`gjsify/dlx`, sha256 +
13
+ // symlink-swap) and its own TTL concerns — a precedent for the XDG helper, not a reuse target.
17
14
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
18
15
  import { homedir } from 'node:os';
19
16
  import { join } from 'node:path';
@@ -29,11 +26,9 @@ export function gjsifyCacheRoot(kind, layoutVersion) {
29
26
  return join(base, 'gjsify', kind, layoutVersion);
30
27
  }
31
28
  /**
32
- * Write `bytes` to `path` atomically: write a `<path>.tmp.<pid>` sibling, then
33
- * `rename` it into place, so a concurrent reader never observes a half-written
34
- * file. Creates the parent directory. Best-effort — any failure (read-only /
35
- * out-of-disk cache volume) is swallowed so a cache hiccup never breaks the
36
- * install; the caller proceeds with its in-memory copy.
29
+ * Write `bytes` to `path` atomically via a `<path>.tmp.<pid>` sibling + `rename`. Best-effort:
30
+ * any failure (read-only / out-of-disk cache volume) is swallowed so a cache hiccup never
31
+ * breaks the install — the caller proceeds with its in-memory copy.
37
32
  */
38
33
  export function atomicWrite(path, bytes) {
39
34
  try {
@@ -44,12 +39,10 @@ export function atomicWrite(path, bytes) {
44
39
  }
45
40
  }
46
41
  /**
47
- * Strict sibling of {@link atomicWrite}: identical tmp+`rename` pattern, but
48
- * failures PROPAGATE to the caller. Use for files whose corruption/loss must
49
- * abort the operation (the `gjsify-lock.json` writer) rather than degrade
50
- * silently like a cache entry. `rename` over the destination is atomic on
51
- * POSIX, so a concurrent reader (or a crash mid-write) sees either the old
52
- * complete file or the new complete file — never a torn one.
42
+ * Strict sibling of {@link atomicWrite}: same tmp+`rename` pattern, but failures PROPAGATE.
43
+ * Use for files whose loss must abort the operation (the `gjsify-lock.json` writer) rather
44
+ * than degrade silently like a cache entry. `rename` over the destination is atomic on POSIX,
45
+ * so a concurrent reader — or a crash mid-write — sees the old or the new file, never a torn one.
53
46
  */
54
47
  export function atomicWriteStrict(path, bytes) {
55
48
  mkdirSync(join(path, '..'), { recursive: true });
@@ -58,10 +51,9 @@ export function atomicWriteStrict(path, bytes) {
58
51
  renameSync(tmp, path);
59
52
  }
60
53
  /**
61
- * Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
62
- * missing file, a zero-byte file (an interrupted previous write), or any read
63
- * error are all treated as a MISS — the file is left untouched so a follow-up
64
- * writer's atomic rename isn't disturbed.
54
+ * Read a cache file: bytes on a HIT, `null` on a MISS. Missing, zero-byte (an interrupted
55
+ * previous write) and unreadable all count as a MISS, and the file is left untouched so a
56
+ * follow-up writer's atomic rename isn't disturbed.
65
57
  */
66
58
  export function readCacheFile(path) {
67
59
  if (!existsSync(path))
@@ -3,12 +3,11 @@ export interface InstallLockHandle {
3
3
  release(): void;
4
4
  }
5
5
  /**
6
- * Acquire the cross-process install lock for `prefix`. Resolves once this
7
- * process may mutate `<prefix>/node_modules` + `<prefix>/gjsify-lock.json`;
8
- * the caller MUST `release()` in a `finally`. Waits (with backoff) while a
9
- * live concurrent install holds the lock; steals provably-stale locks (dead
10
- * owner pid / exceeded staleness budget). `signal` aborts the wait — wired
11
- * to the overall install budget so a waiter never outlives its own timeout.
6
+ * Acquire the cross-process install lock for `prefix`, resolving once this process may mutate
7
+ * `<prefix>/node_modules` + `<prefix>/gjsify-lock.json`. The caller MUST `release()` in a
8
+ * `finally`. Waits with backoff while a live install holds the lock, and steals provably-stale
9
+ * ones. `signal` aborts the wait — wired to the install budget so a waiter never outlives its
10
+ * own timeout.
12
11
  */
13
12
  export declare function acquireInstallLock(prefix: string, opts?: {
14
13
  signal?: AbortSignal;
@@ -1,48 +1,36 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Cross-process install lock for `gjsify install` (ADR 0001, step 2).
3
3
  //
4
- // WHAT is locked: mutation of ONE install prefix — its `node_modules/` tree,
5
- // its `gjsify-lock.json`, and (through the same code path) the user-global
6
- // prefix that `gjsify install -g` extracts into. Two processes installing
7
- // into the SAME prefix used to interleave `rmSync` + extract on the same
8
- // destination directories (corrupt trees, spurious ENOENT mid-extract) and
9
- // tear the lockfile. The lock serializes them. Installs into DIFFERENT
10
- // prefixes stay fully concurrent.
4
+ // LOCKED: mutation of ONE install prefix — its `node_modules/` tree, its `gjsify-lock.json`, and
5
+ // the user-global prefix `gjsify install -g` extracts into. Two processes installing into the
6
+ // SAME prefix interleaved `rmSync` + extract on the same destinations (corrupt trees, spurious
7
+ // ENOENT mid-extract) and tore the lockfile. Different prefixes stay fully concurrent.
11
8
  //
12
- // WHAT is deliberately NOT locked: the shared XDG caches
13
- // (`$XDG_CACHE_HOME/gjsify/{tarballs,metadata}`). Those are content-addressed
14
- // and written via atomic tmp+`rename` (`install-cache-fs.ts`), so concurrent
15
- // writers are already safe lock-free — identical-content last-rename-wins.
9
+ // NOT LOCKED: the shared XDG caches (`$XDG_CACHE_HOME/gjsify/{tarballs,metadata}`) — they are
10
+ // content-addressed and written via atomic tmp+`rename` (`install-cache-fs.ts`), so concurrent
11
+ // writers are already safe lock-free.
16
12
  //
17
- // Mechanism: `mkdir` of `<prefix>/node_modules/.gjsify-install-lock` is the
18
- // atomic acquire. mkdir has exclusive-create semantics on every runtime we
19
- // target: Node's `mkdirSync` throws EEXIST, and the GJS fs polyfill maps
20
- // Gio's G_IO_ERROR_EXISTS to the same code. (A lock FILE with `flag: 'wx'`
21
- // would NOT work — the GJS `writeFileSync` polyfill ignores open flags and
22
- // happily overwrites.) An `owner.json` inside the dir records pid + start
23
- // time for staleness checks and diagnostics.
13
+ // Mechanism: `mkdir` of `<prefix>/node_modules/.gjsify-install-lock` is the atomic acquire, with
14
+ // exclusive-create semantics on every runtime targeted (Node's `mkdirSync` throws EEXIST; the
15
+ // GJS fs polyfill maps Gio's G_IO_ERROR_EXISTS to the same code). A lock FILE with `flag: 'wx'`
16
+ // would NOT work — the GJS `writeFileSync` polyfill ignores open flags and overwrites. An
17
+ // `owner.json` inside records pid + start time for staleness checks.
24
18
  //
25
- // Staleness: a crashed install cannot release its lock, so contenders steal
26
- // when
27
- // (a) the owner pid is provably dead (`/proc/<pid>` on Linux — readable
28
- // under both Node and GJS; a kill(0) probe covers non-/proc Nodes), or
29
- // (b) the lock outlives GJSIFY_INSTALL_LOCK_STALE_MS (default 35 min —
30
- // just above the 30-min overall install budget, so a budget-bound
31
- // install always times out and exits before its lock can be stolen
32
- // out from under it), or
33
- // (c) `mkdir` succeeded but `owner.json` never appeared within a short
34
- // grace period (the owner died between the two steps).
35
- // The steal itself is race-free: the stale dir is claimed via an atomic
36
- // `rename` to a unique sibling name — exactly one contender's rename
37
- // succeeds; losers see ENOENT and simply retry the acquire loop.
19
+ // Staleness — a crashed install cannot release its lock, so contenders steal when
20
+ // (a) the owner pid is provably dead (`/proc/<pid>` on Linux, readable under Node and GJS
21
+ // alike; a kill(0) probe covers non-/proc Nodes), or
22
+ // (b) the lock outlives GJSIFY_INSTALL_LOCK_STALE_MS (default 35 min — just above the 30-min
23
+ // install budget, so a budget-bound install always exits before its lock can be stolen out
24
+ // from under it), or
25
+ // (c) `mkdir` succeeded but `owner.json` never appeared within a short grace period.
26
+ // The steal is race-free: the stale dir is claimed by an atomic `rename` to a unique sibling, so
27
+ // exactly one contender wins and losers see ENOENT and retry the acquire loop.
38
28
  //
39
- // Re-entrant per process: `workspaceInstall` holds the root-prefix lock for
40
- // the whole workspace flow while the inner `installPackagesNative` calls
41
- // re-acquire the same prefix — those just bump a refcount.
29
+ // Re-entrant per process: `workspaceInstall` holds the root-prefix lock for the whole workspace
30
+ // flow while inner `installPackagesNative` calls re-acquire the same prefix and just refcount.
42
31
  //
43
- // Escape hatch: GJSIFY_INSTALL_LOCK=0 disables locking entirely (e.g. for a
44
- // network filesystem with broken rename/mkdir atomicity, or an outer tool
45
- // that already serializes installs).
32
+ // GJSIFY_INSTALL_LOCK=0 disables locking entirely — for a network filesystem with broken
33
+ // rename/mkdir atomicity, or an outer tool that already serializes installs.
46
34
  import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
47
35
  import { dirname, join, resolve } from 'node:path';
48
36
  const LOCK_DIR_NAME = '.gjsify-install-lock';
@@ -75,18 +63,15 @@ function lockingDisabled() {
75
63
  /**
76
64
  * Is `pid` a live process on this machine?
77
65
  *
78
- * `/proc/<pid>` is the reliable cross-runtime probe on Linux (the primary
79
- * target): it works identically under Node and GJS. Elsewhere fall back to a
80
- * signal-0 probe — ESRCH means dead, EPERM means alive-but-foreign. The GJS
81
- * `process.kill` polyfill shells out and cannot report ESRCH, but GJS only
82
- * runs on /proc platforms in practice, so the fallback branch is Node's.
83
- * Unknown outcomes are treated as ALIVE (conservative — never steal a lock
84
- * we cannot prove abandoned; the mtime staleness budget still applies).
66
+ * `/proc/<pid>` is the reliable cross-runtime probe on Linux, identical under Node and GJS;
67
+ * elsewhere a signal-0 probe (ESRCH dead, EPERM alive-but-foreign). The GJS `process.kill`
68
+ * polyfill shells out and cannot report ESRCH, but GJS only runs on /proc platforms in practice,
69
+ * so the fallback branch is Node's. Unknown outcomes count as ALIVE — never steal a lock that
70
+ * cannot be proven abandoned; the mtime staleness budget still applies.
85
71
  */
86
72
  function isPidAlive(pid) {
87
- // `existsSync` never throws by contract: Node returns false on any error,
88
- // and the GJS shim is a typeof guard + Gio `query_exists` (both without a
89
- // throw path in the GIR). Off Linux the `if` is simply false.
73
+ // `existsSync` never throws by contract: Node returns false on any error, and the GJS shim is
74
+ // a typeof guard + Gio `query_exists`, neither with a throw path in the GIR.
90
75
  if (existsSync('/proc/self'))
91
76
  return existsSync(`/proc/${pid}`);
92
77
  try {
@@ -122,9 +107,8 @@ function assessLock(lockDir) {
122
107
  }
123
108
  const started = typeof owner.startedAt === 'number' ? owner.startedAt : mtimeMs(ownerPath);
124
109
  if (started !== null && now - started > staleMs()) {
125
- // Alive but far beyond any plausible install duration — the
126
- // observed 0%-CPU hung-install shape. Steal so one wedged
127
- // process can't block every future install of this prefix.
110
+ // Alive but far beyond any plausible install duration — the observed 0%-CPU
111
+ // hung-install shape. Steal, so one wedged process cannot block this prefix forever.
128
112
  return {
129
113
  stale: true,
130
114
  reason: `owner pid ${owner.pid} exceeded the ${staleMs()}ms staleness budget`,
@@ -133,8 +117,8 @@ function assessLock(lockDir) {
133
117
  }
134
118
  return { stale: false, ownerPid: owner.pid };
135
119
  }
136
- // No readable owner.json: the winner may be between mkdir and the owner
137
- // write — give it a grace period, then treat the dir as abandoned.
120
+ // No readable owner.json: the winner may be between mkdir and the owner write, so allow a
121
+ // grace period before treating the dir as abandoned.
138
122
  const dirMtime = mtimeMs(lockDir);
139
123
  if (dirMtime === null)
140
124
  return { stale: false }; // vanished — retry mkdir
@@ -144,9 +128,8 @@ function assessLock(lockDir) {
144
128
  return { stale: false };
145
129
  }
146
130
  /**
147
- * Atomically claim + remove a stale lock dir. Returns `true` when THIS
148
- * process won the claim (the rename succeeded), `false` when another
149
- * contender got there first — the caller just retries its acquire loop.
131
+ * Atomically claim + remove a stale lock dir. `true` when THIS process won the claim, `false`
132
+ * when another contender got there first — the caller just retries its acquire loop.
150
133
  */
151
134
  function stealStaleLock(lockDir) {
152
135
  const claimed = `${lockDir}.stale-${process.pid}-${++stealCounter}`;
@@ -206,8 +189,8 @@ function makeHandle(lockDir) {
206
189
  if (entry.count > 0)
207
190
  return;
208
191
  held.delete(lockDir);
209
- // Only remove the on-disk dir when it is still OURS — a staleness
210
- // steal could have handed the path to another process meanwhile.
192
+ // Only remove the on-disk dir when it is still OURS — a staleness steal could have
193
+ // handed the path to another process meanwhile.
211
194
  try {
212
195
  const owner = JSON.parse(readFileSync(join(lockDir, OWNER_FILE), 'utf-8'));
213
196
  if (owner.nonce !== undefined && owner.nonce !== entry.nonce)
@@ -226,12 +209,11 @@ function makeHandle(lockDir) {
226
209
  };
227
210
  }
228
211
  /**
229
- * Acquire the cross-process install lock for `prefix`. Resolves once this
230
- * process may mutate `<prefix>/node_modules` + `<prefix>/gjsify-lock.json`;
231
- * the caller MUST `release()` in a `finally`. Waits (with backoff) while a
232
- * live concurrent install holds the lock; steals provably-stale locks (dead
233
- * owner pid / exceeded staleness budget). `signal` aborts the wait — wired
234
- * to the overall install budget so a waiter never outlives its own timeout.
212
+ * Acquire the cross-process install lock for `prefix`, resolving once this process may mutate
213
+ * `<prefix>/node_modules` + `<prefix>/gjsify-lock.json`. The caller MUST `release()` in a
214
+ * `finally`. Waits with backoff while a live install holds the lock, and steals provably-stale
215
+ * ones. `signal` aborts the wait — wired to the install budget so a waiter never outlives its
216
+ * own timeout.
235
217
  */
236
218
  export async function acquireInstallLock(prefix, opts = {}) {
237
219
  if (lockingDisabled()) {
@@ -241,8 +223,7 @@ export async function acquireInstallLock(prefix, opts = {}) {
241
223
  },
242
224
  };
243
225
  }
244
- // Canonicalize so two spellings of the same prefix (symlink, relative
245
- // path) share one re-entrancy slot.
226
+ // Canonicalize so two spellings of one prefix (symlink, relative path) share a re-entrancy slot.
246
227
  let canonicalPrefix;
247
228
  try {
248
229
  canonicalPrefix = realpathSync(prefix);
@@ -1,9 +1,8 @@
1
1
  import type { Packument } from '@gjsify/npm-registry';
2
2
  /**
3
3
  * Which document shape an entry holds. `'corgi'` = the abbreviated
4
- * `application/vnd.npm.install-v1+json` install document (the default for every
5
- * resolve); `'full'` = `application/json`. Same names pacote uses, so the two
6
- * caches are describable in one sentence.
4
+ * `application/vnd.npm.install-v1+json` install document; `'full'` =
5
+ * `application/json`, the only one carrying `libc`. Same names pacote uses.
7
6
  */
8
7
  export type PackumentShape = 'corgi' | 'full';
9
8
  export interface CachedPackument {
@@ -14,47 +13,42 @@ export interface CachedPackument {
14
13
  * The cache key for a (shape, registry, name) triple — exported so the filename
15
14
  * rule below is a PROPERTY a test can assert, not a claim in a comment.
16
15
  *
17
- * THE SEPARATOR IS NOT COSMETIC. It used to be `|`, producing names like
18
- * `corgi|https%3A%2F%2Fregistry.npmjs.org%2F|%40gjsify%2Fnode-gi.json` — and `|`
19
- * is a Win32 RESERVED filename character, so every write failed with `EINVAL`.
20
- * Nothing reported it: `atomicWrite` is best-effort on purpose (a read-only
21
- * cache volume must not break an install) and `readCacheFile` reads an
22
- * unreadable path as a MISS. So on Windows this cache was never broken loudly —
23
- * it was silently DEAD, and the `If-None-Match` → `304` revalidation that is the
24
- * entire reason this file exists never engaged once.
16
+ * THE SEPARATOR IS NOT COSMETIC. `|` is a Win32 RESERVED filename character, so
17
+ * while it was the separator every write there failed with `EINVAL` and nothing
18
+ * reported it: `atomicWrite` is best-effort on purpose (a read-only cache volume
19
+ * must not break an install) and `readCacheFile` treats an unreadable path as a
20
+ * MISS. The cache was not broken loudly on Windows, it was silently DEAD, and the
21
+ * `If-None-Match` → `304` revalidation this file exists for never engaged once.
25
22
  *
26
- * `,` is legal on Win32 AND escaped by `encodeURIComponent`, which is what keeps
27
- * a flat three-part name injective: an encoded part cannot contain the
28
- * separator, so exactly one triple maps to any given filename. The shape needs
29
- * no encoding — it is one of two literals, neither containing the separator —
30
- * and it LEADS so that listing a shard groups by shape.
23
+ * `,` is legal on Win32 AND escaped by `encodeURIComponent`, which keeps a flat
24
+ * three-part name injective: an encoded part cannot contain the separator, so
25
+ * exactly one triple maps to any filename. The shape needs no encoding — one of two
26
+ * literals — and LEADS so listing a shard groups by shape.
31
27
  *
32
- * The shard key and the filename are derived from the SAME encoded parts. They
33
- * used to disagree — the key hashed the raw values while the name used the
34
- * encoded ones — which is how a reserved character in the name survived review
35
- * of a function whose other half looked correct.
28
+ * The shard key and the filename must derive from the SAME encoded parts; hashing
29
+ * the raw values while naming with the encoded ones is how a reserved character
30
+ * survived review of a function whose other half looked correct.
36
31
  */
37
32
  export declare function packumentCacheKey(registry: string, name: string, shape: PackumentShape): string;
38
33
  /**
39
- * Read a cached packument + its ETag for `(registry, name)` in the given
40
- * document `shape`. Returns `null` on a miss, when the cache is disabled, or
41
- * when the entry is unreadable/corrupt (treated as a miss — the caller just
42
- * re-fetches).
34
+ * Read a cached packument + its ETag for `(registry, name)` in the given document
35
+ * `shape`. Returns `null` on a miss, when the cache is disabled, or when the entry
36
+ * is unreadable/corrupt (treated as a miss — the caller re-fetches).
43
37
  *
44
- * `shape` defaults to `'corgi'` because that is what a resolve fetches and what
45
- * every caller predating the full-document escalation meant. A caller that
46
- * escalated MUST pass `'full'` — reading the corgi entry would return a body
47
- * with no `libc`, i.e. the exact wrong answer the shape key exists to prevent.
38
+ * The `shape` must be the one the body was fetched under: the two documents are
39
+ * different bodies with independently-versioned ETags behind ONE URL, so reading
40
+ * across shapes returns a `libc`-less body that looks exactly like a hit. The
41
+ * install backend resolves with `'full'`; the `'corgi'` default serves callers that
42
+ * do not escalate.
48
43
  */
49
44
  export declare function getCachedPackument(registry: string, name: string, shape?: PackumentShape): CachedPackument | null;
50
45
  /**
51
46
  * Persist a packument + ETag for `(registry, name)` under its document `shape`.
52
47
  * Writes to a `<path>.tmp.<pid>` sibling then atomically renames so a concurrent
53
- * reader never sees a partial write. No-op when the cache is disabled, the ETag
54
- * is empty, or the write fails (read-only / out-of-disk cache volume must not
55
- * break the install).
48
+ * reader never sees a partial write. No-op when the cache is disabled, the ETag is
49
+ * empty, or the write fails (a read-only / out-of-disk cache volume must not break
50
+ * the install).
56
51
  *
57
- * `shape` is last and defaults to `'corgi'` to keep the pre-escalation call
58
- * shape valid; it must match the `accept` header the body was fetched with.
52
+ * `shape` must match the `accept` header the body was fetched with.
59
53
  */
60
54
  export declare function putCachedPackument(registry: string, name: string, etag: string, packument: Packument, shape?: PackumentShape): void;