@gjsify/cli 0.52.0 → 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 (81) hide show
  1. package/dist/cli.gjs.mjs +329 -298
  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 +5 -2
  7. package/lib/commands/env.d.ts +31 -0
  8. package/lib/commands/env.js +114 -0
  9. package/lib/commands/exec.d.ts +10 -0
  10. package/lib/commands/exec.js +208 -0
  11. package/lib/commands/foreach.js +5 -97
  12. package/lib/commands/format.js +26 -6
  13. package/lib/commands/index.d.ts +3 -0
  14. package/lib/commands/index.js +3 -0
  15. package/lib/commands/install.d.ts +2 -2
  16. package/lib/commands/install.js +5 -5
  17. package/lib/commands/self-update.js +2 -1
  18. package/lib/commands/test.d.ts +18 -9
  19. package/lib/commands/test.js +56 -11
  20. package/lib/commands/tsc.js +4 -4
  21. package/lib/commands/upgrade.d.ts +15 -0
  22. package/lib/commands/upgrade.js +76 -2
  23. package/lib/commands/webext/index.d.ts +2 -0
  24. package/lib/commands/webext/index.js +15 -0
  25. package/lib/commands/webext/subcommands.d.ts +16 -0
  26. package/lib/commands/webext/subcommands.js +204 -0
  27. package/lib/types/config-data.d.ts +1 -1
  28. package/lib/utils/bin-shim.d.ts +8 -2
  29. package/lib/utils/bin-shim.js +6 -5
  30. package/lib/utils/bundle-inputs.d.ts +84 -0
  31. package/lib/utils/bundle-inputs.js +268 -0
  32. package/lib/utils/check-system-deps.d.ts +4 -2
  33. package/lib/utils/check-system-deps.js +21 -26
  34. package/lib/utils/dev-link.d.ts +9 -6
  35. package/lib/utils/dev-link.js +71 -19
  36. package/lib/utils/exec-bin.d.ts +137 -0
  37. package/lib/utils/exec-bin.js +321 -0
  38. package/lib/utils/gi-runtime-paths.js +3 -2
  39. package/lib/utils/gitignore.d.ts +15 -0
  40. package/lib/utils/gitignore.js +178 -0
  41. package/lib/utils/gjsify-shim.d.ts +47 -0
  42. package/lib/utils/gjsify-shim.js +97 -8
  43. package/lib/utils/install-backend-native.d.ts +19 -1
  44. package/lib/utils/install-backend-native.js +93 -7
  45. package/lib/utils/install-lock.js +4 -25
  46. package/lib/utils/native-skip-scan.d.ts +20 -0
  47. package/lib/utils/native-skip-scan.js +343 -0
  48. package/lib/utils/node-script.js +1 -1
  49. package/lib/utils/oxc-resolve.js +42 -3
  50. package/lib/utils/process-table.d.ts +22 -0
  51. package/lib/utils/process-table.js +188 -0
  52. package/lib/utils/prune-prefix.js +1 -1
  53. package/lib/utils/run-gjs.d.ts +15 -1
  54. package/lib/utils/run-gjs.js +16 -7
  55. package/lib/utils/run-node.d.ts +2 -0
  56. package/lib/utils/run-node.js +2 -1
  57. package/lib/utils/runtimes.js +14 -13
  58. package/lib/utils/ship/gzip.js +1 -1
  59. package/lib/utils/ship/icons.d.ts +6 -0
  60. package/lib/utils/ship/icons.js +6 -5
  61. package/lib/utils/spawn.d.ts +13 -0
  62. package/lib/utils/spawn.js +10 -5
  63. package/lib/utils/watch-loop.d.ts +15 -1
  64. package/lib/utils/watch-loop.js +9 -5
  65. package/lib/utils/webext/build.d.ts +42 -0
  66. package/lib/utils/webext/build.js +120 -0
  67. package/lib/utils/webext/config.d.ts +72 -0
  68. package/lib/utils/webext/config.js +234 -0
  69. package/lib/utils/webext/icons.d.ts +24 -0
  70. package/lib/utils/webext/icons.js +83 -0
  71. package/lib/utils/webext/launch.d.ts +32 -0
  72. package/lib/utils/webext/launch.js +77 -0
  73. package/lib/utils/webext/manifest.d.ts +53 -0
  74. package/lib/utils/webext/manifest.js +219 -0
  75. package/lib/utils/webext/pages.d.ts +26 -0
  76. package/lib/utils/webext/pages.js +61 -0
  77. package/lib/utils/webext/targets.d.ts +13 -0
  78. package/lib/utils/webext/targets.js +36 -0
  79. package/lib/utils/win32-command.d.ts +17 -0
  80. package/lib/utils/win32-command.js +21 -6
  81. package/package.json +19 -19
@@ -0,0 +1,343 @@
1
+ // Scan for files the Node oxfmt would format but the native one (GJS) skips.
2
+ //
3
+ // `@gjsify/oxfmt-native` is oxfmt built WITHOUT its `napi` feature. That build
4
+ // formats JS/TS, JSON, TOML, GraphQL and CSS/SCSS/Less itself, but the formats
5
+ // oxfmt hands to its Prettier host (Markdown, HTML, Vue, YAML, …) are not even
6
+ // classified: oxfmt's walker drops them without a word. `gjsify format --check`
7
+ // under GJS would then exit 0 on a tree that the Node CLI finds unformatted.
8
+ //
9
+ // The answer is only honest when it is asked about the SAME file set oxfmt
10
+ // walks. The first version of this scan walked everything, `node_modules/`
11
+ // included, read no ignore file and flagged CSS the native build formats; on
12
+ // this repository `format --check` exited 1 on dependency READMEs without ever
13
+ // starting oxfmt. So the walk mirrors oxfmt's (refs/oxc
14
+ // `apps/oxfmt/src/cli/{walk,resolve}.rs`, `crates/oxc_config/src/walk.rs`):
15
+ //
16
+ // - VCS directories and `node_modules` are never entered;
17
+ // - `.prettierignore` in cwd and `!`-prefixed CLI paths block everywhere;
18
+ // - `.gitignore` (nested, plus parents up to the repository root) and
19
+ // `.git/info/exclude` apply to walked entries — only inside a repository
20
+ // when every walk root is in one, everywhere when not;
21
+ // - the config's `ignorePatterns` apply to every file, relative to the
22
+ // config's directory; with no explicit config a nested `.oxfmtrc.json(c)`
23
+ // scopes its subtree, like oxfmt's nested-config discovery;
24
+ // - a file named directly is not held to `.gitignore`, as in oxfmt;
25
+ // - symlinks are not followed.
26
+ //
27
+ // Only a file that survives all of that AND that napi oxfmt would route to its
28
+ // external formatter is reported.
29
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
30
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
31
+ import { compileGitignore, globToRegexSource, readGitignoreFile } from './gitignore.js';
32
+ const parsers = (parser, keys) => Object.fromEntries(keys.map((k) => [k, parser]));
33
+ // oxfmt `core/support.rs`: the `#[cfg(feature = "napi")]` sets behind
34
+ // `get_external_parser_name`. Matched case-sensitively, as oxfmt does.
35
+ const EXTERNAL_BY_EXTENSION = {
36
+ ...parsers('yaml', ['yml', 'mir', 'reek', 'rviz', 'sublime-syntax', 'syntax', 'yaml', 'yaml-tmlanguage']),
37
+ ...parsers('markdown', [
38
+ 'md',
39
+ 'livemd',
40
+ 'markdown',
41
+ 'mdown',
42
+ 'mdwn',
43
+ 'mkd',
44
+ 'mkdn',
45
+ 'mkdown',
46
+ 'ronn',
47
+ 'scd',
48
+ 'workbook',
49
+ ]),
50
+ ...parsers('html', ['html', 'hta', 'htm', 'inc', 'xht', 'xhtml']),
51
+ ...parsers('glimmer', ['handlebars', 'hbs']),
52
+ mdx: 'mdx',
53
+ vue: 'vue',
54
+ svelte: 'svelte',
55
+ mjml: 'mjml',
56
+ };
57
+ const EXTERNAL_BY_FILENAME = {
58
+ ...parsers('yaml', ['.clang-format', '.clang-tidy', '.clangd', '.gemrc', 'CITATION.cff', 'glide.lock']),
59
+ ...parsers('yaml', ['pixi.lock', '.prettierrc', '.stylelintrc', '.lintstagedrc']),
60
+ ...parsers('markdown', ['contents.lr', 'README']),
61
+ };
62
+ // Machine-written files oxfmt refuses before classifying — `pnpm-lock.yaml`
63
+ // would otherwise look like YAML the native build skipped.
64
+ const EXCLUDE_FILENAMES = new Set([
65
+ 'package-lock.json',
66
+ 'pnpm-lock.yaml',
67
+ 'yarn.lock',
68
+ 'MODULE.bazel.lock',
69
+ 'bun.lock',
70
+ 'deno.lock',
71
+ 'composer.lock',
72
+ 'Package.resolved',
73
+ 'Pipfile.lock',
74
+ 'flake.lock',
75
+ 'mcmod.info',
76
+ 'Cargo.lock',
77
+ 'Gopkg.lock',
78
+ 'pdm.lock',
79
+ 'poetry.lock',
80
+ 'uv.lock',
81
+ ]);
82
+ const VCS_DIRS = new Set(['.git', '.jj', '.sl', '.svn', '.hg']);
83
+ const CONFIG_NAMES = ['.oxfmtrc.json', '.oxfmtrc.jsonc'];
84
+ /**
85
+ * The Prettier parser napi oxfmt would format `fileName` with, or `null` when
86
+ * the native build formats it itself or no oxfmt formats it. `svelte` counts
87
+ * only when the config enables it, as in oxfmt.
88
+ */
89
+ export function externalParserFor(fileName, svelteEnabled = false) {
90
+ if (EXCLUDE_FILENAMES.has(fileName))
91
+ return null;
92
+ const ext = extname(fileName).slice(1);
93
+ const parser = EXTERNAL_BY_FILENAME[fileName] ?? (ext ? EXTERNAL_BY_EXTENSION[ext] : undefined) ?? null;
94
+ if (parser === 'svelte' && !svelteEnabled)
95
+ return null;
96
+ return parser;
97
+ }
98
+ /** Walk `paths` the way oxfmt does and report what the native build skips. */
99
+ export function scanForNativeSkips(paths, cwd = process.cwd(), options = {}) {
100
+ const skipped = [];
101
+ let total = 0;
102
+ const excludes = paths.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
103
+ const globals = [];
104
+ const prettierignore = readGitignoreFile(join(cwd, '.prettierignore'));
105
+ if (prettierignore)
106
+ globals.push(prettierignore);
107
+ if (excludes.length)
108
+ globals.push(compileGitignore(excludes, cwd));
109
+ const globallyIgnored = (p, isDir, anyParent) => globals.some((g) => (anyParent ? g.matchedPathOrAnyParents(p, isDir) : g.matched(p, isDir)) === 'ignore');
110
+ const explicitConfig = options.configPath ? resolve(cwd, options.configPath) : null;
111
+ const rootScope = loadScope(explicitConfig ?? discoverConfig(cwd));
112
+ const nested = explicitConfig === null;
113
+ const scopeFor = (dir) => (nested ? nearestScope(dir, rootScope) : rootScope);
114
+ // oxfmt matches CLI globs with fast_glob::glob_match, which expands `{a,b}`
115
+ // brace groups (nested too); globToRegexSource — the gitignore translator —
116
+ // escapes the braces instead, so a brace glob would match nothing. Expand
117
+ // them into alternatives first. Gitignore patterns never carry braces, so
118
+ // this stays a CLI-glob-only translation.
119
+ const expandBraces = (glob) => {
120
+ const m = /\{([^{}]*)\}/.exec(glob);
121
+ if (!m)
122
+ return [glob];
123
+ return m[1]
124
+ .split(',')
125
+ .flatMap((alt) => expandBraces(glob.slice(0, m.index) + alt + glob.slice(m.index + m[0].length)));
126
+ };
127
+ // An argument that names nothing on disk but carries glob characters is a
128
+ // pattern: oxfmt then walks cwd and keeps the files it matches.
129
+ const globs = [];
130
+ const targets = new Set();
131
+ for (const p of paths) {
132
+ if (p.startsWith('!'))
133
+ continue;
134
+ const abs = resolve(cwd, p);
135
+ if (/[*?[{]/.test(p) && !existsSync(abs)) {
136
+ for (const alt of expandBraces(p)) {
137
+ globs.push(new RegExp(`^${globToRegexSource(alt.includes('/') ? alt : `**/${alt}`)}$`));
138
+ }
139
+ }
140
+ else {
141
+ targets.add(abs);
142
+ }
143
+ }
144
+ if (globs.length || targets.size === 0)
145
+ targets.add(resolve(cwd));
146
+ const record = (file, scope) => {
147
+ if (scope?.ignore?.matchedPathOrAnyParents(file, false) === 'ignore')
148
+ return;
149
+ total++;
150
+ if (externalParserFor(basename(file), scope?.svelte))
151
+ skipped.push(file);
152
+ };
153
+ const walkRoots = [];
154
+ for (const target of targets) {
155
+ let st;
156
+ try {
157
+ st = statSync(target);
158
+ }
159
+ catch {
160
+ continue;
161
+ }
162
+ if (globallyIgnored(target, st.isDirectory(), true))
163
+ continue;
164
+ if (st.isDirectory())
165
+ walkRoots.push(target);
166
+ else if (st.isFile())
167
+ record(target, scopeFor(dirname(target)));
168
+ }
169
+ const matchesGlob = (file) => {
170
+ if (!globs.length || targets.has(file))
171
+ return true;
172
+ const rel = relative(cwd, file).split(sep).join('/');
173
+ return globs.some((g) => g.test(rel));
174
+ };
175
+ const seen = new Set();
176
+ const walk = (dir, inherited, scope) => {
177
+ if (seen.has(dir))
178
+ return;
179
+ seen.add(dir);
180
+ const own = readGitignoreFile(join(dir, '.gitignore'));
181
+ const stack = own ? [own, ...inherited] : inherited;
182
+ if (nested && dir !== scope?.dir)
183
+ scope = scopeAt(dir) ?? scope;
184
+ let entries;
185
+ try {
186
+ entries = readdirSync(dir, { withFileTypes: true });
187
+ }
188
+ catch {
189
+ return;
190
+ }
191
+ for (const entry of entries) {
192
+ const full = join(dir, entry.name);
193
+ const isDir = entry.isDirectory();
194
+ if (!isDir && !entry.isFile())
195
+ continue;
196
+ if (isDir && (VCS_DIRS.has(entry.name) || entry.name === 'node_modules'))
197
+ continue;
198
+ if (globallyIgnored(full, isDir, false) || gitIgnored(stack, full, isDir))
199
+ continue;
200
+ if (isDir)
201
+ walk(full, stack, scope);
202
+ else if (matchesGlob(full))
203
+ record(full, scope);
204
+ }
205
+ };
206
+ const requireGit = walkRoots.every((r) => repoRootOf(r) !== null);
207
+ for (const root of walkRoots) {
208
+ walk(root, parentGitignores(root, requireGit ? repoRootOf(root) : null, requireGit), scopeFor(root));
209
+ }
210
+ return { skipped, total };
211
+ }
212
+ /** Nearest-first: the first matcher that decides wins, as in the `ignore` crate. */
213
+ function gitIgnored(stack, path, isDir) {
214
+ for (const m of stack) {
215
+ const hit = m.matched(path, isDir);
216
+ if (hit !== null)
217
+ return hit === 'ignore';
218
+ }
219
+ return false;
220
+ }
221
+ /**
222
+ * The `.gitignore` files ABOVE a walk root (the walk reads the root's own),
223
+ * nearest first, then `.git/info/exclude`, which ranks below them. Inside a
224
+ * repository the lookup stops at its root, outside one it runs to `/`.
225
+ */
226
+ function parentGitignores(root, repo, requireGit) {
227
+ if (requireGit && repo === null)
228
+ return [];
229
+ const out = [];
230
+ for (let dir = root; dir !== repo;) {
231
+ const parent = dirname(dir);
232
+ if (parent === dir)
233
+ break;
234
+ dir = parent;
235
+ const m = readGitignoreFile(join(dir, '.gitignore'));
236
+ if (m)
237
+ out.push(m);
238
+ }
239
+ const exclude = repo ? readGitignoreFile(join(repo, '.git', 'info', 'exclude'), repo) : null;
240
+ if (exclude)
241
+ out.push(exclude);
242
+ return out;
243
+ }
244
+ function repoRootOf(start) {
245
+ for (let dir = start;; dir = dirname(dir)) {
246
+ if (existsSync(join(dir, '.git')) || existsSync(join(dir, '.jj')))
247
+ return dir;
248
+ if (dirname(dir) === dir)
249
+ return null;
250
+ }
251
+ }
252
+ function configIn(dir) {
253
+ for (const name of CONFIG_NAMES) {
254
+ const p = join(dir, name);
255
+ if (existsSync(p))
256
+ return p;
257
+ }
258
+ return null;
259
+ }
260
+ function discoverConfig(cwd) {
261
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
262
+ const found = configIn(dir);
263
+ if (found)
264
+ return found;
265
+ if (dirname(dir) === dir)
266
+ return null;
267
+ }
268
+ }
269
+ function scopeAt(dir) {
270
+ const cfg = configIn(dir);
271
+ return cfg ? loadScope(cfg) : null;
272
+ }
273
+ /** The scope of the nearest config at or above `dir`. */
274
+ function nearestScope(dir, fallback) {
275
+ for (;; dir = dirname(dir)) {
276
+ if (fallback && dir === fallback.dir)
277
+ return fallback;
278
+ const scope = scopeAt(dir);
279
+ if (scope)
280
+ return scope;
281
+ if (dirname(dir) === dir)
282
+ return fallback;
283
+ }
284
+ }
285
+ /**
286
+ * A config's `ignorePatterns`, and whether it enables Svelte. A config that
287
+ * does not parse contributes no patterns — more files reported, never fewer;
288
+ * oxfmt rejects that config anyway.
289
+ */
290
+ function loadScope(configPath) {
291
+ if (!configPath)
292
+ return null;
293
+ const dir = dirname(configPath);
294
+ let raw = {};
295
+ try {
296
+ raw = parseJsonc(readFileSync(configPath, 'utf-8'));
297
+ }
298
+ catch {
299
+ raw = {};
300
+ }
301
+ const patterns = Array.isArray(raw.ignorePatterns)
302
+ ? raw.ignorePatterns.filter((p) => typeof p === 'string')
303
+ : [];
304
+ return {
305
+ dir,
306
+ ignore: patterns.length ? compileGitignore(patterns, dir) : null,
307
+ svelte: raw.svelte !== undefined && raw.svelte !== null && raw.svelte !== false,
308
+ };
309
+ }
310
+ /** JSON with comments and trailing commas; string contents are never touched. */
311
+ export function parseJsonc(text) {
312
+ const pass = (src, drop) => {
313
+ let out = '';
314
+ for (let i = 0; i < src.length;) {
315
+ if (src[i] === '"') {
316
+ let j = i + 1;
317
+ while (j < src.length && src[j] !== '"')
318
+ j += src[j] === '\\' ? 2 : 1;
319
+ out += src.slice(i, j + 1);
320
+ i = j + 1;
321
+ continue;
322
+ }
323
+ const skip = drop(src, i);
324
+ if (skip > 0)
325
+ i += skip;
326
+ else
327
+ out += src[i++];
328
+ }
329
+ return out;
330
+ };
331
+ const uncommented = pass(text, (src, i) => {
332
+ if (src.startsWith('//', i)) {
333
+ const nl = src.indexOf('\n', i);
334
+ return (nl === -1 ? src.length : nl) - i;
335
+ }
336
+ if (src.startsWith('/*', i)) {
337
+ const end = src.indexOf('*/', i + 2);
338
+ return (end === -1 ? src.length : end + 2) - i;
339
+ }
340
+ return 0;
341
+ });
342
+ return JSON.parse(pass(uncommented, (src, i) => (src[i] === ',' && /^\s*[}\]]/.test(src.slice(i + 1)) ? 1 : 0)));
343
+ }
@@ -6,7 +6,7 @@
6
6
  // has no `node:` URI scheme and no node_modules walker, so `gjs -m <script>.mjs` dies on the
7
7
  // first import. That gap is why a Node-less host could install gjsify, resolve every native
8
8
  // prebuild, and then fail `build:infra` at `node scripts/process-template.mjs` (see
9
- // `status/open-todos.md`).
9
+ // `status/open-todos/README.md`).
10
10
  //
11
11
  // Rejected alternative: commit a `dist/*.gjs.mjs` per script — one more artifact each under
12
12
  // the committed-bundle freshness gate, scaling with the number of scripts. This is ONE
@@ -45,7 +45,7 @@
45
45
  // `gjsify/register-class-order` rule wired via `.oxlintrc.json` jsPlugins)
46
46
  // lives in the Node launcher, so a native oxlint bridge could only run the
47
47
  // Rust rule subset. Lint stays Node-spawned until that trade-off is decided
48
- // (tracked in status/open-todos.md).
48
+ // (tracked in status/open-todos/README.md).
49
49
  import { existsSync, readFileSync } from 'node:fs';
50
50
  import { join, resolve } from 'node:path';
51
51
  import { createRequire } from 'node:module';
@@ -233,6 +233,8 @@ function findConfigFile(cwd, names) {
233
233
  return null;
234
234
  }
235
235
  let _nativeOxfmtProbe = null;
236
+ /** Why the bridge's library would not open, when `tryLoadNativeOxfmt()` measured it. */
237
+ let _nativeOxfmtLoadError = null;
236
238
  /**
237
239
  * Try to load `@gjsify/oxfmt-native` (GJS only). Same multi-anchor
238
240
  * resolution as `bundler-pick.ts`'s `tryLoadNative()` — GJS's native ESM
@@ -257,6 +259,34 @@ async function tryLoadNativeOxfmt() {
257
259
  const mod = (await import(/* @vite-ignore */ target));
258
260
  if (!mod.hasNativeOxfmt())
259
261
  return null;
262
+ // The typelib resolved; its library opens at the first class access.
263
+ // Open it now, beside that typelib — the wrapper cannot, for the reason
264
+ // `bundler-pick.ts`'s `tryLoadNative()` gives — so a library that will
265
+ // not load names itself instead of failing inside `runOxfmt()`.
266
+ // Resolved and imported, not statically imported — the reasoning is
267
+ // at the top of this file. By the time a formatter asks for the
268
+ // native backend, utils' `lib/esm` is built. `./native-library`
269
+ // rather than `./core`: `core` re-exports `main-loop`, whose
270
+ // module-level singleton would then exist twice in a process that
271
+ // already carries it inlined in the GJS bundle.
272
+ //
273
+ // Its own `try` because the outer one cannot tell this apart from
274
+ // "there is no native backend", and `_nativeOxfmtLoadError` is the
275
+ // one place the npm fallback reports why it took over.
276
+ let openNativeLibrary;
277
+ try {
278
+ const utilsResolved = resolveNpmPackage('@gjsify/utils/native-library', { bundleUrl: import.meta.url }) ??
279
+ createRequire(import.meta.url).resolve('@gjsify/utils/native-library');
280
+ const utilsHref = pathToFileURL(utilsResolved).href;
281
+ ({ openNativeLibrary } = (await import(/* @vite-ignore */ utilsHref)));
282
+ }
283
+ catch (err) {
284
+ _nativeOxfmtLoadError = err instanceof Error ? err : new Error(String(err));
285
+ return null;
286
+ }
287
+ _nativeOxfmtLoadError = openNativeLibrary('GjsifyOxfmt');
288
+ if (_nativeOxfmtLoadError)
289
+ return null;
260
290
  return mod;
261
291
  }
262
292
  catch {
@@ -281,13 +311,22 @@ export async function shouldUseNativeOxfmt() {
281
311
  if (choice === 'native') {
282
312
  const native = await tryLoadNativeOxfmt();
283
313
  if (!native) {
284
- throw new Error('GJSIFY_OXFMT=native but @gjsify/oxfmt-native is not loadable (no prebuild for this architecture, or not running under GJS).');
314
+ throw new Error('GJSIFY_OXFMT=native but @gjsify/oxfmt-native is not loadable (no prebuild for this architecture, or not running under GJS).' +
315
+ (_nativeOxfmtLoadError ? `\n${_nativeOxfmtLoadError.message}` : ''));
285
316
  }
286
317
  return true;
287
318
  }
288
319
  if (!isGjs())
289
320
  return false;
290
- return (await tryLoadNativeOxfmt()) !== null;
321
+ const native = (await tryLoadNativeOxfmt()) !== null;
322
+ // Falling back to the npm launcher is not an error, so the default path is
323
+ // quiet — but a MEASURED reason why the native bridge would not load is the
324
+ // only place the user hears it, and it is the whole point of measuring.
325
+ // `GJSIFY_OXFMT=native` above throws with this text; the default earns the
326
+ // same answer for free.
327
+ if (!native && _nativeOxfmtLoadError)
328
+ console.debug(_nativeOxfmtLoadError.message);
329
+ return native;
291
330
  }
292
331
  /**
293
332
  * Run an oxc tool (oxlint / oxfmt).
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Is `pid` a live process on this machine?
3
+ *
4
+ * `/proc/<pid>` where procfs exists (one stat, no subprocess, identical under
5
+ * Node and GJS); elsewhere Node's signal-0 probe — `ESRCH` dead, `EPERM`
6
+ * alive-but-foreign. Any OTHER outcome counts as ALIVE: callers use this to
7
+ * decide whether something may be taken away from its owner, and "cannot tell"
8
+ * must never read as "gone".
9
+ */
10
+ export declare function isPidAlive(pid: number): boolean;
11
+ /**
12
+ * Parse `ps -A -o pid=,ppid=` output (two integer columns, blank headers) into
13
+ * ppid → children. Exported for the unit test; the format is POSIX, so procps
14
+ * on Linux and the BSD `ps` on macOS print the same shape.
15
+ */
16
+ export declare function parsePsPidPpid(output: string): Map<number, number[]>;
17
+ /**
18
+ * Every transitive descendant of `pid`, breadth-first (reverse the list for
19
+ * deepest-first signalling). A snapshot — processes spawned after it are not in
20
+ * it, which is why `foreach` re-walks before escalating to SIGKILL.
21
+ */
22
+ export declare function collectDescendants(pid: number): number[];
@@ -0,0 +1,188 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // What the CLI asks the OS about OTHER processes — is this pid alive, who are
3
+ // its descendants — answered in ONE place, for Node and GJS hosts alike.
4
+ //
5
+ // Both questions used to be answered only by reading `/proc`, in two files
6
+ // (`install-lock.ts`, `commands/foreach.ts`). procfs is a LINUX filesystem, and
7
+ // on macOS neither reader failed — each returned a plausible, wrong answer:
8
+ //
9
+ // - `foreach`'s fail-fast tree kill found NO descendants, so it signalled only
10
+ // the direct `npm run` child and left its shell → gjs/node build grandchildren
11
+ // running (the orphaned-bundler-at-100%-CPU shape the tree kill exists for).
12
+ // - `install-lock`'s liveness probe fell back to `process.kill(pid, 0)`, whose
13
+ // GJS implementation returned `true` for every pid, so a crashed install's
14
+ // lock was never stolen as dead-owner — only after the 35-min budget.
15
+ //
16
+ // So every reader here is chosen by what the host HAS (procfs readable?), never
17
+ // by platform name — a Linux container can lack procfs too. Only the no-procfs
18
+ // fallback's COMMAND depends on the platform, because Windows has no `ps`.
19
+ import { spawnSync } from 'node:child_process';
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
21
+ /**
22
+ * Is `pid` a live process on this machine?
23
+ *
24
+ * `/proc/<pid>` where procfs exists (one stat, no subprocess, identical under
25
+ * Node and GJS); elsewhere Node's signal-0 probe — `ESRCH` dead, `EPERM`
26
+ * alive-but-foreign. Any OTHER outcome counts as ALIVE: callers use this to
27
+ * decide whether something may be taken away from its owner, and "cannot tell"
28
+ * must never read as "gone".
29
+ */
30
+ export function isPidAlive(pid) {
31
+ // `existsSync` never throws by contract: Node returns false on any error, and
32
+ // the GJS shim is a typeof guard + Gio `query_exists`.
33
+ if (existsSync('/proc/self'))
34
+ return existsSync(`/proc/${pid}`);
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ }
39
+ catch (err) {
40
+ return err.code !== 'ESRCH';
41
+ }
42
+ }
43
+ /**
44
+ * Parse `ps -A -o pid=,ppid=` output (two integer columns, blank headers) into
45
+ * ppid → children. Exported for the unit test; the format is POSIX, so procps
46
+ * on Linux and the BSD `ps` on macOS print the same shape.
47
+ */
48
+ export function parsePsPidPpid(output) {
49
+ const map = new Map();
50
+ for (const line of output.split('\n')) {
51
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
52
+ if (!m)
53
+ continue;
54
+ const pid = Number(m[1]);
55
+ const ppid = Number(m[2]);
56
+ if (pid <= 0 || ppid <= 0 || pid === ppid)
57
+ continue;
58
+ const siblings = map.get(ppid);
59
+ if (siblings)
60
+ siblings.push(pid);
61
+ else
62
+ map.set(ppid, [pid]);
63
+ }
64
+ return map;
65
+ }
66
+ // Direct children of `pid` from /proc/<pid>/task/*/children (Linux,
67
+ // CONFIG_PROC_CHILDREN — standard on every mainstream kernel). `null` when the
68
+ // files are unreadable, which sends the caller to the full-table fallback.
69
+ function readProcChildren(pid) {
70
+ let taskIds;
71
+ try {
72
+ taskIds = readdirSync(`/proc/${pid}/task`);
73
+ }
74
+ catch {
75
+ // Process already gone (common) or no procfs.
76
+ return null;
77
+ }
78
+ const kids = [];
79
+ let readAny = false;
80
+ for (const tid of taskIds) {
81
+ try {
82
+ const data = readFileSync(`/proc/${pid}/task/${tid}/children`, 'utf-8');
83
+ readAny = true;
84
+ for (const tok of data.trim().split(/\s+/)) {
85
+ const n = Number(tok);
86
+ if (Number.isInteger(n) && n > 0)
87
+ kids.push(n);
88
+ }
89
+ }
90
+ catch {
91
+ // Thread vanished mid-walk, or kernel lacks CONFIG_PROC_CHILDREN.
92
+ }
93
+ }
94
+ return readAny ? kids : null;
95
+ }
96
+ // ppid → children from one /proc/*/stat scan. Empty when there is no procfs.
97
+ function procPpidMap() {
98
+ const map = new Map();
99
+ let entries = [];
100
+ try {
101
+ entries = readdirSync('/proc');
102
+ }
103
+ catch {
104
+ return map;
105
+ }
106
+ for (const entry of entries) {
107
+ const pid = Number(entry);
108
+ if (!Number.isInteger(pid) || pid <= 0)
109
+ continue;
110
+ try {
111
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
112
+ // comm (field 2) may contain spaces/parens — parse after last ')'.
113
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
114
+ const ppid = Number(rest[1]);
115
+ if (!Number.isInteger(ppid) || ppid <= 0)
116
+ continue;
117
+ const siblings = map.get(ppid);
118
+ if (siblings)
119
+ siblings.push(pid);
120
+ else
121
+ map.set(ppid, [pid]);
122
+ }
123
+ catch {
124
+ // Process exited between readdir and read — fine.
125
+ }
126
+ }
127
+ return map;
128
+ }
129
+ // ppid → children from the OS's process list where there is no procfs:
130
+ // `ps(1)` on macOS/BSD, CIM's Win32_Process on Windows (no `ps` there — Git's
131
+ // MSYS `ps` rejects `-o` and sees only MSYS processes). Both print "pid ppid"
132
+ // lines, so one parser serves. Empty when neither can be run, which degrades to
133
+ // "signal the direct child only".
134
+ function osPpidMap() {
135
+ const [cmd, args] = process.platform === 'win32'
136
+ ? [
137
+ 'powershell.exe',
138
+ [
139
+ '-NoProfile',
140
+ '-NonInteractive',
141
+ '-Command',
142
+ 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }',
143
+ ],
144
+ ]
145
+ : ['ps', ['-A', '-o', 'pid=,ppid=']];
146
+ try {
147
+ const res = spawnSync(cmd, args, { encoding: 'utf-8' });
148
+ if (res.status !== 0 || typeof res.stdout !== 'string')
149
+ return new Map();
150
+ return parsePsPidPpid(res.stdout);
151
+ }
152
+ catch {
153
+ return new Map();
154
+ }
155
+ }
156
+ /**
157
+ * Every transitive descendant of `pid`, breadth-first (reverse the list for
158
+ * deepest-first signalling). A snapshot — processes spawned after it are not in
159
+ * it, which is why `foreach` re-walks before escalating to SIGKILL.
160
+ */
161
+ export function collectDescendants(pid) {
162
+ const result = [];
163
+ const seen = new Set([pid]);
164
+ const queue = [pid];
165
+ let table = null;
166
+ while (queue.length > 0) {
167
+ const cur = queue.shift();
168
+ let kids = table ? null : readProcChildren(cur);
169
+ if (kids === null) {
170
+ // Per-task children files unreadable: one full table for this and
171
+ // every later level. procfs first, the OS process list where there is none.
172
+ if (!table) {
173
+ table = procPpidMap();
174
+ if (table.size === 0)
175
+ table = osPpidMap();
176
+ }
177
+ kids = table.get(cur) ?? [];
178
+ }
179
+ for (const kid of kids) {
180
+ if (seen.has(kid))
181
+ continue;
182
+ seen.add(kid);
183
+ result.push(kid);
184
+ queue.push(kid);
185
+ }
186
+ }
187
+ return result;
188
+ }
@@ -25,7 +25,7 @@
25
25
  // Reachability ("nothing installed points at this any more") would catch that one
26
26
  // and more, but it needs a record of what the prefix was ASSEMBLED FROM, which no
27
27
  // prefix carries yet. Uncertain means keep — see ADR 0025 and
28
- // `status/open-todos.md`.
28
+ // `status/open-todos/README.md`.
29
29
  import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
30
30
  import { join } from 'node:path';
31
31
  import { checkPlatform, declaresPlatform, describePlatformTarget, readPlatformOverrides, } from './platform-check.js';
@@ -6,7 +6,7 @@ import { type SpawnCompletionContract } from './spawn.js';
6
6
  * variable is set is host-dependent (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see
7
7
  * `buildNativeEnv`.
8
8
  */
9
- export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string, inherited?: Record<string, string | undefined>): {
9
+ export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string, inherited?: Record<string, string | undefined>, extraRoots?: readonly string[]): {
10
10
  env: NativeEnv;
11
11
  envPrefix: string;
12
12
  };
@@ -41,5 +41,19 @@ export interface RunGjsBundleOptions {
41
41
  * (`utils/node-script.ts`).
42
42
  */
43
43
  quiet?: boolean;
44
+ /**
45
+ * Do not add `gjs exited with code N` to a failing child's own output. For a
46
+ * transparent runner (`gjsify exec`) a non-zero exit is often an ANSWER — `semver
47
+ * -r '>2' 1.0.0` exits 1 to say "no match" — and the child's code is re-raised
48
+ * either way.
49
+ */
50
+ quietExit?: boolean;
51
+ /**
52
+ * More directories to look for native `@gjsify/*` prebuilds in, after the cwd and the
53
+ * bundle's own tree. `gjsify exec` passes the running CLI's: a rebuilt bin takes its
54
+ * polyfills from beside the CLI, so their typelibs live there too — web-ext's rebuild
55
+ * died at load with `Typelib file for namespace 'GjsifyHttpSoupBridge' … not found`.
56
+ */
57
+ nativeRoots?: readonly string[];
44
58
  }
45
59
  export declare function runGjsBundle(bundlePath: string, extraArgs: string[] | undefined, options: RunGjsBundleOptions): Promise<void>;