@gjsify/cli 0.39.0 → 0.41.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 (78) hide show
  1. package/dist/cli.gjs.mjs +202 -181
  2. package/lib/cli-app.js +12 -25
  3. package/lib/commands/create.d.ts +3 -0
  4. package/lib/commands/create.js +49 -6
  5. package/lib/commands/flatpak/check.js +1 -1
  6. package/lib/commands/flatpak/ci.js +1 -1
  7. package/lib/commands/flatpak/diff.js +1 -1
  8. package/lib/commands/flatpak/init.js +1 -1
  9. package/lib/commands/flatpak/scaffold.d.ts +2 -4
  10. package/lib/commands/flatpak/scaffold.js +25 -298
  11. package/lib/commands/flatpak/sync-flathub.js +1 -1
  12. package/lib/commands/foreach.js +34 -20
  13. package/lib/commands/index.d.ts +2 -0
  14. package/lib/commands/index.js +2 -0
  15. package/lib/commands/install.d.ts +1 -0
  16. package/lib/commands/install.js +32 -1
  17. package/lib/commands/prune.d.ts +11 -0
  18. package/lib/commands/prune.js +84 -0
  19. package/lib/commands/self-update.d.ts +1 -0
  20. package/lib/commands/self-update.js +55 -0
  21. package/lib/commands/ship.d.ts +11 -0
  22. package/lib/commands/ship.js +217 -0
  23. package/lib/config.d.ts +12 -0
  24. package/lib/config.js +15 -0
  25. package/lib/templates/install.mjs.tmpl +8 -3
  26. package/lib/types/config-data.d.ts +136 -46
  27. package/lib/utils/app-metadata.d.ts +51 -0
  28. package/lib/utils/app-metadata.js +338 -0
  29. package/lib/utils/check-system-deps.d.ts +2 -2
  30. package/lib/utils/check-system-deps.js +43 -6
  31. package/lib/utils/cli-runtime-closure.d.ts +23 -0
  32. package/lib/utils/cli-runtime-closure.js +274 -0
  33. package/lib/utils/install-provenance.d.ts +44 -0
  34. package/lib/utils/install-provenance.js +106 -0
  35. package/lib/utils/prune-prefix.d.ts +83 -0
  36. package/lib/utils/prune-prefix.js +333 -0
  37. package/lib/utils/publish-headers.d.ts +13 -0
  38. package/lib/utils/publish-headers.js +30 -4
  39. package/lib/utils/ship/ar.d.ts +21 -0
  40. package/lib/utils/ship/ar.js +57 -0
  41. package/lib/utils/ship/bytes.d.ts +4 -0
  42. package/lib/utils/ship/bytes.js +23 -0
  43. package/lib/utils/ship/cpio.d.ts +22 -0
  44. package/lib/utils/ship/cpio.js +93 -0
  45. package/lib/utils/ship/deb.d.ts +24 -0
  46. package/lib/utils/ship/deb.js +118 -0
  47. package/lib/utils/ship/depends.d.ts +62 -0
  48. package/lib/utils/ship/depends.js +221 -0
  49. package/lib/utils/ship/discover.d.ts +21 -0
  50. package/lib/utils/ship/discover.js +129 -0
  51. package/lib/utils/ship/formats.d.ts +5 -0
  52. package/lib/utils/ship/formats.js +83 -0
  53. package/lib/utils/ship/gi-namespaces.d.ts +7 -0
  54. package/lib/utils/ship/gi-namespaces.js +49 -0
  55. package/lib/utils/ship/gzip.d.ts +2 -0
  56. package/lib/utils/ship/gzip.js +21 -0
  57. package/lib/utils/ship/launcher.d.ts +7 -0
  58. package/lib/utils/ship/launcher.js +37 -0
  59. package/lib/utils/ship/payload.d.ts +30 -0
  60. package/lib/utils/ship/payload.js +59 -0
  61. package/lib/utils/ship/plan.d.ts +34 -0
  62. package/lib/utils/ship/plan.js +167 -0
  63. package/lib/utils/ship/rpm-header.d.ts +29 -0
  64. package/lib/utils/ship/rpm-header.js +170 -0
  65. package/lib/utils/ship/rpm.d.ts +12 -0
  66. package/lib/utils/ship/rpm.js +377 -0
  67. package/lib/utils/ship/scripts.d.ts +31 -0
  68. package/lib/utils/ship/scripts.js +76 -0
  69. package/lib/utils/ship/settings.d.ts +60 -0
  70. package/lib/utils/ship/settings.js +184 -0
  71. package/lib/utils/ship/stage-writer.d.ts +17 -0
  72. package/lib/utils/ship/stage-writer.js +49 -0
  73. package/lib/utils/ship/types.d.ts +114 -0
  74. package/lib/utils/ship/types.js +10 -0
  75. package/lib/utils/ship/version.d.ts +17 -0
  76. package/lib/utils/ship/version.js +55 -0
  77. package/package.json +27 -18
  78. /package/lib/templates/{flatpak → app}/desktop.tmpl +0 -0
@@ -18,6 +18,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs';
18
18
  // The zero-dependency subpath, deliberately: importing the plugin root would
19
19
  // pull vite/execa/minify-xml into a system CHECK.
20
20
  import { resolveBlueprintCompiler } from '@gjsify/vite-plugin-blueprint/resolve';
21
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
21
22
  /** Run a binary and return its stdout trimmed, or null if it fails. */
22
23
  function tryExecFile(binary, args) {
23
24
  try {
@@ -378,13 +379,41 @@ export function runAllChecks(cwd) {
378
379
  ];
379
380
  }
380
381
  /**
381
- * Minimal checks needed to run any GJS example (Node + GJS binaries only).
382
- * Used by `gjsify showcase` for examples that have no native deps.
382
+ * Is a Node.js runtime available — asked of the host rather than asserted.
383
+ *
384
+ * This row used to be the literal `{found: true, version: process.version}`, which is
385
+ * a lie on three of the four hosts: `@gjsify/process` stubs `process.version` to
386
+ * `"v20.0.0"` for npm compatibility (Bun and Deno fake `process.versions.node` for the
387
+ * same reason), so `gjsify system-check` under GJS reported `✓ Node.js (v20.0.0)` on a
388
+ * machine with no Node on it — under the NODE-FREE toolchain, whose entire premise is
389
+ * that there is none. `cli-app.ts`'s `runtimeLabel()` already had to know better; one
390
+ * question gets one answer, so this asks the same module.
391
+ *
392
+ * Under Node the running process IS the answer and its version cannot be faked from
393
+ * inside; elsewhere it is a PATH question whose answer may legitimately be "no".
394
+ *
395
+ * `isNode()` rather than `hostRuntime() === 'node'`: the latter RETURNS `'node'` as its
396
+ * fallback for an unrecognised host, which would put an exotic runtime's possibly-stubbed
397
+ * `process.version` on the trusted branch. `isNode()` demands positive evidence.
398
+ *
399
+ * `optional`, never `required`: `gjsify showcase` and `gjsify install` both treat a
400
+ * missing `required` row as fatal, and a GJS showcase needs no Node. The old row could
401
+ * not have failed either gate — it was constant — so nothing that used to pass now
402
+ * fails; what changes is only that the printed line is true.
403
+ */
404
+ function checkNodeRuntime() {
405
+ if (isNode()) {
406
+ return { id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'optional' };
407
+ }
408
+ return checkBinary('nodejs', 'Node.js', 'node', ['--version'], 'optional');
409
+ }
410
+ /**
411
+ * Minimal checks needed to run any GJS example (the GJS binary; Node is reported but
412
+ * not required). Used by `gjsify showcase` for examples that have no native deps.
383
413
  */
384
414
  export function runMinimalChecks() {
385
415
  const results = [];
386
- // Node.js — always present
387
- results.push({ id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'required' });
416
+ results.push(checkNodeRuntime());
388
417
  // GJS
389
418
  results.push(checkBinary('gjs', 'GJS', 'gjs', ['--version'], 'required', (out) => out.replace(/^GJS\s+/i, '').split('\n')[0] ?? out));
390
419
  // The one win32 system dependency that is real and was undiagnosed (#997).
@@ -528,6 +557,7 @@ function runOptionalChecks(needed, cwd) {
528
557
  // Entries with multiple space-separated packages are intentional (one dep needs multiple system pkgs).
529
558
  const PM_PACKAGES = {
530
559
  apt: {
560
+ nodejs: 'nodejs',
531
561
  gjs: 'gjs',
532
562
  'blueprint-compiler': 'blueprint-compiler',
533
563
  'pkg-config': 'pkg-config',
@@ -553,6 +583,7 @@ const PM_PACKAGES = {
553
583
  epoxy: 'libepoxy-dev',
554
584
  },
555
585
  dnf: {
586
+ nodejs: 'nodejs',
556
587
  gjs: 'gjs',
557
588
  'blueprint-compiler': 'blueprint-compiler',
558
589
  'pkg-config': 'pkgconf-pkg-config',
@@ -578,6 +609,7 @@ const PM_PACKAGES = {
578
609
  epoxy: 'libepoxy-devel',
579
610
  },
580
611
  pacman: {
612
+ nodejs: 'nodejs',
581
613
  gjs: 'gjs',
582
614
  'blueprint-compiler': 'blueprint-compiler',
583
615
  'pkg-config': 'pkgconf',
@@ -603,6 +635,7 @@ const PM_PACKAGES = {
603
635
  epoxy: 'libepoxy',
604
636
  },
605
637
  zypper: {
638
+ nodejs: 'nodejs',
606
639
  gjs: 'gjs',
607
640
  'blueprint-compiler': 'blueprint-compiler',
608
641
  'pkg-config': 'pkg-config',
@@ -628,6 +661,7 @@ const PM_PACKAGES = {
628
661
  epoxy: 'libepoxy-devel',
629
662
  },
630
663
  apk: {
664
+ nodejs: 'nodejs',
631
665
  gjs: 'gjs',
632
666
  'blueprint-compiler': 'blueprint-compiler',
633
667
  'pkg-config': 'pkgconf',
@@ -675,6 +709,7 @@ const PM_PACKAGES = {
675
709
  // Recorded here so the next reader does not "fix" the gap by guessing a name;
676
710
  // both are genuine platform gaps, not table omissions.
677
711
  brew: {
712
+ nodejs: 'node',
678
713
  gjs: 'gjs',
679
714
  'blueprint-compiler': 'blueprint-compiler',
680
715
  'pkg-config': 'pkgconf',
@@ -750,8 +785,10 @@ export function buildInstallCommand(pm, missing) {
750
785
  'mingw-w64-ucrt-x86_64-libadwaita # inside MSYS2; found automatically afterwards');
751
786
  continue;
752
787
  }
753
- if (dep.id === 'nodejs')
754
- continue; // can't be missing if we're running
788
+ // No `continue` for `nodejs`. It USED to be unmissable — the row was the
789
+ // literal `found: true` — and since it became a real probe it can report
790
+ // absent under GJS, so skipping it here printed "Missing optional: Node.js"
791
+ // above an install command that did not install it.
755
792
  const pkg = pkgMap[dep.id];
756
793
  if (pkg)
757
794
  pkgs.push(pkg);
@@ -0,0 +1,23 @@
1
+ import type { Workspace } from '@gjsify/workspace';
2
+ export declare function moduleSpecifiers(code: string): string[];
3
+ export interface CliRuntimeClosureOptions {
4
+ /**
5
+ * File to start the walk from. Defaults to the running CLI's Node entry
6
+ * (`bin.gjsify`), which is what a spawned child loads.
7
+ */
8
+ entry?: string | null;
9
+ /** The CLI's own manifest, for its optional peers. Read from disk by default. */
10
+ cliManifest?: {
11
+ peerDependencies?: Record<string, string>;
12
+ } | null;
13
+ }
14
+ /**
15
+ * Workspace packages the running CLI imports, so a parallel sweep can build them
16
+ * first instead of underneath itself.
17
+ *
18
+ * Empty is the correct answer in three real situations and none of them is an
19
+ * error: a consumer project (its `@gjsify/*` deps are not workspaces, so nothing
20
+ * the sweep builds can tear), a relocated bundle with no reachable manifest, and
21
+ * a CLI whose entry has not been built.
22
+ */
23
+ export declare function cliRuntimeClosure(workspaces: readonly Workspace[], options?: CliRuntimeClosureOptions): Set<string>;
@@ -0,0 +1,274 @@
1
+ // Which workspace packages does the RUNNING CLI import at runtime?
2
+ //
3
+ // WHY THE SCHEDULER NEEDS THIS
4
+ //
5
+ // `gjsify foreach <script> -p` spawns children, and every child is another
6
+ // `gjsify` process: it BOOTS the CLI before it runs anything. So while a parallel
7
+ // sweep rebuilds the workspace, each child is reading the very `lib/` trees its
8
+ // siblings are writing. Measured on the macOS leg (run 31130155911,
9
+ // darwin-arm64), on the first full build that leg ever reached:
10
+ //
11
+ // [gjsify foreach] start @gjsify/native-platform (49/160 done, 3 in flight)
12
+ // [gjsify foreach] start @gjsify/npm-registry (50/160 done, 3 in flight)
13
+ // [@gjsify/native-platform] ERR_MODULE_NOT_FOUND
14
+ // .../npm-registry/lib/esm/_virtual/_rolldown/runtime.js
15
+ // imported from .../npm-registry/lib/esm/auth.js
16
+ //
17
+ // `auth.js` was on disk and its rolldown chunk was not — a torn write, not a
18
+ // corrupt build. `foreach` uses this set to build those packages BEFORE the
19
+ // parallel sweep starts, so nothing rewrites them while a child boots.
20
+ //
21
+ // WHY IT IS DERIVED AND NOT LISTED
22
+ //
23
+ // The four names a root script once carried by hand (`semver`, `npm-registry`,
24
+ // `tar`, `workspace`) were never the whole answer, and a hand-written list is the
25
+ // same defect one level up: it is right until an import changes. Two measured
26
+ // alternatives were rejected before this one:
27
+ //
28
+ // * the MANIFEST closure of `@gjsify/cli` (transitive workspace `dependencies`)
29
+ // selects 110 of the ~160 packages a full sweep builds — it counts the
30
+ // umbrella polyfill metas the CLI declares for the alias layer and never
31
+ // imports under Node. Serialising 110 packages is not a fix, it is the end of
32
+ // parallelism;
33
+ // * a regex over the sources reports 27, because `commands/storybook.ts` emits
34
+ // GENERATED CODE containing `import { … } from '@gjsify/storybook';` inside a
35
+ // template literal. A text scan cannot tell a program from a string, and that
36
+ // one string drags the whole GTK storybook subtree into the prefix.
37
+ //
38
+ // So the graph is PARSED, from the entry the children actually boot, and the
39
+ // answer was checked against ground truth: a Node loader hook recording every
40
+ // `file:` URL `gjsify --version` loads reports exactly the ten packages this walk
41
+ // returns (see `cli-runtime-closure.spec.ts` for the shape the walk must keep).
42
+ //
43
+ // THE ONE THING A PARSE CANNOT SEE
44
+ //
45
+ // A COMPUTED dynamic import. `bundler-pick.ts` loads the bundler engine as
46
+ // `await import(target)` where `target` is a resolved file URL, and
47
+ // `rolldown-plugin-gjsify`'s `css-as-string.ts` does the same for lightningcss —
48
+ // deliberately, so tsc and rolldown do not resolve an optional peer at build
49
+ // time. Those are invisible to any static walk, and they are invisible to npm for
50
+ // the same reason, which is exactly why the convention is to declare them as
51
+ // optional PEER dependencies. So the peers of every package in the graph are
52
+ // added: one declaration closes both blind spots, and it stays a declaration the
53
+ // package owns rather than a list this file keeps.
54
+ import * as acorn from 'acorn';
55
+ import { existsSync, readFileSync, statSync } from 'node:fs';
56
+ import { dirname, join, resolve as resolvePath } from 'node:path';
57
+ import { cliPackageDir } from './publish-headers.js';
58
+ /**
59
+ * Split a bare specifier into a WORKSPACE package name + its `exports` key, or
60
+ * `null` when it names no workspace.
61
+ *
62
+ * Matched against the workspace index rather than against the `@gjsify/` scope: a
63
+ * scope literal would be a second place that has to be edited when the project is
64
+ * vendored, forked or renamed, and the question here is only ever "is this one of
65
+ * OUR packages". Scoped names take two segments, unscoped one.
66
+ */
67
+ function splitWorkspaceSpecifier(specifier, byName) {
68
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':'))
69
+ return null;
70
+ const parts = specifier.split('/');
71
+ const candidates = specifier.startsWith('@') ? [2] : [1];
72
+ for (const depth of candidates) {
73
+ const name = parts.slice(0, depth).join('/');
74
+ if (!byName.has(name))
75
+ continue;
76
+ const rest = parts.slice(depth).join('/');
77
+ return { name, subpath: rest ? `./${rest}` : '.' };
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * The string a module specifier node names, or `null` when it is computed.
83
+ *
84
+ * A substitution-free TEMPLATE LITERAL is as static as a quoted string, and
85
+ * skipping it is not hypothetical: the minifier rewrites `await
86
+ * import('gi://GLib?version=2.0')` to a backtick form, so a Literal-only reader
87
+ * silently loses every dynamic import in a built bundle.
88
+ */
89
+ function literalSpecifier(node) {
90
+ if (node?.type === 'Literal')
91
+ return typeof node.value === 'string' ? node.value : null;
92
+ if (node?.type === 'TemplateLiteral' && node.expressions?.length === 0) {
93
+ const cooked = node.quasis?.[0]?.value?.cooked;
94
+ return typeof cooked === 'string' ? cooked : null;
95
+ }
96
+ return null;
97
+ }
98
+ export function moduleSpecifiers(code) {
99
+ let ast;
100
+ try {
101
+ ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', allowHashBang: true });
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ const out = [];
107
+ const visit = (node) => {
108
+ if (!node || typeof node !== 'object')
109
+ return;
110
+ if (Array.isArray(node)) {
111
+ for (const child of node)
112
+ visit(child);
113
+ return;
114
+ }
115
+ const n = node;
116
+ if (n.type === 'ImportDeclaration' ||
117
+ n.type === 'ExportNamedDeclaration' ||
118
+ n.type === 'ExportAllDeclaration' ||
119
+ n.type === 'ImportExpression') {
120
+ const specifier = literalSpecifier(n.source);
121
+ if (specifier !== null)
122
+ out.push(specifier);
123
+ }
124
+ for (const [key, value] of Object.entries(n)) {
125
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
126
+ continue;
127
+ visit(value);
128
+ }
129
+ };
130
+ visit(ast);
131
+ return out;
132
+ }
133
+ /** The file a relative specifier names, probing the extensionless spellings Node accepts. */
134
+ function resolveRelative(fromFile, specifier) {
135
+ const base = resolvePath(dirname(fromFile), specifier);
136
+ for (const candidate of [base, `${base}.js`, `${base}.mjs`, join(base, 'index.js')]) {
137
+ try {
138
+ if (statSync(candidate).isFile())
139
+ return candidate;
140
+ }
141
+ catch {
142
+ /* not this spelling */
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ /**
148
+ * The file a workspace package's `<subpath>` resolves to, read from its own
149
+ * manifest. `subpath` is `'.'` for a bare package specifier.
150
+ *
151
+ * The `exports` conditions are walked in the order the CHILD's runtime resolves
152
+ * them; `main`/`module` is the pre-`exports` fallback. Deliberately NOT
153
+ * `import.meta.resolve`: that answers only for the runtime doing the asking, and
154
+ * this walk must give the same answer from Node and from GJS, from a spec with a
155
+ * fixture tree as from a real install. Resolving the SUBPATH rather than the
156
+ * package root matters — `@gjsify/rolldown-plugin-gjsify/runtime` is a leaf, and
157
+ * substituting the root barrel for it would drag in everything the barrel
158
+ * re-exports and nothing imports.
159
+ */
160
+ function workspaceEntry(ws, subpath) {
161
+ const manifest = ws.manifest;
162
+ const pick = (node) => {
163
+ if (typeof node === 'string')
164
+ return node;
165
+ if (!node || typeof node !== 'object' || Array.isArray(node))
166
+ return null;
167
+ const record = node;
168
+ for (const condition of ['node', 'import', 'default', 'gjs']) {
169
+ if (condition in record) {
170
+ const picked = pick(record[condition]);
171
+ if (picked)
172
+ return picked;
173
+ }
174
+ }
175
+ return null;
176
+ };
177
+ let target = null;
178
+ if (typeof manifest.exports === 'string')
179
+ target = subpath === '.' ? manifest.exports : null;
180
+ else if (manifest.exports && typeof manifest.exports === 'object') {
181
+ target = pick(manifest.exports[subpath]);
182
+ }
183
+ if (!target && subpath === '.') {
184
+ target = typeof manifest.module === 'string' ? manifest.module : null;
185
+ target ??= typeof manifest.main === 'string' ? manifest.main : null;
186
+ }
187
+ if (!target)
188
+ return null;
189
+ const file = resolvePath(ws.location, target);
190
+ return existsSync(file) ? file : null;
191
+ }
192
+ /** `peerDependencies` of `manifest` that name a workspace package. */
193
+ function workspacePeers(manifest, byName) {
194
+ return Object.keys(manifest.peerDependencies ?? {}).filter((name) => byName.has(name));
195
+ }
196
+ /**
197
+ * Workspace packages the running CLI imports, so a parallel sweep can build them
198
+ * first instead of underneath itself.
199
+ *
200
+ * Empty is the correct answer in three real situations and none of them is an
201
+ * error: a consumer project (its `@gjsify/*` deps are not workspaces, so nothing
202
+ * the sweep builds can tear), a relocated bundle with no reachable manifest, and
203
+ * a CLI whose entry has not been built.
204
+ */
205
+ export function cliRuntimeClosure(workspaces, options = {}) {
206
+ const byName = new Map(workspaces.map((ws) => [ws.name, ws]));
207
+ const found = new Set();
208
+ let entry = options.entry;
209
+ let cliManifest = options.cliManifest;
210
+ if (entry === undefined || cliManifest === undefined) {
211
+ const packageDir = cliPackageDir();
212
+ let manifest = null;
213
+ if (packageDir) {
214
+ try {
215
+ manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
216
+ }
217
+ catch {
218
+ /* unreadable — the closure degrades to empty, which is a no-op prefix */
219
+ }
220
+ }
221
+ cliManifest ??= manifest;
222
+ if (entry === undefined) {
223
+ // The npm `bin` entry, NOT `gjsify.bin`: the GJS bundle is self-contained,
224
+ // so walking it would report nothing, while a GJS child still loads the
225
+ // engine facades through the peers below. The `lib/` graph is a superset
226
+ // of what either entry reaches, and one code path beats two.
227
+ const bin = manifest?.bin;
228
+ const relative = typeof bin === 'string' ? bin : (bin?.['gjsify'] ?? manifest?.main);
229
+ entry = packageDir && relative ? resolvePath(packageDir, relative) : null;
230
+ }
231
+ }
232
+ for (const peer of workspacePeers(cliManifest ?? {}, byName))
233
+ found.add(peer);
234
+ if (!entry || !existsSync(entry))
235
+ return found;
236
+ const seen = new Set();
237
+ const queue = [entry];
238
+ while (queue.length > 0) {
239
+ const file = queue.shift();
240
+ if (seen.has(file))
241
+ continue;
242
+ seen.add(file);
243
+ let code;
244
+ try {
245
+ code = readFileSync(file, 'utf8');
246
+ }
247
+ catch {
248
+ continue;
249
+ }
250
+ for (const specifier of moduleSpecifiers(code)) {
251
+ if (specifier.startsWith('.')) {
252
+ const next = resolveRelative(file, specifier);
253
+ if (next)
254
+ queue.push(next);
255
+ continue;
256
+ }
257
+ const split = splitWorkspaceSpecifier(specifier, byName);
258
+ const ws = split ? byName.get(split.name) : undefined;
259
+ if (!split || !ws)
260
+ continue;
261
+ found.add(split.name);
262
+ for (const peer of workspacePeers(ws.manifest, byName))
263
+ found.add(peer);
264
+ // Queued per SUBPATH, not per package: `seen` dedupes files, so a
265
+ // package imported through two subpaths is walked through both. A
266
+ // package-level visited set would stop at whichever subpath came
267
+ // first and miss what the other one reaches.
268
+ const next = workspaceEntry(ws, split.subpath);
269
+ if (next)
270
+ queue.push(next);
271
+ }
272
+ }
273
+ return found;
274
+ }
@@ -0,0 +1,44 @@
1
+ /** Where the running CLI came from. */
2
+ export type InstallProvenanceKind =
3
+ /** `install.mjs` or `gjsify install -g` — the layout `self-update` manages. */
4
+ 'xdg-global'
5
+ /** Inside a Flatpak sandbox. */
6
+ | 'flatpak'
7
+ /** A distro package (`.deb`/`.rpm`) staged under a system prefix. */
8
+ | 'system-package'
9
+ /** `npm install -g` (or yarn/pnpm global) — a `node_modules` tree elsewhere. */
10
+ | 'npm-global'
11
+ /** A checkout, a container image, an unpacked tarball — anything else. */
12
+ | 'unknown';
13
+ export interface InstallProvenance {
14
+ kind: InstallProvenanceKind;
15
+ /** What the decision was made ON, so a wrong verdict is debuggable. */
16
+ evidence: string;
17
+ /** How the user should update, when it is not `self-update`'s job. */
18
+ updateWith?: string;
19
+ /** True when `self-update` must refuse rather than write the XDG prefix. */
20
+ managedElsewhere: boolean;
21
+ }
22
+ export interface ProvenanceInput {
23
+ /** Absolute path of the running CLI's own package directory. */
24
+ selfDir: string;
25
+ /** `defaultGlobalLayout().prefix` — the layout `self-update` owns. */
26
+ xdgPrefix: string;
27
+ env?: Record<string, string | undefined>;
28
+ platform?: NodeJS.Platform;
29
+ }
30
+ /**
31
+ * Classify an installation from its path and environment.
32
+ *
33
+ * ORDER MATTERS and is not alphabetical:
34
+ *
35
+ * 1. **Flatpak first.** Its `/app` prefix would also satisfy no other test, but
36
+ * `FLATPAK_ID` is the unambiguous signal and is present even when the tree is
37
+ * mounted somewhere unusual.
38
+ * 2. **XDG before system.** The XDG prefix is under `$HOME`, so it can never be
39
+ * `/usr` — but a user whose `$HOME` is `/opt/someuser` exists, and asking the
40
+ * narrow question first means that user is not told to run `dnf`.
41
+ * 3. **System before npm.** A `.deb` stages a `node_modules`-shaped tree under
42
+ * `/usr/lib`, so the npm test alone would claim it.
43
+ */
44
+ export declare function classifyInstall(input: ProvenanceInput): InstallProvenance;
@@ -0,0 +1,106 @@
1
+ // How was THIS gjsify installed?
2
+ //
3
+ // `self-update` re-runs the `gjsify install -g` pipeline, which writes into the
4
+ // user-global XDG prefix. That is correct for an install that came from
5
+ // `install.mjs` or `gjsify install -g`, and WRONG for one a package manager owns:
6
+ // it would lay a second copy into `~/.local` that shadows — or is shadowed by —
7
+ // the one apt/dnf/flatpak is tracking, and the package manager would then be
8
+ // managing a file the user no longer runs. The next `apt upgrade` reverts or
9
+ // diverges, silently, and "I updated it and nothing changed" is the report.
10
+ //
11
+ // Before `gjsify ship` there was only one non-XDG case worth naming (`npm -g`),
12
+ // and the command warned and continued. Now that gjsify ships as `.deb`, `.rpm`
13
+ // and Flatpak, continuing is the defect. So the question gets a real answer, and
14
+ // the answer carries the command the user should run instead.
15
+ //
16
+ // Everything here is derived from where the RUNNING code sits, never from a
17
+ // build-time constant: one tarball is unpacked by all of these routes, so a flag
18
+ // baked at build time would describe the builder rather than the installation.
19
+ import { posix, win32 } from 'node:path';
20
+ /** System prefixes a distro package stages into. POSIX only — win32 handled separately. */
21
+ const SYSTEM_PREFIXES = ['/usr/', '/opt/', '/snap/'];
22
+ /** Inside a Flatpak the whole app tree lives under this prefix. */
23
+ const FLATPAK_PREFIX = '/app/';
24
+ /**
25
+ * The TARGET platform's path module, never the host's.
26
+ *
27
+ * Same rule as `pathFor` in `install-global.ts`, and for the same reason: this
28
+ * function ACCEPTS a `platform` and must therefore answer for it. Reading the
29
+ * host's `node:path` instead makes every POSIX-shaped case unanswerable from a
30
+ * Windows runner — which is exactly what happened. Four of these tests went red on
31
+ * the win32 leg, on `main`, because `resolve('/app/lib')` there is `C:\app\lib`
32
+ * and `sep` is a backslash.
33
+ */
34
+ function pathFor(platform) {
35
+ return platform === 'win32' ? win32 : posix;
36
+ }
37
+ /** Does `child` sit inside `parent`? Both must be absolute and already resolved. */
38
+ function isInside(child, parent, platform) {
39
+ const p = pathFor(platform);
40
+ if (!p.isAbsolute(child) || !p.isAbsolute(parent))
41
+ return false;
42
+ const withSep = parent.endsWith(p.sep) ? parent : parent + p.sep;
43
+ return child === parent || child.startsWith(withSep);
44
+ }
45
+ /**
46
+ * Classify an installation from its path and environment.
47
+ *
48
+ * ORDER MATTERS and is not alphabetical:
49
+ *
50
+ * 1. **Flatpak first.** Its `/app` prefix would also satisfy no other test, but
51
+ * `FLATPAK_ID` is the unambiguous signal and is present even when the tree is
52
+ * mounted somewhere unusual.
53
+ * 2. **XDG before system.** The XDG prefix is under `$HOME`, so it can never be
54
+ * `/usr` — but a user whose `$HOME` is `/opt/someuser` exists, and asking the
55
+ * narrow question first means that user is not told to run `dnf`.
56
+ * 3. **System before npm.** A `.deb` stages a `node_modules`-shaped tree under
57
+ * `/usr/lib`, so the npm test alone would claim it.
58
+ */
59
+ export function classifyInstall(input) {
60
+ const env = input.env ?? process.env;
61
+ const platform = input.platform ?? process.platform;
62
+ const p = pathFor(platform);
63
+ const selfDir = p.resolve(input.selfDir);
64
+ if (env.FLATPAK_ID || isInside(selfDir, FLATPAK_PREFIX, platform)) {
65
+ const id = env.FLATPAK_ID ?? 'io.github.gjsify.Cli';
66
+ return {
67
+ kind: 'flatpak',
68
+ evidence: env.FLATPAK_ID ? `FLATPAK_ID=${env.FLATPAK_ID}` : `running from ${FLATPAK_PREFIX}`,
69
+ updateWith: `flatpak update ${id}`,
70
+ managedElsewhere: true,
71
+ };
72
+ }
73
+ if (isInside(selfDir, p.resolve(input.xdgPrefix), platform)) {
74
+ return {
75
+ kind: 'xdg-global',
76
+ evidence: `running from the user-global prefix ${input.xdgPrefix}`,
77
+ managedElsewhere: false,
78
+ };
79
+ }
80
+ if (platform !== 'win32' && SYSTEM_PREFIXES.some((pfx) => isInside(selfDir, pfx, platform))) {
81
+ return {
82
+ kind: 'system-package',
83
+ evidence: `running from the system prefix ${selfDir}`,
84
+ // Not `apt` or `dnf` specifically: the same path serves both, and
85
+ // guessing wrong sends the user to a package manager that does not
86
+ // know this file. The release assets are named because they are the
87
+ // one answer that is true on every distribution.
88
+ updateWith: 'your distribution package manager (`apt upgrade` / `dnf upgrade` / …), or install the newer package from ' +
89
+ 'https://github.com/gjsify/gjsify/releases/latest',
90
+ managedElsewhere: true,
91
+ };
92
+ }
93
+ if (selfDir.split(p.sep).includes('node_modules')) {
94
+ return {
95
+ kind: 'npm-global',
96
+ evidence: `running from a node_modules tree at ${selfDir}`,
97
+ updateWith: 'npm install -g @gjsify/cli@latest (or switch to the Node-free bootstrap)',
98
+ managedElsewhere: true,
99
+ };
100
+ }
101
+ return {
102
+ kind: 'unknown',
103
+ evidence: `running from ${selfDir}, which matches no known install layout`,
104
+ managedElsewhere: false,
105
+ };
106
+ }
@@ -0,0 +1,83 @@
1
+ import { type PlatformDeclaration, type PlatformTarget, type PlatformVerdict } from './platform-check.js';
2
+ /** One package directory found under a prefix's `node_modules` tree. */
3
+ export interface InstalledPackage {
4
+ /** `name` from its manifest, or the directory path when the manifest is unreadable. */
5
+ name: string;
6
+ version: string;
7
+ /** Absolute path to the package directory. */
8
+ dir: string;
9
+ /**
10
+ * A symlink, i.e. a workspace source tree linked into `node_modules` rather than
11
+ * a copy the installer extracted. Never pruned and never descended: the installer
12
+ * refuses to remove one for the same reason, and following it would walk the
13
+ * user's own sources.
14
+ */
15
+ linked: boolean;
16
+ /** The `os`/`cpu`/`libc` slice, absent when the manifest declares none. */
17
+ platform?: PlatformDeclaration;
18
+ }
19
+ export interface PruneEntry {
20
+ pkg: InstalledPackage;
21
+ /** Apparent size of the package directory, summed from `statSync().size`. */
22
+ bytes: number;
23
+ verdict: PlatformVerdict;
24
+ }
25
+ export interface PrunePlan {
26
+ prefix: string;
27
+ target: PlatformTarget;
28
+ /** How many package directories were examined, so an empty plan is distinguishable from an empty scan. */
29
+ scanned: number;
30
+ entries: PruneEntry[];
31
+ bytes: number;
32
+ }
33
+ export interface PruneResult {
34
+ plan: PrunePlan;
35
+ removed: PruneEntry[];
36
+ failed: Array<{
37
+ entry: PruneEntry;
38
+ error: string;
39
+ }>;
40
+ bytes: number;
41
+ /** `.bin` entries removed because they were symlinks with no target left. */
42
+ binLinks: string[];
43
+ }
44
+ /** Every package directory under `<prefix>/node_modules`, at any depth. */
45
+ export declare function scanPrefix(prefix: string): InstalledPackage[];
46
+ /**
47
+ * The packages `target` cannot use. Pure — no filesystem, no `process.*` — so every
48
+ * OS/arch/libc branch is decidable from any host.
49
+ */
50
+ export declare function planPlatformPrune(pkgs: readonly InstalledPackage[], target: PlatformTarget): PruneEntry[];
51
+ /** Apparent size of a directory tree — what the files claim, not what the block device spent. */
52
+ export declare function dirBytes(dir: string): number;
53
+ export declare function planPrune(opts: {
54
+ prefix: string;
55
+ target: PlatformTarget;
56
+ }): PrunePlan;
57
+ /**
58
+ * Remove a plan's entries.
59
+ *
60
+ * `remove` is injected so the failure path is testable without a filesystem that
61
+ * can fail on demand. Nothing here throws: this runs as housekeeping at the end of
62
+ * an install, and an install that already succeeded must not be reported as failed
63
+ * because a directory could not be unlinked.
64
+ */
65
+ export declare function executePrune(plan: PrunePlan, opts?: {
66
+ dryRun?: boolean;
67
+ remove?: (dir: string) => void;
68
+ }): PruneResult;
69
+ export declare function formatPruneReport(result: PruneResult, opts: {
70
+ dryRun: boolean;
71
+ verbose: boolean;
72
+ }): string;
73
+ /** Why an automatic pass declined to run, or null when it may proceed. */
74
+ export declare function automaticPruneRefusal(env: Record<string, string | undefined>, immutable: boolean): string | null;
75
+ /**
76
+ * The automatic pass: prune what this install would not have placed, report one
77
+ * line, and never fail the install that called it.
78
+ */
79
+ export declare function pruneAfterInstall(prefix: string, target: PlatformTarget, opts?: {
80
+ env?: Record<string, string | undefined>;
81
+ immutable?: boolean;
82
+ hint?: string;
83
+ }): void;