@gjsify/cli 0.44.0 → 0.45.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 (73) hide show
  1. package/dist/cli.gjs.mjs +192 -165
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +833 -54
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/types/config-data.d.ts +63 -1
  11. package/lib/utils/msgfmt-merge.d.ts +35 -0
  12. package/lib/utils/msgfmt-merge.js +102 -0
  13. package/lib/utils/npm-otp.d.ts +27 -2
  14. package/lib/utils/npm-otp.js +82 -7
  15. package/lib/utils/onboard-discovery.d.ts +68 -0
  16. package/lib/utils/onboard-discovery.js +178 -0
  17. package/lib/utils/onboard-probe.d.ts +118 -1
  18. package/lib/utils/onboard-probe.js +153 -4
  19. package/lib/utils/prompt-output.d.ts +12 -0
  20. package/lib/utils/prompt-output.js +54 -0
  21. package/lib/utils/prompt.js +22 -2
  22. package/lib/utils/publishable-packages.d.ts +1 -1
  23. package/lib/utils/publishable-packages.js +1 -0
  24. package/lib/utils/resolve-npm-package.d.ts +56 -0
  25. package/lib/utils/resolve-npm-package.js +69 -0
  26. package/lib/utils/ship/app-runtime.d.ts +214 -0
  27. package/lib/utils/ship/app-runtime.js +429 -0
  28. package/lib/utils/ship/depends.d.ts +50 -0
  29. package/lib/utils/ship/depends.js +117 -3
  30. package/lib/utils/ship/discover.js +18 -1
  31. package/lib/utils/ship/dmg.d.ts +71 -0
  32. package/lib/utils/ship/dmg.js +181 -0
  33. package/lib/utils/ship/flatpak.js +1 -1
  34. package/lib/utils/ship/formats.d.ts +85 -7
  35. package/lib/utils/ship/formats.js +734 -15
  36. package/lib/utils/ship/launcher.d.ts +13 -3
  37. package/lib/utils/ship/launcher.js +362 -17
  38. package/lib/utils/ship/layout.d.ts +268 -0
  39. package/lib/utils/ship/layout.js +351 -0
  40. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  41. package/lib/utils/ship/localize-metadata.js +126 -0
  42. package/lib/utils/ship/mime.js +1 -9
  43. package/lib/utils/ship/msi.d.ts +126 -0
  44. package/lib/utils/ship/msi.js +417 -0
  45. package/lib/utils/ship/node-runtime.d.ts +71 -0
  46. package/lib/utils/ship/node-runtime.js +122 -0
  47. package/lib/utils/ship/payload.d.ts +156 -5
  48. package/lib/utils/ship/payload.js +580 -14
  49. package/lib/utils/ship/plan.d.ts +21 -0
  50. package/lib/utils/ship/plan.js +20 -7
  51. package/lib/utils/ship/plist.d.ts +58 -0
  52. package/lib/utils/ship/plist.js +198 -0
  53. package/lib/utils/ship/rpm.js +9 -3
  54. package/lib/utils/ship/schemas.d.ts +61 -0
  55. package/lib/utils/ship/schemas.js +118 -0
  56. package/lib/utils/ship/scripts.js +7 -3
  57. package/lib/utils/ship/settings.d.ts +19 -1
  58. package/lib/utils/ship/settings.js +12 -1
  59. package/lib/utils/ship/share-dirs.d.ts +23 -0
  60. package/lib/utils/ship/share-dirs.js +46 -0
  61. package/lib/utils/ship/signing.d.ts +195 -0
  62. package/lib/utils/ship/signing.js +317 -0
  63. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  64. package/lib/utils/ship/stage-manifest.js +99 -13
  65. package/lib/utils/ship/stage-writer.d.ts +48 -0
  66. package/lib/utils/ship/stage-writer.js +71 -1
  67. package/lib/utils/ship/types.d.ts +158 -13
  68. package/lib/utils/ship/xml.d.ts +7 -0
  69. package/lib/utils/ship/xml.js +25 -0
  70. package/lib/utils/ship/zip.d.ts +69 -0
  71. package/lib/utils/ship/zip.js +245 -0
  72. package/lib/utils/trust-registry.js +5 -0
  73. package/package.json +19 -19
@@ -20,6 +20,12 @@ export interface TrustRequestResult {
20
20
  status: number;
21
21
  json: unknown;
22
22
  text: string;
23
+ /**
24
+ * Response headers, so a caller can honour npm's own `Retry-After` on a 429
25
+ * instead of guessing a backoff. Optional because test doubles construct this
26
+ * shape by hand and a missing header is a well-defined "it said nothing".
27
+ */
28
+ headers?: Headers;
23
29
  }
24
30
  /** A `(method, url, body?) => TrustRequestResult` function bound to a registry auth + OTP context. */
25
31
  export type TrustRequester = (method: string, url: string, body?: unknown) => Promise<TrustRequestResult>;
@@ -279,7 +279,7 @@ export function createTrustRequester(ctx) {
279
279
  catch {
280
280
  /* non-JSON body — leave json undefined */
281
281
  }
282
- return { status: res.status, json, text };
282
+ return { status: res.status, json, text, headers: res.headers };
283
283
  };
284
284
  }
285
285
  function firstLine(s) {
@@ -430,7 +430,15 @@ export interface ConfigDataShip extends AppMetadata {
430
430
  release?: string;
431
431
  /** `Maintainer:` / `Packager:` as `Name <email>`. Default: `package.json#author`. */
432
432
  maintainer?: string;
433
- /** Formats to build when `--target` is not given. Default `['deb', 'rpm']`. */
433
+ /**
434
+ * Formats to build when `--target` is not given. Default `['deb', 'rpm']`.
435
+ *
436
+ * A project-level DEFAULT, so it is filtered to the layout `gjsify ship <os>`
437
+ * assembles rather than refused against it — a name here that wraps another
438
+ * OS's layout is dropped, and `gjsify ship` prints which. A `--target` on the
439
+ * command line is a claim about ONE run and is refused instead. Without that
440
+ * split, declaring this key made `gjsify ship darwin --stage` impossible.
441
+ */
434
442
  targets?: string[];
435
443
  /** Output root, relative to the project. Default `'ship'`. */
436
444
  outDir?: string;
@@ -457,6 +465,18 @@ export interface ConfigDataShip extends AppMetadata {
457
465
  * start.
458
466
  */
459
467
  minGjsVersion?: string;
468
+ /**
469
+ * Minimum Node major the emitted dependency asks for, when the payload is a
470
+ * `--app node` bundle. Default `'24'` — the LTS line the
471
+ * `@gjsify/node-runtime-*` packages bundle for macOS and Windows.
472
+ *
473
+ * Lower it only if the bundle genuinely runs on an older Node. The default
474
+ * excludes every current DEB stable/LTS (trixie 20, Ubuntu 24.04 18, Ubuntu
475
+ * 26.04 22), so it makes a `.deb` that apt refuses there rather than one that
476
+ * installs and then dies on the first unsupported syntax — the same trade
477
+ * `minGjsVersion` makes, and `gjsify ship` warns about it either way.
478
+ */
479
+ minNodeVersion?: string;
460
480
  /**
461
481
  * Runtime dependencies appended to the set derived from the bundle's
462
482
  * `gi://` imports — the escape hatch for a namespace the table does not
@@ -519,6 +539,48 @@ export interface ConfigDataShip extends AppMetadata {
519
539
  * NOT move, is `utils/ship/flatpak-config.ts`.
520
540
  */
521
541
  flatpak?: ShipFlatpakOptions;
542
+ /**
543
+ * The signing identity this project uses by default, per OS.
544
+ *
545
+ * `--sign <identity>` on the command line wins. Absent means unsigned, which
546
+ * is the default path and a legitimate output (ADR 0024 § A13).
547
+ */
548
+ sign?: ShipSignOptions;
549
+ }
550
+ /**
551
+ * `gjsify.ship.sign` — an IDENTITY per OS, and never a certificate.
552
+ *
553
+ * KEYED PER OS, and the two keys must not be one field: a Developer ID string
554
+ * and an Authenticode subject name live in different namespaces, and the value
555
+ * that resolves is chosen by the LAYOUT being packed (`FormatDescriptor.layoutOs`
556
+ * and the stage manifest's `target.os`), not by the host doing the packing
557
+ * (ADR 0024 § A12).
558
+ *
559
+ * `linux` is deliberately absent: a `.deb` and an `.rpm` carry no per-file
560
+ * signature — the artifact is signed as a whole by the repository that serves
561
+ * it, with that repository's key. A key here would look like it did something.
562
+ *
563
+ * WHAT IS NOT HERE, and each is a decision rather than an omission:
564
+ * - a certificate, a `.p12` path or a password. `codesign` and `signtool` are
565
+ * handed a NAME and resolve the private key themselves, so `gjsify ship`
566
+ * never sees a secret and nothing on this surface has to be redacted from a
567
+ * log line (§ A14).
568
+ * - an `entitlements` key. § A16 leaves library validation vs. hardened runtime
569
+ * explicitly unmeasured, and a config key for an unmeasured mechanism is a
570
+ * promise the implementation cannot keep.
571
+ * - a notarisation credential. That is a per-RUN account credential, not a
572
+ * machine-held identity, and a config key for one is an invitation to commit
573
+ * it — `--notarize <keychain-profile>` is the only way to pass it.
574
+ */
575
+ export interface ShipSignOptions {
576
+ /** `codesign --sign <identity>`. `'-'` is ad-hoc and needs no developer identity. */
577
+ darwin?: {
578
+ identity?: string;
579
+ };
580
+ /** `signtool sign /n <identity>` — the certificate's SUBJECT NAME in a Windows store. */
581
+ win32?: {
582
+ identity?: string;
583
+ };
522
584
  }
523
585
  /**
524
586
  * The Flatpak knobs `gjsify ship` reads. Every one has a derived default.
@@ -0,0 +1,35 @@
1
+ /** Which of msgfmt's two template writers to run. */
2
+ export type MsgfmtMergeMode = '--desktop' | '--xml';
3
+ /** One catalogue: the language tag msgfmt is TOLD, and the `.po` holding it. */
4
+ export interface MsgfmtCatalogue {
5
+ /** Passed as `--locale=`. Named explicitly so no `LINGUAS` file is needed — constraint 2. */
6
+ locale: string;
7
+ /** Path to a `.po`. msgfmt reads only this format here: a `.mo` gives `syntax error`, exit 1. */
8
+ po: string;
9
+ }
10
+ export interface MsgfmtMergeOptions {
11
+ mode: MsgfmtMergeMode;
12
+ /** The file the FIRST call substitutes into. Every later call substitutes into the previous output. */
13
+ template: string;
14
+ /** The suffix every intermediate keeps — load-bearing for `--xml`, see constraint 4. */
15
+ extension: string;
16
+ catalogues: readonly MsgfmtCatalogue[];
17
+ /** A directory this function may fill with intermediates; the caller owns its removal. */
18
+ workDir: string;
19
+ /** Called with each msgfmt argv, for `--verbose`. */
20
+ onCall?: (args: readonly string[]) => void;
21
+ }
22
+ /**
23
+ * Fold every catalogue into one file, chaining `msgfmt` once per language.
24
+ *
25
+ * Returns the PATH of the last file written — the caller decides whether to read
26
+ * it, post-process it or copy it. With no catalogues that is `template` itself,
27
+ * so a project with nothing to merge never spawns msgfmt at all.
28
+ *
29
+ * `stdio: 'pipe'` so a failure's stderr survives on the thrown error rather than
30
+ * going to the terminal and leaving the caller with an exit code.
31
+ *
32
+ * Throws when two catalogues name the same locale — constraint 5, which msgfmt
33
+ * itself reports as two successes and one invalid file.
34
+ */
35
+ export declare function mergeCatalogues(options: MsgfmtMergeOptions): string;
@@ -0,0 +1,102 @@
1
+ // Merging gettext catalogues INTO a template — the `msgfmt --desktop` / `--xml` half.
2
+ //
3
+ // Two callers need this and they are not variants of each other: `gjsify gettext`
4
+ // substitutes a template a user wrote, `gjsify ship` substitutes metadata it just
5
+ // rendered. What they share is every constraint below, all five measured against
6
+ // GNU gettext 0.26, and each one fails a DIFFERENT way — two of them silently.
7
+ // Written twice, the silent ones are the copies that lose them.
8
+ //
9
+ // 1. `--desktop` and `--xml` REQUIRE `--template`:
10
+ // `msgfmt: --desktop requires a "--template template" specification`, exit 1.
11
+ // Neither format can be produced from `.po` files alone, so a caller without a
12
+ // template has nothing to ask for.
13
+ //
14
+ // 2. The bulk form (`-d <podir>`) fails SILENTLY. With no `LINGUAS` file beside
15
+ // the `.po` files:
16
+ // msgfmt --desktop --template=app.desktop.in -d po -o out.desktop
17
+ // exit 0 · stderr `po/LINGUAS does not exist` · out.desktop UNTRANSLATED
18
+ // and `desktop-file-validate out.desktop` then exits 0 as well. Exit 0, an
19
+ // English file and a passing oracle is the worst of the three outcomes, which
20
+ // is why this module names each language with `--locale=` instead: that form
21
+ // needs no `LINGUAS` and cannot degrade to a no-op without a non-zero exit.
22
+ //
23
+ // 3. The template and the output must never be the SAME path. msgfmt truncates
24
+ // `--output-file` before it reads the template, so chaining in place destroys
25
+ // it: measured, `--desktop` wrote a 0-byte file and exited 0, `--xml` printed
26
+ // `cannot read <file>: Document is empty` and died on SIGSEGV (exit 139).
27
+ // Hence the numbered intermediates below rather than one file rewritten.
28
+ //
29
+ // 4. `--xml` finds its ITS rules by FILENAME PATTERN, not by reading the document.
30
+ // gettext walks `/usr/share/gettext/its/*.loc`, where each rule pairs a glob
31
+ // with a root element; AppStream's is `pattern="*.metainfo.xml"` +
32
+ // `localName="component"`, so the same bytes named `foo.xml` die with
33
+ // `msgfmt: cannot locate ITS rules for foo.xml` (exit 1). `extension` is
34
+ // therefore part of this function's contract, not a temp-file detail.
35
+ //
36
+ // Stated as "`--xml` needs a `.metainfo.xml` name" the rule would be wrong in
37
+ // the direction that matters next: `shared-mime-info.loc` pairs `pattern="*.xml"`
38
+ // with `localName="mime-info"`, so a MIME package needs no suffix care at all.
39
+ // The constraint belongs to the AppStream rule, not to `--xml`.
40
+ //
41
+ // 5. A locale may appear at most ONCE in a chain. Two calls naming the same
42
+ // `--locale` both APPEND — neither replaces the other's key — and the result
43
+ // is a file BOTH oracles reject, from two exit-0 msgfmt calls:
44
+ // msgfmt --desktop --template=app.desktop.in --locale=de -o d0 a.po # 0
45
+ // msgfmt --desktop --template=d0 --locale=de -o d1 b.po # 0
46
+ // desktop-file-validate d1
47
+ // → error: file contains multiple keys named "Name[de]" # 1
48
+ // appstreamcli validate --no-net x1.metainfo.xml (same shape, --xml)
49
+ // → E: tag-duplicated name (lang=de) · Validation failed # 3
50
+ // So this is worse than constraint 2: not an untranslated file that installs
51
+ // fine, but an INVALID one, produced without a single non-zero exit. It is
52
+ // refused here rather than deduplicated here, because the caller holding two
53
+ // catalogues for one language is the only party that can say which
54
+ // translation wins — `utils/ship/localize-metadata.ts` answers it with
55
+ // `msgcat --use-first`.
56
+ import { execFileSync } from 'node:child_process';
57
+ import { join } from 'node:path';
58
+ /**
59
+ * Fold every catalogue into one file, chaining `msgfmt` once per language.
60
+ *
61
+ * Returns the PATH of the last file written — the caller decides whether to read
62
+ * it, post-process it or copy it. With no catalogues that is `template` itself,
63
+ * so a project with nothing to merge never spawns msgfmt at all.
64
+ *
65
+ * `stdio: 'pipe'` so a failure's stderr survives on the thrown error rather than
66
+ * going to the terminal and leaving the caller with an exit code.
67
+ *
68
+ * Throws when two catalogues name the same locale — constraint 5, which msgfmt
69
+ * itself reports as two successes and one invalid file.
70
+ */
71
+ export function mergeCatalogues(options) {
72
+ let current = options.template;
73
+ // BEFORE the first spawn, so a caller that got this wrong is told rather than
74
+ // handed a half-written chain to clean up. `seen` is a Set of the locale, not
75
+ // of the catalogue: the same language reached through two `.po` files is
76
+ // exactly the case that produces the duplicate key.
77
+ const seen = new Set();
78
+ for (const { locale } of options.catalogues) {
79
+ if (seen.has(locale)) {
80
+ throw new Error(`mergeCatalogues: two catalogues both claim locale "${locale}". msgfmt APPENDS each ` +
81
+ `--locale, so the chain would emit a duplicate key (desktop-file-validate: ` +
82
+ `"multiple keys named Name[${locale}]"; appstreamcli: "tag-duplicated") from two ` +
83
+ 'exit-0 calls. Merge them into one .po first (msgcat --use-first) and pass that.');
84
+ }
85
+ seen.add(locale);
86
+ }
87
+ for (const [index, catalogue] of options.catalogues.entries()) {
88
+ const next = join(options.workDir, `${index}${options.extension}`);
89
+ const args = [
90
+ options.mode,
91
+ `--template=${current}`,
92
+ `--locale=${catalogue.locale}`,
93
+ `--output-file=${next}`,
94
+ catalogue.po,
95
+ ];
96
+ options.onCall?.(args);
97
+ execFileSync('msgfmt', args, { stdio: 'pipe' });
98
+ // Every language after the first merges INTO what the previous one wrote.
99
+ current = next;
100
+ }
101
+ return current;
102
+ }
@@ -31,6 +31,23 @@ export declare class OtpProvider {
31
31
  private cached;
32
32
  private readonly promptFn;
33
33
  private readonly registry;
34
+ /**
35
+ * Bumped every time a NEW code becomes current. Concurrent callers compare the
36
+ * epoch they last saw against this one to tell "my code was rejected" from
37
+ * "somebody already replaced it while I was waiting" — the second needs no
38
+ * prompt, it needs the other one's code.
39
+ */
40
+ private generation;
41
+ /**
42
+ * The prompt currently on screen, if any. THE fix for a sweep that probes with
43
+ * concurrency > 1: `promptLine` puts the terminal in RAW mode and echoes each
44
+ * keystroke by hand (utils/prompt.ts), so two live prompts mean two `data`
45
+ * listeners on the same stdin and every digit is echoed TWICE. Measured on a
46
+ * real 703-package sweep at the default concurrency of 4: five prompts stacked
47
+ * up and the typed code came back as `666644440000999944444444` — four echoes
48
+ * per keystroke. Whoever arrives second awaits this promise instead.
49
+ */
50
+ private pending;
34
51
  /**
35
52
  * @param seed Initial cached code (from `--otp`), or undefined.
36
53
  * @param promptFn How to read a fresh code. Defaults to the TTY-aware
@@ -41,17 +58,25 @@ export declare class OtpProvider {
41
58
  constructor(seed?: string, promptFn?: OtpPromptFn, options?: OtpProviderOptions);
42
59
  /** The currently cached OTP code, if any. */
43
60
  current(): string | undefined;
61
+ /**
62
+ * How many times a new code has become current. Capture it before an attempt
63
+ * and hand it back to {@link refresh} / {@link invalidate} so a concurrent
64
+ * caller neither prompts for a code somebody already typed nor throws that
65
+ * code away on its own stale rejection.
66
+ */
67
+ epoch(): number;
44
68
  /**
45
69
  * Prompt for a fresh code, cache it (in-process + file cache), and return the
46
70
  * trimmed input — `''` when nothing was entered, which callers read as "give up".
47
71
  */
48
- refresh(question?: string): Promise<string>;
72
+ refresh(question?: string, sinceEpoch?: number): Promise<string>;
73
+ private promptOnce;
49
74
  /**
50
75
  * Drop the cached code (in-process AND the cross-invocation file entry) after npm
51
76
  * rejects it, so the NEXT command re-prompts instead of replaying a single-use
52
77
  * code the registry already burned.
53
78
  */
54
- invalidate(): void;
79
+ invalidate(sinceEpoch?: number): void;
55
80
  }
56
81
  export interface OtpRetryOptions {
57
82
  /**
@@ -14,6 +14,15 @@
14
14
  import { promptLine } from './prompt.js';
15
15
  import { clearCachedOtp, readCachedOtp, writeCachedOtp } from './npm-otp-cache.js';
16
16
  const DEFAULT_QUESTION = 'This operation requires a one-time password.\nEnter OTP: ';
17
+ /**
18
+ * The question for every prompt AFTER the first one in a run.
19
+ *
20
+ * A long sweep outlives a TOTP window (npm codes last ~30 s), so it legitimately
21
+ * asks again — but repeating the first-time wording makes that read as "it did
22
+ * not take my code" rather than "that code has aged out". The user then retypes
23
+ * the SAME digits, which is the one answer guaranteed not to work.
24
+ */
25
+ const RENEW_QUESTION = 'The previous one-time password is no longer valid (npm codes last about 30 seconds).\nEnter a NEW OTP: ';
17
26
  /**
18
27
  * True when a response is an npm OTP / 2FA challenge (401 + a `www-authenticate`
19
28
  * header naming `otp`, or a body mentioning a one-time password / `EOTP`).
@@ -44,6 +53,23 @@ export class OtpProvider {
44
53
  cached;
45
54
  promptFn;
46
55
  registry;
56
+ /**
57
+ * Bumped every time a NEW code becomes current. Concurrent callers compare the
58
+ * epoch they last saw against this one to tell "my code was rejected" from
59
+ * "somebody already replaced it while I was waiting" — the second needs no
60
+ * prompt, it needs the other one's code.
61
+ */
62
+ generation = 0;
63
+ /**
64
+ * The prompt currently on screen, if any. THE fix for a sweep that probes with
65
+ * concurrency > 1: `promptLine` puts the terminal in RAW mode and echoes each
66
+ * keystroke by hand (utils/prompt.ts), so two live prompts mean two `data`
67
+ * listeners on the same stdin and every digit is echoed TWICE. Measured on a
68
+ * real 703-package sweep at the default concurrency of 4: five prompts stacked
69
+ * up and the typed code came back as `666644440000999944444444` — four echoes
70
+ * per keystroke. Whoever arrives second awaits this promise instead.
71
+ */
72
+ pending;
47
73
  /**
48
74
  * @param seed Initial cached code (from `--otp`), or undefined.
49
75
  * @param promptFn How to read a fresh code. Defaults to the TTY-aware
@@ -71,14 +97,46 @@ export class OtpProvider {
71
97
  current() {
72
98
  return this.cached;
73
99
  }
100
+ /**
101
+ * How many times a new code has become current. Capture it before an attempt
102
+ * and hand it back to {@link refresh} / {@link invalidate} so a concurrent
103
+ * caller neither prompts for a code somebody already typed nor throws that
104
+ * code away on its own stale rejection.
105
+ */
106
+ epoch() {
107
+ return this.generation;
108
+ }
74
109
  /**
75
110
  * Prompt for a fresh code, cache it (in-process + file cache), and return the
76
111
  * trimmed input — `''` when nothing was entered, which callers read as "give up".
77
112
  */
78
- async refresh(question = DEFAULT_QUESTION) {
113
+ async refresh(question, sinceEpoch) {
114
+ // Somebody supplied a newer code than this caller last saw. Its own attempt
115
+ // failed against the OLD code, so the answer is that code, not a prompt.
116
+ if (sinceEpoch !== undefined && this.generation > sinceEpoch && this.cached) {
117
+ return this.cached;
118
+ }
119
+ // Single-flight: one prompt at a time, ever. Two raw-mode prompts on one
120
+ // stdin double every echoed keystroke, and the user is being asked twice
121
+ // for a code that is shared anyway.
122
+ if (this.pending)
123
+ return this.pending;
124
+ // The provider is the only thing that knows whether a code has already
125
+ // been supplied in this run, so it owns the wording. An explicit
126
+ // `question` from the caller always wins.
127
+ this.pending = this.promptOnce(question ?? (this.generation > 0 ? RENEW_QUESTION : DEFAULT_QUESTION));
128
+ try {
129
+ return await this.pending;
130
+ }
131
+ finally {
132
+ this.pending = undefined;
133
+ }
134
+ }
135
+ async promptOnce(question) {
79
136
  const code = (await this.promptFn(question)).trim();
80
137
  if (code) {
81
138
  this.cached = code;
139
+ this.generation++;
82
140
  if (this.registry)
83
141
  writeCachedOtp(this.registry, code);
84
142
  }
@@ -89,7 +147,13 @@ export class OtpProvider {
89
147
  * rejects it, so the NEXT command re-prompts instead of replaying a single-use
90
148
  * code the registry already burned.
91
149
  */
92
- invalidate() {
150
+ invalidate(sinceEpoch) {
151
+ // Only the caller whose own code was rejected may clear it. Without this
152
+ // guard the first rejection in a concurrent burst wipes a code another
153
+ // worker just typed, and every remaining worker prompts again — the same
154
+ // stacked-prompt pile-up from the other direction.
155
+ if (sinceEpoch !== undefined && this.generation !== sinceEpoch)
156
+ return;
93
157
  this.cached = undefined;
94
158
  if (this.registry)
95
159
  clearCachedOtp(this.registry);
@@ -107,34 +171,45 @@ export class OtpProvider {
107
171
  */
108
172
  export async function withOtpRetry(doFetch, provider, opts = {}) {
109
173
  const maxPrompts = opts.maxPrompts ?? 2;
110
- const question = opts.question ?? DEFAULT_QUESTION;
174
+ // Deliberately NOT defaulted here: `undefined` is what lets the provider pick
175
+ // between the first-time wording and the "your previous code aged out" one.
176
+ const question = opts.question;
111
177
  const cached = provider.current();
112
178
  const seededFirst = opts.seedFirstAttempt === true && cached !== undefined;
179
+ // Every invalidate/refresh below is scoped to the epoch of the code THIS call
180
+ // actually tried. Concurrent callers share the provider, so an unscoped
181
+ // `invalidate()` discards a code a sibling typed a millisecond ago and sends
182
+ // everyone back to the prompt.
183
+ let epoch = provider.epoch();
113
184
  let res = await doFetch(seededFirst ? cached : undefined);
114
185
  if (!(await isOtpChallenge(res)))
115
186
  return res;
116
187
  // The seeded code was rejected/consumed — drop it so it is not replayed and the
117
188
  // cross-invocation cache entry is cleared.
118
189
  if (seededFirst)
119
- provider.invalidate();
190
+ provider.invalidate(epoch);
120
191
  if (!seededFirst && provider.current() !== undefined) {
192
+ epoch = provider.epoch();
121
193
  res = await doFetch(provider.current());
122
194
  if (!(await isOtpChallenge(res)))
123
195
  return res;
124
196
  // Stale seed, or a sibling command already burned the file-cached single-use
125
197
  // code.
126
- provider.invalidate();
198
+ provider.invalidate(epoch);
127
199
  }
128
200
  for (let i = 0; i < maxPrompts; i++) {
129
- const fresh = await provider.refresh(question);
201
+ // `refresh` with the epoch: if another concurrent caller has already typed a
202
+ // newer code, this returns THAT code and shows no prompt.
203
+ const fresh = await provider.refresh(question, epoch);
130
204
  if (!fresh)
131
205
  return res; // no input — hand the challenge back to the caller
206
+ epoch = provider.epoch();
132
207
  res = await doFetch(fresh);
133
208
  if (!(await isOtpChallenge(res)))
134
209
  return res;
135
210
  // Clear a rejected fresh code before re-prompting, so a wedged sequence never
136
211
  // leaves a burned code in the file cache.
137
- provider.invalidate();
212
+ provider.invalidate(epoch);
138
213
  }
139
214
  return res;
140
215
  }
@@ -0,0 +1,68 @@
1
+ import { type Workspace } from '@gjsify/workspace';
2
+ /** One enumeration source and how much it contributed. */
3
+ export interface DiscoverySource {
4
+ /** `workspaces` (root manifest globs) | `packages` (--packages globs) | `scanned-dirs`. */
5
+ kind: 'workspaces' | 'packages' | 'scanned-dirs';
6
+ /** The globs this source used, when it had any. */
7
+ patterns?: readonly string[];
8
+ /** How many packages this source contributed AFTER dedupe against earlier sources. */
9
+ count: number;
10
+ }
11
+ export interface PackageSet {
12
+ packages: Workspace[];
13
+ sources: DiscoverySource[];
14
+ }
15
+ /**
16
+ * The directory the globs and the workspace manifest are resolved against.
17
+ *
18
+ * A workspace root wins, because that is what `discoverWorkspaces` needs. When
19
+ * there is none, the GIT top level is the answer and the cwd is only the last
20
+ * resort: `--packages '*'` run from anywhere inside a non-workspace monorepo
21
+ * must mean the same set every time, and resolving it against the cwd would
22
+ * quietly mean "the packages below wherever I happen to stand".
23
+ */
24
+ export declare function resolveRepoRoot(cwd: string): string;
25
+ /**
26
+ * Every `--packages` glob must match at least one directory holding a
27
+ * `package.json` with a name. Throws with the unmatched patterns otherwise.
28
+ *
29
+ * Mirrors `gjsify foreach`'s `--include` rule, and matters more here: foreach's
30
+ * empty selection runs no scripts, while onboard's empty selection is a sweep
31
+ * that reports itself complete having examined nothing.
32
+ */
33
+ export declare function assertEveryPatternMatches(root: string, patterns: readonly string[]): void;
34
+ /**
35
+ * Collect the publishable package set from every source that applies.
36
+ *
37
+ * Order is precedence: a real workspace beats a `--packages` hit beats a scanned
38
+ * directory, deduped by package NAME, so the manifest a build would use is the
39
+ * manifest the sweep publishes.
40
+ */
41
+ export declare function collectOnboardPackages(root: string, cwd: string, options?: {
42
+ patterns?: readonly string[];
43
+ }): PackageSet;
44
+ /** One-line rendering of where the package set came from. */
45
+ export declare function describeSources(sources: readonly DiscoverySource[]): string;
46
+ /** The `owner/repo` a package's own manifest claims, or null if it claims none. */
47
+ export declare function declaredRepository(ws: Workspace): string | null;
48
+ /**
49
+ * Refuse to configure a Trusted Publisher for a package whose OWN manifest says
50
+ * it lives in a different repository.
51
+ *
52
+ * This replaces a hardcoded `exclude: ['@girs/*']`, and it is worth more than
53
+ * the thing it replaces. Measured: `gjsify onboard` in `gjsify/ts-for-gir`
54
+ * selects 716 packages, ~703 of them the generated `@girs/*` under
55
+ * `types-dev/*` — which are workspaces there, but are PUBLISHED FROM
56
+ * `gjsify/types`. Trusting them for `gjsify/ts-for-gir`'s `release.yml` would
57
+ * point their OIDC exchange at a workflow that never publishes them, i.e. break
58
+ * the release the sweep exists to protect.
59
+ *
60
+ * A name pattern only ever covered the one case somebody had already been bitten
61
+ * by. The manifest's `repository` is the package's own claim about where it
62
+ * comes from, so this covers every monorepo that has another project's packages
63
+ * inside it — vendored, submoduled, or generated.
64
+ *
65
+ * Silence is the only safe default in the other direction: a package that
66
+ * declares NO repository is not evidence of a mismatch, so it passes.
67
+ */
68
+ export declare function assertRepositoryAgreement(selected: readonly Workspace[], repository: string): void;