@gjsify/cli 0.35.0 → 0.37.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 (124) hide show
  1. package/dist/cli.gjs.mjs +136 -126
  2. package/lib/actions/build.d.ts +19 -0
  3. package/lib/actions/build.js +2 -2
  4. package/lib/bundler-pick.js +72 -98
  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/commands/run.d.ts +1 -0
  11. package/lib/commands/run.js +37 -0
  12. package/lib/templates/install.mjs.tmpl +38 -68
  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 +125 -221
  16. package/lib/utils/auth-npmrc.d.ts +5 -10
  17. package/lib/utils/auth-npmrc.js +11 -21
  18. package/lib/utils/check-system-deps.d.ts +16 -0
  19. package/lib/utils/check-system-deps.js +49 -0
  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 +83 -116
  27. package/lib/utils/detect-native-packages.js +111 -149
  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 +26 -8
  43. package/lib/utils/gjsify-shim.js +128 -80
  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.d.ts +12 -0
  67. package/lib/utils/node-script.js +95 -0
  68. package/lib/utils/node-version.d.ts +5 -14
  69. package/lib/utils/node-version.js +15 -24
  70. package/lib/utils/normalize-bundler-options.d.ts +7 -11
  71. package/lib/utils/normalize-bundler-options.js +24 -42
  72. package/lib/utils/npm-oidc.d.ts +23 -37
  73. package/lib/utils/npm-oidc.js +28 -53
  74. package/lib/utils/npm-otp-cache.d.ts +4 -4
  75. package/lib/utils/npm-otp-cache.js +20 -25
  76. package/lib/utils/npm-otp.d.ts +20 -30
  77. package/lib/utils/npm-otp.js +32 -51
  78. package/lib/utils/onboard-probe.d.ts +7 -13
  79. package/lib/utils/onboard-probe.js +16 -27
  80. package/lib/utils/parse-spec.d.ts +3 -3
  81. package/lib/utils/parse-spec.js +4 -5
  82. package/lib/utils/pkg-json-edit.d.ts +7 -12
  83. package/lib/utils/pkg-json-edit.js +13 -26
  84. package/lib/utils/pkg-json.d.ts +11 -18
  85. package/lib/utils/pkg-json.js +19 -28
  86. package/lib/utils/prefixed-output.d.ts +3 -4
  87. package/lib/utils/prefixed-output.js +13 -19
  88. package/lib/utils/prompt.d.ts +3 -4
  89. package/lib/utils/prompt.js +14 -18
  90. package/lib/utils/publish-diagnose.d.ts +11 -16
  91. package/lib/utils/publish-diagnose.js +15 -28
  92. package/lib/utils/publish-headers.d.ts +7 -11
  93. package/lib/utils/publish-headers.js +36 -65
  94. package/lib/utils/publishable-packages.d.ts +4 -6
  95. package/lib/utils/publishable-packages.js +14 -18
  96. package/lib/utils/resolve-gjs-entry.d.ts +5 -8
  97. package/lib/utils/resolve-gjs-entry.js +10 -27
  98. package/lib/utils/resolve-npm-package.d.ts +6 -11
  99. package/lib/utils/resolve-npm-package.js +29 -63
  100. package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
  101. package/lib/utils/resolve-plugin-by-name.js +9 -14
  102. package/lib/utils/run-gjs.d.ts +19 -17
  103. package/lib/utils/run-gjs.js +37 -51
  104. package/lib/utils/run-lifecycle-script.d.ts +5 -9
  105. package/lib/utils/run-lifecycle-script.js +23 -42
  106. package/lib/utils/run-node.d.ts +23 -33
  107. package/lib/utils/run-node.js +56 -60
  108. package/lib/utils/runtimes.d.ts +25 -35
  109. package/lib/utils/runtimes.js +34 -46
  110. package/lib/utils/spawn.d.ts +32 -48
  111. package/lib/utils/spawn.js +51 -84
  112. package/lib/utils/stdin.d.ts +2 -2
  113. package/lib/utils/stdin.js +10 -25
  114. package/lib/utils/suggest.d.ts +6 -7
  115. package/lib/utils/suggest.js +8 -10
  116. package/lib/utils/system-gi.d.ts +18 -27
  117. package/lib/utils/system-gi.js +60 -114
  118. package/lib/utils/trust-registry.d.ts +4 -8
  119. package/lib/utils/trust-registry.js +7 -15
  120. package/lib/utils/win32-command.d.ts +8 -14
  121. package/lib/utils/win32-command.js +29 -43
  122. package/lib/utils/workspace-root.js +7 -12
  123. package/package.json +18 -17
  124. package/showcases.json +6 -0
@@ -4,7 +4,6 @@ import type { TrustRequester } from '../commands/trust.js';
4
4
  import { type TrustState } from './trust-registry.js';
5
5
  /** What a package needs, computed from its published + trust state. */
6
6
  export type PkgAction = 'skip' | 'trust' | 'publish-and-trust' | 'blocked';
7
- /** A per-package plan produced by the probe phase. */
8
7
  export interface PkgPlan {
9
8
  ws: Workspace;
10
9
  registry: string;
@@ -13,7 +12,6 @@ export interface PkgPlan {
13
12
  httpStatus: number;
14
13
  action: PkgAction;
15
14
  }
16
- /** Everything a probe needs beyond the requester + workspace. */
17
15
  export interface ProbeContext {
18
16
  registryOverride?: string;
19
17
  npmrc: NpmrcConfig;
@@ -29,26 +27,22 @@ export interface ProbeOptions {
29
27
  /** Injected delay (tests pass a no-op). Default: real `setTimeout`. */
30
28
  sleep?: (ms: number) => Promise<void>;
31
29
  }
32
- /** Small backoff before the one-shot retry of a transient 401 read. */
33
30
  export declare const PROBE_RETRY_DELAY_MS = 400;
34
31
  /** Default probe concurrency — kept SMALL so a single token never bursts npm. */
35
32
  export declare const DEFAULT_PROBE_CONCURRENCY = 4;
36
33
  /** Map a classified trust state to the action the onboard sweep should take. */
37
34
  export declare function actionForState(state: TrustState): PkgAction;
38
35
  /**
39
- * Read one package's published + trust state via the SHARED trust requester —
40
- * the exact `trustUrl` + `buildHeaders` auth + `withOtpRetry` path `gjsify
41
- * trust` uses. On a transient `auth-required` (401 that was not an answerable
42
- * OTP challenge — e.g. a parallel-burst rejection or a lapsed 2FA-skip window),
43
- * it retries ONCE after a short backoff.
36
+ * Read one package's published + trust state through the SHARED trust requester.
37
+ * A surviving `auth-required` means the 401 was not an answerable OTP challenge —
38
+ * a parallel-burst rejection or a lapsed 2FA-skip window — and both recover on a
39
+ * single retry after a short backoff.
44
40
  */
45
41
  export declare function probeTrustState(request: TrustRequester, ws: Workspace, ctx: ProbeContext, opts?: ProbeOptions): Promise<PkgPlan>;
46
42
  /**
47
- * Probe every selected workspace. The FIRST package is read SERIALLY so a
48
- * 2FA-gated read triggers the single shared-OTP prompt (typed once) before the
49
- * concurrent burst — the rest reuse the now-cached code — and so a single token
50
- * never faces a 127-wide parallel read. The remaining packages are read with a
51
- * bounded (small) concurrency cap.
43
+ * Probe every selected workspace. The FIRST is read SERIALLY on purpose: a
44
+ * 2FA-gated read triggers the shared-OTP prompt once, before the burst, so the rest
45
+ * reuse the cached code instead of each prompting.
52
46
  */
53
47
  export declare function probeAllTrustStates(request: TrustRequester, selected: readonly Workspace[], concurrency: number, ctx: ProbeContext, opts?: ProbeOptions): Promise<PkgPlan[]>;
54
48
  /** Map with bounded concurrency, preserving input order in the result. */
@@ -1,23 +1,15 @@
1
- // Trusted-Publisher STATE-READ path for `gjsify onboard`, extracted so it can
2
- // be unit-tested against a mocked requester (no live npm).
1
+ // Trusted-Publisher STATE-READ path for `gjsify onboard`, extracted so it can be
2
+ // unit-tested against a mocked requester (no live npm).
3
3
  //
4
- // The bug this fixes (task #60): `gjsify onboard` read all ~127 packages'
5
- // trust state with a PLAIN authenticated `fetch` (no OTP handling) run in a
6
- // wide concurrent burst — every read came back `401 → auth-required →
7
- // unreadable`, so the sweep planned `0 to publish+trust, 0 to trust, 127
8
- // unreadable`. Meanwhile a targeted `gjsify trust <pkg> --otp <code>` READ the
9
- // same state fine with the SAME token, because its read runs through the shared
10
- // `TrustRequester` (→ `withOtpRetry`): a 401 that is an OTP challenge is
11
- // answered with the shared code, and non-OTP transient 401s are tolerated.
12
- //
13
- // Fix: onboard now reads through the EXACT same `TrustRequester` path as
14
- // `gjsify trust` (identical `trustUrl` + `buildHeaders` auth + OTP handling),
15
- // PLUS a one-shot retry on a transient 401, PLUS a first-serial-then-bounded
16
- // probe order so the ONE shared OTP is prompted at most once and npm is never
17
- // hit with a 127-wide parallel burst from a single token.
4
+ // Onboard MUST read through the same `TrustRequester` as `gjsify trust`, never a
5
+ // plain authenticated `fetch`. It once used the latter, in a wide concurrent burst:
6
+ // every one of ~127 reads came back `401 → auth-required → unreadable` and the
7
+ // sweep planned `0 to publish+trust, 0 to trust, 127 unreadable`, while a targeted
8
+ // `gjsify trust <pkg> --otp <code>` read the same state fine on the SAME token —
9
+ // because the shared requester goes through `withOtpRetry`, which answers a 401
10
+ // that is an OTP challenge and tolerates transient non-OTP ones.
18
11
  import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
19
12
  import { classifyTrustList, trustUrl } from './trust-registry.js';
20
- /** Small backoff before the one-shot retry of a transient 401 read. */
21
13
  export const PROBE_RETRY_DELAY_MS = 400;
22
14
  /** Default probe concurrency — kept SMALL so a single token never bursts npm. */
23
15
  export const DEFAULT_PROBE_CONCURRENCY = 4;
@@ -39,11 +31,10 @@ function defaultSleep(ms) {
39
31
  return new Promise((resolve) => setTimeout(resolve, ms));
40
32
  }
41
33
  /**
42
- * Read one package's published + trust state via the SHARED trust requester —
43
- * the exact `trustUrl` + `buildHeaders` auth + `withOtpRetry` path `gjsify
44
- * trust` uses. On a transient `auth-required` (401 that was not an answerable
45
- * OTP challenge — e.g. a parallel-burst rejection or a lapsed 2FA-skip window),
46
- * it retries ONCE after a short backoff.
34
+ * Read one package's published + trust state through the SHARED trust requester.
35
+ * A surviving `auth-required` means the 401 was not an answerable OTP challenge —
36
+ * a parallel-burst rejection or a lapsed 2FA-skip window — and both recover on a
37
+ * single retry after a short backoff.
47
38
  */
48
39
  export async function probeTrustState(request, ws, ctx, opts = {}) {
49
40
  const registry = ctx.registryOverride ?? registryFor(ws.name, ctx.npmrc) ?? DEFAULT_REGISTRY;
@@ -60,11 +51,9 @@ export async function probeTrustState(request, ws, ctx, opts = {}) {
60
51
  return { ws, registry, url, state, httpStatus: res.status, action: actionForState(state) };
61
52
  }
62
53
  /**
63
- * Probe every selected workspace. The FIRST package is read SERIALLY so a
64
- * 2FA-gated read triggers the single shared-OTP prompt (typed once) before the
65
- * concurrent burst — the rest reuse the now-cached code — and so a single token
66
- * never faces a 127-wide parallel read. The remaining packages are read with a
67
- * bounded (small) concurrency cap.
54
+ * Probe every selected workspace. The FIRST is read SERIALLY on purpose: a
55
+ * 2FA-gated read triggers the shared-OTP prompt once, before the burst, so the rest
56
+ * reuse the cached code instead of each prompting.
68
57
  */
69
58
  export async function probeAllTrustStates(request, selected, concurrency, ctx, opts = {}) {
70
59
  if (selected.length === 0)
@@ -13,8 +13,8 @@ export type ParsedSpec = {
13
13
  * Local: starts with `./`, `../`, `/`, or is an existing directory path.
14
14
  * Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
15
15
  *
16
- * The full spec string is preserved on the registry case so it can be passed
17
- * verbatim to `npm install` (which already understands dist-tags, ranges,
18
- * git URIs, tarball URLs, etc. — we don't re-parse those).
16
+ * `spec` keeps the input verbatim so the registry case can be handed straight to
17
+ * `npm install`, which already understands dist-tags, ranges, git URIs and tarball
18
+ * URLs — none of those are re-parsed here.
19
19
  */
20
20
  export declare function parseSpec(input: string): ParsedSpec;
@@ -8,9 +8,9 @@ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
8
8
  * Local: starts with `./`, `../`, `/`, or is an existing directory path.
9
9
  * Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
10
10
  *
11
- * The full spec string is preserved on the registry case so it can be passed
12
- * verbatim to `npm install` (which already understands dist-tags, ranges,
13
- * git URIs, tarball URLs, etc. — we don't re-parse those).
11
+ * `spec` keeps the input verbatim so the registry case can be handed straight to
12
+ * `npm install`, which already understands dist-tags, ranges, git URIs and tarball
13
+ * URLs — none of those are re-parsed here.
14
14
  */
15
15
  export function parseSpec(input) {
16
16
  if (!input)
@@ -21,8 +21,7 @@ export function parseSpec(input) {
21
21
  if (existsSync(input)) {
22
22
  return { kind: 'local', path: resolve(input) };
23
23
  }
24
- // Registry spec: split off the version after the LAST `@` that isn't the
25
- // leading scope separator.
24
+ // Split the version off the LAST `@` — `lastAt > 0` skips a leading scope `@`.
26
25
  let name = input;
27
26
  let version;
28
27
  const lastAt = input.lastIndexOf('@');
@@ -29,21 +29,16 @@ export declare function parseSpec(spec: string): {
29
29
  range?: string;
30
30
  };
31
31
  /**
32
- * Collect existing dependencies + devDependencies + optionalDependencies
33
- * from a project package.json into installable specs of the form
34
- * `name@range`. Used by `gjsify install` (no args) to seed the resolver
35
- * with the project's existing dependency manifest — equivalent to
36
- * `npm install` reading `package.json`.
32
+ * Existing dependencies + devDependencies + optionalDependencies as `name@range`
33
+ * specs — what seeds the resolver for a bare `gjsify install`, the way `npm install`
34
+ * reads `package.json`.
37
35
  */
38
36
  export declare function projectSpecsFromPackageJson(pkg: PackageJson): string[];
39
37
  /**
40
- * Add or update a dependency entry in `pkg`. If the spec didn't include
41
- * a range, callers fill in the installed version after resolution and
42
- * call this again with `installedVersion` set.
38
+ * Add or update a dependency entry in `pkg`. When the spec carried no range, the
39
+ * caller calls this a second time after resolution, with `range` derived from the
40
+ * installed version via {@link defaultRangeFromVersion}.
43
41
  */
44
42
  export declare function addDependencyEntry(pkg: PackageJson, name: string, range: string, kind: DependencyKind): void;
45
- /**
46
- * Default version range when the user didn't pin one: `^x.y.z` from the
47
- * installed version. Mirrors npm's `save-prefix` default (`^`).
48
- */
43
+ /** Range used when the user pinned none — npm's `save-prefix` default (`^`). */
49
44
  export declare function defaultRangeFromVersion(version: string): string;
@@ -1,14 +1,7 @@
1
- // Helpers for editing `package.json` during `gjsify install <pkg>`.
2
- //
3
- // Mirrors npm's `--save-{prod,dev,peer,optional}` semantics:
4
- // - default → dependencies (production)
5
- // - --save-dev → devDependencies
6
- // - --save-peer → peerDependencies
7
- // - --save-optional → optionalDependencies
8
- //
9
- // Version specifier resolution mirrors npm's default (`^x.y.z` from the
10
- // installed version), unless the user passed an explicit range in the spec
11
- // (`react@^18` → keep `^18`).
1
+ // Helpers for editing `package.json` during `gjsify install <pkg>`, mirroring npm:
2
+ // `--save-{dev,peer,optional}` pick the matching block and no flag means
3
+ // `dependencies`; the saved range is `^x.y.z` off the installed version unless the
4
+ // spec carried an explicit one (`react@^18` stays `^18`).
12
5
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
13
6
  export function readPackageJson(pkgPath) {
14
7
  if (!existsSync(pkgPath))
@@ -48,11 +41,9 @@ export function parseSpec(spec) {
48
41
  return { name: spec.slice(0, at), range: spec.slice(at + 1) };
49
42
  }
50
43
  /**
51
- * Collect existing dependencies + devDependencies + optionalDependencies
52
- * from a project package.json into installable specs of the form
53
- * `name@range`. Used by `gjsify install` (no args) to seed the resolver
54
- * with the project's existing dependency manifest — equivalent to
55
- * `npm install` reading `package.json`.
44
+ * Existing dependencies + devDependencies + optionalDependencies as `name@range`
45
+ * specs — what seeds the resolver for a bare `gjsify install`, the way `npm install`
46
+ * reads `package.json`.
56
47
  */
57
48
  export function projectSpecsFromPackageJson(pkg) {
58
49
  const out = [];
@@ -61,9 +52,8 @@ export function projectSpecsFromPackageJson(pkg) {
61
52
  if (!block)
62
53
  continue;
63
54
  for (const [name, range] of Object.entries(block)) {
64
- // Skip workspace: / link: / file: / portal: specifiers — those
65
- // are workspace-local references handled by Phase D.3, not by
66
- // the project-local install path.
55
+ // `workspace:`/`link:`/`file:`/`portal:` are workspace-local references
56
+ // resolved by the workspace install path, not this project-local one.
67
57
  if (typeof range !== 'string')
68
58
  continue;
69
59
  if (/^(workspace|link|file|portal|git\+|https?):/.test(range))
@@ -74,9 +64,9 @@ export function projectSpecsFromPackageJson(pkg) {
74
64
  return out;
75
65
  }
76
66
  /**
77
- * Add or update a dependency entry in `pkg`. If the spec didn't include
78
- * a range, callers fill in the installed version after resolution and
79
- * call this again with `installedVersion` set.
67
+ * Add or update a dependency entry in `pkg`. When the spec carried no range, the
68
+ * caller calls this a second time after resolution, with `range` derived from the
69
+ * installed version via {@link defaultRangeFromVersion}.
80
70
  */
81
71
  export function addDependencyEntry(pkg, name, range, kind) {
82
72
  if (pkg[kind] === undefined) {
@@ -84,10 +74,7 @@ export function addDependencyEntry(pkg, name, range, kind) {
84
74
  }
85
75
  pkg[kind][name] = range;
86
76
  }
87
- /**
88
- * Default version range when the user didn't pin one: `^x.y.z` from the
89
- * installed version. Mirrors npm's `save-prefix` default (`^`).
90
- */
77
+ /** Range used when the user pinned none — npm's `save-prefix` default (`^`). */
91
78
  export function defaultRangeFromVersion(version) {
92
79
  return `^${version}`;
93
80
  }
@@ -1,30 +1,23 @@
1
1
  import type { PackageJson } from './pkg-json-edit.js';
2
2
  /**
3
- * Drop a leading UTF-8 BOM (`U+FEFF`, the decoded form of `EF BB BF`).
3
+ * Drop a leading UTF-8 BOM (`U+FEFF`).
4
4
  *
5
- * `JSON.parse` rejects a BOM — it is not whitespace — so a manifest carrying
6
- * one is "invalid JSON" to every reader here. That is not a hypothetical file
7
- * shape on Windows: Windows PowerShell 5.1 writes a BOM for `-Encoding utf8`,
8
- * which is its DEFAULT for `Out-File`/`Set-Content`, so any script that edits a
9
- * manifest produces one. Notepad offers "UTF-8 with BOM" too.
5
+ * `JSON.parse` rejects a BOM — it is not whitespace — yet Windows PowerShell 5.1
6
+ * writes one for `-Encoding utf8`, its DEFAULT for `Out-File`/`Set-Content`, and
7
+ * npm reads such a manifest without complaint. The file is valid to the
8
+ * ecosystem, so gjsify must not be the strict one.
10
9
  *
11
- * npm reads such a file without complaint (`npm pkg get name` on a BOM'd
12
- * manifest returns the name), so the file is valid as far as the ecosystem is
13
- * concerned and gjsify was the strict one. Stripping matches npm, VS Code and
14
- * `parse-json`'s own guidance; nothing downstream wants the character.
15
- *
16
- * Only a LEADING BOM: `U+FEFF` elsewhere is a zero-width no-break space inside
17
- * a string value, and removing it would corrupt the data.
10
+ * Only a LEADING BOM: `U+FEFF` elsewhere is a zero-width no-break space inside a
11
+ * string value, and stripping it would corrupt the data.
18
12
  */
19
13
  export declare function stripBom(text: string): string;
20
14
  /**
21
15
  * Read + parse a `package.json` at `pkgPath`. Permissive: any error (missing
22
16
  * file, unreadable, invalid JSON) yields `null`.
23
17
  *
24
- * The BOM strip matters MORE here than in a throwing reader: this one reports
25
- * an unparseable manifest as `null`, which every caller reads as "no
26
- * package.json". On Windows a BOM therefore did not produce an error — it made
27
- * a package look like it had no manifest at all, so `gjsify check` found no
28
- * `check` script, discovery skipped the package, and nothing said why.
18
+ * The BOM strip matters more here than in a throwing reader, because callers
19
+ * read `null` as "no package.json": on Windows a BOM produced no error at all,
20
+ * it made the package look manifest-less, so `gjsify check` found no `check`
21
+ * script, discovery skipped the package, and nothing said why.
29
22
  */
30
23
  export declare function readPackageJson(pkgPath: string): PackageJson | null;
@@ -1,31 +1,23 @@
1
- // Single permissive reader for `package.json` files, shared by the CLI's
2
- // workspace- and dependency-discovery paths (`gjsify run`/`workspace`/`foreach`/
3
- // `check`, native-prebuild scanning). Returns `null` when the file is missing
4
- // OR unparseable — discovery must degrade gracefully on a stray or corrupt
5
- // manifest rather than crash the whole command.
1
+ // Permissive `package.json` reader for the CLI's workspace- and
2
+ // dependency-discovery paths: `null` when the file is missing OR unparseable, so
3
+ // discovery degrades gracefully on a stray or corrupt manifest instead of
4
+ // crashing the whole command.
6
5
  //
7
- // The install-save path keeps its own STRICTER reader in `pkg-json-edit.ts`
8
- // (`readPackageJson` there): it returns `null` only when the file is absent but
9
- // THROWS on invalid JSON, so a corrupt manifest is never silently overwritten
10
- // during `gjsify install <pkg>`. That contract is intentional and load-bearing
11
- // — do not fold it into this one.
6
+ // The install-save path deliberately keeps a STRICTER reader in
7
+ // `pkg-json-edit.ts` — `null` only when absent, THROWS on invalid JSON — so
8
+ // `gjsify install <pkg>` never silently overwrites a corrupt manifest. Do not
9
+ // fold the two together.
12
10
  import { readFileSync } from 'node:fs';
13
11
  /**
14
- * Drop a leading UTF-8 BOM (`U+FEFF`, the decoded form of `EF BB BF`).
12
+ * Drop a leading UTF-8 BOM (`U+FEFF`).
15
13
  *
16
- * `JSON.parse` rejects a BOM — it is not whitespace — so a manifest carrying
17
- * one is "invalid JSON" to every reader here. That is not a hypothetical file
18
- * shape on Windows: Windows PowerShell 5.1 writes a BOM for `-Encoding utf8`,
19
- * which is its DEFAULT for `Out-File`/`Set-Content`, so any script that edits a
20
- * manifest produces one. Notepad offers "UTF-8 with BOM" too.
14
+ * `JSON.parse` rejects a BOM — it is not whitespace — yet Windows PowerShell 5.1
15
+ * writes one for `-Encoding utf8`, its DEFAULT for `Out-File`/`Set-Content`, and
16
+ * npm reads such a manifest without complaint. The file is valid to the
17
+ * ecosystem, so gjsify must not be the strict one.
21
18
  *
22
- * npm reads such a file without complaint (`npm pkg get name` on a BOM'd
23
- * manifest returns the name), so the file is valid as far as the ecosystem is
24
- * concerned and gjsify was the strict one. Stripping matches npm, VS Code and
25
- * `parse-json`'s own guidance; nothing downstream wants the character.
26
- *
27
- * Only a LEADING BOM: `U+FEFF` elsewhere is a zero-width no-break space inside
28
- * a string value, and removing it would corrupt the data.
19
+ * Only a LEADING BOM: `U+FEFF` elsewhere is a zero-width no-break space inside a
20
+ * string value, and stripping it would corrupt the data.
29
21
  */
30
22
  export function stripBom(text) {
31
23
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
@@ -34,11 +26,10 @@ export function stripBom(text) {
34
26
  * Read + parse a `package.json` at `pkgPath`. Permissive: any error (missing
35
27
  * file, unreadable, invalid JSON) yields `null`.
36
28
  *
37
- * The BOM strip matters MORE here than in a throwing reader: this one reports
38
- * an unparseable manifest as `null`, which every caller reads as "no
39
- * package.json". On Windows a BOM therefore did not produce an error — it made
40
- * a package look like it had no manifest at all, so `gjsify check` found no
41
- * `check` script, discovery skipped the package, and nothing said why.
29
+ * The BOM strip matters more here than in a throwing reader, because callers
30
+ * read `null` as "no package.json": on Windows a BOM produced no error at all,
31
+ * it made the package look manifest-less, so `gjsify check` found no `check`
32
+ * script, discovery skipped the package, and nothing said why.
42
33
  */
43
34
  export function readPackageJson(pkgPath) {
44
35
  try {
@@ -1,7 +1,6 @@
1
1
  /**
2
- * Prefix every line of `src` with `prefix` and route it to `sink`. When
3
- * `buffered` is false (tty), lines are written live. When true (non-tty /
4
- * CI pipe), they are accumulated and emitted only by the returned `flush()`
5
- * — call it once the child has closed.
2
+ * Prefix every line of `src` with `prefix` and route it to `sink`. `buffered`
3
+ * withholds everything until the returned `flush()` runs — call that only once
4
+ * the child has closed.
6
5
  */
7
6
  export declare function prefixLines(src: NodeJS.ReadableStream, sink: NodeJS.WritableStream, prefix: string, buffered: boolean): () => void;
@@ -1,25 +1,19 @@
1
1
  // Shared line-prefixed child-output forwarding for the parallel runners
2
- // (`gjsify foreach`, `gjsify check`).
2
+ // (`gjsify foreach`, `gjsify check`) — one home for it, because the second,
3
+ // unbuffered copy kept the hang below alive after the first was fixed.
3
4
  //
4
- // Under GJS, `process.stdout.write` is a BLOCKING `Gio.write_all`. Writing
5
- // each prefixed line LIVE during a PARALLEL run into a backpressuring pipe
6
- // (a CI log collector that drains slowly) stalls the single GLib main loop
7
- // on a full pipe → every parallel child's pipe backs up → their reads stall
8
- // → the whole run HANGS. (A tty or file sink never backpressures, which is
9
- // why this only ever bites in CI.) On a NON-tty sink we therefore BUFFER
10
- // each child's prefixed output and flush it as ONE write when that child
11
- // closes: the child is already done by then, so its own read can't stall,
12
- // and concurrent flushes serialize into brief loop stalls instead of a
13
- // deadlock. On a tty (interactive) lines are written live for responsive
14
- // output.
15
- //
16
- // This helper is the single home for that logic — `foreach` shipped the fix
17
- // first; `check` had an unbuffered copy that kept the hang class alive.
5
+ // Under GJS `process.stdout.write` is a BLOCKING `Gio.write_all`. Writing lines
6
+ // live during a PARALLEL run into a backpressuring sink (a slow-draining CI log
7
+ // collector) stalls the single GLib main loop on a full pipe → every child's pipe
8
+ // backs up → their reads stall → the run HANGS. A tty or file sink never
9
+ // backpressures, which is why this only bites in CI. So on a NON-tty sink each
10
+ // child's output is buffered and flushed as ONE write after it closes: the child
11
+ // can no longer stall, and concurrent flushes serialize into brief loop stalls
12
+ // instead of a deadlock. A tty gets live lines, for responsiveness.
18
13
  /**
19
- * Prefix every line of `src` with `prefix` and route it to `sink`. When
20
- * `buffered` is false (tty), lines are written live. When true (non-tty /
21
- * CI pipe), they are accumulated and emitted only by the returned `flush()`
22
- * — call it once the child has closed.
14
+ * Prefix every line of `src` with `prefix` and route it to `sink`. `buffered`
15
+ * withholds everything until the returned `flush()` runs — call that only once
16
+ * the child has closed.
23
17
  */
24
18
  export function prefixLines(src, sink, prefix, buffered) {
25
19
  let buf = '';
@@ -15,10 +15,9 @@ export interface KeyOutcome {
15
15
  interrupt: boolean;
16
16
  }
17
17
  /**
18
- * Pure key handler for the raw-mode line editor — the single source of truth
19
- * for how a keystroke updates the buffer + echo. Kept pure (no I/O) so it is
20
- * unit-testable: this is what makes Enter (`\r` OR `\n`) reliably submit, masks
21
- * passwords, and treats Ctrl-C as an interrupt rather than text.
18
+ * Pure key handler for the raw-mode line editor — the single source of truth for how a keystroke
19
+ * updates the buffer + echo. Pure (no I/O) so the rules are unit-testable: Enter is `\r` OR `\n`,
20
+ * passwords mask, and Ctrl-C is an interrupt rather than text.
22
21
  */
23
22
  export declare function applyKey(buf: string, ch: string, mask: boolean): KeyOutcome;
24
23
  /** Print a question and read one visible line. */
@@ -1,20 +1,17 @@
1
1
  // Interactive prompts for `gjsify login` / `gjsify trust`.
2
2
  //
3
- // On a TTY every prompt runs inside a single RAW-mode session (`runRawSession`)
4
- // that reads key-by-key with manual echo and a guaranteed cooked-mode restore
5
- // (try/finally). This deliberately does NOT rely on the terminal's cooked
6
- // line-discipline nor on the shared `process.stdin` resume/pause cycle:
7
- // - The old visible prompt read a cooked line via `process.stdin` 'data'
8
- // events. With the line discipline in an unexpected state (e.g. ICRNL off
9
- // after a prior raw prompt) Enter arrived as a bare `\r` that never
10
- // terminated the cooked line — the prompt hung showing `name^M`. The
11
- // resume/pause churn on the shared stdin singleton between two sequential
12
- // prompts also intermittently dropped the line or resolved it empty.
13
- // - Reading raw + handling `\r`/`\n` ourselves removes both failure modes.
3
+ // On a TTY every prompt runs inside a single RAW-mode session (`runRawSession`) reading
4
+ // key-by-key with manual echo and a guaranteed cooked-mode restore. It deliberately relies on
5
+ // neither the terminal's cooked line-discipline nor the shared `process.stdin` resume/pause
6
+ // cycle: a cooked read with the line discipline in an unexpected state (ICRNL off after a prior
7
+ // raw prompt) saw Enter arrive as a bare `\r` that never terminated the line, hanging the prompt
8
+ // showing `name^M`, and the resume/pause churn on the shared stdin singleton between sequential
9
+ // prompts intermittently dropped the line or resolved it empty. Handling `\r`/`\n` here removes
10
+ // both failure modes.
14
11
  //
15
- // Raw mode (via @gjsify/process → terminal-native) now also clears ISIG, so
16
- // Ctrl-C is delivered as a `\x03` keystroke we handle (restore + exit) instead
17
- // of a SIGINT that would kill the process leaving the terminal in raw mode.
12
+ // Raw mode (via @gjsify/process → terminal-native) also clears ISIG, so Ctrl-C arrives as a
13
+ // `\x03` keystroke handled below (restore + exit) rather than a SIGINT that would kill the
14
+ // process leaving the terminal in raw mode.
18
15
  //
19
16
  // Non-TTY stdin (piped input, CI) keeps a plain line read so
20
17
  // `printf 'user\npass\n' | gjsify login` still works.
@@ -26,10 +23,9 @@ function isTtyStdin() {
26
23
  return Boolean(stdin.isTTY) && typeof stdin.setRawMode === 'function';
27
24
  }
28
25
  /**
29
- * Pure key handler for the raw-mode line editor — the single source of truth
30
- * for how a keystroke updates the buffer + echo. Kept pure (no I/O) so it is
31
- * unit-testable: this is what makes Enter (`\r` OR `\n`) reliably submit, masks
32
- * passwords, and treats Ctrl-C as an interrupt rather than text.
26
+ * Pure key handler for the raw-mode line editor — the single source of truth for how a keystroke
27
+ * updates the buffer + echo. Pure (no I/O) so the rules are unit-testable: Enter is `\r` OR `\n`,
28
+ * passwords mask, and Ctrl-C is an interrupt rather than text.
33
29
  */
34
30
  export function applyKey(buf, ch, mask) {
35
31
  if (ch === '\r' || ch === '\n') {
@@ -3,36 +3,31 @@ export type Diagnose404Reason = 'dead-token' | 'live-token-404' | 'unknown';
3
3
  export interface Diagnose404Result {
4
4
  /** Discriminant — drives the JSON shape + exit-code path in the caller. */
5
5
  reason: Diagnose404Reason;
6
- /** Username from `/-/whoami` when reason === 'live-token-404'. */
6
+ /** Set only when `reason === 'live-token-404'`. */
7
7
  username?: string;
8
- /** Multi-line, human-friendly hint ready for stderr emission. */
8
+ /** Multi-line hint, ready for stderr as-is. */
9
9
  message: string;
10
10
  }
11
11
  interface Diagnose404Input {
12
12
  /** Full package name including scope, e.g. `@gjsify/abort-controller`. */
13
13
  packageName: string;
14
- /** Pinned version we tried to publish. */
15
14
  version: string;
16
- /** Registry URL (with or without trailing slash). */
15
+ /** Registry URL, with or without trailing slash. */
17
16
  registry: string;
18
- /** Parsed .npmrc — used to derive Authorization on the whoami probe. */
17
+ /** Source of the Authorization header on the whoami probe. */
19
18
  npmrc: NpmrcConfig | undefined;
20
19
  }
21
20
  /**
22
- * Probe `/-/whoami` to disambiguate the 404 cause. Best-effort: any thrown
23
- * error / non-2xx falls through to `reason: 'unknown'` so the caller's
24
- * generic error path runs untouched.
25
- *
26
- * Pure async I/O — no side effects, no process.exit, no console writes.
27
- * The caller owns presentation (stderr vs stdout, plain vs JSON).
21
+ * Probe `/-/whoami` to disambiguate the 404 cause. Best-effort: a thrown error or
22
+ * non-2xx yields `reason: 'unknown'`, leaving the caller's generic error path
23
+ * untouched. No side effects and no writes — the caller owns presentation.
28
24
  */
29
25
  export declare function diagnose404(input: Diagnose404Input): Promise<Diagnose404Result>;
30
26
  /**
31
- * Heuristic: is the 404 body the "dead-token-or-missing-package" shape?
32
- * npm's PUT returns plain text `Not Found` (sometimes empty body). We trigger
33
- * the diagnostic for both, plus for the JSON `{"error":"Not Found"}` shape
34
- * just in case. Other 404 bodies (e.g. with a structured npm error code)
35
- * keep the generic error path.
27
+ * Is the 404 body the "dead-token-or-missing-package" shape? npm's PUT answers
28
+ * with plain-text `Not Found` or an empty body; the JSON `{"error":"Not Found"}`
29
+ * form is accepted too. Any other 404 body — notably one carrying a structured npm
30
+ * error code — keeps the generic error path.
36
31
  */
37
32
  export declare function is404DiagnosticCandidate(body: string): boolean;
38
33
  export {};
@@ -1,31 +1,19 @@
1
1
  // Dead-token vs new-package diagnostic for `gjsify publish`.
2
2
  //
3
- // When the publish PUT returns `404 Not Found` (body `"Not Found"` or empty)
4
- // it is ambiguous between two very different situations:
3
+ // A `404 Not Found` on the publish PUT is npm's shape for two very different
4
+ // causes, and guessing sends the user down the wrong path entirely: the
5
+ // `_authToken` has been revoked/expired (npm answers an unrecognised bearer with
6
+ // 404, not 401), or the scoped package genuinely is not on npm yet and needs the
7
+ // first-publish bootstrap from AGENTS.md.
5
8
  //
6
- // 1. The `_authToken` in ~/.npmrc has been revoked / expired. The npm
7
- // registry's 404 is its way of saying "we don't recognize this
8
- // bearer". Easy to mistake for a missing-package error and send the
9
- // user down the wrong path (Trusted-Publisher-bootstrap setup).
10
- // 2. The scoped `@gjsify/<pkg>` genuinely doesn't exist on npm yet —
11
- // the first-publish bootstrap step from AGENTS.md.
12
- //
13
- // `GET /-/whoami` with the SAME Authorization header disambiguates: a live
14
- // token returns `{"username": "..."}`, a dead token returns `{}` (status 200
15
- // in both cases — the empty body is npm's signal).
16
- //
17
- // `npm publish` from npm-cli handles dead tokens with `401 EOTP` + a clear
18
- // "one-time-password or refresh the token" hint. The 404 path is npm's
19
- // PUT-specific shape and `gjsify publish` previously just printed the
20
- // opaque body. This helper closes the diagnostic gap.
9
+ // `GET /-/whoami` with the SAME Authorization header disambiguates: a live token
10
+ // returns `{"username": "…"}`, a dead one returns `{}` — status 200 for both, so
11
+ // the EMPTY BODY is the signal.
21
12
  import { whoami } from '@gjsify/npm-registry';
22
13
  /**
23
- * Probe `/-/whoami` to disambiguate the 404 cause. Best-effort: any thrown
24
- * error / non-2xx falls through to `reason: 'unknown'` so the caller's
25
- * generic error path runs untouched.
26
- *
27
- * Pure async I/O — no side effects, no process.exit, no console writes.
28
- * The caller owns presentation (stderr vs stdout, plain vs JSON).
14
+ * Probe `/-/whoami` to disambiguate the 404 cause. Best-effort: a thrown error or
15
+ * non-2xx yields `reason: 'unknown'`, leaving the caller's generic error path
16
+ * untouched. No side effects and no writes — the caller owns presentation.
29
17
  */
30
18
  export async function diagnose404(input) {
31
19
  const { packageName, version, registry, npmrc } = input;
@@ -85,11 +73,10 @@ function formatUnknown(name, version) {
85
73
  return `gjsify publish: ${name}@${version} — 404 Not Found`;
86
74
  }
87
75
  /**
88
- * Heuristic: is the 404 body the "dead-token-or-missing-package" shape?
89
- * npm's PUT returns plain text `Not Found` (sometimes empty body). We trigger
90
- * the diagnostic for both, plus for the JSON `{"error":"Not Found"}` shape
91
- * just in case. Other 404 bodies (e.g. with a structured npm error code)
92
- * keep the generic error path.
76
+ * Is the 404 body the "dead-token-or-missing-package" shape? npm's PUT answers
77
+ * with plain-text `Not Found` or an empty body; the JSON `{"error":"Not Found"}`
78
+ * form is accepted too. Any other 404 body — notably one carrying a structured npm
79
+ * error code — keeps the generic error path.
93
80
  */
94
81
  export function is404DiagnosticCandidate(body) {
95
82
  const trimmed = body.trim();