@gjsify/cli 0.4.44 → 0.5.1

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.
@@ -33,10 +33,25 @@ export const runCommand = {
33
33
  type: 'string',
34
34
  array: true,
35
35
  default: [],
36
+ })
37
+ .parserConfiguration({
38
+ // Preserve `--` as args['--'] so callers can write
39
+ // gjsify run ./server.mjs -- --port 8080
40
+ // without yargs intercepting --port as its own option.
41
+ // Without this, anything after `--` lands in args._ and is
42
+ // silently dropped by the handler.
43
+ 'populate--': true,
36
44
  }),
37
45
  handler: async (args) => {
38
46
  const target = args.target;
39
- const extraArgs = args.args ?? [];
47
+ // Collect positional args AND anything after the `--` separator.
48
+ // With `parserConfiguration({ 'populate--': true })` in the builder,
49
+ // `gjsify run target -- a b c` puts a, b, c into args['--'] rather
50
+ // than silently discarding them (without it they land in args._ and
51
+ // are never forwarded to the child).
52
+ const positionalArgs = args.args ?? [];
53
+ const doubleDashArgs = (args['--'] ?? []).filter((v) => typeof v === 'string');
54
+ const extraArgs = [...positionalArgs, ...doubleDashArgs];
40
55
  // Script lookup wins over file detection. Without this, a bare
41
56
  // `gjsify run build` would resolve to a file when a `./build`
42
57
  // directory exists in cwd (e.g. meson's build dir from a Vala
@@ -47,7 +62,9 @@ export const runCommand = {
47
62
  const hasScript = pkg?.scripts && typeof pkg.scripts[target] === 'string';
48
63
  if (!hasScript && looksLikeFile(target)) {
49
64
  const file = resolve(target);
50
- await runGjsBundle(file, extraArgs);
65
+ // Terminal call — exit on success, or the GJS main loop parks
66
+ // this process forever (see RunGjsBundleOptions.exitOnSuccess).
67
+ await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
51
68
  return;
52
69
  }
53
70
  await runScript(target, extraArgs);
@@ -117,6 +134,43 @@ async function runScript(script, extraArgs) {
117
134
  npm_package_name: pkg.name ?? '',
118
135
  npm_package_version: pkg.version ?? '',
119
136
  };
137
+ // In-process fast path: under GJS, the build orchestration chains a
138
+ // heavyweight gjs per level (`foreach → npm → gjsify run X → gjsify <cmd>`),
139
+ // which oversubscribes CI's few cores and thrashes. When the script is a
140
+ // single `gjsify <subcommand>` (no shell operators / substitutions /
141
+ // unquoted globs), dispatch it through the same yargs surface IN THIS
142
+ // process instead of spawning another gjs — collapsing two gjs into one.
143
+ // Anything fancier (compound `&&`, pipes, a non-gjsify command) falls
144
+ // through to the shell spawn below. Node keeps spawning (cheap there); this
145
+ // only triggers under GJS, where the nesting actually hurts. See cli-app.ts.
146
+ const inProcArgv = runningUnderGjs() ? gjsifyInProcessArgv(literal, extraArgs) : null;
147
+ if (inProcArgv) {
148
+ // The subcommand runs in our process, so surface the script env on
149
+ // `process.env` (PATH with the workspace .bin dirs, npm_* lifecycle
150
+ // vars) exactly as the spawn path would. We process.exit afterwards, so
151
+ // mutating the global env is fine.
152
+ process.env.PATH = env.PATH;
153
+ process.env.npm_lifecycle_event = env.npm_lifecycle_event;
154
+ process.env.npm_package_name = env.npm_package_name;
155
+ process.env.npm_package_version = env.npm_package_version;
156
+ if (env.FORCE_COLOR !== undefined)
157
+ process.env.FORCE_COLOR = env.FORCE_COLOR;
158
+ try {
159
+ // Dynamic import to avoid a static cli-app ↔ run.ts import cycle;
160
+ // cli-app is already loaded (the CLI is running through it), so this
161
+ // resolves from cache instantly.
162
+ const { runCli } = await import('../cli-app.js');
163
+ await runCli(inProcArgv);
164
+ }
165
+ catch (err) {
166
+ console.error(err.message);
167
+ process.exit(1);
168
+ }
169
+ // Honor a non-zero `process.exitCode` set by the dispatched handler
170
+ // (e.g. a failing check that sets the code instead of exiting) —
171
+ // a bare exit(0) here masked such failures as success.
172
+ process.exit(process.exitCode != null ? Number(process.exitCode) : 0);
173
+ }
120
174
  const fullCmd = extraArgs.length > 0 ? `${literal} ${extraArgs.map(shellEscape).join(' ')}` : literal;
121
175
  // ensureMainLoop() (called inside spawn) keeps GJS alive after the
122
176
  // child exits — without an explicit process.exit() the success path
@@ -141,3 +195,79 @@ function shellEscape(arg) {
141
195
  return arg;
142
196
  return `'${arg.replace(/'/g, "'\\''")}'`;
143
197
  }
198
+ // Detected at CALL time (a module-load const reads `undefined` in the bundled
199
+ // `--app gjs` form). `bundler-pick.ts` uses the same expression.
200
+ function runningUnderGjs() {
201
+ return typeof globalThis.imports?.gi !== 'undefined';
202
+ }
203
+ /**
204
+ * If `literal` is a SINGLE `gjsify <subcommand> …` command with no shell
205
+ * operators / substitutions / unquoted globs, return the argv to feed `runCli`
206
+ * (the subcommand + its args, with `extraArgs` appended). Otherwise null — the
207
+ * caller falls back to the shell spawn path. Quotes are honoured so a quoted
208
+ * glob (`'src/**'`) survives as a single literal token, exactly as the shell
209
+ * would hand it to `gjsify` (which does its own glob expansion).
210
+ */
211
+ function gjsifyInProcessArgv(literal, extraArgs) {
212
+ const tokens = tokenizeSimpleCommand(literal);
213
+ if (!tokens || tokens.length < 2 || tokens[0] !== 'gjsify')
214
+ return null;
215
+ return [...tokens.slice(1), ...extraArgs];
216
+ }
217
+ /**
218
+ * Quote-aware tokenizer for a SIMPLE command line (no shell features beyond
219
+ * single/double quoting). Returns the tokens, or null if the string contains
220
+ * anything the shell would treat specially — operators (`&& | ; < > &`),
221
+ * substitutions (`$(...)` / backticks / `$VAR`), unquoted globs/expansions
222
+ * (`* ? { } [ ] ~`), comments (`#`), or an unterminated quote — in which case
223
+ * the caller MUST use the real shell instead.
224
+ */
225
+ function tokenizeSimpleCommand(cmd) {
226
+ const tokens = [];
227
+ let cur = '';
228
+ let has = false;
229
+ let quote = null;
230
+ for (let i = 0; i < cmd.length; i++) {
231
+ const c = cmd[i];
232
+ if (quote === "'") {
233
+ if (c === "'")
234
+ quote = null;
235
+ else
236
+ cur += c;
237
+ continue;
238
+ }
239
+ if (quote === '"') {
240
+ if (c === '"')
241
+ quote = null;
242
+ else if (c === '\\' && (cmd[i + 1] === '"' || cmd[i + 1] === '\\'))
243
+ cur += cmd[++i];
244
+ else if (c === '$' || c === '`')
245
+ return null; // substitution inside "…"
246
+ else
247
+ cur += c;
248
+ continue;
249
+ }
250
+ if (c === "'" || c === '"') {
251
+ quote = c;
252
+ has = true;
253
+ continue;
254
+ }
255
+ if (c === ' ' || c === '\t') {
256
+ if (has) {
257
+ tokens.push(cur);
258
+ cur = '';
259
+ has = false;
260
+ }
261
+ continue;
262
+ }
263
+ if ('|&;<>`$()\\\n\r*?{}[]~#!'.includes(c))
264
+ return null;
265
+ cur += c;
266
+ has = true;
267
+ }
268
+ if (quote)
269
+ return null; // unterminated quote
270
+ if (has)
271
+ tokens.push(cur);
272
+ return tokens;
273
+ }
@@ -126,6 +126,11 @@ export const testCommand = {
126
126
  if (anyFailed) {
127
127
  process.exit(1);
128
128
  }
129
+ // Explicit success exit: under GJS the spawn-armed main loop would
130
+ // otherwise park this process after the summary. (runGjsBundle
131
+ // deliberately does NOT exit mid-flow — an unconditional exit there
132
+ // truncated this multi-runtime loop after the first gjs bundle.)
133
+ process.exit(0);
129
134
  },
130
135
  };
131
136
  /** Build a single test bundle in-process via `BuildAction`. */
@@ -19,6 +19,7 @@
19
19
  // Reference: packages/infra/cli/src/commands/run.ts (env-setup precedent).
20
20
  import { spawn } from 'node:child_process';
21
21
  import { createRequire } from 'node:module';
22
+ import { dirname, join } from 'node:path';
22
23
  import { pathToFileURL } from 'node:url';
23
24
  import { detectNativePackages, buildNativeEnv } from '../utils/detect-native-packages.js';
24
25
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
@@ -63,16 +64,46 @@ export const tscCommand = {
63
64
  // (e.g. `packages/web/fetch`) still finds the hoisted `@gjsify/tsc`
64
65
  // at the root's `node_modules`. Falls back to cwd for standalone
65
66
  // (non-monorepo) projects that have the package locally.
67
+ // Resolve `@gjsify/tsc/bundle` from two anchors, in order:
68
+ // 1. the consumer's workspace root / cwd (the normal install — a
69
+ // project that has `@gjsify/tsc` as a dev dependency), then
70
+ // 2. the running CLI bundle's own location (`import.meta.url`).
71
+ // Anchor 2 is what makes a *bundled* gjsify (the Flatpak SDK
72
+ // extension's `node_modules/@gjsify/tsc`, or any install where the CLI
73
+ // ships its own toolchain) resolve `@gjsify/tsc` even when the consumer
74
+ // project has none — mirroring how `bundler-pick.ts` dual-anchors the
75
+ // native bridges at cwd AND the bundle.
66
76
  const anchorDir = findWorkspaceRoot(cwd) ?? cwd;
67
- const anchor = pathToFileURL(`${anchorDir}/__gjsify_tsc__.js`).href;
68
- const require = createRequire(anchor);
77
+ const anchors = [pathToFileURL(`${anchorDir}/__gjsify_tsc__.js`).href, import.meta.url];
69
78
  let bundlePath;
70
- try {
71
- bundlePath = require.resolve('@gjsify/tsc/bundle');
79
+ for (const anchor of anchors) {
80
+ try {
81
+ bundlePath = createRequire(anchor).resolve('@gjsify/tsc/bundle');
82
+ break;
83
+ }
84
+ catch {
85
+ // try the next anchor
86
+ }
72
87
  }
73
- catch {
74
- console.error('gjsify tsc: @gjsify/tsc is not installed.');
75
- console.error(' Install with: gjsify install --save-dev @gjsify/tsc');
88
+ // Node fallback: upstream npm `typescript`'s CLI (`lib/tsc.js`),
89
+ // resolved from the same anchors. gjsify tsc is GJS-first — the
90
+ // @gjsify/tsc bundle is the Node-free path — but it stays runnable
91
+ // under Node: on a machine with no `gjs`, it transparently spawns
92
+ // upstream `typescript`. Mirrors how `bundler-pick.ts` falls back to
93
+ // npm `rolldown` off GJS, so "both with both" holds for the type-gate.
94
+ let nodeTscPath;
95
+ for (const anchor of anchors) {
96
+ try {
97
+ nodeTscPath = join(dirname(createRequire(anchor).resolve('typescript/package.json')), 'lib', 'tsc.js');
98
+ break;
99
+ }
100
+ catch {
101
+ // try the next anchor
102
+ }
103
+ }
104
+ if (!bundlePath && !nodeTscPath) {
105
+ console.error('gjsify tsc: neither @gjsify/tsc (GJS bundle) nor npm `typescript` (Node) is installed.');
106
+ console.error(' Install with: gjsify install --save-dev @gjsify/tsc (or add `typescript`).');
76
107
  process.exit(1);
77
108
  }
78
109
  // Mirror `gjsify run`'s native-env composition so any future
@@ -85,25 +116,48 @@ export const tscCommand = {
85
116
  ...process.env,
86
117
  ...nativeEnv,
87
118
  };
88
- const gjsArgs = ['-m', bundlePath, ...tscArgs];
89
- const child = spawn('gjs', gjsArgs, { env, stdio: 'inherit' });
90
- await new Promise((resolvePromise) => {
91
- child.on('close', (code) => {
92
- process.exit(code ?? 1);
93
- });
119
+ // Run upstream `typescript` under this Node — the Node-only fallback.
120
+ const runNodeTsc = () => {
121
+ const child = spawn(process.execPath, [nodeTscPath, ...tscArgs], { env, stdio: 'inherit' });
122
+ child.on('close', (code) => process.exit(code ?? 1));
94
123
  child.on('error', (err) => {
95
- if (err.code === 'ENOENT') {
96
- console.error('gjsify tsc: `gjs` not found on PATH. Install GJS (e.g. `dnf install gjs`).');
97
- }
98
- else {
99
- console.error(`gjsify tsc: ${err.message}`);
100
- }
124
+ console.error(`gjsify tsc (node typescript): ${err.message}`);
101
125
  process.exit(1);
102
126
  });
103
- // Never resolves naturally — process.exit() above terminates
104
- // the program once the child closes. The promise just keeps
105
- // the handler alive while gjs runs.
106
- void resolvePromise;
107
- });
127
+ };
128
+ // Prefer GJS — the Node-free @gjsify/tsc bundle — when it resolves. If
129
+ // `gjs` is not on PATH (ENOENT), transparently fall back to upstream
130
+ // `typescript` under Node when available.
131
+ if (bundlePath) {
132
+ const child = spawn('gjs', ['-m', bundlePath, ...tscArgs], { env, stdio: 'inherit' });
133
+ await new Promise((resolvePromise) => {
134
+ child.on('close', (code) => {
135
+ process.exit(code ?? 1);
136
+ });
137
+ child.on('error', (err) => {
138
+ if (err.code === 'ENOENT' && nodeTscPath) {
139
+ // No gjs on PATH — run upstream typescript under Node.
140
+ runNodeTsc();
141
+ }
142
+ else if (err.code === 'ENOENT') {
143
+ console.error('gjsify tsc: `gjs` not found on PATH and no npm `typescript` fallback.');
144
+ console.error(' Install GJS (e.g. `dnf install gjs`), or add `typescript` to the project.');
145
+ process.exit(1);
146
+ }
147
+ else {
148
+ console.error(`gjsify tsc: ${err.message}`);
149
+ process.exit(1);
150
+ }
151
+ });
152
+ // Never resolves naturally — process.exit() above terminates the
153
+ // program once the child closes. The promise just keeps the
154
+ // handler alive while the child runs.
155
+ void resolvePromise;
156
+ });
157
+ }
158
+ else {
159
+ // No GJS bundle resolvable — go straight to the Node fallback.
160
+ runNodeTsc();
161
+ }
108
162
  },
109
163
  };
package/lib/index.js CHANGED
@@ -1,98 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import yargs from 'yargs';
2
+ // gjsify CLI entry point. The whole command surface lives in `cli-app.ts`
3
+ // (`runCli`), factored out so that `gjsify run` can dispatch a `gjsify
4
+ // <subcommand>` script IN-PROCESS instead of spawning a fresh gjs — the
5
+ // nested-gjs explosion that makes the GJS-first build orchestration thrash CI.
6
+ // This file is just the executable wrapper around `runCli`.
6
7
  import { hideBin } from 'yargs/helpers';
7
- import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, 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, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, } from './commands/index.js';
8
- import { APP_NAME } from './constants.js';
9
- // Detect which runtime is executing the CLI (GJS or Node.js).
10
- // GJS MUST be checked first because @gjsify/process sets
11
- // `process.versions.node = '20.0.0'` for compatibility — a plain
12
- // `process.versions.node` check would be a false Node positive under GJS.
13
- function runtimeLabel() {
14
- try {
15
- const sys = globalThis.imports?.system;
16
- if (sys?.version !== undefined) {
17
- const v = Number(sys.version);
18
- return `GJS ${Math.floor(v / 10000)}.${Math.floor((v % 10000) / 100)}.${v % 100} (SpiderMonkey)`;
19
- }
20
- }
21
- catch {
22
- /* not GJS */
23
- }
24
- if (typeof process !== 'undefined' && typeof process.versions?.node === 'string') {
25
- return `Node.js ${process.version}`;
26
- }
27
- return 'unknown runtime';
28
- }
29
- // Read the version from package.json adjacent to the bundle. yargs's
30
- // auto-version-discovery (its `pkg-up`-driven default) doesn't reach
31
- // through the bundled `dist/cli.gjs.mjs` path on GJS — falls back to
32
- // "unknown". Both layouts are covered:
33
- // - dev (tsx, `yarn workspace`): src/index.ts → ../package.json
34
- // - bundled (install -g): dist/cli.gjs.mjs → ../package.json
35
- function readBundleVersion() {
36
- try {
37
- const here = dirname(fileURLToPath(import.meta.url));
38
- const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
39
- return typeof pkg.version === 'string' ? pkg.version : 'unknown';
40
- }
41
- catch {
42
- return 'unknown';
43
- }
44
- }
45
- // `parseAsync()` instead of `.argv` so the top-level await keeps the
46
- // process alive until command handlers complete. Under Node this is
47
- // cosmetic — the event loop holds the process up — but under GJS the
48
- // script ends as soon as the top-level synchronous flow finishes, and
49
- // fire-and-forget handlers silently exit before any async work runs.
50
- const cli = yargs(hideBin(process.argv));
51
- await cli
52
- .scriptName(APP_NAME)
53
- .version(readBundleVersion())
54
- .strict()
55
- // Use the full terminal width for help. yargs's default caps at 80
56
- // (`Math.min(80, process.stdout.columns)`); we explicitly opt into
57
- // the real terminal width so long option/description lines wrap at
58
- // the actual terminal edge instead of an arbitrary 80-col limit.
59
- // `terminalWidth()` reads `process.stdout.columns`, which under GJS
60
- // is backed by @gjsify/terminal-native (ioctl TIOCGWINSZ) when the
61
- // typelib is on GI_TYPELIB_PATH — see the global launcher in
62
- // packages/infra/cli/src/utils/install-global.ts.
63
- .wrap(cli.terminalWidth())
64
- .command(create.command, create.description, create.builder, create.handler)
65
- .command(install.command, install.description, install.builder, install.handler)
66
- .command(build.command, build.description, build.builder, build.handler)
67
- .command(test.command, test.description, test.builder, test.handler)
68
- .command(run.command, run.description, run.builder, run.handler)
69
- .command(dlx.command, dlx.description, dlx.builder, dlx.handler)
70
- .command(info.command, info.description, info.builder, info.handler)
71
- .command(systemCheck.command, systemCheck.description, systemCheck.builder, systemCheck.handler)
72
- .command(check.command, check.description, check.builder, check.handler)
73
- .command(showcase.command, showcase.description, showcase.builder, showcase.handler)
74
- .command(gresource.command, gresource.description, gresource.builder, gresource.handler)
75
- .command(gettext.command, gettext.description, gettext.builder, gettext.handler)
76
- .command(gsettings.command, gsettings.description, gsettings.builder, gsettings.handler)
77
- .command(flatpak.command, flatpak.description, flatpak.builder, flatpak.handler)
78
- .command(foreach.command, foreach.description, foreach.builder, foreach.handler)
79
- .command(workspace.command, workspace.description, workspace.builder, workspace.handler)
80
- .command(pack.command, pack.description, pack.builder, pack.handler)
81
- .command(publish.command, publish.description, publish.builder, publish.handler)
82
- .command(whoami.command, whoami.description, whoami.builder, whoami.handler)
83
- .command(login.command, login.description, login.builder, login.handler)
84
- .command(logout.command, logout.description, logout.builder, logout.handler)
85
- .command(selfUpdate.command, selfUpdate.description, selfUpdate.builder, selfUpdate.handler)
86
- .command(generateInstaller.command, generateInstaller.description, generateInstaller.builder, generateInstaller.handler)
87
- .command(uninstall.command, uninstall.description, uninstall.builder, uninstall.handler)
88
- .command(upgrade.command, upgrade.description, upgrade.builder, upgrade.handler)
89
- .command(format.command, format.description, format.builder, format.handler)
90
- .command(lint.command, lint.description, lint.builder, lint.handler)
91
- .command(fix.command, fix.description, fix.builder, fix.handler)
92
- .command(barrels.command, barrels.description, barrels.builder, barrels.handler)
93
- .command(tsc.command, tsc.description, tsc.builder, tsc.handler)
94
- .command(affected.command, affected.description, affected.builder, affected.handler)
95
- .demandCommand(1)
96
- .epilogue(`Running on ${runtimeLabel()}`)
97
- .help()
98
- .parseAsync();
8
+ import { runCli } from './cli-app.js';
9
+ await runCli(hideBin(process.argv));
@@ -20,8 +20,30 @@ export declare class OidcExchangeError extends Error {
20
20
  readonly status: number;
21
21
  readonly body: string;
22
22
  readonly packageName: string;
23
- constructor(message: string, status: number, body: string, packageName: string);
23
+ /**
24
+ * The non-secret claims decoded from the GitHub OIDC JWT that npm
25
+ * rejected — `repository`, `workflow_ref`, `environment`, … These are
26
+ * exactly what npm matches a Trusted Publisher config against, so on a
27
+ * 401/403 they tell you precisely which fields the config must carry.
28
+ */
29
+ readonly claims?: Record<string, unknown> | undefined;
30
+ constructor(message: string, status: number, body: string, packageName: string,
31
+ /**
32
+ * The non-secret claims decoded from the GitHub OIDC JWT that npm
33
+ * rejected — `repository`, `workflow_ref`, `environment`, … These are
34
+ * exactly what npm matches a Trusted Publisher config against, so on a
35
+ * 401/403 they tell you precisely which fields the config must carry.
36
+ */
37
+ claims?: Record<string, unknown> | undefined);
24
38
  }
39
+ /**
40
+ * Decode the (non-secret) payload of a JWT — the middle base64url segment.
41
+ * A GitHub OIDC id-token's payload is public metadata about the workflow run
42
+ * (`repository`, `repository_owner`, `workflow_ref`, `job_workflow_ref`,
43
+ * `ref`, `environment`, `sub`, …); the SIGNATURE is what makes it trustworthy.
44
+ * Returns null if the token isn't a well-formed three-part JWT.
45
+ */
46
+ export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
25
47
  /**
26
48
  * Probe whether OIDC publishing is available in the current process —
27
49
  * cheap env-var check, no network access. Used by `gjsify publish` to
@@ -31,14 +31,43 @@ export class OidcExchangeError extends Error {
31
31
  status;
32
32
  body;
33
33
  packageName;
34
- constructor(message, status, body, packageName) {
34
+ claims;
35
+ constructor(message, status, body, packageName,
36
+ /**
37
+ * The non-secret claims decoded from the GitHub OIDC JWT that npm
38
+ * rejected — `repository`, `workflow_ref`, `environment`, … These are
39
+ * exactly what npm matches a Trusted Publisher config against, so on a
40
+ * 401/403 they tell you precisely which fields the config must carry.
41
+ */
42
+ claims) {
35
43
  super(message);
36
44
  this.status = status;
37
45
  this.body = body;
38
46
  this.packageName = packageName;
47
+ this.claims = claims;
39
48
  this.name = 'OidcExchangeError';
40
49
  }
41
50
  }
51
+ /**
52
+ * Decode the (non-secret) payload of a JWT — the middle base64url segment.
53
+ * A GitHub OIDC id-token's payload is public metadata about the workflow run
54
+ * (`repository`, `repository_owner`, `workflow_ref`, `job_workflow_ref`,
55
+ * `ref`, `environment`, `sub`, …); the SIGNATURE is what makes it trustworthy.
56
+ * Returns null if the token isn't a well-formed three-part JWT.
57
+ */
58
+ export function decodeJwtPayload(jwt) {
59
+ const parts = jwt.split('.');
60
+ if (parts.length !== 3)
61
+ return null;
62
+ try {
63
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
64
+ const json = typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
65
+ return JSON.parse(json);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
42
71
  /**
43
72
  * Probe whether OIDC publishing is available in the current process —
44
73
  * cheap env-var check, no network access. Used by `gjsify publish` to
@@ -59,10 +88,19 @@ export async function fetchGithubOidcToken(audience, log) {
59
88
  throw new OidcUnavailableError('GitHub Actions OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_{URL,TOKEN}) not set. ' +
60
89
  'The calling workflow needs `permissions: id-token: write`.', 'no-env');
61
90
  }
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, {
91
+ // Append the audience by STRING CONCATENATION, mirroring @actions/core's
92
+ // getIDToken (`${runtimeUrl}&audience=${encodeURIComponent(aud)}`) and the
93
+ // raw-curl probe that returns HTTP 201. Do NOT use `URL.searchParams.set` +
94
+ // `.href`: under GJS that serialization dropped the `audience` query param,
95
+ // so GitHub issued an id-token with the DEFAULT audience (the repo API URL)
96
+ // instead of `npm:registry.npmjs.org`. npm then rejected the otherwise-valid
97
+ // token with 401 "unauthorized" — the JWT's repository/workflow_ref/sub
98
+ // claims all matched the Trusted Publisher, only `aud` was wrong, which is
99
+ // exactly the failure we saw (curl from the same workflow → 201).
100
+ const sep = url.includes('?') ? '&' : '?';
101
+ const requestUrl = `${url}${sep}audience=${encodeURIComponent(audience)}`;
102
+ log?.(`gjsify oidc: GET ${requestUrl.replace(bearer, '<bearer>')}`);
103
+ const res = await fetch(requestUrl, {
66
104
  method: 'GET',
67
105
  headers: {
68
106
  Accept: 'application/json',
@@ -114,7 +152,7 @@ export async function exchangeOidcForNpmToken(args) {
114
152
  });
115
153
  const text = await res.text().catch(() => '');
116
154
  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);
155
+ throw new OidcExchangeError(`npm OIDC token exchange failed for ${packageName}: ${res.status} ${res.statusText} — ${text.slice(0, 300)}`, res.status, text, packageName, decodeJwtPayload(idToken) ?? undefined);
118
156
  }
119
157
  let json;
120
158
  try {
@@ -26,12 +26,27 @@ export interface RunOxcOptions {
26
26
  verbose?: boolean;
27
27
  }
28
28
  /**
29
- * Spawn an oxc tool (oxlint / oxfmt) via its Node ESM launcher. Inherits
30
- * stdio so the tool's own output (diagnostics, reformatted files, summary
31
- * lines) reaches the user.
29
+ * Engine pick for oxfmt — mirrors `bundler-pick.ts`'s `shouldUseNative()`:
30
+ * - `GJSIFY_OXFMT=npm` forces the Node launcher.
31
+ * - `GJSIFY_OXFMT=native` forces the GI bridge and throws when it is not
32
+ * loadable (instead of silently switching engines).
33
+ * - Default: native under GJS when loadable (the Node launcher cannot run
34
+ * without a Node host), npm launcher on Node.
35
+ */
36
+ export declare function shouldUseNativeOxfmt(): Promise<boolean>;
37
+ /**
38
+ * Run an oxc tool (oxlint / oxfmt).
39
+ *
40
+ * oxfmt: dual-engine — under GJS the `@gjsify/oxfmt-native` GI bridge runs
41
+ * the full oxfmt CLI in-process (Node-free); otherwise the npm Node ESM
42
+ * launcher is spawned. `GJSIFY_OXFMT=native|npm` overrides the pick.
43
+ *
44
+ * oxlint: always spawned via its Node ESM launcher with the current Node
45
+ * executable (`process.execPath`) so the JS plugin host is available —
46
+ * required for the `jsPlugins`-wired `gjsify/register-class-order` rule.
32
47
  *
33
- * The launcher is run with the current Node executable (`process.execPath`)
34
- * so the JS plugin host is available — required for oxlint's `jsPlugins`.
48
+ * Inherits stdio so the tool's own output (diagnostics, reformatted files,
49
+ * summary lines) reaches the user.
35
50
  *
36
51
  * Returns the exit code; never throws on non-zero exit (callers check it).
37
52
  */
@@ -52,6 +67,22 @@ export declare function loadOxlintTemplate(): string;
52
67
  export declare function loadOxfmtTemplate(): string;
53
68
  /** Helper for callers to surface the install hint to the user cleanly. */
54
69
  export declare function printOxcNotFound(err: OxcNotFoundError): void;
70
+ /**
71
+ * Propagate an oxc tool's exit code from a command handler.
72
+ *
73
+ * On Node, setting `process.exitCode` is the gentle idiom (streams flush,
74
+ * the process ends naturally). Under GJS there is no atexit hook, so
75
+ * `process.exitCode` alone is NOT honored at natural shutdown — the gjs
76
+ * process would exit 0 after a failed `--check`. Call `imports.system.exit`
77
+ * directly for non-zero codes there: SpiderMonkey raises its uncatchable
78
+ * exit exception from any context — including the yargs `parseAsync`
79
+ * microtask continuation this runs in — and stdout writes are synchronous
80
+ * under GJS, so the tool's report is already flushed. (`process.exit`'s
81
+ * idle-scheduled path is NOT used here: the scheduled idle never fires from
82
+ * this continuation — the module's top-level await is still pending and gjs
83
+ * exits 0 before the hook-registered loop runs.)
84
+ */
85
+ export declare function setOxcExitCode(code: number): void;
55
86
  /**
56
87
  * Has the given oxc tool's npm package (or its companion) been declared in the
57
88
  * project's dependencies? Useful as a cheap pre-flight check — `gjsify flatpak