@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
@@ -1,9 +1,31 @@
1
1
  /**
2
- * Read a cached tarball by SRI integrity — raw bytes on a HIT, `null` otherwise. A
3
- * read failure (e.g. a partial write from an interrupted run) is a MISS, and the
4
- * file is left untouched so a follow-up writer's atomic rename is not tripped.
2
+ * Read a cached tarball by SRI integrity — raw bytes on a VERIFIED hit, `null`
3
+ * otherwise. A read failure (e.g. a partial write from an interrupted run) is a MISS,
4
+ * and the file is left untouched so a follow-up writer's atomic rename is not tripped.
5
+ *
6
+ * The bytes are re-hashed against the integrity that named them, through the SAME
7
+ * {@link verifyIntegrity} the download path uses — so both paths agree by
8
+ * construction instead of by comment.
9
+ *
10
+ * This used to trust the path: "content-addressed, so a hash hit is byte-identical to
11
+ * what the registry would return". That is a claim ABOUT the bytes, and nothing read
12
+ * them to check it. It held while the store was a local directory this machine had
13
+ * written itself. It stops holding the moment the store is a restorable CI artifact —
14
+ * a GitHub Actions cache entry that is truncated, corrupted, or written by a workflow
15
+ * on another branch would have been consumed as authentic, because the only thing
16
+ * asserting its contents was its filename. `gjsify-setup` now restores exactly such an
17
+ * artifact, so the check is no longer optional. Yarn re-hashes on every install for
18
+ * this reason (refs/yarn-berry Cache.ts, "we check the file hashes during each install
19
+ * anyway"); measured here, it costs ~0.5 s over this repo's ~420 MB closure, against
20
+ * an extract phase of minutes.
21
+ *
22
+ * A mismatch DELETES the entry rather than merely skipping it. That is not the prune
23
+ * this repo refuses elsewhere — the name asserts the content and the content
24
+ * disproves the name, so the file is garbage by its own definition. It has to go:
25
+ * {@link putCachedTarball} is idempotent on existence, so a corrupt blob left in place
26
+ * would never be replaced and every future install would re-download it forever.
5
27
  */
6
- export declare function getCachedTarball(integrity: string | undefined): Uint8Array | null;
28
+ export declare function getCachedTarball(integrity: string | undefined): Promise<Uint8Array | null>;
7
29
  /**
8
30
  * Persist a tarball to the cache, via a `<path>.tmp.<pid>` sibling and an atomic
9
31
  * rename so concurrent installs never observe a half-written entry. No-op when
@@ -17,9 +39,15 @@ export declare function cacheRootForLogging(): string;
17
39
  export declare function isCacheHit(integrity: string | undefined): boolean;
18
40
  /**
19
41
  * Read a tarball from npm's cacache content store by SRI integrity — raw `.tgz`
20
- * bytes on a HIT, `null` on a MISS, disabled interop or read failure. Like
21
- * {@link getCachedTarball} it trusts the content-addressed path rather than
22
- * re-hashing: cacache verified the bytes on write, and the extractor surfaces a
23
- * genuinely corrupt tarball loudly.
42
+ * bytes on a VERIFIED hit, `null` on a MISS, disabled interop or read failure.
43
+ *
44
+ * Re-hashed like {@link getCachedTarball}, and with more reason: these bytes come
45
+ * from ANOTHER tool's store, on a path this code does not control and cannot assume
46
+ * was written by a cacache that verified anything. The old note argued cacache
47
+ * verified on write and a corrupt tarball would fail loudly in the extractor — but
48
+ * "the next stage will probably notice" is not verification, and the caller
49
+ * write-throughs a foreign hit into OUR store, which would launder an unchecked blob
50
+ * into a first-class entry. A mismatch is left alone here rather than deleted: this
51
+ * store belongs to npm, and removing another tool's files is not this code's call.
24
52
  */
25
- export declare function getForeignCachedTarball(integrity: string | undefined): Uint8Array | null;
53
+ export declare function getForeignCachedTarball(integrity: string | undefined): Promise<Uint8Array | null>;
@@ -25,9 +25,10 @@
25
25
  // on too. Tarballs without an integrity hash (older registries) fall through to a
26
26
  // no-op cache and download every time.
27
27
  import { Buffer } from 'node:buffer';
28
- import { existsSync, statSync } from 'node:fs';
28
+ import { existsSync, statSync, unlinkSync } from 'node:fs';
29
29
  import { homedir } from 'node:os';
30
30
  import { join } from 'node:path';
31
+ import { verifyIntegrity } from '@gjsify/npm-registry';
31
32
  import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
32
33
  const CACHE_LAYOUT_VERSION = 'v1';
33
34
  /** Root of the tarball cache: `$XDG_CACHE_HOME/gjsify/tarballs/v1`. */
@@ -68,15 +69,50 @@ function pathFor(integrity) {
68
69
  return join(cacheRoot(), sri.algorithm, shard, `${sri.hex}.tgz`);
69
70
  }
70
71
  /**
71
- * Read a cached tarball by SRI integrity — raw bytes on a HIT, `null` otherwise. A
72
- * read failure (e.g. a partial write from an interrupted run) is a MISS, and the
73
- * file is left untouched so a follow-up writer's atomic rename is not tripped.
72
+ * Read a cached tarball by SRI integrity — raw bytes on a VERIFIED hit, `null`
73
+ * otherwise. A read failure (e.g. a partial write from an interrupted run) is a MISS,
74
+ * and the file is left untouched so a follow-up writer's atomic rename is not tripped.
75
+ *
76
+ * The bytes are re-hashed against the integrity that named them, through the SAME
77
+ * {@link verifyIntegrity} the download path uses — so both paths agree by
78
+ * construction instead of by comment.
79
+ *
80
+ * This used to trust the path: "content-addressed, so a hash hit is byte-identical to
81
+ * what the registry would return". That is a claim ABOUT the bytes, and nothing read
82
+ * them to check it. It held while the store was a local directory this machine had
83
+ * written itself. It stops holding the moment the store is a restorable CI artifact —
84
+ * a GitHub Actions cache entry that is truncated, corrupted, or written by a workflow
85
+ * on another branch would have been consumed as authentic, because the only thing
86
+ * asserting its contents was its filename. `gjsify-setup` now restores exactly such an
87
+ * artifact, so the check is no longer optional. Yarn re-hashes on every install for
88
+ * this reason (refs/yarn-berry Cache.ts, "we check the file hashes during each install
89
+ * anyway"); measured here, it costs ~0.5 s over this repo's ~420 MB closure, against
90
+ * an extract phase of minutes.
91
+ *
92
+ * A mismatch DELETES the entry rather than merely skipping it. That is not the prune
93
+ * this repo refuses elsewhere — the name asserts the content and the content
94
+ * disproves the name, so the file is garbage by its own definition. It has to go:
95
+ * {@link putCachedTarball} is idempotent on existence, so a corrupt blob left in place
96
+ * would never be replaced and every future install would re-download it forever.
74
97
  */
75
- export function getCachedTarball(integrity) {
98
+ export async function getCachedTarball(integrity) {
76
99
  const path = pathFor(integrity);
77
- if (!path)
100
+ if (!path || !integrity)
101
+ return null;
102
+ const bytes = readCacheFile(path);
103
+ if (!bytes)
78
104
  return null;
79
- return readCacheFile(path);
105
+ if (await verifyIntegrity(bytes, integrity))
106
+ return bytes;
107
+ // Best-effort: a read-only cache volume must not break the install, and the
108
+ // caller falls through to a verifying download either way.
109
+ try {
110
+ unlinkSync(path);
111
+ }
112
+ catch {
113
+ /* keep going — the download path replaces the value we return as a MISS */
114
+ }
115
+ return null;
80
116
  }
81
117
  /**
82
118
  * Persist a tarball to the cache, via a `<path>.tmp.<pid>` sibling and an atomic
@@ -158,14 +194,23 @@ function npmCachePathFor(integrity) {
158
194
  }
159
195
  /**
160
196
  * Read a tarball from npm's cacache content store by SRI integrity — raw `.tgz`
161
- * bytes on a HIT, `null` on a MISS, disabled interop or read failure. Like
162
- * {@link getCachedTarball} it trusts the content-addressed path rather than
163
- * re-hashing: cacache verified the bytes on write, and the extractor surfaces a
164
- * genuinely corrupt tarball loudly.
197
+ * bytes on a VERIFIED hit, `null` on a MISS, disabled interop or read failure.
198
+ *
199
+ * Re-hashed like {@link getCachedTarball}, and with more reason: these bytes come
200
+ * from ANOTHER tool's store, on a path this code does not control and cannot assume
201
+ * was written by a cacache that verified anything. The old note argued cacache
202
+ * verified on write and a corrupt tarball would fail loudly in the extractor — but
203
+ * "the next stage will probably notice" is not verification, and the caller
204
+ * write-throughs a foreign hit into OUR store, which would launder an unchecked blob
205
+ * into a first-class entry. A mismatch is left alone here rather than deleted: this
206
+ * store belongs to npm, and removing another tool's files is not this code's call.
165
207
  */
166
- export function getForeignCachedTarball(integrity) {
208
+ export async function getForeignCachedTarball(integrity) {
167
209
  const path = npmCachePathFor(integrity);
168
- if (!path)
210
+ if (!path || !integrity)
211
+ return null;
212
+ const bytes = readCacheFile(path);
213
+ if (!bytes)
169
214
  return null;
170
- return readCacheFile(path);
215
+ return (await verifyIntegrity(bytes, integrity)) ? bytes : null;
171
216
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Prettier parser napi oxfmt would format `fileName` with, or `null` when
3
+ * the native build formats it itself or no oxfmt formats it. `svelte` counts
4
+ * only when the config enables it, as in oxfmt.
5
+ */
6
+ export declare function externalParserFor(fileName: string, svelteEnabled?: boolean): string | null;
7
+ export interface NativeSkipScanOptions {
8
+ /** The `--config` handed to oxfmt. Absent → discovery, as in oxfmt. */
9
+ configPath?: string;
10
+ }
11
+ export interface NativeSkipScanResult {
12
+ /** Files napi oxfmt would format that the native build skips. */
13
+ skipped: string[];
14
+ /** Files the walk hands to oxfmt's classifier. */
15
+ total: number;
16
+ }
17
+ /** Walk `paths` the way oxfmt does and report what the native build skips. */
18
+ export declare function scanForNativeSkips(paths: string[], cwd?: string, options?: NativeSkipScanOptions): NativeSkipScanResult;
19
+ /** JSON with comments and trailing commas; string contents are never touched. */
20
+ export declare function parseJsonc(text: string): unknown;
@@ -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
@@ -130,6 +130,17 @@ export function mergeBundlerOptions(base, overrides) {
130
130
  if (base.transform?.define || overrides.transform?.define) {
131
131
  out.transform.define = { ...base.transform?.define, ...overrides.transform?.define };
132
132
  }
133
+ // `inject` is a map like `define` and merges the same way. Replacing it wholesale
134
+ // meant a user who injected ONE identifier silently lost the orchestrator's
135
+ // `console` → `@gjsify/rolldown-plugin-gjsify/shims/console-gjs` rewrite, which
136
+ // `--app gjs` cannot express any other way: GJS defines `globalThis.console`
137
+ // non-writable and non-configurable, so assigning to it from a register module
138
+ // no-ops. It also has to stay in lockstep with `mergeTransformOptions` in the
139
+ // plugin's auto-globals analyser — when the two merge the same options
140
+ // differently, the detector measures a bundle that is not the one that ships.
141
+ if (base.transform?.inject || overrides.transform?.inject) {
142
+ out.transform.inject = { ...base.transform?.inject, ...overrides.transform?.inject };
143
+ }
133
144
  }
134
145
  if (base.resolve || overrides.resolve) {
135
146
  out.resolve = { ...base.resolve, ...overrides.resolve };
@@ -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[];