@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.
- package/dist/cli.gjs.mjs +192 -165
- package/lib/commands/gettext.d.ts +2 -1
- package/lib/commands/gettext.js +105 -47
- package/lib/commands/onboard.d.ts +7 -0
- package/lib/commands/onboard.js +349 -96
- package/lib/commands/ship.d.ts +3 -0
- package/lib/commands/ship.js +833 -54
- package/lib/commands/trust.d.ts +6 -0
- package/lib/commands/trust.js +1 -1
- package/lib/types/config-data.d.ts +63 -1
- package/lib/utils/msgfmt-merge.d.ts +35 -0
- package/lib/utils/msgfmt-merge.js +102 -0
- package/lib/utils/npm-otp.d.ts +27 -2
- package/lib/utils/npm-otp.js +82 -7
- package/lib/utils/onboard-discovery.d.ts +68 -0
- package/lib/utils/onboard-discovery.js +178 -0
- package/lib/utils/onboard-probe.d.ts +118 -1
- package/lib/utils/onboard-probe.js +153 -4
- package/lib/utils/prompt-output.d.ts +12 -0
- package/lib/utils/prompt-output.js +54 -0
- package/lib/utils/prompt.js +22 -2
- package/lib/utils/publishable-packages.d.ts +1 -1
- package/lib/utils/publishable-packages.js +1 -0
- package/lib/utils/resolve-npm-package.d.ts +56 -0
- package/lib/utils/resolve-npm-package.js +69 -0
- package/lib/utils/ship/app-runtime.d.ts +214 -0
- package/lib/utils/ship/app-runtime.js +429 -0
- package/lib/utils/ship/depends.d.ts +50 -0
- package/lib/utils/ship/depends.js +117 -3
- package/lib/utils/ship/discover.js +18 -1
- package/lib/utils/ship/dmg.d.ts +71 -0
- package/lib/utils/ship/dmg.js +181 -0
- package/lib/utils/ship/flatpak.js +1 -1
- package/lib/utils/ship/formats.d.ts +85 -7
- package/lib/utils/ship/formats.js +734 -15
- package/lib/utils/ship/launcher.d.ts +13 -3
- package/lib/utils/ship/launcher.js +362 -17
- package/lib/utils/ship/layout.d.ts +268 -0
- package/lib/utils/ship/layout.js +351 -0
- package/lib/utils/ship/localize-metadata.d.ts +22 -0
- package/lib/utils/ship/localize-metadata.js +126 -0
- package/lib/utils/ship/mime.js +1 -9
- package/lib/utils/ship/msi.d.ts +126 -0
- package/lib/utils/ship/msi.js +417 -0
- package/lib/utils/ship/node-runtime.d.ts +71 -0
- package/lib/utils/ship/node-runtime.js +122 -0
- package/lib/utils/ship/payload.d.ts +156 -5
- package/lib/utils/ship/payload.js +580 -14
- package/lib/utils/ship/plan.d.ts +21 -0
- package/lib/utils/ship/plan.js +20 -7
- package/lib/utils/ship/plist.d.ts +58 -0
- package/lib/utils/ship/plist.js +198 -0
- package/lib/utils/ship/rpm.js +9 -3
- package/lib/utils/ship/schemas.d.ts +61 -0
- package/lib/utils/ship/schemas.js +118 -0
- package/lib/utils/ship/scripts.js +7 -3
- package/lib/utils/ship/settings.d.ts +19 -1
- package/lib/utils/ship/settings.js +12 -1
- package/lib/utils/ship/share-dirs.d.ts +23 -0
- package/lib/utils/ship/share-dirs.js +46 -0
- package/lib/utils/ship/signing.d.ts +195 -0
- package/lib/utils/ship/signing.js +317 -0
- package/lib/utils/ship/stage-manifest.d.ts +40 -12
- package/lib/utils/ship/stage-manifest.js +99 -13
- package/lib/utils/ship/stage-writer.d.ts +48 -0
- package/lib/utils/ship/stage-writer.js +71 -1
- package/lib/utils/ship/types.d.ts +158 -13
- package/lib/utils/ship/xml.d.ts +7 -0
- package/lib/utils/ship/xml.js +25 -0
- package/lib/utils/ship/zip.d.ts +69 -0
- package/lib/utils/ship/zip.js +245 -0
- package/lib/utils/trust-registry.js +5 -0
- package/package.json +19 -19
package/lib/commands/trust.d.ts
CHANGED
|
@@ -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>;
|
package/lib/commands/trust.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|
package/lib/utils/npm-otp.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/utils/npm-otp.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|