@gjsify/cli 0.51.1 → 0.53.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 (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -0,0 +1,218 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Who READS a root `scripts/*` file — the edge the `affected` closure walk has no way to see.
3
+ //
4
+ // WHY THIS EXISTS. `scripts/` belongs to no workspace, so every change there used to land in
5
+ // `unmatched` and buy a FULL `main.yml` run. Measured over the 60 PRs before #1774: 33 went
6
+ // full for that reason alone, and most of them touched only a website or gallery gate that
7
+ // `audit-runtimes.yml` runs and `main.yml` never opens. The full run was never wrong — it was
8
+ // the classifier saying "I cannot tell" about a question the tree answers: a script matters to
9
+ // `main.yml` exactly when something `main.yml` builds or runs names it.
10
+ //
11
+ // So a changed root script is REPLACED by the files that name it, and those go through the
12
+ // ordinary IGNORE → GLOBAL → workspace mapping. A spec that imports it seeds its workspace; a
13
+ // workspace build script that runs it seeds that workspace; a sibling workflow that runs it is
14
+ // IGNOREd like the workflow itself; `.github/actions`, `tests/e2e/**` or anything else the
15
+ // classifier does not model still forces the full run. A script that names another script
16
+ // hands the question on (`affected-classify.ts` walks that recursively).
17
+ //
18
+ // THE MATCH IS DELIBERATELY LOOSE, because it may only fail towards running MORE. It is the
19
+ // script's file name (stem plus a JS/TS extension, `.d.mts` included), not an import
20
+ // statement: `join(root, 'scripts', 'x.mjs')` and `node scripts/x.mjs` are both reads, and a
21
+ // parser for "is this an import" would miss the first. The price is that an error message
22
+ // naming a script counts as a reader too — measured, that turns some script changes back
23
+ // into full runs, which is the direction this is allowed to be wrong in. Comment lines are
24
+ // dropped first; without that, the prose cross-references in this repository chain every
25
+ // script to every other one and the whole mechanism collapses back into "run everything".
26
+ //
27
+ // TWO READERS ARE NARROWED, and each narrowing is a claim about `main.yml`'s shape:
28
+ // · `main.yml` itself counts only from a job that (transitively) `needs: changes`. A job
29
+ // that does not — `tree-checks` today — runs on every PR whatever this classifier says,
30
+ // so a script it runs cannot be skipped by anything decided here.
31
+ // · the root `package.json` counts only when the script is named outside `scripts`, or by
32
+ // a lifecycle/build/test entry, or by an entry another root script or a gated `main.yml`
33
+ // job runs. The `check:*` conveniences that only a person or a sibling workflow invokes
34
+ // are not `main.yml` inputs.
35
+ //
36
+ // NOT SEEN, stated so nobody reads this as more: a reader that builds the file name at run
37
+ // time, and a tool that walks `scripts/` as a directory. The second shape exists
38
+ // (`generate-status.mjs`, `check-comment-budget.mjs`) but only in jobs outside `main.yml`'s
39
+ // gated set. Push-to-main and the nightly still run the FULL suite.
40
+ import { spawnSync } from 'node:child_process';
41
+ import { readFileSync, statSync } from 'node:fs';
42
+ import { join, posix } from 'node:path';
43
+ const MAIN_WORKFLOW = '.github/workflows/main.yml';
44
+ const ROOT_MANIFEST = 'package.json';
45
+ /** Larger files are data (lockfile, generated corpora), never a script's reader. */
46
+ const MAX_TEXT_BYTES = 2 * 1024 * 1024;
47
+ /**
48
+ * A root package.json entry whose run is part of installing, building or testing the tree.
49
+ * Not `check:*`: those are single-gate conveniences (`node scripts/check-….mjs`), and the
50
+ * one `main.yml` does run (`check:examples`) is caught by name in the workflow instead.
51
+ */
52
+ const LIFECYCLE_SCRIPT = /^(pre|post)?(install|prepare|build|test)(:|$)/;
53
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
54
+ /**
55
+ * Comment lines out: `#` for YAML, shell, git and Docker files, `//` + block-comment lines
56
+ * for everything else. `#` is not stripped from JS, where a line may open with a private
57
+ * field.
58
+ */
59
+ function stripCommentLines(path, text) {
60
+ const hashComments = /\.(ya?ml|sh|bash|py|toml)$|(^|\/)\.git[a-z]*$|Dockerfile$/.test(path);
61
+ const comment = hashComments ? /^\s*#/ : /^\s*(\/\/|\/\*|\*)/;
62
+ return text
63
+ .split('\n')
64
+ .filter((line) => !comment.test(line))
65
+ .join('\n');
66
+ }
67
+ /**
68
+ * The file-name pattern for a script: `adwaita-elements.mjs` is also read as
69
+ * `adwaita-elements.d.mts` (a TS import of the `.mjs` resolves to its declaration) and a
70
+ * `.d.mts` change is read through its `.mjs`. The leading boundary excludes `-` so
71
+ * `value-types.mjs` does not match `generate-value-types.mjs`.
72
+ */
73
+ export function scriptNamePattern(scriptPath) {
74
+ // A git path, always `/`-separated whatever the host: POSIX basename, not the host's.
75
+ const base = posix.basename(scriptPath);
76
+ const js = /^(.*?)(\.d)?\.[mc]?[jt]sx?$/.exec(base);
77
+ const tail = js ? `${escapeRe(js[1])}(\\.d)?\\.[mc]?[jt]sx?\\b` : `${escapeRe(base)}\\b`;
78
+ return new RegExp(`(^|[^\\w.-])${tail}`);
79
+ }
80
+ /**
81
+ * Jobs of `main.yml` that depend, directly or through `needs:`, on the `changes` job — the
82
+ * ones the classifier can switch off. Comment lines are already gone: `setup`'s comments
83
+ * NAME `needs.changes.outputs.*`, and counting that made `tree-checks` look gated.
84
+ */
85
+ function gatedJobs(lines) {
86
+ const needs = new Map();
87
+ const mentionsChanges = new Set();
88
+ for (const { job, text } of lines) {
89
+ if (!job)
90
+ continue;
91
+ if (!needs.has(job))
92
+ needs.set(job, []);
93
+ const n = /^ {4}needs:\s*\[(.*)\]/.exec(text) ?? /^ {4}needs:\s*([A-Za-z0-9_-]+)\s*$/.exec(text);
94
+ if (n)
95
+ needs.set(job, n[1]
96
+ .split(',')
97
+ .map((d) => d.trim())
98
+ .filter(Boolean));
99
+ // A block-list `needs:` is a spelling this parse does not read, so the job counts
100
+ // as gated: misreading one here could only ever skip less.
101
+ if (/needs\.changes\b/.test(text) || /^ {4}needs:\s*$/.test(text))
102
+ mentionsChanges.add(job);
103
+ }
104
+ const gated = new Set(['changes', ...mentionsChanges]);
105
+ let grew = true;
106
+ while (grew) {
107
+ grew = false;
108
+ for (const [j, deps] of needs) {
109
+ if (!gated.has(j) && deps.some((d) => gated.has(d))) {
110
+ gated.add(j);
111
+ grew = true;
112
+ }
113
+ }
114
+ }
115
+ // `changes` itself runs the classifier; it is not switched off by it.
116
+ gated.delete('changes');
117
+ return gated;
118
+ }
119
+ function workflowLines(text) {
120
+ const lines = text.split('\n');
121
+ const out = [];
122
+ let job;
123
+ let inJobs = false;
124
+ for (const line of lines) {
125
+ if (/^jobs:\s*$/.test(line))
126
+ inJobs = true;
127
+ else if (/^\S/.test(line))
128
+ inJobs = false;
129
+ const key = inJobs ? /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line) : null;
130
+ if (key)
131
+ job = key[1];
132
+ if (/^\s*#/.test(line))
133
+ continue;
134
+ out.push({ job: inJobs ? job : undefined, text: line });
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * Build the reader lookup over `texts` (repo-relative path → raw content). Pure: the command
140
+ * feeds it `git ls-files`, the spec feeds it a fixture map.
141
+ */
142
+ export function buildScriptReferrers(texts) {
143
+ const stripped = new Map();
144
+ for (const [path, text] of texts) {
145
+ if (path === MAIN_WORKFLOW || path === ROOT_MANIFEST)
146
+ continue;
147
+ stripped.set(path, stripCommentLines(path, text));
148
+ }
149
+ const workflowText = texts.get(MAIN_WORKFLOW);
150
+ const workflow = workflowText === undefined ? [] : workflowLines(workflowText);
151
+ const gated = gatedJobs(workflow);
152
+ // Top-level workflow keys (`env:`) reach every job, so they count as gated too.
153
+ const gatedWorkflowNames = (re) => workflow.some((l) => re.test(l.text) && (l.job === undefined || gated.has(l.job)));
154
+ let manifest;
155
+ const manifestText = texts.get(ROOT_MANIFEST);
156
+ if (manifestText !== undefined)
157
+ manifest = JSON.parse(manifestText);
158
+ const manifestReads = (re) => {
159
+ if (!manifest)
160
+ return false;
161
+ for (const [k, v] of Object.entries(manifest)) {
162
+ if (k !== 'scripts' && re.test(JSON.stringify(v)))
163
+ return true;
164
+ }
165
+ const scripts = manifest.scripts ?? {};
166
+ for (const [key, value] of Object.entries(scripts)) {
167
+ if (!re.test(value))
168
+ continue;
169
+ if (LIFECYCLE_SCRIPT.test(key))
170
+ return true;
171
+ const byName = new RegExp(`(^|[\\s"'])${escapeRe(key)}($|[\\s"'&;|])`);
172
+ if (Object.entries(scripts).some(([other, v]) => other !== key && byName.test(v)))
173
+ return true;
174
+ if (gatedWorkflowNames(byName))
175
+ return true;
176
+ }
177
+ return false;
178
+ };
179
+ return (scriptPath) => {
180
+ const re = scriptNamePattern(scriptPath);
181
+ const readers = [];
182
+ for (const [path, text] of stripped) {
183
+ if (path !== scriptPath && re.test(text))
184
+ readers.push(path);
185
+ }
186
+ if (gatedWorkflowNames(re))
187
+ readers.push(MAIN_WORKFLOW);
188
+ if (manifestReads(re))
189
+ readers.push(ROOT_MANIFEST);
190
+ return readers.sort();
191
+ };
192
+ }
193
+ /**
194
+ * Every tracked text file under `rootDir`, for {@link buildScriptReferrers}. Throws when git
195
+ * cannot list the tree — the caller then classifies without the lookup, which is the old,
196
+ * conservative behaviour (a root script lands in `unmatched`).
197
+ */
198
+ export function readTrackedTexts(rootDir) {
199
+ const r = spawnSync('git', ['ls-files', '-z'], { cwd: rootDir, encoding: 'utf8' });
200
+ if (r.status !== 0)
201
+ throw new Error(`git ls-files failed (${r.status}): ${String(r.stderr).trim()}`);
202
+ const texts = new Map();
203
+ for (const path of r.stdout.split('\0')) {
204
+ if (!path || path.startsWith('refs/'))
205
+ continue;
206
+ const abs = join(rootDir, path);
207
+ // A gitlink (submodule) is listed but is a directory; a deleted-but-staged path is
208
+ // listed and absent. Neither can name a script.
209
+ const st = statSync(abs, { throwIfNoEntry: false });
210
+ if (!st?.isFile() || st.size > MAX_TEXT_BYTES)
211
+ continue;
212
+ const text = readFileSync(abs, 'utf8');
213
+ if (text.includes('\0'))
214
+ continue;
215
+ texts.set(path, text);
216
+ }
217
+ return texts;
218
+ }
@@ -58,6 +58,7 @@ import { appendFileSync } from 'node:fs';
58
58
  import { readStdinLines } from '../utils/stdin.js';
59
59
  import { discoverWorkspaces } from '@gjsify/workspace';
60
60
  import { classifyAndExpand } from './affected-classify.js';
61
+ import { buildScriptReferrers, readTrackedTexts } from './affected-script-refs.js';
61
62
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
62
63
  export const affectedCommand = {
63
64
  command: 'affected',
@@ -92,15 +93,36 @@ export const affectedCommand = {
92
93
  const changedFiles = args['changed-from-stdin']
93
94
  ? readStdinLines()
94
95
  : runGitDiff(rootDir, args.base ?? 'origin/main', args.head ?? 'HEAD');
95
- const result = classifyAndExpand(workspaces, changedFiles);
96
+ const result = classifyAndExpand(workspaces, changedFiles, scriptReadersFor(rootDir, changedFiles));
96
97
  emit(args.format, result);
97
98
  },
98
99
  };
100
+ /**
101
+ * The root-script reader lookup, built only when the diff has a root script in it — reading
102
+ * the tree costs a second or two and most diffs do not need it. `undefined` when git cannot
103
+ * list the tree (a fixture without a repository): the classifier then treats a root script
104
+ * as it always did, as `unmatched`, which forces the full run.
105
+ */
106
+ function scriptReadersFor(rootDir, changedFiles) {
107
+ if (!changedFiles.some((f) => /^scripts\/[^/]+$/.test(f.replace(/\\/g, '/'))))
108
+ return undefined;
109
+ try {
110
+ return buildScriptReferrers(readTrackedTexts(rootDir));
111
+ }
112
+ catch (err) {
113
+ process.stderr.write(`gjsify affected: cannot index the tree for script readers (${String(err)}); root scripts stay unmatched\n`);
114
+ return undefined;
115
+ }
116
+ }
99
117
  function runGitDiff(cwd, base, head) {
100
118
  // `base...head` lists changed paths on `head` relative to the MERGE-BASE, which
101
119
  // matches what a GitHub PR diff shows and survives stacked PRs without picking up
102
120
  // commits from base.
103
- const r = spawnSync('git', ['diff', '--name-only', `${base}...${head}`], {
121
+ // `--no-renames`: with rename detection on (git's default since 2.9), a pure move lists
122
+ // only the DESTINATION. Moving `packages/node/os/src/x.ts` to `docs/x.ts` then reads as
123
+ // a docs-only change and skips every suite that would have seen `@gjsify/os` break.
124
+ // Without detection a move is a delete plus an add, and the deleted side seeds as usual.
125
+ const r = spawnSync('git', ['diff', '--no-renames', '--name-only', `${base}...${head}`], {
104
126
  cwd,
105
127
  encoding: 'utf8',
106
128
  });
@@ -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
+ };
@@ -125,10 +125,16 @@ async function loadFlathubSource(args, sourceIndex) {
125
125
  const sources = modules[0]?.sources ?? [];
126
126
  if (sources.length === 0)
127
127
  return null;
128
- const idx = sourceIndex ?? sources.findIndex((s) => s?.type === 'git');
128
+ const idx = sourceIndex ?? sources.findIndex((s) => isSourceObject(s) && s.type === 'git');
129
129
  if (idx < 0 || idx >= sources.length)
130
130
  return null;
131
- return sources[idx] ?? null;
131
+ // A `--source-index` aimed at a file reference has no tag to compare, which
132
+ // the caller already reports as a manifest without one.
133
+ const entry = sources[idx];
134
+ return isSourceObject(entry) ? entry : null;
135
+ }
136
+ function isSourceObject(entry) {
137
+ return typeof entry === 'object' && entry !== null;
132
138
  }
133
139
  async function fetchFlathubManifest(flathubRepo, appId, verbose) {
134
140
  for (const branch of ['master', 'main']) {
@@ -153,7 +153,10 @@ export const flatpakInitCommand = {
153
153
  // `flatpak.extraModules` — prepended to the meson default.
154
154
  // Right shape for meson-built GTK apps
155
155
  // that want a few extra sibling modules
156
- // (e.g. blueprint-compiler).
156
+ // (e.g. a GI library the runtime lacks). NOT
157
+ // blueprint-compiler: ADR 0053 clause 5 parses
158
+ // `.blp` in the bundler, so a manifest that
159
+ // builds it builds a tool nothing runs.
157
160
  const modules = [];
158
161
  if (flatpak.modules?.length) {
159
162
  modules.push(...flatpak.modules);