@gjsify/cli 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/cli.gjs.mjs +351 -305
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +72 -1
  5. package/lib/bundler-pick.js +175 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/blueprint/index.d.ts +4 -0
  8. package/lib/commands/blueprint/index.js +15 -0
  9. package/lib/commands/blueprint/types.d.ts +8 -0
  10. package/lib/commands/blueprint/types.js +124 -0
  11. package/lib/commands/devtools.d.ts +21 -0
  12. package/lib/commands/devtools.js +146 -0
  13. package/lib/commands/env.d.ts +31 -0
  14. package/lib/commands/env.js +114 -0
  15. package/lib/commands/exec.d.ts +10 -0
  16. package/lib/commands/exec.js +208 -0
  17. package/lib/commands/foreach.js +5 -97
  18. package/lib/commands/format.js +26 -6
  19. package/lib/commands/index.d.ts +5 -0
  20. package/lib/commands/index.js +5 -0
  21. package/lib/commands/install.d.ts +2 -2
  22. package/lib/commands/install.js +5 -5
  23. package/lib/commands/self-update.js +2 -1
  24. package/lib/commands/ship.js +51 -8
  25. package/lib/commands/test.d.ts +18 -9
  26. package/lib/commands/test.js +56 -11
  27. package/lib/commands/tsc.js +4 -4
  28. package/lib/commands/upgrade.d.ts +15 -0
  29. package/lib/commands/upgrade.js +76 -2
  30. package/lib/commands/webext/index.d.ts +2 -0
  31. package/lib/commands/webext/index.js +15 -0
  32. package/lib/commands/webext/subcommands.d.ts +16 -0
  33. package/lib/commands/webext/subcommands.js +204 -0
  34. package/lib/types/config-data.d.ts +1 -1
  35. package/lib/utils/bin-shim.d.ts +8 -2
  36. package/lib/utils/bin-shim.js +6 -5
  37. package/lib/utils/bundle-inputs.d.ts +84 -0
  38. package/lib/utils/bundle-inputs.js +268 -0
  39. package/lib/utils/check-system-deps.d.ts +4 -2
  40. package/lib/utils/check-system-deps.js +21 -26
  41. package/lib/utils/dev-link.d.ts +9 -6
  42. package/lib/utils/dev-link.js +71 -19
  43. package/lib/utils/exec-bin.d.ts +137 -0
  44. package/lib/utils/exec-bin.js +321 -0
  45. package/lib/utils/gi-runtime-paths.js +3 -2
  46. package/lib/utils/gitignore.d.ts +15 -0
  47. package/lib/utils/gitignore.js +178 -0
  48. package/lib/utils/gjsify-shim.d.ts +47 -0
  49. package/lib/utils/gjsify-shim.js +97 -8
  50. package/lib/utils/install-backend-native.d.ts +19 -1
  51. package/lib/utils/install-backend-native.js +93 -7
  52. package/lib/utils/install-lock.js +4 -25
  53. package/lib/utils/native-skip-scan.d.ts +20 -0
  54. package/lib/utils/native-skip-scan.js +343 -0
  55. package/lib/utils/node-script.js +1 -1
  56. package/lib/utils/oxc-resolve.js +46 -5
  57. package/lib/utils/process-table.d.ts +22 -0
  58. package/lib/utils/process-table.js +188 -0
  59. package/lib/utils/prune-prefix.js +1 -1
  60. package/lib/utils/run-gjs.d.ts +15 -1
  61. package/lib/utils/run-gjs.js +16 -7
  62. package/lib/utils/run-node.d.ts +2 -0
  63. package/lib/utils/run-node.js +2 -1
  64. package/lib/utils/runtimes.js +14 -13
  65. package/lib/utils/ship/app-runtime.d.ts +7 -5
  66. package/lib/utils/ship/app-runtime.js +11 -8
  67. package/lib/utils/ship/appimage.d.ts +9 -0
  68. package/lib/utils/ship/appimage.js +2 -1
  69. package/lib/utils/ship/deb.d.ts +10 -0
  70. package/lib/utils/ship/deb.js +7 -0
  71. package/lib/utils/ship/depends.d.ts +37 -3
  72. package/lib/utils/ship/depends.js +21 -9
  73. package/lib/utils/ship/gi-namespaces.d.ts +16 -0
  74. package/lib/utils/ship/gi-namespaces.js +64 -1
  75. package/lib/utils/ship/gzip.js +1 -1
  76. package/lib/utils/ship/icons.d.ts +6 -0
  77. package/lib/utils/ship/icons.js +6 -5
  78. package/lib/utils/ship/launcher.js +2 -2
  79. package/lib/utils/ship/layout.js +25 -2
  80. package/lib/utils/ship/rpm.d.ts +5 -0
  81. package/lib/utils/ship/rpm.js +42 -0
  82. package/lib/utils/ship/settings.js +6 -0
  83. package/lib/utils/ship/signing.d.ts +7 -2
  84. package/lib/utils/ship/signing.js +59 -32
  85. package/lib/utils/ship/stage-manifest.d.ts +14 -0
  86. package/lib/utils/ship/stage-manifest.js +7 -0
  87. package/lib/utils/spawn.d.ts +13 -0
  88. package/lib/utils/spawn.js +10 -5
  89. package/lib/utils/watch-loop.d.ts +15 -1
  90. package/lib/utils/watch-loop.js +9 -5
  91. package/lib/utils/webext/build.d.ts +42 -0
  92. package/lib/utils/webext/build.js +120 -0
  93. package/lib/utils/webext/config.d.ts +72 -0
  94. package/lib/utils/webext/config.js +234 -0
  95. package/lib/utils/webext/icons.d.ts +24 -0
  96. package/lib/utils/webext/icons.js +83 -0
  97. package/lib/utils/webext/launch.d.ts +32 -0
  98. package/lib/utils/webext/launch.js +77 -0
  99. package/lib/utils/webext/manifest.d.ts +53 -0
  100. package/lib/utils/webext/manifest.js +219 -0
  101. package/lib/utils/webext/pages.d.ts +26 -0
  102. package/lib/utils/webext/pages.js +61 -0
  103. package/lib/utils/webext/targets.d.ts +13 -0
  104. package/lib/utils/webext/targets.js +48 -0
  105. package/lib/utils/win32-command.d.ts +17 -0
  106. package/lib/utils/win32-command.js +21 -6
  107. package/package.json +20 -19
@@ -0,0 +1,124 @@
1
+ // `gjsify blueprint types [paths..]` — write the `.d.blp.ts` sidecars ADR 0088 § 4 defines.
2
+ //
3
+ // WHY THE CLI HAS THIS AT ALL, when `@gjsify/vite-plugin-blueprint` already writes a sidecar
4
+ // during `load`: a type-check is not a build. `gjsify check` runs `tsc` and nothing else, so a
5
+ // fresh clone — or a `.blp` whose consumer is being WRITTEN, before any bundle exists — has no
6
+ // sidecar unless something outside the bundler can produce one. This is that something, and it
7
+ // is why the command is usable before `gjsify check` rather than only after a build.
8
+ //
9
+ // `--check` IS FOR CONSUMERS OF THE PUBLISHED CLI. In this repository the sidecar gate is
10
+ // `scripts/check-blueprint-sidecars.mjs`, which CI runs with plain `node` beside the other three
11
+ // Blueprint gates — it has to be, because the CI bootstrap is the PREVIOUS RELEASE's bundle
12
+ // (ADR 0002) and cannot run a command this tree just added. A third-party project has no
13
+ // `scripts/` of ours, so without this flag it could generate sidecars and never hold them.
14
+ // The two share the derivation; what is duplicated is the byte comparison.
15
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
+ import { basename, isAbsolute, join, resolve } from 'node:path';
17
+ import { emitTypedSidecar, parseBlueprint, sidecarPathFor } from '@gjsify/blueprint';
18
+ /** What a walk never descends into: build output, and other projects' sources. */
19
+ const SKIP = new Set(['node_modules', 'dist', 'lib', '.git']);
20
+ /**
21
+ * Every `.blp` under one path, or the path itself when it names one.
22
+ *
23
+ * `statSync` rather than `withFileTypes`, the same choice `utils/build-cache.ts` makes and for
24
+ * the same reason: this command also runs from the GJS bundle, where `@gjsify/fs` is the readdir
25
+ * implementation.
26
+ */
27
+ function blueprintsUnder(path, found) {
28
+ let entry;
29
+ try {
30
+ entry = statSync(path);
31
+ }
32
+ catch {
33
+ return found;
34
+ }
35
+ if (!entry.isDirectory()) {
36
+ if (path.endsWith('.blp'))
37
+ found.push(path);
38
+ return found;
39
+ }
40
+ for (const name of readdirSync(path).sort()) {
41
+ if (SKIP.has(name) || name.startsWith('.'))
42
+ continue;
43
+ blueprintsUnder(join(path, name), found);
44
+ }
45
+ return found;
46
+ }
47
+ export const blueprintTypesCommand = {
48
+ command: 'types [paths..]',
49
+ description: 'Generate the `<name>.d.blp.ts` type sidecar for each .blp (ADR 0088), so `import { build }` / ' +
50
+ '`import { GTypeName }` type-check before anything is built. Defaults to the current directory.',
51
+ builder: (yargs) => yargs
52
+ .positional('paths', {
53
+ description: 'Files or directories to scan for .blp (default: the current directory)',
54
+ type: 'string',
55
+ array: true,
56
+ normalize: true,
57
+ default: [],
58
+ })
59
+ .option('check', {
60
+ description: 'Write nothing; exit non-zero when a sidecar is missing or stale',
61
+ type: 'boolean',
62
+ default: false,
63
+ })
64
+ .option('verbose', {
65
+ description: 'Print every file considered, not only the ones that changed',
66
+ type: 'boolean',
67
+ default: false,
68
+ }),
69
+ handler: async (args) => {
70
+ const given = args.paths ?? [];
71
+ const roots = (given.length > 0 ? given : ['.']).map((path) => isAbsolute(path) ? path : resolve(process.cwd(), path));
72
+ const blueprints = [];
73
+ for (const root of roots)
74
+ blueprintsUnder(root, blueprints);
75
+ const outdated = [];
76
+ let written = 0;
77
+ for (const file of blueprints) {
78
+ const sidecar = sidecarPathFor(file);
79
+ // A parse or derive refusal is NOT caught. It names its file and its line (ADR 0053
80
+ // clause 3), which is the whole contract, and swallowing it here would report "0
81
+ // sidecars written" for a `.blp` that cannot compile.
82
+ // `basename` from `node:path`, which is the host's own answer under the GJS bundle as
83
+ // well as under node: `@gjsify/node-path` picks its win32 or posix flavour at module
84
+ // init, and the path here is the user's, on their machine. `@gjsify/blueprint` cannot
85
+ // take the name itself — see `typed-exports.mjs` § `emitTypedSidecar`.
86
+ const ast = parseBlueprint(readFileSync(file, 'utf8'), file);
87
+ const expected = emitTypedSidecar(ast, basename(file));
88
+ let current;
89
+ try {
90
+ current = readFileSync(sidecar, 'utf8');
91
+ }
92
+ catch {
93
+ current = undefined;
94
+ }
95
+ if (current === expected) {
96
+ if (args.verbose)
97
+ console.log(`[gjsify blueprint types] current: ${sidecar}`);
98
+ continue;
99
+ }
100
+ if (args.check) {
101
+ outdated.push(sidecar);
102
+ continue;
103
+ }
104
+ // Only on a difference: an identical rewrite still moves the mtime, which a watcher
105
+ // reads as a change — the same rule `vite-plugin-blueprint`'s own writer follows.
106
+ writeFileSync(sidecar, expected, 'utf8');
107
+ written += 1;
108
+ console.log(`[gjsify blueprint types] wrote ${sidecar}`);
109
+ }
110
+ if (args.check) {
111
+ if (outdated.length === 0) {
112
+ console.log(`[gjsify blueprint types] ${blueprints.length} .blp checked, every sidecar current.`);
113
+ return;
114
+ }
115
+ for (const sidecar of outdated)
116
+ console.error(`[gjsify blueprint types] missing or stale: ${sidecar}`);
117
+ console.error('[gjsify blueprint types] run without --check to regenerate.');
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ console.log(`[gjsify blueprint types] ${blueprints.length} .blp scanned, ${written} sidecar(s) written, ` +
122
+ `${blueprints.length - written} already current.`);
123
+ },
124
+ };
@@ -0,0 +1,21 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface DevtoolsCliOptions {
3
+ /** Positional 1: the control-plane operation, e.g. `shot`. */
4
+ operation: string;
5
+ /** Positional 2+: its arguments — the output path for `shot`, the selector for `find`. */
6
+ operands?: string[];
7
+ busName?: string;
8
+ address?: string;
9
+ instance?: string;
10
+ globals?: string;
11
+ out?: string;
12
+ timeout: number;
13
+ }
14
+ /**
15
+ * `gjsify devtools` builds and runs a tiny client over the `org.gjsify.Devtools` control
16
+ * plane. Subcommands mirror the control-plane methods — `shot`, `find`, `activate`, `key`,
17
+ * `tree`, `property`, `resize`, `status`, `actions`, `toplevels`, `css`, `gsettings`,
18
+ * `instances` — so anything a rig does by hand over `gdbus` has a scriptable spelling.
19
+ */
20
+ export declare const devtoolsCommand: Command<unknown, DevtoolsCliOptions>;
21
+ export {};
@@ -0,0 +1,146 @@
1
+ // `gjsify devtools` — drive a running, devtools-enabled gjsify app from a SCRIPT.
2
+ //
3
+ // The peer of `gjsify debug`, not a replacement for it. `debug` speaks MCP over stdio, which
4
+ // is the right shape for an AI agent and the wrong one for a shell script, a Makefile, or a CI
5
+ // step: there is no way to pipe `Screenshot` into a file, because MCP's answer is base64 inside
6
+ // a JSON-RPC frame. So every consumer that wanted a screenshot on disk wrote its own GJS
7
+ // caller to unpack the `ay` variant — and every one of them had to re-decide the same two
8
+ // things this command decides once:
9
+ //
10
+ // · EMPTY BYTES ARE A FAILURE. `Screenshot` answers `ay[0]` when there is no active window
11
+ // or the window was never realised (the deliberate `CaptureBlocker` contract). Writing
12
+ // that produces a 0-byte file named `.png` and a success message.
13
+ // · THE SIZE COMES FROM THE PNG. `ResizeWindow` answers with the size it was ASKED for, so a
14
+ // window that ignored the resize still reports success — measured in a consumer: 1280
15
+ // requested, 1100 in the file, four checks green.
16
+ //
17
+ // Both live in `@gjsify/devtools` (`captureShot`), so a consumer driving the control plane from
18
+ // its own code gets them by importing one function rather than by copying a script.
19
+ //
20
+ // Dispatch is IN-PROCESS like `debug`/`storybook`: the generated entry imports
21
+ // `@gjsify/devtools` + `@gjsify/devtools-mcp` from the CONSUMER's node_modules (so the CLI keeps
22
+ // no dependency on either), is built `--app gjs`, and the RESULT is printed on stdout.
23
+ //
24
+ // WHY A GENERATED BUNDLE AND NOT `gdbus`: the peer transport (macOS/Windows, which have no
25
+ // session bus) is `Gio.DBusServer` + an address file, and `gdbus` cannot speak it. A command
26
+ // that reached the app only over the session bus would be the Linux-only half of a capability
27
+ // the package deliberately made OS-agnostic.
28
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
29
+ import { join, resolve } from 'node:path';
30
+ function readPackage(cwd) {
31
+ try {
32
+ return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ }
38
+ /**
39
+ * The generated entry. It takes its options as ARGV rather than baking them in, so one cached
40
+ * bundle serves every invocation — the command-line flags of a rig change far more often than
41
+ * the code does, and a rebuild per flag would make `--timeout` cost a full bundle build.
42
+ *
43
+ * `system.exit(await …)` is load-bearing rather than decorative: 0 done / 1 the app said no /
44
+ * 2 the request itself was wrong is the whole contract a shell rig reads out of `$?`, and an
45
+ * awaited value cannot become a process exit status under GJS. `system.programArgs` are the
46
+ * user arguments — under `gjs -m bundle.js …` the `-m` and the path are `programInvocationName`,
47
+ * not args, so nothing has to be sliced off.
48
+ */
49
+ function generateEntry() {
50
+ return `// Auto-generated by \`gjsify devtools\` — do not edit by hand.
51
+ import system from 'system';
52
+
53
+ import { runDevtoolsCli } from '@gjsify/devtools-mcp';
54
+
55
+ system.exit(await runDevtoolsCli(system.programArgs));
56
+ `;
57
+ }
58
+ /**
59
+ * `gjsify devtools` builds and runs a tiny client over the `org.gjsify.Devtools` control
60
+ * plane. Subcommands mirror the control-plane methods — `shot`, `find`, `activate`, `key`,
61
+ * `tree`, `property`, `resize`, `status`, `actions`, `toplevels`, `css`, `gsettings`,
62
+ * `instances` — so anything a rig does by hand over `gdbus` has a scriptable spelling.
63
+ */
64
+ export const devtoolsCommand = {
65
+ command: 'devtools',
66
+ description: 'Drive a running devtools-enabled app from a script: capture a screenshot to a file, find a widget, ' +
67
+ 'activate it, send a key. Peer of `gjsify debug`, which speaks MCP for an AI agent.',
68
+ builder: (yargs) => yargs
69
+ .positional('operation', {
70
+ description: 'Control-plane operation: shot | find | activate | key | tree | property | …',
71
+ type: 'string',
72
+ demandOption: true,
73
+ })
74
+ .positional('operands', {
75
+ description: 'Subcommand arguments — e.g. the output path for `shot`, the selector for `find`.',
76
+ type: 'string',
77
+ array: true,
78
+ })
79
+ .option('bus-name', {
80
+ description: "App DBus base name — the app's Gtk application id (default: gjsify.devtools.busNameBase, " +
81
+ 'then the storybook app-id). Unlike `gjsify debug` there is no derived fallback: the bus name ' +
82
+ 'IS the application id, and guessing it produces a name nothing answers to',
83
+ type: 'string',
84
+ })
85
+ .option('address', {
86
+ description: 'Peer D-Bus address of the app (unix:path=… / nonce-tcp:…) instead of the session bus — for ' +
87
+ 'macOS/Windows, which have no session bus. Default: GJSIFY_DEVTOOLS_ADDRESS, then the address ' +
88
+ 'file the app publishes, then the session bus',
89
+ type: 'string',
90
+ })
91
+ .option('instance', {
92
+ description: 'App instance label for a multi-instance app (default: GJSIFY_DEVTOOLS_INSTANCE)',
93
+ type: 'string',
94
+ })
95
+ .option('globals', {
96
+ description: 'Value for `gjsify build --globals` (default: auto)',
97
+ type: 'string',
98
+ })
99
+ .option('out', {
100
+ description: 'Output bundle path (default: node_modules/.cache/gjsify-devtools)',
101
+ type: 'string',
102
+ })
103
+ .option('timeout', {
104
+ description: 'Seconds to keep polling GetStatus for the control plane to answer (default: 30). 0 = do not wait',
105
+ type: 'number',
106
+ default: 30,
107
+ }),
108
+ handler: async (args) => {
109
+ const cwd = process.cwd();
110
+ const pkg = readPackage(cwd);
111
+ const config = pkg.gjsify?.devtools ?? {};
112
+ const busNameBase = args.busName ??
113
+ config.busNameBase ??
114
+ pkg.gjsify?.storybook?.applicationId ??
115
+ pkg.gjsify?.browse?.applicationId;
116
+ // Asked for BEFORE a bundle is built: without a bus name the build succeeds and the
117
+ // client then exits 2 having done nothing, which reads like a broken app.
118
+ if (!busNameBase) {
119
+ console.error("gjsify devtools: no bus name — the bus name IS the app's Gtk application id, so it cannot be " +
120
+ 'derived from the package name. Pass --bus-name <org.example.App> (the app prints it when it ' +
121
+ 'exports the control plane) or set package.json#gjsify.devtools.busNameBase.');
122
+ return process.exit(1);
123
+ }
124
+ const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-devtools');
125
+ mkdirSync(cacheDir, { recursive: true });
126
+ const entryPath = join(cacheDir, 'entry.ts');
127
+ const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'cli.gjs.mjs');
128
+ const globals = args.globals ?? 'auto';
129
+ writeFileSync(entryPath, generateEntry());
130
+ // stderr for progress — stdout is the RESULT the caller captures.
131
+ console.error(`[gjsify devtools] bus=${busNameBase}${args.address ? ` address=${args.address}` : ''}${args.instance ? ` instance=${args.instance}` : ''}`);
132
+ const { runCli } = await import('../cli-app.js');
133
+ await runCli(['build', entryPath, '--app', 'gjs', '--globals', globals, '--outfile', outPath]);
134
+ // The RESOLVED values are forwarded, never the raw argv: the child parses its own flags
135
+ // and knows nothing about package.json, so forwarding only what was typed would drop the
136
+ // configured bus name on the floor.
137
+ const forwarded = [args.operation, ...(args.operands ?? []), '--bus-name', busNameBase];
138
+ if (args.address)
139
+ forwarded.push('--address', args.address);
140
+ if (args.instance)
141
+ forwarded.push('--instance', args.instance);
142
+ forwarded.push('--timeout', String(args.timeout));
143
+ const { runGjsBundle } = await import('../utils/run-gjs.js');
144
+ await runGjsBundle(outPath, forwarded, { completion: 'exit', exitOnSuccess: true });
145
+ },
146
+ };
@@ -0,0 +1,31 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface EnvOptions {
3
+ entries: string[];
4
+ }
5
+ export interface EnvInvocation {
6
+ assignments: Array<[name: string, value: string]>;
7
+ command: string;
8
+ args: string[];
9
+ }
10
+ /**
11
+ * Split env(1)-style argv: leading `NAME=VALUE` words, then the command and its
12
+ * arguments. A later `X=y` is an ARGUMENT, as in env(1). `null` when no command
13
+ * follows the assignments.
14
+ */
15
+ export declare function splitEnvArgv(argv: readonly string[]): EnvInvocation | null;
16
+ /**
17
+ * Rejoin the argv yargs split at the first `--`. One AFTER the command belongs to
18
+ * the command, as in env(1) (`gjsify env A=1 node x.mjs -- --flag` hands node the
19
+ * `--`); only one BEFORE it separates (`gjsify env A=1 -- node …`).
20
+ */
21
+ export declare function joinEnvArgv(entries: readonly string[], tail: readonly string[]): string[];
22
+ /**
23
+ * The child's environment: `base` with `assignments` applied.
24
+ *
25
+ * On win32 names are case-insensitive, and a spread of `process.env` keeps the
26
+ * stored spelling (`Path`). Assigning `PATH` beside it would hand the child two
27
+ * variables for one name, so any differently-cased key is dropped first.
28
+ */
29
+ export declare function applyAssignments(base: NodeJS.ProcessEnv, assignments: ReadonlyArray<readonly [string, string]>, platform?: string): NodeJS.ProcessEnv;
30
+ export declare const envCommand: Command<unknown, EnvOptions>;
31
+ export {};
@@ -0,0 +1,114 @@
1
+ // `gjsify env NAME=VALUE… <command> [args…]` — env(1) for package scripts.
2
+ //
3
+ // npm runs package scripts through `cmd.exe` on Windows, and cmd has no form of
4
+ // the POSIX `NAME=value command` prefix: `LC_ALL=C node --test` fails there with
5
+ // "'LC_ALL' is not recognized as an internal or external command" (measured on
6
+ // the win11-gjsify VM for `@gjsify/node-gi`'s `npm test`). The prefix cannot be
7
+ // rewritten into a cmd spelling either, because `set NAME=value && …` leaks into
8
+ // every later clause and keeps the trailing space in the value.
9
+ //
10
+ // Same answer as `gjsify clear`/`gjsify copy` gave `rm -rf`/`cp -r`: one portable
11
+ // command, so the fix is prepending `gjsify env` rather than a platform branch per
12
+ // script. `portable-scripts` rejects the bare prefix so it stays fixed.
13
+ //
14
+ // The child is spawned through `spawnToCompletion`, so a bare `.cmd` shim (`npm`,
15
+ // `tsc`) resolves on Windows the same way it does for every other CLI spawn.
16
+ import { doubleDashArgs } from '../utils/double-dash-args.js';
17
+ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
18
+ /** POSIX `name=value` assignment word: a portable identifier, then `=`. */
19
+ const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
20
+ /**
21
+ * Split env(1)-style argv: leading `NAME=VALUE` words, then the command and its
22
+ * arguments. A later `X=y` is an ARGUMENT, as in env(1). `null` when no command
23
+ * follows the assignments.
24
+ */
25
+ export function splitEnvArgv(argv) {
26
+ const assignments = [];
27
+ let i = 0;
28
+ for (; i < argv.length; i++) {
29
+ const m = ASSIGNMENT_RE.exec(argv[i]);
30
+ if (!m)
31
+ break;
32
+ assignments.push([m[1], m[2]]);
33
+ }
34
+ if (i >= argv.length)
35
+ return null;
36
+ return { assignments, command: argv[i], args: argv.slice(i + 1) };
37
+ }
38
+ /**
39
+ * Rejoin the argv yargs split at the first `--`. One AFTER the command belongs to
40
+ * the command, as in env(1) (`gjsify env A=1 node x.mjs -- --flag` hands node the
41
+ * `--`); only one BEFORE it separates (`gjsify env A=1 -- node …`).
42
+ */
43
+ export function joinEnvArgv(entries, tail) {
44
+ if (tail.length === 0)
45
+ return [...entries];
46
+ return splitEnvArgv(entries) ? [...entries, '--', ...tail] : [...entries, ...tail];
47
+ }
48
+ /**
49
+ * The child's environment: `base` with `assignments` applied.
50
+ *
51
+ * On win32 names are case-insensitive, and a spread of `process.env` keeps the
52
+ * stored spelling (`Path`). Assigning `PATH` beside it would hand the child two
53
+ * variables for one name, so any differently-cased key is dropped first.
54
+ */
55
+ export function applyAssignments(base, assignments, platform = process.platform) {
56
+ const env = { ...base };
57
+ for (const [name, value] of assignments) {
58
+ if (platform === 'win32') {
59
+ for (const key of Object.keys(env)) {
60
+ if (key !== name && key.toLowerCase() === name.toLowerCase())
61
+ delete env[key];
62
+ }
63
+ }
64
+ env[name] = value;
65
+ }
66
+ return env;
67
+ }
68
+ export const envCommand = {
69
+ command: 'env <entries..>',
70
+ description: 'Run a command with extra environment variables: `gjsify env NAME=VALUE… <command> [args…]`. Portable replacement for the POSIX `NAME=value command` prefix, which cmd.exe cannot run.',
71
+ builder: (yargs) => yargs
72
+ .positional('entries', {
73
+ description: '`NAME=VALUE` assignments, then the command and its arguments.',
74
+ type: 'string',
75
+ array: true,
76
+ })
77
+ // The command owns its whole flag namespace, as with `gjsify run`:
78
+ // `gjsify env LC_ALL=C node --test --help` must reach node.
79
+ .version(false)
80
+ .help(false)
81
+ .parserConfiguration({
82
+ 'populate--': true,
83
+ 'parse-positional-numbers': false,
84
+ 'unknown-options-as-args': true,
85
+ }),
86
+ handler: async (args) => {
87
+ const invocation = splitEnvArgv(joinEnvArgv(args.entries ?? [], doubleDashArgs(args)));
88
+ if (!invocation) {
89
+ console.error('gjsify env: no command given. Usage: gjsify env NAME=VALUE… <command> [args…]');
90
+ return process.exit(1);
91
+ }
92
+ const { assignments, command, args: childArgs } = invocation;
93
+ let result;
94
+ try {
95
+ result = await spawnToCompletion(command, childArgs, {
96
+ completion: 'exit',
97
+ env: applyAssignments(process.env, assignments),
98
+ });
99
+ }
100
+ catch (err) {
101
+ const e = err;
102
+ console.error(e.code === 'ENOENT'
103
+ ? `gjsify env: \`${command}\` not found on PATH`
104
+ : `gjsify env: ${command}: ${e.message}`);
105
+ return process.exit(127);
106
+ }
107
+ if (result.code !== 0) {
108
+ if (result.code === null)
109
+ console.error(`gjsify env: ${command} ended by ${describeExit(result)}`);
110
+ return process.exit(result.code ?? 1);
111
+ }
112
+ return process.exit(0);
113
+ },
114
+ };
@@ -0,0 +1,10 @@
1
+ import type { Command } from '../types/index.js';
2
+ import { type ResolvedBin } from '../utils/exec-bin.js';
3
+ interface ExecOptions {
4
+ entries?: string[];
5
+ }
6
+ export declare class ExecRebuildError extends Error {
7
+ constructor(bin: ResolvedBin, entry: string, cause: unknown, verbose: boolean);
8
+ }
9
+ export declare const execCommand: Command<unknown, ExecOptions>;
10
+ export {};
@@ -0,0 +1,208 @@
1
+ // `gjsify exec [--runtime <r>] [--rebuild] <bin> [args…]` — run an installed npm bin
2
+ // on the runtime gjsify itself is running on (ADR 0076).
3
+ //
4
+ // node / bun / deno host → the bin's npm entry, unchanged, on that runtime.
5
+ // gjs host → the package's own `gjsify.bin` bundle if it ships one,
6
+ // else the npm entry REBUILT `--app gjs` (Node APIs mapped
7
+ // to `@gjsify/*`, `.node` addons routed through
8
+ // `@gjsify/napi`), cached by content, run with `gjs -m`.
9
+ //
10
+ // A failed rebuild is an error that carries the bundler's diagnostics. There is no
11
+ // silent fallback to `node` under GJS: the host may have none, and a tool that
12
+ // "worked" on a runtime the user did not ask for hides exactly the gap this command
13
+ // exists to expose. `--runtime node` is the explicit, documented escape hatch.
14
+ import { existsSync, readdirSync, renameSync, rmSync } from 'node:fs';
15
+ import { basename, dirname, join, resolve } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
18
+ import { doubleDashArgs } from '../utils/double-dash-args.js';
19
+ import { ExecBinNotFoundError, ExecPlanError, entryWithinPackage, execArtifactPath, execCacheKey, execCacheRoot, findProjectLockfile, hashFile, planExec, resolveExecBin, splitExecArgv, } from '../utils/exec-bin.js';
20
+ import { cliPackageDir, cliVersion } from '../utils/publish-headers.js';
21
+ import { runGjsBundle } from '../utils/run-gjs.js';
22
+ import { runRuntimeBundle } from '../utils/run-node.js';
23
+ export class ExecRebuildError extends Error {
24
+ constructor(bin, entry, cause, verbose) {
25
+ const detail = cause instanceof Error ? cause.message : String(cause);
26
+ super(`gjsify exec: rebuilding "${bin.binName}" (${bin.pkgName}@${bin.pkgVersion}) for GJS failed.\n` +
27
+ ` entry: ${entry}\n` +
28
+ 'Nothing was run — gjsify exec never falls back to another runtime on its own. To run it on Node ' +
29
+ `explicitly: \`gjsify exec --runtime node ${bin.binName}\`.\n` +
30
+ // The warnings were silenced for the rebuild; an error can point back at one
31
+ // (UNRESOLVED_IMPORT behind a load-check failure), so say how to get them.
32
+ (verbose ? '' : `Re-run with \`gjsify exec --verbose ${bin.binName}\` for the bundler's warnings.\n`) +
33
+ `Bundler diagnostics:\n${detail}`, { cause });
34
+ this.name = 'ExecRebuildError';
35
+ }
36
+ }
37
+ /** The nearest `node_modules` walking up from `start`, or null. */
38
+ function nearestNodeModules(start) {
39
+ let dir = resolve(start);
40
+ for (;;) {
41
+ const candidate = join(dir, 'node_modules');
42
+ if (existsSync(candidate))
43
+ return candidate;
44
+ const parent = dirname(dir);
45
+ if (parent === dir)
46
+ return null;
47
+ dir = parent;
48
+ }
49
+ }
50
+ /**
51
+ * Rebuild `entry` as a `--app gjs` bundle and return its path, reusing a cached one
52
+ * whose content key still matches (see `execCacheKey`).
53
+ *
54
+ * Written to a private temp directory and RENAMED into place, so two concurrent
55
+ * `gjsify exec` runs never read a half-written bundle: the loser of the rename race
56
+ * uses the winner's artifact, which has the same key and therefore the same inputs.
57
+ */
58
+ async function rebuildForGjs(bin, entry, opts) {
59
+ const nodeModules = bin.nodeModules ?? nearestNodeModules(process.cwd()) ?? join(bin.pkgDir, 'node_modules');
60
+ const lockfile = findProjectLockfile(dirname(nodeModules));
61
+ const key = execCacheKey({
62
+ pkgName: bin.pkgName,
63
+ pkgVersion: bin.pkgVersion,
64
+ entry: entryWithinPackage(bin, entry),
65
+ cliVersion: cliVersion(),
66
+ lockfileHash: lockfile ? hashFile(lockfile) : null,
67
+ });
68
+ const outfile = execArtifactPath(execCacheRoot(nodeModules), bin, entry, bin.installed ? key : 'local');
69
+ const reusable = bin.installed && !opts.rebuild;
70
+ if (reusable && existsSync(outfile)) {
71
+ if (opts.verbose)
72
+ console.error(`gjsify exec: reusing ${outfile}`);
73
+ return outfile;
74
+ }
75
+ console.error(`gjsify exec: rebuilding ${bin.pkgName}@${bin.pkgVersion} "${bin.binName}" for GJS…`);
76
+ const finalDir = dirname(outfile);
77
+ const tmpDir = `${finalDir}.tmp-${process.pid.toString(16)}-${Date.now().toString(16)}`;
78
+ const tmpOut = join(tmpDir, basename(outfile));
79
+ try {
80
+ const { BuildAction } = await import('../actions/build.js');
81
+ await BuildAction.bundleFileForGjs(entry, tmpOut, {
82
+ verbose: opts.verbose,
83
+ // A bin is executed, never imported.
84
+ preserveDefaultExport: false,
85
+ // An installed dependency's `import.meta.url` is rewritten per module by
86
+ // the node_modules path rewriter; the project's OWN bin is not under
87
+ // node_modules, so its entry keeps its source URL the way `--node-script`
88
+ // does it.
89
+ ...(bin.installed ? {} : { define: { 'import.meta.url': JSON.stringify(pathToFileURL(entry).href) } }),
90
+ // The CLI is bundling on the user's behalf: the project installed wxt,
91
+ // not `@gjsify/fs`, so the polyfills come from beside the running CLI.
92
+ toolchainAnchor: BuildAction.runningCliFile(),
93
+ // Every polyfill of a rebuilt bin comes from the toolchain, so the per-import
94
+ // "did not resolve from the project" notice is the rule here, not a symptom —
95
+ // dozens of lines ahead of `--version`. Warnings only: a failing build still
96
+ // rejects with its diagnostics, and `--verbose` shows them all.
97
+ ...(opts.verbose ? {} : { logLevel: 'silent' }),
98
+ });
99
+ }
100
+ catch (err) {
101
+ rmSync(tmpDir, { recursive: true, force: true });
102
+ throw new ExecRebuildError(bin, entry, err, opts.verbose);
103
+ }
104
+ if (reusable && existsSync(outfile)) {
105
+ // A concurrent run with the same key finished first; its artifact is ours.
106
+ rmSync(tmpDir, { recursive: true, force: true });
107
+ return outfile;
108
+ }
109
+ // Whatever sits under this name now is stale: a forced rebuild, or a directory
110
+ // left by an older layout without the file this one expects.
111
+ rmSync(finalDir, { recursive: true, force: true });
112
+ try {
113
+ renameSync(tmpDir, finalDir);
114
+ }
115
+ catch (err) {
116
+ // Lost the race to a concurrent run with the same key — its bundle is ours.
117
+ rmSync(tmpDir, { recursive: true, force: true });
118
+ if (!existsSync(outfile))
119
+ throw err;
120
+ }
121
+ pruneStaleArtifacts(dirname(finalDir), bin, basename(finalDir));
122
+ return outfile;
123
+ }
124
+ /**
125
+ * Drop earlier artifacts of the SAME package version: a rebuild under a new CLI or a
126
+ * moved lockfile leaves the old key behind, and nothing else would ever read it.
127
+ * Best-effort — housekeeping must not fail the run it follows.
128
+ */
129
+ function pruneStaleArtifacts(cacheRoot, bin, keep) {
130
+ const prefix = `${bin.pkgName}@${bin.pkgVersion}`.replace(/[^a-zA-Z0-9._@-]/g, '_');
131
+ let names;
132
+ try {
133
+ names = readdirSync(cacheRoot);
134
+ }
135
+ catch {
136
+ return;
137
+ }
138
+ for (const name of names) {
139
+ if (name === keep || !name.startsWith(`${prefix}-`) || name.includes('.tmp-'))
140
+ continue;
141
+ rmSync(join(cacheRoot, name), { recursive: true, force: true });
142
+ }
143
+ }
144
+ export const execCommand = {
145
+ command: 'exec <entries..>',
146
+ description: 'Run an installed npm bin on the runtime gjsify runs on: `gjsify exec [--runtime gjs|node|bun|deno] [--rebuild] [--verbose] <bin> [args…]`. ' +
147
+ 'On Node/Bun/Deno the bin runs unchanged; on GJS it is rebuilt `--app gjs` once, cached under node_modules/.cache/gjsify/exec/, and run with gjs. ' +
148
+ "Options go BEFORE the bin; everything after it is the bin's own.",
149
+ builder: (yargs) => yargs
150
+ .positional('entries', {
151
+ description: 'gjsify exec options, then the bin name and its arguments.',
152
+ type: 'string',
153
+ array: true,
154
+ })
155
+ // The bin owns its whole flag namespace, as with `gjsify env`/`run`:
156
+ // `gjsify exec prettier --version` must reach prettier.
157
+ .version(false)
158
+ .help(false)
159
+ .parserConfiguration({
160
+ 'populate--': true,
161
+ 'parse-positional-numbers': false,
162
+ 'unknown-options-as-args': true,
163
+ }),
164
+ handler: async (args) => {
165
+ let invocation;
166
+ let bin;
167
+ let plan;
168
+ try {
169
+ invocation = splitExecArgv(args.entries ?? [], doubleDashArgs(args));
170
+ bin = resolveExecBin(invocation.bin, process.cwd());
171
+ plan = planExec(bin, invocation.runtime ?? hostRuntime());
172
+ }
173
+ catch (err) {
174
+ if (err instanceof ExecBinNotFoundError) {
175
+ console.error(err.message);
176
+ // The shell's "command not found", which is what this is.
177
+ return process.exit(127);
178
+ }
179
+ if (err instanceof ExecPlanError) {
180
+ console.error(err.message);
181
+ return process.exit(1);
182
+ }
183
+ throw err;
184
+ }
185
+ const launch = {
186
+ completion: 'exit',
187
+ exitOnSuccess: true,
188
+ quiet: !invocation.verbose,
189
+ quietExit: true,
190
+ };
191
+ if (plan.kind === 'direct') {
192
+ await runRuntimeBundle(plan.runtime, plan.file, invocation.args, launch);
193
+ return;
194
+ }
195
+ if (plan.kind === 'gjs-bundle') {
196
+ await runGjsBundle(plan.file, invocation.args, launch);
197
+ return;
198
+ }
199
+ const bundle = await rebuildForGjs(bin, plan.entry, {
200
+ rebuild: invocation.rebuild,
201
+ verbose: invocation.verbose,
202
+ });
203
+ // The rebuild took its polyfills from beside the CLI, so their native prebuilds
204
+ // (typelibs, shared libraries) are found there too.
205
+ const cliDir = cliPackageDir();
206
+ await runGjsBundle(bundle, invocation.args, { ...launch, nativeRoots: cliDir ? [cliDir] : [] });
207
+ },
208
+ };