@gjsify/cli 0.4.14 → 0.4.16
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 +80 -78
- package/lib/actions/barrels-generate.d.ts +31 -0
- package/lib/actions/barrels-generate.js +78 -0
- package/lib/commands/barrels.d.ts +15 -0
- package/lib/commands/barrels.js +103 -0
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/install.js +11 -9
- package/lib/commands/publish.d.ts +2 -0
- package/lib/commands/publish.js +123 -2
- package/lib/index.js +2 -1
- package/lib/utils/install-backend-native.js +14 -1
- package/lib/utils/npm-oidc.d.ts +53 -0
- package/lib/utils/npm-oidc.js +140 -0
- package/package.json +15 -15
- package/showcases.json +14 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type BarrelExtension = 'js' | 'ts' | 'none';
|
|
2
|
+
export interface BarrelsArgs {
|
|
3
|
+
/** Directories to scan. Each gets an `index.ts` re-exporting its sibling source files. */
|
|
4
|
+
paths: string[];
|
|
5
|
+
/** Import-specifier extension. `js`/`ts`/`none`. */
|
|
6
|
+
extension: BarrelExtension;
|
|
7
|
+
/** Resolve `paths` against this directory. Default: `process.cwd()`. */
|
|
8
|
+
baseDir: string;
|
|
9
|
+
/** Skip files whose names match any regex. */
|
|
10
|
+
exclude: RegExp[];
|
|
11
|
+
/** Header comment prepended to every generated file. */
|
|
12
|
+
header: string;
|
|
13
|
+
/** Omit trailing `;` on each export line. */
|
|
14
|
+
noSemicolon: boolean;
|
|
15
|
+
/** Use `'` quotes (default). When false, uses `"`. */
|
|
16
|
+
singleQuotes: boolean;
|
|
17
|
+
/** Report drift without writing. Returns drift count from generator. */
|
|
18
|
+
check: boolean;
|
|
19
|
+
/** Log each file scanned + written. */
|
|
20
|
+
verbose: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare const DEFAULT_BARRELS_HEADER = "// Auto-generated by `gjsify barrels` \u2014 do not edit by hand.";
|
|
23
|
+
export declare const DEFAULT_BARRELS_EXCLUDES: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Regenerate `index.ts` in every directory in `args.paths`.
|
|
26
|
+
*
|
|
27
|
+
* Returns the number of files that drifted from the canonical output —
|
|
28
|
+
* always 0 when `check` is false (drift is rewritten in-place); non-zero
|
|
29
|
+
* under `check: true` signals CI failure.
|
|
30
|
+
*/
|
|
31
|
+
export declare function generateBarrels(args: BarrelsArgs): Promise<number>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `gjsify barrels` action — regenerate `index.ts` barrel files for one or
|
|
2
|
+
// more directories. Pure async generator, decoupled from yargs so it can
|
|
3
|
+
// be unit-tested in isolation.
|
|
4
|
+
//
|
|
5
|
+
// Adapted from beabee-communityrm/monorepo apps/dev-cli's `generate-index`
|
|
6
|
+
// action (refs/dev-cli — not vendored here): same separation of action vs.
|
|
7
|
+
// command module, same `types/`-dir auto-detection that emits
|
|
8
|
+
// `export type *`, same `export {};` fallback for empty directories,
|
|
9
|
+
// same sorted-output principle. Rewritten for gjsify's yargs CommandModule
|
|
10
|
+
// and `@gjsify/unit` test framework; no external deps. Replaces
|
|
11
|
+
// `barrelsby` (unmaintained since 2022) at consumer sites.
|
|
12
|
+
//
|
|
13
|
+
// Original: Copyright (c) Beabee Community Repo contributors. AGPL-3.0.
|
|
14
|
+
// Reimplemented for gjsify under the project's MIT license.
|
|
15
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
17
|
+
export const DEFAULT_BARRELS_HEADER = '// Auto-generated by `gjsify barrels` — do not edit by hand.';
|
|
18
|
+
export const DEFAULT_BARRELS_EXCLUDES = [
|
|
19
|
+
'\\.test\\.',
|
|
20
|
+
'\\.spec\\.',
|
|
21
|
+
'\\.test-data\\.',
|
|
22
|
+
];
|
|
23
|
+
const SOURCE_FILE_RE = /\.(ts|tsx|mts|cts)$/;
|
|
24
|
+
/**
|
|
25
|
+
* Regenerate `index.ts` in every directory in `args.paths`.
|
|
26
|
+
*
|
|
27
|
+
* Returns the number of files that drifted from the canonical output —
|
|
28
|
+
* always 0 when `check` is false (drift is rewritten in-place); non-zero
|
|
29
|
+
* under `check: true` signals CI failure.
|
|
30
|
+
*/
|
|
31
|
+
export async function generateBarrels(args) {
|
|
32
|
+
const quote = args.singleQuotes ? "'" : '"';
|
|
33
|
+
const semicolon = args.noSemicolon ? '' : ';';
|
|
34
|
+
const extSuffix = args.extension === 'none' ? '' : `.${args.extension}`;
|
|
35
|
+
let drift = 0;
|
|
36
|
+
for (const p of args.paths) {
|
|
37
|
+
const dir = resolve(args.baseDir, p);
|
|
38
|
+
const isTypesDir = basename(dir) === 'types';
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = (await readdir(dir, { withFileTypes: true }))
|
|
42
|
+
.filter((e) => e.isFile())
|
|
43
|
+
.filter((e) => SOURCE_FILE_RE.test(e.name))
|
|
44
|
+
.filter((e) => e.name !== 'index.ts')
|
|
45
|
+
.filter((e) => !args.exclude.some((r) => r.test(e.name)))
|
|
46
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (args.verbose)
|
|
50
|
+
console.error(`[gjsify barrels] skip ${dir}: ${err.message}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const lines = entries.map((e) => {
|
|
54
|
+
const stem = basename(e.name, extname(e.name));
|
|
55
|
+
const keyword = isTypesDir ? 'export type *' : 'export *';
|
|
56
|
+
return `${keyword} from ${quote}./${stem}${extSuffix}${quote}${semicolon}`;
|
|
57
|
+
});
|
|
58
|
+
const body = lines.length ? `${lines.join('\n')}\n` : 'export {};\n';
|
|
59
|
+
const out = `${args.header}\n\n${body}`;
|
|
60
|
+
const indexPath = join(dir, 'index.ts');
|
|
61
|
+
const previous = await readFile(indexPath, 'utf-8').catch(() => '');
|
|
62
|
+
if (previous === out) {
|
|
63
|
+
if (args.verbose)
|
|
64
|
+
console.log(`[gjsify barrels] up-to-date: ${indexPath}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (args.check) {
|
|
68
|
+
console.error(`[gjsify barrels] drift: ${indexPath}`);
|
|
69
|
+
drift++;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
await writeFile(indexPath, out, 'utf-8');
|
|
73
|
+
if (args.verbose)
|
|
74
|
+
console.log(`[gjsify barrels] wrote: ${indexPath}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return drift;
|
|
78
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Command } from '../types/index.js';
|
|
2
|
+
import { type BarrelExtension } from '../actions/barrels-generate.js';
|
|
3
|
+
interface BarrelsOptions {
|
|
4
|
+
paths?: string[];
|
|
5
|
+
ext?: BarrelExtension;
|
|
6
|
+
baseDir?: string;
|
|
7
|
+
exclude?: string[];
|
|
8
|
+
header?: string;
|
|
9
|
+
semicolon?: boolean;
|
|
10
|
+
singleQuotes?: boolean;
|
|
11
|
+
check?: boolean;
|
|
12
|
+
verbose?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare const barrelsCommand: Command<unknown, BarrelsOptions>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `gjsify barrels` — regenerate `index.ts` barrel files for a set of
|
|
2
|
+
// directories. Drop-in replacement for `barrelsby` (unmaintained 2022+).
|
|
3
|
+
//
|
|
4
|
+
// Examples:
|
|
5
|
+
// gjsify barrels src/systems src/components # regenerate two barrels
|
|
6
|
+
// gjsify barrels --check src/types # CI guard — exit 1 on drift
|
|
7
|
+
// gjsify barrels --ext js src/utils # NodeNext-style import-extensions
|
|
8
|
+
//
|
|
9
|
+
// Conventions:
|
|
10
|
+
// - A directory literally named `types/` emits `export type *` (type-only
|
|
11
|
+
// re-export) — avoids dragging value imports through type-only barrels.
|
|
12
|
+
// - An empty directory yields `export {};` so TypeScript still parses the
|
|
13
|
+
// file as a module.
|
|
14
|
+
// - Output is sorted by file name (locale-compare) — deterministic diffs.
|
|
15
|
+
// - `--check` exits non-zero on drift without writing (pre-commit / CI use).
|
|
16
|
+
import { DEFAULT_BARRELS_EXCLUDES, DEFAULT_BARRELS_HEADER, generateBarrels, } from '../actions/barrels-generate.js';
|
|
17
|
+
export const barrelsCommand = {
|
|
18
|
+
command: 'barrels [paths..]',
|
|
19
|
+
description: 'Regenerate `index.ts` barrel files for the given directories. Drop-in replacement for `barrelsby`.',
|
|
20
|
+
builder: (yargs) => {
|
|
21
|
+
return yargs
|
|
22
|
+
.positional('paths', {
|
|
23
|
+
description: 'Directories whose `index.ts` to (re)generate. May also be passed via `--paths`.',
|
|
24
|
+
type: 'string',
|
|
25
|
+
array: true,
|
|
26
|
+
})
|
|
27
|
+
.option('paths', {
|
|
28
|
+
alias: 'p',
|
|
29
|
+
description: 'Alternative to positional — repeatable list of directories.',
|
|
30
|
+
type: 'string',
|
|
31
|
+
array: true,
|
|
32
|
+
})
|
|
33
|
+
.option('ext', {
|
|
34
|
+
description: 'Import-specifier extension. Default: `none` (bundler-mode resolution).',
|
|
35
|
+
type: 'string',
|
|
36
|
+
choices: ['js', 'ts', 'none'],
|
|
37
|
+
default: 'none',
|
|
38
|
+
})
|
|
39
|
+
.option('base-dir', {
|
|
40
|
+
alias: 'b',
|
|
41
|
+
description: 'Resolve `paths` against this directory. Default: cwd.',
|
|
42
|
+
type: 'string',
|
|
43
|
+
})
|
|
44
|
+
.option('exclude', {
|
|
45
|
+
description: 'Regex(es) of file names to skip. Repeatable. Defaults: `\\.test\\.`, `\\.spec\\.`, `\\.test-data\\.`.',
|
|
46
|
+
type: 'string',
|
|
47
|
+
array: true,
|
|
48
|
+
})
|
|
49
|
+
.option('header', {
|
|
50
|
+
description: 'Header comment prepended to every generated file.',
|
|
51
|
+
type: 'string',
|
|
52
|
+
})
|
|
53
|
+
.option('semicolon', {
|
|
54
|
+
description: 'Emit trailing `;` on each export line. Negate with `--no-semicolon`. Default: omitted.',
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
default: false,
|
|
57
|
+
})
|
|
58
|
+
.option('single-quotes', {
|
|
59
|
+
description: 'Use `\'` for import specifiers. Default: true. Pass `--no-single-quotes` for `"`.',
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
default: true,
|
|
62
|
+
})
|
|
63
|
+
.option('check', {
|
|
64
|
+
description: 'Report drift without modifying files; exit non-zero if any barrel is stale.',
|
|
65
|
+
type: 'boolean',
|
|
66
|
+
default: false,
|
|
67
|
+
})
|
|
68
|
+
.option('verbose', {
|
|
69
|
+
description: 'Log each file scanned + written.',
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
default: false,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
handler: async (args) => {
|
|
75
|
+
// Both positional (`gjsify barrels src/a src/b`) and the named option
|
|
76
|
+
// (`--paths src/a --paths src/b`) land in `args.paths` since they share
|
|
77
|
+
// the same name. Yargs concatenates when both are present.
|
|
78
|
+
const paths = Array.from(new Set((args.paths ?? []).filter(Boolean)));
|
|
79
|
+
if (paths.length === 0) {
|
|
80
|
+
console.error('[gjsify barrels] no paths provided. Pass directories as positional arguments or via --paths.');
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const excludePatterns = args.exclude?.length
|
|
85
|
+
? args.exclude
|
|
86
|
+
: [...DEFAULT_BARRELS_EXCLUDES];
|
|
87
|
+
const drift = await generateBarrels({
|
|
88
|
+
paths,
|
|
89
|
+
extension: args.ext ?? 'none',
|
|
90
|
+
baseDir: args.baseDir ?? process.cwd(),
|
|
91
|
+
exclude: excludePatterns.map((src) => new RegExp(src)),
|
|
92
|
+
header: args.header ?? DEFAULT_BARRELS_HEADER,
|
|
93
|
+
noSemicolon: args.semicolon === false || args.semicolon === undefined,
|
|
94
|
+
singleQuotes: args.singleQuotes !== false,
|
|
95
|
+
check: args.check ?? false,
|
|
96
|
+
verbose: args.verbose ?? false,
|
|
97
|
+
});
|
|
98
|
+
if (args.check && drift > 0) {
|
|
99
|
+
console.error(`[gjsify barrels] ${drift} barrel file(s) drifted. Run without --check to fix.`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
package/lib/commands/index.d.ts
CHANGED
package/lib/commands/index.js
CHANGED
package/lib/commands/install.js
CHANGED
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
// subprocess flow (useful as escape-hatch for projects that hit a
|
|
12
12
|
// missing native-backend feature).
|
|
13
13
|
//
|
|
14
|
-
// Workspace
|
|
15
|
-
// `"workspaces"` field)
|
|
16
|
-
//
|
|
14
|
+
// Workspace install (`gjsify install` in a monorepo root with a
|
|
15
|
+
// `"workspaces"` field) hoists every workspace's externals into the root
|
|
16
|
+
// `node_modules/` and symlinks `workspace:*` / `workspace:^` / `workspace:~`
|
|
17
|
+
// refs to their target source. Open follow-ups (see STATUS.md "Open TODOs"):
|
|
18
|
+
// per-workspace dedup of conflicting transitive version ranges (first-match
|
|
19
|
+
// wins today).
|
|
17
20
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
18
21
|
import { dirname, join, relative } from 'node:path';
|
|
19
22
|
import { spawn } from 'node:child_process';
|
|
@@ -115,7 +118,7 @@ async function projectInstallNative(args) {
|
|
|
115
118
|
'not PnP-aware yet. Use `yarn install` or set ' +
|
|
116
119
|
'GJSIFY_INSTALL_BACKEND=npm.');
|
|
117
120
|
}
|
|
118
|
-
// Workspace install (no args, root pkg.json has `workspaces`)
|
|
121
|
+
// Workspace install (no args, root pkg.json has `workspaces`).
|
|
119
122
|
// Project-local `gjsify install <pkg>` inside a workspace child still
|
|
120
123
|
// goes through the single-package code path below (this branch only
|
|
121
124
|
// fires for the root no-args case, which is the `yarn install`
|
|
@@ -199,8 +202,7 @@ function syncLockfileRequested(cwd, specs) {
|
|
|
199
202
|
}
|
|
200
203
|
}
|
|
201
204
|
/**
|
|
202
|
-
*
|
|
203
|
-
* at a monorepo root:
|
|
205
|
+
* Workspace-aware install. Mirrors what `yarn install` does at a monorepo root:
|
|
204
206
|
* 1. Discover every workspace under the root.
|
|
205
207
|
* 2. Aggregate the union of their external (non-`workspace:`) deps.
|
|
206
208
|
* 3. Run the native install backend ONCE at the root prefix so all
|
|
@@ -211,9 +213,9 @@ function syncLockfileRequested(cwd, specs) {
|
|
|
211
213
|
* directory into the requesting workspace's `node_modules/<dep>`
|
|
212
214
|
* so `import '@gjsify/utils'` resolves to the local source.
|
|
213
215
|
*
|
|
214
|
-
* Hoisting strategy is intentionally minimal —
|
|
215
|
-
*
|
|
216
|
-
*
|
|
216
|
+
* Hoisting strategy is intentionally minimal — per-workspace dedup +
|
|
217
|
+
* nested `node_modules/` for version conflicts are tracked as a follow-up
|
|
218
|
+
* in STATUS.md "Open TODOs".
|
|
217
219
|
*/
|
|
218
220
|
async function workspaceInstall(cwd, args) {
|
|
219
221
|
const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
|
package/lib/commands/publish.js
CHANGED
|
@@ -37,6 +37,7 @@ import { homedir } from 'node:os';
|
|
|
37
37
|
import { join, resolve } from 'node:path';
|
|
38
38
|
import { DEFAULT_REGISTRY, parseNpmrc, registryFor, buildHeaders, } from '@gjsify/npm-registry';
|
|
39
39
|
import { packWorkspace } from './pack.js';
|
|
40
|
+
import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError, } from '../utils/npm-oidc.js';
|
|
40
41
|
export const publishCommand = {
|
|
41
42
|
command: 'publish [path]',
|
|
42
43
|
description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
|
|
@@ -70,6 +71,19 @@ export const publishCommand = {
|
|
|
70
71
|
description: 'Emit publish metadata as JSON on stdout.',
|
|
71
72
|
type: 'boolean',
|
|
72
73
|
default: false,
|
|
74
|
+
})
|
|
75
|
+
.option('trusted', {
|
|
76
|
+
description: 'Authenticate via npm Trusted Publishing (OIDC): exchange the GitHub Actions id-token for a short-lived npm token. ' +
|
|
77
|
+
'Pass `--trusted` to force this mode (errors if env vars missing). ' +
|
|
78
|
+
'Omit to auto-detect: OIDC is used iff `ACTIONS_ID_TOKEN_REQUEST_URL`+`_TOKEN` are set AND no `_authToken` is present in the resolved npmrc; otherwise the long-lived token path is used. ' +
|
|
79
|
+
'Requires the calling workflow to declare `permissions: id-token: write` AND the target package to have a Trusted Publisher configured on npmjs.com.',
|
|
80
|
+
type: 'boolean',
|
|
81
|
+
default: undefined,
|
|
82
|
+
})
|
|
83
|
+
.option('check-trusted', {
|
|
84
|
+
description: 'Diagnostic mode: perform the OIDC id-token request + npm token exchange, report success/failure, then exit WITHOUT publishing. Useful as a bulk-verifier (e.g. via `gjsify foreach publish --check-trusted`) to confirm Trusted Publisher config across many packages.',
|
|
85
|
+
type: 'boolean',
|
|
86
|
+
default: false,
|
|
73
87
|
}),
|
|
74
88
|
handler: async (args) => {
|
|
75
89
|
const wsDir = resolve(args.path ?? process.cwd());
|
|
@@ -78,9 +92,56 @@ export const publishCommand = {
|
|
|
78
92
|
const tolerate = args['tolerate-republish'] === true;
|
|
79
93
|
const provenance = args.provenance === true;
|
|
80
94
|
const dryRun = args['dry-run'] === true;
|
|
95
|
+
const checkTrustedOnly = args['check-trusted'] === true;
|
|
96
|
+
const trustedFlag = args.trusted;
|
|
97
|
+
const verbose = Boolean(process.env.GJSIFY_PUBLISH_DEBUG);
|
|
81
98
|
if (provenance) {
|
|
82
99
|
console.warn('gjsify publish: --provenance recorded but not signed (no sigstore integration yet).');
|
|
83
100
|
}
|
|
101
|
+
// `--check-trusted` short-circuits the entire pack + publish flow.
|
|
102
|
+
// Reports the OIDC exchange result for the workspace's package and
|
|
103
|
+
// exits 0 either way — by design, so `gjsify foreach publish
|
|
104
|
+
// --check-trusted` walks every workspace without bailing on the
|
|
105
|
+
// first misconfigured one. CI can grep `^✗ ` (or parse `--json`
|
|
106
|
+
// entries with `ok: false`) to surface failures.
|
|
107
|
+
if (checkTrustedOnly) {
|
|
108
|
+
const rawPkgPath = join(wsDir, 'package.json');
|
|
109
|
+
const rawPkg = JSON.parse(readFileSync(rawPkgPath, 'utf-8'));
|
|
110
|
+
if (typeof rawPkg.name !== 'string') {
|
|
111
|
+
process.stderr.write(`gjsify publish --check-trusted: ${rawPkgPath} has no \`name\` field\n`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
if (rawPkg.private === true) {
|
|
115
|
+
const out = { ok: true, action: 'check-trusted', name: rawPkg.name, skipped: 'private' };
|
|
116
|
+
if (args.json)
|
|
117
|
+
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
118
|
+
else
|
|
119
|
+
process.stdout.write(`- ${rawPkg.name}: skipped (private package)\n`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const npmrcCheck = await loadNpmrc(wsDir);
|
|
123
|
+
const registry = process.env.npm_config_registry ?? registryFor(rawPkg.name, npmrcCheck) ?? DEFAULT_REGISTRY;
|
|
124
|
+
try {
|
|
125
|
+
await getNpmTrustedToken({
|
|
126
|
+
packageName: rawPkg.name,
|
|
127
|
+
registry,
|
|
128
|
+
log: verbose ? (m) => console.error(m) : undefined,
|
|
129
|
+
});
|
|
130
|
+
const out = { ok: true, action: 'check-trusted', name: rawPkg.name, registry };
|
|
131
|
+
if (args.json)
|
|
132
|
+
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
133
|
+
else
|
|
134
|
+
process.stdout.write(`✓ ${rawPkg.name}: trusted publisher OK\n`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
handleOidcFailure(err, rawPkg.name, args.json === true);
|
|
139
|
+
// Report-mode: exit 0 so `gjsify foreach` keeps walking. The
|
|
140
|
+
// `✗ <name>: <reason>` (or JSON `ok:false`) line is the
|
|
141
|
+
// failure signal for CI to grep / parse.
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
84
145
|
// 1. Pack the workspace (rewrites workspace:^, computes integrity)
|
|
85
146
|
const packOpts = { dryRun: true };
|
|
86
147
|
const packed = await packWorkspace(wsDir, packOpts);
|
|
@@ -159,10 +220,45 @@ export const publishCommand = {
|
|
|
159
220
|
const headers = buildHeaders(url, { npmrc });
|
|
160
221
|
headers['content-type'] = 'application/json';
|
|
161
222
|
headers['accept'] = '*/*';
|
|
162
|
-
if
|
|
223
|
+
// Trusted Publishing path. `--trusted` forces OIDC (errors if env
|
|
224
|
+
// vars are missing); the default `undefined` triggers auto-detect:
|
|
225
|
+
// OIDC is used iff GitHub OIDC env vars are present AND no
|
|
226
|
+
// `NODE_AUTH_TOKEN` is set. With `NODE_AUTH_TOKEN` set the user has
|
|
227
|
+
// explicitly opted into token auth, so we don't shadow their choice.
|
|
228
|
+
const wantTrusted = trustedFlag === true ||
|
|
229
|
+
(trustedFlag === undefined && hasGithubOidcEnv() && !process.env.NODE_AUTH_TOKEN);
|
|
230
|
+
let authMode = 'token';
|
|
231
|
+
if (wantTrusted) {
|
|
232
|
+
try {
|
|
233
|
+
const { token: oidcToken, audience } = await getNpmTrustedToken({
|
|
234
|
+
packageName: packed.name,
|
|
235
|
+
registry,
|
|
236
|
+
log: verbose ? (m) => console.error(m) : undefined,
|
|
237
|
+
});
|
|
238
|
+
headers['authorization'] = `Bearer ${oidcToken}`;
|
|
239
|
+
authMode = 'oidc';
|
|
240
|
+
if (verbose) {
|
|
241
|
+
console.error(`gjsify publish: OIDC token obtained (audience=${audience})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
if (trustedFlag === true) {
|
|
246
|
+
// Explicit --trusted: bail with a clear error.
|
|
247
|
+
handleOidcFailure(err, packed.name, args.json === true);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
// Auto-detect: fall back to whatever buildHeaders found.
|
|
251
|
+
if (verbose) {
|
|
252
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
+
console.error(`gjsify publish: OIDC auto-detect failed (${msg}) — falling back to token auth`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (verbose) {
|
|
163
258
|
console.error(`gjsify publish: PUT ${url} (${packed.name}@${packed.version})`);
|
|
259
|
+
console.error(` auth-mode: ${authMode}`);
|
|
164
260
|
console.error(` authorization: ${headers['authorization'] ? '(set)' : '(none)'}`);
|
|
165
|
-
console.error(` payload size:
|
|
261
|
+
console.error(` payload size: ${JSON.stringify(payload).length} bytes`);
|
|
166
262
|
}
|
|
167
263
|
const res = await fetch(url, {
|
|
168
264
|
method: 'PUT',
|
|
@@ -317,3 +413,28 @@ function base64Encode(bytes) {
|
|
|
317
413
|
}
|
|
318
414
|
return btoa(str);
|
|
319
415
|
}
|
|
416
|
+
function handleOidcFailure(err, packageName, asJson) {
|
|
417
|
+
if (err instanceof OidcUnavailableError) {
|
|
418
|
+
const msg = `gjsify publish: OIDC not available — ${err.message}`;
|
|
419
|
+
if (asJson)
|
|
420
|
+
process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'oidc-unavailable', reason: err.reason, message: err.message })}\n`);
|
|
421
|
+
else
|
|
422
|
+
process.stderr.write(`${msg}\n`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (err instanceof OidcExchangeError) {
|
|
426
|
+
const friendly = err.status === 401 || err.status === 403
|
|
427
|
+
? `npm rejected the OIDC exchange (${err.status}) — check that ${packageName} has a Trusted Publisher configured at https://www.npmjs.com/package/${encodeURIComponent(packageName)}/access pointing at this workflow.`
|
|
428
|
+
: err.message;
|
|
429
|
+
if (asJson)
|
|
430
|
+
process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'oidc-exchange', status: err.status, body: err.body, message: err.message })}\n`);
|
|
431
|
+
else
|
|
432
|
+
process.stderr.write(`✗ ${packageName}: ${friendly}\n`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
436
|
+
if (asJson)
|
|
437
|
+
process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'unknown', message: msg })}\n`);
|
|
438
|
+
else
|
|
439
|
+
process.stderr.write(`✗ ${packageName}: ${msg}\n`);
|
|
440
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import yargs from 'yargs';
|
|
3
3
|
import { hideBin } from 'yargs/helpers';
|
|
4
|
-
import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, } from './commands/index.js';
|
|
4
|
+
import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, } from './commands/index.js';
|
|
5
5
|
import { APP_NAME } from './constants.js';
|
|
6
6
|
// `parseAsync()` instead of `.argv` so the top-level await keeps the
|
|
7
7
|
// process alive until command handlers complete. Under Node this is
|
|
@@ -35,6 +35,7 @@ await yargs(hideBin(process.argv))
|
|
|
35
35
|
.command(format.command, format.description, format.builder, format.handler)
|
|
36
36
|
.command(lint.command, lint.description, lint.builder, lint.handler)
|
|
37
37
|
.command(fix.command, fix.description, fix.builder, fix.handler)
|
|
38
|
+
.command(barrels.command, barrels.description, barrels.builder, barrels.handler)
|
|
38
39
|
.demandCommand(1)
|
|
39
40
|
.help()
|
|
40
41
|
.parseAsync();
|
|
@@ -71,6 +71,11 @@ export async function installPackagesNative(opts) {
|
|
|
71
71
|
// behavior). Sub-deps are not included.
|
|
72
72
|
return topLevelResolutions(opts.specs, nodes);
|
|
73
73
|
}
|
|
74
|
+
function errMsg(err) {
|
|
75
|
+
if (err instanceof Error)
|
|
76
|
+
return err.message;
|
|
77
|
+
return String(err);
|
|
78
|
+
}
|
|
74
79
|
function topLevelResolutions(specs, nodes) {
|
|
75
80
|
// Top-level installs live at `node_modules/<name>` (no nesting). Build
|
|
76
81
|
// a name → root-node lookup limited to the top-level set.
|
|
@@ -131,7 +136,12 @@ async function resolveDeps(specs, npmrc, log, overrides) {
|
|
|
131
136
|
const cached = packumentCache.get(name);
|
|
132
137
|
if (cached)
|
|
133
138
|
return cached;
|
|
134
|
-
const fresh = fetchPackument(name, {
|
|
139
|
+
const fresh = fetchPackument(name, {
|
|
140
|
+
npmrc,
|
|
141
|
+
onRetry: ({ attempt, error, delayMs }) => {
|
|
142
|
+
log("packument %s: retry %d after %dms (%s)", name, attempt, delayMs, errMsg(error));
|
|
143
|
+
},
|
|
144
|
+
});
|
|
135
145
|
packumentCache.set(name, fresh);
|
|
136
146
|
return fresh;
|
|
137
147
|
};
|
|
@@ -455,6 +465,9 @@ async function extractOne(node, prefix, npmrc, log) {
|
|
|
455
465
|
const bytes = await fetchTarball(node.tarballUrl, {
|
|
456
466
|
npmrc,
|
|
457
467
|
integrity: node.integrity,
|
|
468
|
+
onRetry: ({ attempt, error, delayMs }) => {
|
|
469
|
+
log("tarball %s@%s: retry %d after %dms (%s)", node.name, node.version, attempt, delayMs, errMsg(error));
|
|
470
|
+
},
|
|
458
471
|
});
|
|
459
472
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
460
473
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
interface OidcExchangeOptions {
|
|
2
|
+
/** Full package name including scope, e.g. `@gjsify/cli`. */
|
|
3
|
+
packageName: string;
|
|
4
|
+
/** Registry URL, e.g. `https://registry.npmjs.org`. */
|
|
5
|
+
registry: string;
|
|
6
|
+
/** Optional verbose logger — receives single-line strings. */
|
|
7
|
+
log?: (msg: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface OidcExchangeResult {
|
|
10
|
+
/** Short-lived npm token (`Authorization: Bearer <token>`-compatible). */
|
|
11
|
+
token: string;
|
|
12
|
+
/** Audience used for the GitHub OIDC token request. */
|
|
13
|
+
audience: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class OidcUnavailableError extends Error {
|
|
16
|
+
readonly reason: 'no-env' | 'fetch-id-token' | 'no-id-token';
|
|
17
|
+
constructor(message: string, reason: 'no-env' | 'fetch-id-token' | 'no-id-token');
|
|
18
|
+
}
|
|
19
|
+
export declare class OidcExchangeError extends Error {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
readonly body: string;
|
|
22
|
+
readonly packageName: string;
|
|
23
|
+
constructor(message: string, status: number, body: string, packageName: string);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Probe whether OIDC publishing is available in the current process —
|
|
27
|
+
* cheap env-var check, no network access. Used by `gjsify publish` to
|
|
28
|
+
* decide between OIDC and token-based auth in auto-detect mode.
|
|
29
|
+
*/
|
|
30
|
+
export declare function hasGithubOidcEnv(): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Request a GitHub Actions OIDC ID token for the given audience.
|
|
33
|
+
* Throws `OidcUnavailableError` when the required env vars are missing
|
|
34
|
+
* (caller can fall back to token auth) or when GitHub rejects the request.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fetchGithubOidcToken(audience: string, log?: (msg: string) => void): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Exchange a GitHub OIDC JWT for a short-lived npm publish token at
|
|
39
|
+
* `/-/npm/v1/oidc/token/exchange/package/<escaped-name>`. The npm
|
|
40
|
+
* registry validates the JWT against the package's Trusted Publisher
|
|
41
|
+
* config — if no Trusted Publisher is configured, or the JWT comes from
|
|
42
|
+
* a repo/workflow that doesn't match, the exchange returns a 4xx with
|
|
43
|
+
* a descriptive body which we propagate as `OidcExchangeError`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function exchangeOidcForNpmToken(args: OidcExchangeOptions & {
|
|
46
|
+
idToken: string;
|
|
47
|
+
}): Promise<string>;
|
|
48
|
+
/**
|
|
49
|
+
* End-to-end: probe env → fetch id-token → exchange for npm token.
|
|
50
|
+
* One-shot convenience for `gjsify publish` and the verification command.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getNpmTrustedToken(opts: OidcExchangeOptions): Promise<OidcExchangeResult>;
|
|
53
|
+
export {};
|