@gjsify/cli 0.4.15 → 0.4.17
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 +143 -142
- package/lib/commands/install.js +23 -4
- package/lib/commands/publish.d.ts +2 -0
- package/lib/commands/publish.js +123 -2
- package/lib/commands/uninstall.js +17 -5
- package/lib/index.js +11 -1
- package/lib/utils/install-backend-native.js +14 -1
- package/lib/utils/install-global.js +34 -1
- package/lib/utils/npm-oidc.d.ts +53 -0
- package/lib/utils/npm-oidc.js +140 -0
- package/package.json +15 -15
package/lib/commands/install.js
CHANGED
|
@@ -354,6 +354,14 @@ async function workspaceInstall(cwd, args) {
|
|
|
354
354
|
// target in a shell script that picks the right interpreter (`gjs -m`
|
|
355
355
|
// for `.mjs` bundles, `node` for `.js` files).
|
|
356
356
|
const wsBinDir = join(cwd, 'node_modules', '.bin');
|
|
357
|
+
// Discover native prebuilds reachable from the workspace cwd so the
|
|
358
|
+
// workspace-local `node_modules/.bin/gjsify` shim sets GI_TYPELIB_PATH /
|
|
359
|
+
// LD_LIBRARY_PATH for them. Same rationale as the global launcher in
|
|
360
|
+
// install-global.ts — the bin shim invokes the CLI bundle via `gjs -m`
|
|
361
|
+
// directly, with no chance to set env after the fact, so without this
|
|
362
|
+
// preamble `imports.gi.GjsifyTerminal` etc. fail and process.stdout
|
|
363
|
+
// collapses to no-color, 80-col defaults.
|
|
364
|
+
const nativePrebuildDirs = detectNativePackages(cwd).map((p) => p.prebuildsDir);
|
|
357
365
|
let wsBinsCreated = 0;
|
|
358
366
|
for (const ws of workspaces) {
|
|
359
367
|
const m = ws.manifest;
|
|
@@ -375,7 +383,7 @@ async function workspaceInstall(cwd, args) {
|
|
|
375
383
|
rmSync(linkPath, { force: true });
|
|
376
384
|
}
|
|
377
385
|
catch { /* fine */ }
|
|
378
|
-
writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget), { mode: 0o755 });
|
|
386
|
+
writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs), { mode: 0o755 });
|
|
379
387
|
chmodSync(linkPath, 0o755);
|
|
380
388
|
wsBinsCreated++;
|
|
381
389
|
}
|
|
@@ -441,16 +449,27 @@ function extractOverrides(rootManifest) {
|
|
|
441
449
|
merge(rootManifest.resolutions, 'resolutions');
|
|
442
450
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
443
451
|
}
|
|
444
|
-
function buildBinShim(wsLocation, nodeTarget, gjsTarget) {
|
|
452
|
+
function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
|
|
445
453
|
const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
|
|
446
454
|
const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
|
|
455
|
+
// GJS-only env preamble — Node ignores GI_TYPELIB_PATH so we scope the
|
|
456
|
+
// export to the gjs branch, keeping the shim minimal when no native pkgs
|
|
457
|
+
// exist or only the Node bin is in play.
|
|
458
|
+
const gjsPreamble = nativePrebuildDirs.length === 0
|
|
459
|
+
? ''
|
|
460
|
+
: (() => {
|
|
461
|
+
const joined = `'${nativePrebuildDirs.join(':').replace(/'/g, `'\\''`)}'`;
|
|
462
|
+
return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
|
|
463
|
+
`LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
|
|
464
|
+
`export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
|
|
465
|
+
})();
|
|
447
466
|
if (nodeAbs && gjsAbs) {
|
|
448
|
-
return `#!/bin/sh\nif [ -f "${nodeAbs}" ]; then\n exec node "${nodeAbs}" "$@"\nfi\
|
|
467
|
+
return `#!/bin/sh\nif [ -f "${nodeAbs}" ]; then\n exec node "${nodeAbs}" "$@"\nfi\n${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
|
|
449
468
|
}
|
|
450
469
|
if (nodeAbs)
|
|
451
470
|
return `#!/bin/sh\nexec node "${nodeAbs}" "$@"\n`;
|
|
452
471
|
if (gjsAbs)
|
|
453
|
-
return `#!/bin/sh\
|
|
472
|
+
return `#!/bin/sh\n${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
|
|
454
473
|
throw new Error('buildBinShim: either nodeTarget or gjsTarget must be provided');
|
|
455
474
|
}
|
|
456
475
|
/**
|
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
|
+
}
|
|
@@ -97,13 +97,18 @@ export const uninstallCommand = {
|
|
|
97
97
|
* #!/bin/sh
|
|
98
98
|
* exec '<absolute-path>' "$@"
|
|
99
99
|
*
|
|
100
|
-
* or (for `.gjs.mjs` / `.mjs` targets
|
|
100
|
+
* or (for `.gjs.mjs` / `.mjs` targets, with optional GI_TYPELIB_PATH /
|
|
101
|
+
* LD_LIBRARY_PATH preamble for native @gjsify/* prebuilds):
|
|
101
102
|
*
|
|
102
103
|
* #!/bin/sh
|
|
104
|
+
* GI_TYPELIB_PATH='<dirs>'${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}
|
|
105
|
+
* LD_LIBRARY_PATH='<dirs>'${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}
|
|
106
|
+
* export GI_TYPELIB_PATH LD_LIBRARY_PATH
|
|
103
107
|
* exec gjs -m '<absolute-path>' "$@"
|
|
104
108
|
*
|
|
105
|
-
* We
|
|
106
|
-
*
|
|
109
|
+
* We extract the single-quoted path on the `exec` line (NOT the first quoted
|
|
110
|
+
* string in the file, which with the preamble is the prebuild dir list) and
|
|
111
|
+
* check whether it's under `pkgDir`. Non-shim files (e.g. unrelated binaries
|
|
107
112
|
* the user installed via `npm install -g`) are skipped silently.
|
|
108
113
|
*/
|
|
109
114
|
function findBinShimsForPackage(binDir, pkgDir, verbose) {
|
|
@@ -126,8 +131,15 @@ function findBinShimsForPackage(binDir, pkgDir, verbose) {
|
|
|
126
131
|
const content = readFileSync(fullPath, 'utf-8');
|
|
127
132
|
if (!content.startsWith('#!/bin/sh'))
|
|
128
133
|
continue;
|
|
129
|
-
//
|
|
130
|
-
|
|
134
|
+
// Find the `exec [gjs -m] '<target>' "$@"` line; the path may
|
|
135
|
+
// contain `:` from the optional prebuild preamble lines, which
|
|
136
|
+
// is why we anchor to `exec ` rather than the first quoted run.
|
|
137
|
+
const execLine = content
|
|
138
|
+
.split('\n')
|
|
139
|
+
.find((line) => /^exec (?:gjs -m )?'/.test(line));
|
|
140
|
+
if (!execLine)
|
|
141
|
+
continue;
|
|
142
|
+
const m = execLine.match(/'([^']+)'/);
|
|
131
143
|
if (!m)
|
|
132
144
|
continue;
|
|
133
145
|
const target = m[1];
|
package/lib/index.js
CHANGED
|
@@ -8,9 +8,19 @@ import { APP_NAME } from './constants.js';
|
|
|
8
8
|
// cosmetic — the event loop holds the process up — but under GJS the
|
|
9
9
|
// script ends as soon as the top-level synchronous flow finishes, and
|
|
10
10
|
// fire-and-forget handlers silently exit before any async work runs.
|
|
11
|
-
|
|
11
|
+
const cli = yargs(hideBin(process.argv));
|
|
12
|
+
await cli
|
|
12
13
|
.scriptName(APP_NAME)
|
|
13
14
|
.strict()
|
|
15
|
+
// Use the full terminal width for help. yargs's default caps at 80
|
|
16
|
+
// (`Math.min(80, process.stdout.columns)`); we explicitly opt into
|
|
17
|
+
// the real terminal width so long option/description lines wrap at
|
|
18
|
+
// the actual terminal edge instead of an arbitrary 80-col limit.
|
|
19
|
+
// `terminalWidth()` reads `process.stdout.columns`, which under GJS
|
|
20
|
+
// is backed by @gjsify/terminal-native (ioctl TIOCGWINSZ) when the
|
|
21
|
+
// typelib is on GI_TYPELIB_PATH — see the global launcher in
|
|
22
|
+
// packages/infra/cli/src/utils/install-global.ts.
|
|
23
|
+
.wrap(cli.terminalWidth())
|
|
14
24
|
.command(create.command, create.description, create.builder, create.handler)
|
|
15
25
|
.command(install.command, install.description, install.builder, install.handler)
|
|
16
26
|
.command(build.command, build.description, build.builder, build.handler)
|
|
@@ -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 });
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
24
|
import * as os from 'node:os';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
|
+
import { detectNativePackages } from './detect-native-packages.js';
|
|
26
27
|
/**
|
|
27
28
|
* Compute the canonical global install layout for the current user. Honours
|
|
28
29
|
* `XDG_DATA_HOME` (per the XDG Base Directory Spec) plus
|
|
@@ -67,6 +68,18 @@ export function defaultGlobalLayout() {
|
|
|
67
68
|
export function linkGlobalBins(packageNames, layout) {
|
|
68
69
|
fs.mkdirSync(layout.binDir, { recursive: true });
|
|
69
70
|
const created = [];
|
|
71
|
+
// Discover @gjsify/* packages with native prebuilds (Vala/GObject typelibs
|
|
72
|
+
// + shared libs) under the global prefix. The launcher bakes their
|
|
73
|
+
// directories into GI_TYPELIB_PATH / LD_LIBRARY_PATH so `imports.gi.X`
|
|
74
|
+
// resolves at CLI startup — required for e.g. @gjsify/terminal-native,
|
|
75
|
+
// without which process.stdout.isTTY / columns / colors all fall back to
|
|
76
|
+
// the conservative env-only defaults (no colors, 80-col wrap).
|
|
77
|
+
//
|
|
78
|
+
// `runGjsBundle()` does the same dance for `gjsify run <bundle>` at
|
|
79
|
+
// runtime; here we do it at install time because the global launcher
|
|
80
|
+
// invokes the CLI bundle directly, with no opportunity to set env first.
|
|
81
|
+
const nativePrebuildDirs = detectNativePackages(layout.prefix).map((p) => p.prebuildsDir);
|
|
82
|
+
const envPreamble = buildLauncherEnvPreamble(nativePrebuildDirs);
|
|
70
83
|
for (const pkgName of packageNames) {
|
|
71
84
|
const pkgDir = path.join(layout.prefix, 'node_modules', pkgName);
|
|
72
85
|
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
@@ -101,8 +114,11 @@ export function linkGlobalBins(packageNames, layout) {
|
|
|
101
114
|
// then tries to parse JavaScript as shell. Plain Node scripts
|
|
102
115
|
// with shebangs (lib/index.js) keep the direct-exec path.
|
|
103
116
|
const isGjsBundle = targetAbs.endsWith('.gjs.mjs') || targetAbs.endsWith('.mjs');
|
|
117
|
+
// Only GJS bundles need the GI typelib search path. Plain Node
|
|
118
|
+
// scripts ignore GI_TYPELIB_PATH, so skipping the preamble there
|
|
119
|
+
// keeps the launcher minimal.
|
|
104
120
|
const launcher = isGjsBundle
|
|
105
|
-
? `#!/bin/sh\
|
|
121
|
+
? `#!/bin/sh\n${envPreamble}exec gjs -m ${shQuote(targetAbs)} "$@"\n`
|
|
106
122
|
: `#!/bin/sh\nexec ${shQuote(targetAbs)} "$@"\n`;
|
|
107
123
|
fs.writeFileSync(linkPath, launcher);
|
|
108
124
|
fs.chmodSync(linkPath, 0o755);
|
|
@@ -114,6 +130,23 @@ export function linkGlobalBins(packageNames, layout) {
|
|
|
114
130
|
function shQuote(s) {
|
|
115
131
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
116
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the POSIX-sh `export` lines that prepend the given prebuild
|
|
135
|
+
* directories to GI_TYPELIB_PATH and LD_LIBRARY_PATH. Any pre-existing value
|
|
136
|
+
* inherited from the user's environment is preserved as a suffix so
|
|
137
|
+
* user-installed typelibs/libraries still resolve.
|
|
138
|
+
*
|
|
139
|
+
* Returns the empty string when no prebuilds were found — avoids emitting an
|
|
140
|
+
* inert assignment in the launcher.
|
|
141
|
+
*/
|
|
142
|
+
function buildLauncherEnvPreamble(prebuildsDirs) {
|
|
143
|
+
if (prebuildsDirs.length === 0)
|
|
144
|
+
return '';
|
|
145
|
+
const joined = shQuote(prebuildsDirs.join(':'));
|
|
146
|
+
return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
|
|
147
|
+
`LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
|
|
148
|
+
`export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
|
|
149
|
+
}
|
|
117
150
|
function pickBinMap(pkgName, pkgJson) {
|
|
118
151
|
const gjsifyEntry = pkgJson.gjsify;
|
|
119
152
|
if (gjsifyEntry?.bin !== undefined) {
|
|
@@ -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 {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// npm Trusted Publishing — OIDC token exchange for `gjsify publish`.
|
|
2
|
+
//
|
|
3
|
+
// Two-step flow, mirroring `refs/npm-cli/lib/utils/oidc.js`:
|
|
4
|
+
//
|
|
5
|
+
// 1. Request a GitHub Actions OIDC ID token (JWT) from the runner.
|
|
6
|
+
// Requires `permissions: id-token: write` in the calling workflow.
|
|
7
|
+
// GitHub provides `ACTIONS_ID_TOKEN_REQUEST_URL` and
|
|
8
|
+
// `ACTIONS_ID_TOKEN_REQUEST_TOKEN` env vars; we GET the URL with
|
|
9
|
+
// the runner-provided audience (`npm:registry.npmjs.org`).
|
|
10
|
+
//
|
|
11
|
+
// 2. Exchange that JWT at npm's `/-/npm/v1/oidc/token/exchange/package/<name>`
|
|
12
|
+
// endpoint for a short-lived (~5 min) npm publish token. npm verifies
|
|
13
|
+
// the JWT against the package's configured Trusted Publisher
|
|
14
|
+
// (repository + workflow filename + optional environment) and either
|
|
15
|
+
// issues a token or rejects with an explanatory error.
|
|
16
|
+
//
|
|
17
|
+
// The token returned by step 2 is used for the publish PUT in the same
|
|
18
|
+
// way the long-lived NPM_TOKEN would have been — drop-in replacement.
|
|
19
|
+
//
|
|
20
|
+
// Reference: refs/npm-cli/lib/utils/oidc.js
|
|
21
|
+
// Original: Copyright (c) npm contributors. Artistic-2.0.
|
|
22
|
+
export class OidcUnavailableError extends Error {
|
|
23
|
+
reason;
|
|
24
|
+
constructor(message, reason) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
this.name = 'OidcUnavailableError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class OidcExchangeError extends Error {
|
|
31
|
+
status;
|
|
32
|
+
body;
|
|
33
|
+
packageName;
|
|
34
|
+
constructor(message, status, body, packageName) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.body = body;
|
|
38
|
+
this.packageName = packageName;
|
|
39
|
+
this.name = 'OidcExchangeError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Probe whether OIDC publishing is available in the current process —
|
|
44
|
+
* cheap env-var check, no network access. Used by `gjsify publish` to
|
|
45
|
+
* decide between OIDC and token-based auth in auto-detect mode.
|
|
46
|
+
*/
|
|
47
|
+
export function hasGithubOidcEnv() {
|
|
48
|
+
return Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Request a GitHub Actions OIDC ID token for the given audience.
|
|
52
|
+
* Throws `OidcUnavailableError` when the required env vars are missing
|
|
53
|
+
* (caller can fall back to token auth) or when GitHub rejects the request.
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchGithubOidcToken(audience, log) {
|
|
56
|
+
const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
57
|
+
const bearer = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
58
|
+
if (!url || !bearer) {
|
|
59
|
+
throw new OidcUnavailableError('GitHub Actions OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_{URL,TOKEN}) not set. ' +
|
|
60
|
+
'The calling workflow needs `permissions: id-token: write`.', 'no-env');
|
|
61
|
+
}
|
|
62
|
+
const requestUrl = new URL(url);
|
|
63
|
+
requestUrl.searchParams.set('audience', audience);
|
|
64
|
+
log?.(`gjsify oidc: GET ${requestUrl.href.replace(bearer, '<bearer>')}`);
|
|
65
|
+
const res = await fetch(requestUrl.href, {
|
|
66
|
+
method: 'GET',
|
|
67
|
+
headers: {
|
|
68
|
+
Accept: 'application/json',
|
|
69
|
+
Authorization: `Bearer ${bearer}`,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const text = await res.text().catch(() => '<no body>');
|
|
74
|
+
throw new OidcUnavailableError(`Failed to fetch GitHub OIDC id_token: ${res.status} ${res.statusText} — ${text.slice(0, 200)}`, 'fetch-id-token');
|
|
75
|
+
}
|
|
76
|
+
const json = (await res.json().catch(() => ({})));
|
|
77
|
+
if (!json.value) {
|
|
78
|
+
throw new OidcUnavailableError('GitHub OIDC response missing `value` field', 'no-id-token');
|
|
79
|
+
}
|
|
80
|
+
return json.value;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Exchange a GitHub OIDC JWT for a short-lived npm publish token at
|
|
84
|
+
* `/-/npm/v1/oidc/token/exchange/package/<escaped-name>`. The npm
|
|
85
|
+
* registry validates the JWT against the package's Trusted Publisher
|
|
86
|
+
* config — if no Trusted Publisher is configured, or the JWT comes from
|
|
87
|
+
* a repo/workflow that doesn't match, the exchange returns a 4xx with
|
|
88
|
+
* a descriptive body which we propagate as `OidcExchangeError`.
|
|
89
|
+
*/
|
|
90
|
+
export async function exchangeOidcForNpmToken(args) {
|
|
91
|
+
const { packageName, registry, idToken, log } = args;
|
|
92
|
+
const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
|
|
93
|
+
// npm-package-arg's escapedName convention — same as gjsify publish.ts.
|
|
94
|
+
const escapedName = packageName.startsWith('@')
|
|
95
|
+
? (() => {
|
|
96
|
+
const slash = packageName.indexOf('/');
|
|
97
|
+
const scope = packageName.slice(1, slash);
|
|
98
|
+
const base = packageName.slice(slash + 1);
|
|
99
|
+
return `@${encodeURIComponent(scope)}%2f${encodeURIComponent(base)}`;
|
|
100
|
+
})()
|
|
101
|
+
: encodeURIComponent(packageName);
|
|
102
|
+
const exchangeUrl = `${registryClean}/-/npm/v1/oidc/token/exchange/package/${escapedName}`;
|
|
103
|
+
log?.(`gjsify oidc: POST ${exchangeUrl}`);
|
|
104
|
+
const res = await fetch(exchangeUrl, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${idToken}`,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
Accept: 'application/json',
|
|
110
|
+
},
|
|
111
|
+
// npm's exchange endpoint accepts an empty JSON body — the JWT is
|
|
112
|
+
// the proof, no additional claims needed from us.
|
|
113
|
+
body: '{}',
|
|
114
|
+
});
|
|
115
|
+
const text = await res.text().catch(() => '');
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
throw new OidcExchangeError(`npm OIDC token exchange failed for ${packageName}: ${res.status} ${res.statusText} — ${text.slice(0, 300)}`, res.status, text, packageName);
|
|
118
|
+
}
|
|
119
|
+
let json;
|
|
120
|
+
try {
|
|
121
|
+
json = JSON.parse(text);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new OidcExchangeError(`npm OIDC token exchange returned non-JSON body for ${packageName}: ${text.slice(0, 200)}`, res.status, text, packageName);
|
|
125
|
+
}
|
|
126
|
+
if (!json.token) {
|
|
127
|
+
throw new OidcExchangeError(`npm OIDC token exchange returned no \`token\` field for ${packageName}`, res.status, text, packageName);
|
|
128
|
+
}
|
|
129
|
+
return json.token;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* End-to-end: probe env → fetch id-token → exchange for npm token.
|
|
133
|
+
* One-shot convenience for `gjsify publish` and the verification command.
|
|
134
|
+
*/
|
|
135
|
+
export async function getNpmTrustedToken(opts) {
|
|
136
|
+
const audience = `npm:${new URL(opts.registry).hostname}`;
|
|
137
|
+
const idToken = await fetchGithubOidcToken(audience, opts.log);
|
|
138
|
+
const token = await exchangeOidcForNpmToken({ ...opts, idToken });
|
|
139
|
+
return { token, audience };
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
4
4
|
"description": "CLI for Gjsify",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
"cli"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@gjsify/buffer": "^0.4.
|
|
41
|
-
"@gjsify/create-app": "^0.4.
|
|
42
|
-
"@gjsify/node-globals": "^0.4.
|
|
43
|
-
"@gjsify/node-polyfills": "^0.4.
|
|
44
|
-
"@gjsify/npm-registry": "^0.4.
|
|
45
|
-
"@gjsify/resolve-npm": "^0.4.
|
|
46
|
-
"@gjsify/rolldown-plugin-gjsify": "^0.4.
|
|
47
|
-
"@gjsify/rolldown-plugin-pnp": "^0.4.
|
|
48
|
-
"@gjsify/semver": "^0.4.
|
|
49
|
-
"@gjsify/tar": "^0.4.
|
|
50
|
-
"@gjsify/web-polyfills": "^0.4.
|
|
51
|
-
"@gjsify/workspace": "^0.4.
|
|
40
|
+
"@gjsify/buffer": "^0.4.17",
|
|
41
|
+
"@gjsify/create-app": "^0.4.17",
|
|
42
|
+
"@gjsify/node-globals": "^0.4.17",
|
|
43
|
+
"@gjsify/node-polyfills": "^0.4.17",
|
|
44
|
+
"@gjsify/npm-registry": "^0.4.17",
|
|
45
|
+
"@gjsify/resolve-npm": "^0.4.17",
|
|
46
|
+
"@gjsify/rolldown-plugin-gjsify": "^0.4.17",
|
|
47
|
+
"@gjsify/rolldown-plugin-pnp": "^0.4.17",
|
|
48
|
+
"@gjsify/semver": "^0.4.17",
|
|
49
|
+
"@gjsify/tar": "^0.4.17",
|
|
50
|
+
"@gjsify/web-polyfills": "^0.4.17",
|
|
51
|
+
"@gjsify/workspace": "^0.4.17",
|
|
52
52
|
"cosmiconfig": "^9.0.1",
|
|
53
53
|
"get-tsconfig": "^4.14.0",
|
|
54
54
|
"pkg-types": "^2.3.1",
|
|
@@ -56,12 +56,12 @@
|
|
|
56
56
|
"yargs": "^18.0.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@gjsify/unit": "^0.4.
|
|
59
|
+
"@gjsify/unit": "^0.4.17",
|
|
60
60
|
"@types/yargs": "^17.0.35",
|
|
61
61
|
"typescript": "^6.0.3"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@gjsify/rolldown-native": "^0.4.
|
|
64
|
+
"@gjsify/rolldown-native": "^0.4.17"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@gjsify/rolldown-native": {
|