@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
@@ -13,9 +13,15 @@ import { readFileSync, readdirSync } from 'node:fs';
13
13
  import { cpus } from 'node:os';
14
14
  import { affectedClosure, buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
15
15
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
16
+ import { cliRuntimeClosure } from '../utils/cli-runtime-closure.js';
16
17
  import { prefixLines } from '../utils/prefixed-output.js';
17
18
  import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
18
- import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
19
+ // The ONE runner-selection rule, shared rather than re-derived. This file used to
20
+ // carry its own copy; `workspace.ts` grew the bootstrap-CLI branch and the copy
21
+ // here did not, so a `gjsify foreach` reached from a cold-tree bootstrap would
22
+ // have delegated to npm and lost the shim one level down — silently, and only on
23
+ // the OS legs that bootstrap cold. `onboard.ts` already imports it from here.
24
+ import { detectPackageManager } from './workspace.js';
19
25
  // Every child spawned by spawnPrefixed registers here so fail-fast can
20
26
  // terminate the whole in-flight set instead of waiting on it. On CI a
21
27
  // single failed package used to leave foreach awaiting its still-running
@@ -425,20 +431,44 @@ export const foreachCommand = {
425
431
  targets: selected.map((w) => w.name),
426
432
  })
427
433
  : undefined;
434
+ // The serial prefix: every child of this command BOOTS the CLI, so a
435
+ // package the CLI imports must not be written while siblings are starting
436
+ // (the torn `lib/esm` in `utils/cli-runtime-closure.ts`'s header). Building
437
+ // that closure first — alone, in the order the caller already sorted — is a
438
+ // REORDERING, not extra work: the prefix is removed from the parallel set,
439
+ // so the cost is only the parallelism given up over those few packages.
440
+ //
441
+ // Applied to every PARALLEL invocation, not only to `build`. Which scripts
442
+ // rewrite an output tree is not knowable from a script NAME — `build`,
443
+ // `build:types`, a package's own `dist` step and `clear` all do — and
444
+ // gating on the literal string "build" would be the hand-written list this
445
+ // closure exists to replace. A serial run needs no prefix at all: nothing
446
+ // else is in flight to read what it writes.
447
+ const prefixNames = args.parallel === true ? cliRuntimeClosure(allWorkspaces) : new Set();
448
+ const prefix = prefixNames.size > 0 ? selected.filter((ws) => prefixNames.has(ws.name)) : [];
449
+ const sweep = prefix.length > 0 ? selected.filter((ws) => !prefixNames.has(ws.name)) : selected;
428
450
  try {
451
+ if (prefix.length > 0) {
452
+ console.error(`[gjsify foreach] serial prefix: ${prefix.length} package(s) the running CLI imports — ` +
453
+ `${prefix.map((ws) => ws.name).join(', ')}`);
454
+ for (const ws of prefix) {
455
+ console.error(`[gjsify foreach] start ${ws.name} (serial prefix)`);
456
+ await runOne(ws, finalCmd, cmdArgs, /* prefixOutput */ true, verbose, exec, cache);
457
+ }
458
+ }
429
459
  if (args.parallel && !args.topological && !args['topological-dev']) {
430
460
  const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
431
- await runParallel(selected, finalCmd, cmdArgs, jobs, verbose, exec, cache);
461
+ await runParallel(sweep, finalCmd, cmdArgs, jobs, verbose, exec, cache);
432
462
  }
433
463
  else if (args.parallel) {
434
464
  // Topological + parallel: each workspace starts as soon as its
435
465
  // deps (in the selected set) have finished. Yarn calls this
436
466
  // "topological order with concurrency"; we cap at --jobs.
437
467
  const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
438
- await runTopologicalParallel(selected, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec, cache);
468
+ await runTopologicalParallel(sweep, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec, cache);
439
469
  }
440
470
  else {
441
- await runSequential(selected, finalCmd, cmdArgs, verbose, exec, cache);
471
+ await runSequential(sweep, finalCmd, cmdArgs, verbose, exec, cache);
442
472
  }
443
473
  }
444
474
  catch (err) {
@@ -703,22 +733,6 @@ async function runOne(ws, script, args, prefixOutput, verbose, exec, cache) {
703
733
  if (cache && before)
704
734
  cache.storeAfterSuccess(ws, before);
705
735
  }
706
- function detectPackageManager() {
707
- // Under GJS there is no Node — neither `npm` nor `yarn` can run scripts, so
708
- // the runner is `gjsify` itself via the GJS shim on PATH
709
- // (`ensureGjsifyShimOnPath`). Makes node-free `gjsify foreach` work.
710
- if (isGjs())
711
- return 'gjsify';
712
- // `npm_config_user_agent` is set by npm/yarn/pnpm — first token is
713
- // `<name>/<version>`. Reuse it so `gjsify foreach build` invoked
714
- // through `yarn run` keeps using yarn, etc.
715
- const ua = process.env.npm_config_user_agent ?? '';
716
- if (ua.startsWith('yarn/'))
717
- return 'yarn';
718
- if (ua.startsWith('gjsify/'))
719
- return 'gjsify';
720
- return 'npm';
721
- }
722
736
  async function spawnPrefixed(cmd, args, cwd, prefix) {
723
737
  // `completion: 'exit'` — every path out of this command's handler ends in
724
738
  // `process.exit(…)` (see the handler's tail), which is what quits the GLib
@@ -12,6 +12,7 @@ export * from './gresource.js';
12
12
  export * from './gettext.js';
13
13
  export * from './gsettings.js';
14
14
  export { flatpakCommand } from './flatpak/index.js';
15
+ export * from './ship.js';
15
16
  export * from './dlx.js';
16
17
  export * from './install.js';
17
18
  export * from './foreach.js';
@@ -25,6 +26,7 @@ export * from './trust.js';
25
26
  export * from './onboard.js';
26
27
  export * from './self-update.js';
27
28
  export * from './generate-installer.js';
29
+ export * from './prune.js';
28
30
  export * from './uninstall.js';
29
31
  export * from './format.js';
30
32
  export * from './lint.js';
@@ -12,6 +12,7 @@ export * from './gresource.js';
12
12
  export * from './gettext.js';
13
13
  export * from './gsettings.js';
14
14
  export { flatpakCommand } from './flatpak/index.js';
15
+ export * from './ship.js';
15
16
  export * from './dlx.js';
16
17
  export * from './install.js';
17
18
  export * from './foreach.js';
@@ -25,6 +26,7 @@ export * from './trust.js';
25
26
  export * from './onboard.js';
26
27
  export * from './self-update.js';
27
28
  export * from './generate-installer.js';
29
+ export * from './prune.js';
28
30
  export * from './uninstall.js';
29
31
  export * from './format.js';
30
32
  export * from './lint.js';
@@ -7,6 +7,7 @@ interface InstallOptions {
7
7
  'save-peer'?: boolean;
8
8
  'save-optional'?: boolean;
9
9
  immutable?: boolean;
10
+ prune?: boolean;
10
11
  'refresh-lockfile'?: boolean;
11
12
  verbose: boolean;
12
13
  quiet?: boolean;
@@ -32,6 +32,8 @@ import { nodeBinary } from '../utils/run-node.js';
32
32
  import { spawnToCompletion } from '../utils/spawn.js';
33
33
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
34
34
  import { binDirOnPath, BUNDLER_ENGINE_PACKAGE, defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
35
+ import { resolveHostPlatform } from '../utils/platform-check.js';
36
+ import { pruneAfterInstall } from '../utils/prune-prefix.js';
35
37
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
36
38
  /**
37
39
  * Link type for the workspace↔workspace directory links.
@@ -125,6 +127,11 @@ export const installCommand = {
125
127
  description: 'Install a REQUIRED dependency even when its os/cpu/libc excludes the target, instead of failing with EBADPLATFORM (npm config key `force`). Incompatible OPTIONAL dependencies stay skipped — npm ignores --force for those too, and a binary that cannot load is not worth downloading.',
126
128
  type: 'boolean',
127
129
  default: false,
130
+ })
131
+ .option('prune', {
132
+ description: 'After installing, remove packages an earlier install left behind that this host cannot use (foreign os/cpu/libc). Use --no-prune to disable. Skipped automatically under --immutable, and whenever --os/--cpu/--libc is given: an install must never delete against a target the user typed.',
133
+ type: 'boolean',
134
+ default: true,
128
135
  }),
129
136
  handler: async (args) => {
130
137
  // Must run before anything resolves — `--os/--cpu/--libc/--force` are npm
@@ -161,7 +168,7 @@ export const installCommand = {
161
168
  console.warn(`gjsify install --global ignores --${flag}: global installs do not modify a project package.json.`);
162
169
  }
163
170
  }
164
- await installGlobalAndLink(args.packages, { verbose: args.verbose });
171
+ await installGlobalAndLink(args.packages, { verbose: args.verbose, prune: args.prune });
165
172
  return;
166
173
  }
167
174
  // Before the heavy install, so the skew is visible even if a later phase
@@ -170,6 +177,7 @@ export const installCommand = {
170
177
  const backend = args.backend ?? process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
171
178
  if (backend === 'npm') {
172
179
  await projectInstallViaNpm(args);
180
+ pruneProjectPrefix(args);
173
181
  await runPostInstallChecks(args);
174
182
  return;
175
183
  }
@@ -217,6 +225,7 @@ export const installCommand = {
217
225
  hardExitTimerId?.unref?.();
218
226
  try {
219
227
  await projectInstallNative(args, overallController?.signal);
228
+ pruneProjectPrefix(args);
220
229
  await runPostInstallChecks(args);
221
230
  }
222
231
  catch (err) {
@@ -1198,6 +1207,14 @@ async function installGlobalAndLink(specs, opts) {
1198
1207
  const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
1199
1208
  await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
1200
1209
  }
1210
+ // Before the LINK on purpose: `linkGlobalBins`' launchers bake the resolved
1211
+ // prebuild directories into GI_TYPELIB_PATH/LD_LIBRARY_PATH, and must not name
1212
+ // a directory this pass is about to delete.
1213
+ if (opts.prune !== false) {
1214
+ pruneAfterInstall(layout.prefix, resolveHostPlatform({ env: {} }), {
1215
+ hint: 'gjsify prune -g --dry-run',
1216
+ });
1217
+ }
1201
1218
  const created = linkGlobalBins(packageNames, layout);
1202
1219
  reportLinkedBins(created, layout.binDir);
1203
1220
  }
@@ -1205,6 +1222,20 @@ async function installGlobalAndLink(specs, opts) {
1205
1222
  lock.release();
1206
1223
  }
1207
1224
  }
1225
+ /**
1226
+ * The automatic pass for a PROJECT prefix.
1227
+ *
1228
+ * Hooked at the command level rather than inside `installPackages`, so `dlx`'s
1229
+ * throwaway cache prefix — created, used once and dropped — pays nothing for it.
1230
+ */
1231
+ function pruneProjectPrefix(args) {
1232
+ if (args.prune === false)
1233
+ return;
1234
+ pruneAfterInstall(process.cwd(), resolveHostPlatform({ env: {} }), {
1235
+ immutable: args.immutable === true,
1236
+ hint: 'gjsify prune --dry-run',
1237
+ });
1238
+ }
1208
1239
  function reportLinkedBins(created, binDir) {
1209
1240
  if (created.length === 0) {
1210
1241
  console.warn('\nNo bins declared (neither `gjsify.bin` nor `bin` in package.json) — nothing was symlinked.');
@@ -0,0 +1,11 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface PruneOptions {
3
+ global: boolean;
4
+ dryRun: boolean;
5
+ verbose: boolean;
6
+ os?: string;
7
+ cpu?: string;
8
+ libc?: string;
9
+ }
10
+ export declare const pruneCommand: Command<unknown, PruneOptions>;
11
+ export {};
@@ -0,0 +1,84 @@
1
+ // `gjsify prune` — remove installed packages this target cannot use.
2
+ //
3
+ // The repair half of ADR 0025. `install`/`self-update` prune what they would not
4
+ // have placed, but only going forward: a prefix that accreted foreign-platform
5
+ // packages under an older CLI is only reachable by asking. That is this command,
6
+ // and it is why it exists at all rather than the automatic pass alone.
7
+ //
8
+ // `--os/--cpu/--libc` are accepted here and deliberately NOT honoured by the
9
+ // automatic pass: typed on `prune` they are a request ("show me what a darwin-arm64
10
+ // host could not use"), while inherited by an install they would delete the host's
11
+ // own packages. See `automaticPruneRefusal`.
12
+ import { existsSync } from 'node:fs';
13
+ import { acquireInstallLock } from '../utils/install-lock.js';
14
+ import { defaultGlobalLayout } from '../utils/install-global.js';
15
+ import { resolveHostPlatform } from '../utils/platform-check.js';
16
+ import { executePrune, formatPruneReport, planPrune } from '../utils/prune-prefix.js';
17
+ export const pruneCommand = {
18
+ command: 'prune',
19
+ description: 'Remove installed packages this host cannot use (foreign os/cpu/libc). ' +
20
+ 'Reads each package manifest — a package that declares no platform is never touched.',
21
+ builder: (yargs) => yargs
22
+ .option('global', {
23
+ description: 'Prune the user-global XDG prefix (the `install -g` target) instead of the project.',
24
+ type: 'boolean',
25
+ alias: 'g',
26
+ default: false,
27
+ })
28
+ .option('dry-run', {
29
+ description: 'Report what would be removed without touching the filesystem.',
30
+ type: 'boolean',
31
+ default: false,
32
+ })
33
+ .option('verbose', {
34
+ description: 'List every package rather than the first few.',
35
+ type: 'boolean',
36
+ default: false,
37
+ })
38
+ .option('os', {
39
+ description: 'Prune AS IF the host were this OS (linux, darwin, win32). Defaults to this host.',
40
+ type: 'string',
41
+ })
42
+ .option('cpu', {
43
+ description: 'Prune AS IF the host were this architecture (x64, arm64, …). Defaults to this host.',
44
+ type: 'string',
45
+ })
46
+ .option('libc', {
47
+ description: 'Prune AS IF the host used this libc (glibc, musl). Defaults to this host.',
48
+ type: 'string',
49
+ }),
50
+ handler: async (args) => {
51
+ const layout = args.global ? defaultGlobalLayout() : null;
52
+ const prefix = layout ? layout.prefix : process.cwd();
53
+ if (!existsSync(`${prefix}/node_modules`)) {
54
+ console.log(`gjsify prune: nothing installed at ${prefix}`);
55
+ return;
56
+ }
57
+ // The flags are read HERE rather than through the npm config keys, so the
58
+ // automatic pass — which refuses to act on an overridden target — cannot be
59
+ // reached by way of this command's own options.
60
+ const target = resolveHostPlatform({
61
+ env: {
62
+ ...(args.os ? { npm_config_os: args.os } : {}),
63
+ ...(args.cpu ? { npm_config_cpu: args.cpu } : {}),
64
+ ...(args.libc ? { npm_config_libc: args.libc } : {}),
65
+ },
66
+ });
67
+ // A prune racing an install's extract would delete a half-written tree. The
68
+ // lock is the same one every writer of this prefix takes (ADR 0001).
69
+ const lock = await acquireInstallLock(prefix, {});
70
+ let result;
71
+ try {
72
+ result = executePrune(planPrune({ prefix, target }), { dryRun: args.dryRun });
73
+ }
74
+ finally {
75
+ lock.release();
76
+ }
77
+ console.log(formatPruneReport(result, { dryRun: args.dryRun, verbose: args.verbose }));
78
+ // Removing nothing is a SUCCESS — idempotent housekeeping, unlike `uninstall`
79
+ // where naming a package that is not there is a mistake worth reporting. Only
80
+ // a removal the user asked for and did not get is a failure.
81
+ if (result.failed.length > 0)
82
+ return process.exit(1);
83
+ },
84
+ };
@@ -4,6 +4,7 @@ interface SelfUpdateOptions {
4
4
  force?: boolean;
5
5
  tag: string;
6
6
  skipDeps?: boolean;
7
+ prune?: boolean;
7
8
  }
8
9
  export declare const selfUpdateCommand: Command<unknown, SelfUpdateOptions>;
9
10
  /** Verdict of the post-install "did this change what runs?" check. */
@@ -25,7 +25,10 @@ import { dirname, join, posix, resolve, win32 } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { fetchPackument } from '@gjsify/npm-registry';
27
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
28
+ import { classifyInstall } from '../utils/install-provenance.js';
28
29
  import { defaultGlobalLayout, globalLayoutIsDefault, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, resolveBinOnPath, } from '../utils/install-global.js';
30
+ import { resolveHostPlatform } from '../utils/platform-check.js';
31
+ import { pruneAfterInstall } from '../utils/prune-prefix.js';
29
32
  const PACKAGE_NAME = '@gjsify/cli';
30
33
  export const selfUpdateCommand = {
31
34
  command: 'self-update',
@@ -45,6 +48,11 @@ export const selfUpdateCommand = {
45
48
  description: 'npm dist-tag or pinned version to install (e.g. `latest`, `next`, `0.5.0`).',
46
49
  type: 'string',
47
50
  default: 'latest',
51
+ })
52
+ .option('prune', {
53
+ description: 'After updating, remove packages an earlier install left behind that this host cannot use (foreign os/cpu/libc). Use --no-prune to disable.',
54
+ type: 'boolean',
55
+ default: true,
48
56
  })
49
57
  .option('skip-deps', {
50
58
  description: 'Update only the @gjsify/cli bundle itself; do not pull its runtime dependencies ' +
@@ -60,6 +68,24 @@ export const selfUpdateCommand = {
60
68
  const currentVersion = readCurrentVersion();
61
69
  const installedAtPrefix = existsSync(installedPkgJson);
62
70
  console.log(`Current ${PACKAGE_NAME}: v${currentVersion ?? '(unknown)'}`);
71
+ // REFUSE, do not warn-and-continue, when something else owns this install.
72
+ //
73
+ // Everything below writes the user-global XDG prefix. Under a `.deb`,
74
+ // `.rpm` or Flatpak that produces a SECOND copy which shadows the one the
75
+ // package manager tracks — so the next `apt upgrade` reverts it, or the two
76
+ // diverge, and the user's report is "I updated and nothing changed". A
77
+ // clean refusal naming the right command is strictly more useful than a
78
+ // successful write to the wrong place; #1064 is the same lesson one layer
79
+ // down, where the write succeeded and PATH still resolved the old binary.
80
+ const provenance = classifyInstall({ selfDir: readSelfDir(), xdgPrefix: layout.prefix });
81
+ if (provenance.managedElsewhere) {
82
+ console.error(`\ngjsify self-update: this gjsify is managed by something else — ${provenance.evidence}.\n` +
83
+ `Updating it from here would write ${layout.prefix} and leave you running two installs.\n\n` +
84
+ `Update with: ${provenance.updateWith}\n`);
85
+ // `return process.exit()` — a bare `process.exit()` is DEFERRED under
86
+ // GJS and the handler would run on into the install it just refused.
87
+ return process.exit(1);
88
+ }
63
89
  if (!installedAtPrefix) {
64
90
  console.warn(`\nWarning: no @gjsify/cli install found under ${layout.prefix}.\n` +
65
91
  `self-update only manages installs created by install.mjs or \`gjsify install -g\`.\n` +
@@ -157,6 +183,13 @@ export const selfUpdateCommand = {
157
183
  if (pullDeps) {
158
184
  await installGjsEnginePackages(layout.prefix, target, { verbose: false });
159
185
  }
186
+ // Same ordering reason as `install -g`: before the LINK, whose launchers bake
187
+ // the resolved prebuild directories into their env.
188
+ if (args.prune !== false) {
189
+ pruneAfterInstall(layout.prefix, resolveHostPlatform({ env: {} }), {
190
+ hint: 'gjsify prune -g --dry-run',
191
+ });
192
+ }
160
193
  const linked = linkGlobalBins([PACKAGE_NAME], layout);
161
194
  if (linked.length === 0) {
162
195
  console.warn('self-update: install completed but no bins were linked — package.json may be missing a `bin` field.');
@@ -246,6 +279,28 @@ export function verifyPathResolution(opts) {
246
279
  * `import.meta.url` (the bundle file under GJS, or `lib/index.js` under
247
280
  * Node) until it finds a package.json with `name === '@gjsify/cli'`.
248
281
  */
282
+ /**
283
+ * The directory of the running CLI's own package — the input the provenance
284
+ * question is actually about.
285
+ *
286
+ * The same upward walk `readCurrentVersion()` does, and for the same reason: it
287
+ * works from `lib/index.js` under Node and from the published `dist/cli.gjs.mjs`
288
+ * bundle under GJS. Honours the same `GJSIFY_CLI_PACKAGE_JSON` escape hatch, so a
289
+ * test can place a synthetic install anywhere without a real one.
290
+ */
291
+ function readSelfDir() {
292
+ const override = process.env.GJSIFY_CLI_PACKAGE_JSON;
293
+ if (override)
294
+ return dirname(resolve(override));
295
+ const here = fileURLToPath(import.meta.url);
296
+ let dir = dirname(resolve(here));
297
+ for (let i = 0; i < 8 && dir !== dirname(dir); i++) {
298
+ if (existsSync(join(dir, 'package.json')))
299
+ return dir;
300
+ dir = dirname(dir);
301
+ }
302
+ return dirname(resolve(here));
303
+ }
249
304
  function readCurrentVersion() {
250
305
  try {
251
306
  // Escape hatch for tests: point GJSIFY_CLI_PACKAGE_JSON at a synthetic
@@ -0,0 +1,11 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface ShipOptions {
3
+ target?: string[];
4
+ out?: string;
5
+ stage: boolean;
6
+ 'skip-build': boolean;
7
+ arch?: string;
8
+ verbose: boolean;
9
+ }
10
+ export declare const shipCommand: Command<unknown, ShipOptions>;
11
+ export {};
@@ -0,0 +1,217 @@
1
+ // `gjsify ship` — turn a built application into something a stranger can
2
+ // install (ADR 0024).
3
+ //
4
+ // One payload, produced once, then wrapped per format. The command itself is
5
+ // thin on purpose: every decision worth testing lives in `utils/ship/*` as a
6
+ // pure function, and what is left here is filesystem work and printing.
7
+ //
8
+ // Scope today is Linux and `--app gjs`: no runtime is bundled, because GJS and
9
+ // GTK come from the distribution and shipping ~100 MiB of them would be cargo
10
+ // cult (ADR 0024 § 4). macOS and Windows layouts, and Flatpak as a target under
11
+ // this command, are the later stages of the same ADR.
12
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { basename, join, relative, resolve } from 'node:path';
14
+ import { Config } from '../config.js';
15
+ import { renderDesktopEntry, renderMetainfoApp, renderMetainfoCli, validateAppMetadata, } from '../utils/app-metadata.js';
16
+ import { readPackageJson } from '../utils/pkg-json.js';
17
+ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
18
+ import { buildDeb } from '../utils/ship/deb.js';
19
+ import { deriveDepends, warnAboutGjsFloor } from '../utils/ship/depends.js';
20
+ import { discoverPayload } from '../utils/ship/discover.js';
21
+ import { resolveFormats, FORMAT_IDS } from '../utils/ship/formats.js';
22
+ import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
23
+ import { buildTimestamp, isArchIndependent } from '../utils/ship/payload.js';
24
+ import { planOverlay, planStage } from '../utils/ship/plan.js';
25
+ import { renderLauncher } from '../utils/ship/launcher.js';
26
+ import { buildRpm } from '../utils/ship/rpm.js';
27
+ import { declaredBundlePath, resolveShipSettings } from '../utils/ship/settings.js';
28
+ import { readStage, writeStage } from '../utils/ship/stage-writer.js';
29
+ const LOG = '[gjsify ship]';
30
+ export const shipCommand = {
31
+ command: 'ship',
32
+ description: `Build installable artifacts (${FORMAT_IDS.join(', ')}) from the current project.`,
33
+ builder: (yargs) => yargs
34
+ .option('target', {
35
+ type: 'string',
36
+ array: true,
37
+ description: `Formats to build, comma-separated or repeated. Default: ${FORMAT_IDS.join(',')}.`,
38
+ })
39
+ .option('out', {
40
+ type: 'string',
41
+ normalize: true,
42
+ description: 'Output root, relative to the project. Default: `gjsify.ship.outDir`, else `ship`.',
43
+ })
44
+ .option('stage', {
45
+ type: 'boolean',
46
+ default: false,
47
+ description: 'Produce the staged payload and stop, without packing anything.',
48
+ })
49
+ .option('skip-build', {
50
+ type: 'boolean',
51
+ default: false,
52
+ description: "Package what is already built instead of running the project's `build` script.",
53
+ })
54
+ .option('arch', {
55
+ type: 'string',
56
+ description: 'Target architecture in `process.arch` spelling. Default: this host.',
57
+ })
58
+ .option('verbose', {
59
+ type: 'boolean',
60
+ default: false,
61
+ description: 'Print each staged file and the GI namespaces the bundle imports.',
62
+ }),
63
+ handler: async (args) => {
64
+ const projectDir = process.cwd();
65
+ const config = new Config();
66
+ // No `.catch(() => ({}))` here, unlike the flatpak commands: a config
67
+ // file that fails to load would silently become "no config", and the
68
+ // result is a shipped artifact carrying default metadata the author
69
+ // never chose. An unreadable config is an error worth printing.
70
+ const configData = await config.forCommand(projectDir);
71
+ const ship = configData.ship ?? {};
72
+ const flatpak = configData.flatpak ?? {};
73
+ assertShippableTarget(configData.app);
74
+ const pkg = (readPackageJson(join(projectDir, 'package.json')) ?? {});
75
+ // Resolved before anything is built or written: a typo'd `--target`
76
+ // should not cost a build and leave a half-written `ship/stage/`.
77
+ const formats = resolveFormats(args.target ?? ship.targets ?? FORMAT_IDS);
78
+ if (!args['skip-build'])
79
+ await runProjectBuild(projectDir);
80
+ const discovered = discoverPayload({
81
+ projectDir,
82
+ pkg,
83
+ ship,
84
+ flatpakIcon: flatpak.icon,
85
+ declaredBundle: declaredBundlePath(pkg, ship),
86
+ });
87
+ const { settings, metadata, warnings } = resolveShipSettings({
88
+ projectDir,
89
+ pkg,
90
+ ship,
91
+ flatpak,
92
+ cli: { outDir: args.out, arch: args.arch },
93
+ discovered,
94
+ });
95
+ for (const warning of warnings)
96
+ console.warn(`${LOG} ${warning}`);
97
+ const mtime = buildTimestamp(settings.bundlePath);
98
+ const metadataInputs = {
99
+ appId: settings.appId,
100
+ name: settings.name,
101
+ command: settings.binaryName,
102
+ kind: settings.kind,
103
+ metadata,
104
+ configKey: 'gjsify.ship',
105
+ copyrightYear: new Date(mtime * 1000).getUTCFullYear(),
106
+ };
107
+ // Warn rather than fail: an incomplete AppStream component still
108
+ // installs and still runs. It is app STORES that will reject it, and
109
+ // that is a different day's problem than getting the package built.
110
+ for (const missing of validateAppMetadata(metadataInputs)) {
111
+ console.warn(`${LOG} ${missing.field} is not set — ${missing.hint}`);
112
+ }
113
+ const stageInputs = {
114
+ bundleFiles: discovered.bundleFiles,
115
+ launcher: renderLauncher(settings, basename(settings.bundlePath)),
116
+ metainfo: settings.kind === 'app' ? renderMetainfoApp(metadataInputs) : renderMetainfoCli(metadataInputs),
117
+ desktopEntry: settings.kind === 'app' ? renderDesktopEntry(metadataInputs) : undefined,
118
+ licenseText: settings.licenseFile === undefined ? undefined : readFileSync(settings.licenseFile, 'utf-8'),
119
+ };
120
+ const outRoot = resolve(projectDir, settings.outDir);
121
+ const stageDir = join(outRoot, 'stage');
122
+ const staged = planStage(settings, stageInputs);
123
+ writeStage(stageDir, staged);
124
+ console.log(`${LOG} staged ${staged.length} file(s) in ${relative(projectDir, stageDir)}/`);
125
+ if (args.verbose)
126
+ for (const file of staged)
127
+ console.log(`${LOG} ${file.path}`);
128
+ if (args.stage)
129
+ return;
130
+ const namespaces = scanGiNamespaces(readFileSync(settings.bundlePath, 'utf-8'));
131
+ if (args.verbose)
132
+ console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
133
+ const artifacts = [];
134
+ for (const format of formats) {
135
+ artifacts.push(await packOne({ format, settings, stageInputs, staged, stageDir, outRoot, namespaces, mtime }));
136
+ }
137
+ for (const artifact of artifacts) {
138
+ console.log(`${LOG} ${artifact.format}: ${relative(projectDir, artifact.path)} (${artifact.size} bytes)`);
139
+ }
140
+ },
141
+ };
142
+ async function packOne(input) {
143
+ const { format, settings, stageDir, outRoot, mtime } = input;
144
+ const overlay = planOverlay(settings, format, input.stageInputs);
145
+ const overlayDir = join(outRoot, 'overlay', format.id);
146
+ writeStage(overlayDir, overlay);
147
+ const payload = readStage([stageDir, overlayDir], [...input.staged, ...overlay]);
148
+ const depends = deriveDepends(format.id, {
149
+ namespaces: input.namespaces,
150
+ kind: settings.kind,
151
+ hasSchemas: settings.schemaFiles.length > 0,
152
+ extra: settings.extraDepends[format.id],
153
+ typelibPackages: settings.typelibPackages,
154
+ minGjsVersion: settings.minGjsVersion,
155
+ });
156
+ for (const warning of warnAboutGjsFloor(format.id, settings.minGjsVersion))
157
+ console.warn(`${LOG} ${warning}`);
158
+ const archLabel = format.archName(settings.arch, isArchIndependent(payload));
159
+ const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
160
+ const bytes = format.id === 'deb' ? await buildDeb(common) : await buildRpm(common);
161
+ const outDir = join(outRoot, 'out');
162
+ mkdirSync(outDir, { recursive: true });
163
+ const target = join(outDir, format.fileName(settings, archLabel));
164
+ writeFileSync(target, bytes);
165
+ return { format: format.id, path: target, size: bytes.byteLength };
166
+ }
167
+ /**
168
+ * Refuse a build target this command cannot package correctly.
169
+ *
170
+ * The launcher execs `gjs -m <bundle>`, so a `--app node` bundle would produce
171
+ * a package that installs and then fails at startup — and ADR 0024 § 4 says
172
+ * that case needs a BUNDLED Node, whose delivery (fetched at ship time versus a
173
+ * platform package) is still an open decision. Better to say so than to ship
174
+ * the broken half of it.
175
+ *
176
+ * An undeclared target is the common case for a GJS app and is allowed: `app`
177
+ * otherwise defaults to the HOST runtime, which would refuse a perfectly good
178
+ * GJS project merely for running this command under Node.
179
+ */
180
+ function assertShippableTarget(app) {
181
+ if (app === undefined || app === 'gjs')
182
+ return;
183
+ throw new Error(`gjsify ship: this project declares \`gjsify.app: "${app}"\`, and only \`gjs\` can be packaged today. ` +
184
+ 'The launcher execs `gjs -m <bundle>`, so any other target would install and then fail to start. ' +
185
+ 'ADR 0024 § 4 has the runtime-per-OS table and what is still open for the other targets.');
186
+ }
187
+ /**
188
+ * Run the project's own `build` script, as a CHILD process.
189
+ *
190
+ * NOT dispatched in-process, even though `cli-app.ts` exists to allow exactly
191
+ * that: `gjsify run`'s script path ends in `return process.exit(code)` on every
192
+ * branch — deliberately, because under GJS a bare `process.exit` falls through
193
+ * and it needs exactly one exit scheduled. Calling it from here would end the
194
+ * process after the build and never reach the packing, and neither the types
195
+ * nor a `--skip-build` test would have caught it.
196
+ *
197
+ * `completion: 'return'` because this handler returns afterwards; on GJS that
198
+ * selects the blocking path rather than arming a main loop only an exit quits.
199
+ */
200
+ async function runProjectBuild(projectDir) {
201
+ const pkg = readPackageJson(join(projectDir, 'package.json'));
202
+ if (typeof pkg?.scripts?.build !== 'string') {
203
+ throw new Error('gjsify ship: this project has no `build` script to run. Add one, or pass --skip-build to ' +
204
+ 'package what is already built.');
205
+ }
206
+ console.log(`${LOG} running the project's build script`);
207
+ const result = await spawnToCompletion('gjsify', ['run', 'build'], {
208
+ completion: 'return',
209
+ cwd: projectDir,
210
+ stdio: 'inherit',
211
+ notFound: () => new Error('gjsify ship: `gjsify` is not on PATH, so the project build cannot be run. Build the project ' +
212
+ 'yourself and pass --skip-build.'),
213
+ });
214
+ if (result.code !== 0) {
215
+ throw new Error(`gjsify ship: the project's build script failed${describeExit(result)}.`);
216
+ }
217
+ }
package/lib/config.d.ts CHANGED
@@ -24,5 +24,17 @@ export declare class Config {
24
24
  globals?: string;
25
25
  excludeGlobals?: string[];
26
26
  }>;
27
+ /**
28
+ * The config for a command that is NOT a build — `gjsify flatpak …`,
29
+ * `gjsify ship …`.
30
+ *
31
+ * They all want exactly what the loader already produces: the merged
32
+ * `package.json#gjsify` + config-file object, with none of `forBuild`'s
33
+ * build-specific defaulting. Reaching it through `forBuild({} as never)`
34
+ * — five copies of that line before this method existed — lies to the type
35
+ * system and drags a tsconfig read and bundler defaults into a command
36
+ * that has no bundle.
37
+ */
38
+ forCommand(searchFrom?: string): Promise<ConfigData>;
27
39
  forBuild(cliArgs: ArgumentsCamelCase<CliBuildOptions>): Promise<ConfigData>;
28
40
  }