@gjsify/cli 0.45.0 → 0.47.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 (37) hide show
  1. package/dist/cli.gjs.mjs +144 -135
  2. package/lib/commands/onboard.js +11 -0
  3. package/lib/commands/publish.d.ts +41 -0
  4. package/lib/commands/publish.js +222 -2
  5. package/lib/commands/ship.js +89 -76
  6. package/lib/commands/upgrade.d.ts +23 -0
  7. package/lib/commands/upgrade.js +95 -12
  8. package/lib/templates/install.mjs.tmpl +2 -2
  9. package/lib/types/config-data.d.ts +64 -0
  10. package/lib/utils/gi-search-path.js +11 -3
  11. package/lib/utils/publish-readback.d.ts +132 -0
  12. package/lib/utils/publish-readback.js +273 -0
  13. package/lib/utils/ship/discover.js +85 -0
  14. package/lib/utils/ship/formats.d.ts +23 -0
  15. package/lib/utils/ship/formats.js +44 -0
  16. package/lib/utils/ship/launcher.js +57 -0
  17. package/lib/utils/ship/layout.d.ts +64 -2
  18. package/lib/utils/ship/layout.js +169 -12
  19. package/lib/utils/ship/msi.js +24 -7
  20. package/lib/utils/ship/payload.js +22 -3
  21. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  22. package/lib/utils/ship/pe-launcher.js +663 -0
  23. package/lib/utils/ship/plan.js +35 -0
  24. package/lib/utils/ship/plist.d.ts +41 -1
  25. package/lib/utils/ship/plist.js +59 -1
  26. package/lib/utils/ship/rpm.js +7 -0
  27. package/lib/utils/ship/settings.d.ts +76 -5
  28. package/lib/utils/ship/settings.js +89 -8
  29. package/lib/utils/ship/share-dirs.d.ts +30 -0
  30. package/lib/utils/ship/share-dirs.js +30 -0
  31. package/lib/utils/ship/signing.d.ts +122 -6
  32. package/lib/utils/ship/signing.js +256 -67
  33. package/lib/utils/ship/stage-writer.js +20 -1
  34. package/lib/utils/ship/types.d.ts +34 -2
  35. package/lib/utils/system-gi.d.ts +20 -5
  36. package/lib/utils/system-gi.js +36 -13
  37. package/package.json +19 -19
@@ -35,19 +35,20 @@ import { discoverPayload } from '../utils/ship/discover.js';
35
35
  import { buildDmgImage, dmgVolumeDir, dmgVolumeName } from '../utils/ship/dmg.js';
36
36
  import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
37
37
  import { localizeMetadata } from '../utils/ship/localize-metadata.js';
38
- import { assertHostCanFinish, assertToolsInstalled, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
38
+ import { assertHostCanFinish, assertToolsInstalled, canCarryTicket, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
39
39
  import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, LAYOUT_NAMES, } from '../utils/ship/layout.js';
40
40
  import { isNodeRuntimeTarget, resolveNodeRuntime } from '../utils/ship/node-runtime.js';
41
+ import { isUnder, SHARE } from '../utils/ship/share-dirs.js';
41
42
  import { compileSchemasForStage } from '../utils/ship/schemas.js';
42
43
  import { buildMsi, MSI_PAYLOAD_DIR } from '../utils/ship/msi.js';
43
44
  import { buildZip, zipEntriesFromPayload } from '../utils/ship/zip.js';
44
45
  import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
45
46
  import { assertLauncherMatchesInterpreter, assertPayloadMatchesArch, buildTimestamp, isArchIndependent, linuxInstallDependent, readPayloadFacts, } from '../utils/ship/payload.js';
46
47
  import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
47
- import { assertHostCanSign, notarizeArtifact, resolveNotaryPlan, resolveSignPlan, signPayload, } from '../utils/ship/signing.js';
48
+ import { assertHostCanSign, notarizeArtifact, stapleArtifact, resolveNotaryPlan, resolveSignPlan, signPayload, } from '../utils/ship/signing.js';
48
49
  import { renderLauncher } from '../utils/ship/launcher.js';
49
50
  import { buildRpm } from '../utils/ship/rpm.js';
50
- import { declaredBundlePath, resolveShipSettings } from '../utils/ship/settings.js';
51
+ import { declaredBundlePath, resolveShipApp, resolveShipSettings, } from '../utils/ship/settings.js';
51
52
  import { assertExpectedTarget, assertFormatsStaged, formatTarget, overlayFiles, readStageManifest, resolveStageMtime, warnOnToolSkew, writeStageManifest, STAGE_MANIFEST_FILE, } from '../utils/ship/stage-manifest.js';
52
53
  import { directorySize, readStage, writePayload, writeStage } from '../utils/ship/stage-writer.js';
53
54
  const LOG = '[gjsify ship]';
@@ -177,13 +178,21 @@ async function assemble(args) {
177
178
  const configData = await config.forCommand(projectDir);
178
179
  const ship = configData.ship ?? {};
179
180
  const flatpak = configData.flatpak ?? {};
180
- assertShippableTarget(configData.app);
181
- // Resolved HERE and not from `settings`, because the format list is decided
182
- // before anything is built and `resolveShipSettings` runs after. The
183
- // defaulting rule is the one `resolveShipSettings` states and must stay the
184
- // same one: an undeclared target means `gjs`, never the host runtime this
185
- // command happens to run under.
186
- const interpreter = configData.app === 'node' ? 'node' : 'gjs';
181
+ // THE ONE RESOLUTION, and it runs HERE because the format list is decided
182
+ // before anything is built while `resolveShipSettings` runs after. Its result
183
+ // is threaded into the settings rather than re-derived there: two defaulting
184
+ // rules for one question is how the format list and the emitted `Depends:`
185
+ // came to answer for different targets (#1486). Why the runtime is per target
186
+ // at all, and why an absent override means the project field: `resolveShipApp`.
187
+ const resolvedApp = resolveShipApp({ project: configData.app, perTarget: ship.app, layoutOs: layout.os });
188
+ const interpreter = resolvedApp.app;
189
+ // SAID, not swallowed, like every other narrowing in this function. An
190
+ // override that changed the launcher, the dependency and the format list
191
+ // without a line of output is one nobody can tell from the project default.
192
+ if (resolvedApp.overridden) {
193
+ console.log(`${LOG} runtime for ${layout.name}: ${interpreter} — \`${resolvedApp.key}\` overrides ` +
194
+ `\`gjsify.app${configData.app === undefined ? '' : `: "${configData.app}"`}\`.`);
195
+ }
187
196
  const pkg = (readPackageJson(join(projectDir, 'package.json')) ?? {});
188
197
  // Resolved before anything is built or written: a typo'd `--target`
189
198
  // should not cost a build and leave a half-written `ship/stage/`.
@@ -233,8 +242,11 @@ async function assemble(args) {
233
242
  // and the two need different next steps.
234
243
  if (unusable.length > 0) {
235
244
  console.log(`${LOG} ${unusable.map((format) => format.id).join(', ')} wrap the ${layout.name} layout but ` +
236
- `cannot run \`gjsify.app: "${interpreter}"\` — ${unusable[0]?.interpreterGap ?? ''}. ` +
237
- 'Assembling the layout only; pass --target to be told the same thing as an error.');
245
+ `cannot run \`${interpreter}\`, which \`${resolvedApp.key}\` resolves to — ` +
246
+ `${unusable[0]?.interpreterGap ?? ''}. Set \`gjsify.ship.app.${layout.os}\` to ` +
247
+ `"${unusable[0]?.interpreters.join('" / "')}" to ship this OS on that runtime; it leaves every ` +
248
+ 'other target on `gjsify.app`. Assembling the layout only; pass --target to be told the same ' +
249
+ 'thing as an error.');
238
250
  }
239
251
  }
240
252
  if (!args.stage)
@@ -282,7 +294,11 @@ async function assemble(args) {
282
294
  flatpak,
283
295
  cli: { outDir: args.out, arch: args.arch, layoutOs: layout.os },
284
296
  discovered,
285
- app: configData.app,
297
+ // The RESOLVED runtime, never `configData.app`. Everything downstream —
298
+ // the launcher, `deriveDepends`, the carried runtime, the stage manifest —
299
+ // reads `settings.app`, so this is the one hand-off that decides whether
300
+ // those generators speak about this target or about the project.
301
+ app: interpreter,
286
302
  });
287
303
  for (const warning of warnings)
288
304
  console.warn(`${LOG} ${warning}`);
@@ -420,6 +436,17 @@ async function assemble(args) {
420
436
  if (runtime.launcher.interpreter === undefined && layout.runtimeGap !== undefined) {
421
437
  console.warn(`${LOG} ${layout.runtimeGap}`);
422
438
  }
439
+ // The same shape one axis over (ADR 0038). `share/fonts` is NOT in the list
440
+ // above — it needs no install step on any layout, so `linuxInstallDependent`
441
+ // classifies it as portable — but "the directory is reached" and "this OS's
442
+ // Pango reads that directory at all" are different questions, and only the
443
+ // first is settled by the payload. CONDITIONAL on the payload actually
444
+ // carrying a face, like `runtimeGap` is on the missing interpreter: telling a
445
+ // project that ships no font which fontconfig its Mac would consult is noise,
446
+ // and noise is what trains people to stop reading these lines.
447
+ if (layout.fontGap !== undefined && planned.some((file) => isUnder(file.path, SHARE.fonts))) {
448
+ console.warn(`${LOG} ${layout.fontGap}`);
449
+ }
423
450
  // THE OTHER AXIS, warned about HERE and refused in `packOne`, and the split
424
451
  // is the same one `runtimeGap` and `assertFormatCanRunInterpreter` already
425
452
  // draw. A stage is a build intermediate, and assembling one for an
@@ -517,7 +544,11 @@ async function assemble(args) {
517
544
  // none; or the configured `targets` all wrap another one, which the branch
518
545
  // above has already SAID in its own words rather than repeating here.
519
546
  const none = unusable.length > 0
520
- ? `(none — ${unusable.map((format) => format.id).join(' and ')} need \`gjsify.app: "node"\`)`
547
+ ? // The key that fixes it, which used to be `gjsify.app` and is now
548
+ // this target's own: naming the project field here is how a macOS
549
+ // stage came to move the Linux `Depends:` (#1486).
550
+ `(none — ${unusable.map((format) => format.id).join(' and ')} need ` +
551
+ `\`gjsify.ship.app.${layout.os}: "node"\`)`
521
552
  : formatIdsFor(layout.os).length === 0
522
553
  ? `(none — no format wraps the ${layout.name} layout)`
523
554
  : '(none asked for)';
@@ -842,6 +873,13 @@ async function packOne(input) {
842
873
  identity: input.sign.identity,
843
874
  signer: input.sign.signer,
844
875
  workDir: join(outRoot, 'signed', format.id),
876
+ // What the darwin signer SEALS, from the layout that named it —
877
+ // `<App>.app` there, `''` on the two layouts with no bundle.
878
+ bundleRoot: layout.root(settings),
879
+ // OUTSIDE `signed/<id>/`: that directory is listed and compared
880
+ // with the payload, so an entitlements plist written into it would
881
+ // read as a file `codesign` added.
882
+ entitlementsPath: join(outRoot, 'signed', `${format.id}.entitlements.plist`),
845
883
  verbose: input.verbose,
846
884
  log: (line) => console.log(`${LOG} ${line}`),
847
885
  })
@@ -1030,6 +1068,27 @@ async function packOne(input) {
1030
1068
  verbose: input.verbose,
1031
1069
  log: (line) => console.log(`${LOG} ${line}`),
1032
1070
  });
1071
+ // AND THEN THE TICKET, on the artifacts that can hold one. Submitting
1072
+ // and stapling are two questions with two answers, and the `.zip` is
1073
+ // where they come apart: the notary service accepts it and `stapler`
1074
+ // refuses it. Without the ticket the artifact still installs — every
1075
+ // launch asks Gatekeeper online instead, and fails closed on a machine
1076
+ // with no network, which is the failure a user reads as "the app is
1077
+ // damaged".
1078
+ if (canCarryTicket(format.id)) {
1079
+ await stapleArtifact({
1080
+ notary: input.notarize.notary,
1081
+ artifact: target,
1082
+ verbose: input.verbose,
1083
+ log: (line) => console.log(`${LOG} ${line}`),
1084
+ });
1085
+ }
1086
+ else {
1087
+ console.warn(`${LOG} ${format.id} is notarised and NOT stapled: \`stapler\` takes a UDIF image, a ` +
1088
+ "signed flat package or a code-signed bundle, and an archive is none of them. Apple's " +
1089
+ 'remedy is to staple each item that went INTO the archive and re-create it — for this ' +
1090
+ 'artifact that is the `.app`, which `--target macos-app` writes beside it.');
1091
+ }
1033
1092
  }
1034
1093
  }
1035
1094
  return { format: format.id, path: target, size };
@@ -1078,8 +1137,10 @@ function assertPackable(formats, layout, chosenBy, unusable = []) {
1078
1137
  if (unusable.length > 0) {
1079
1138
  throw new Error(`gjsify ship: ${unusable.map((format) => format.id).join(' and ')} wrap the ${layout.name} ` +
1080
1139
  `layout, and neither can run this project — ${unusable[0]?.interpreterGap ?? ''}.\n` +
1081
- ` \`gjsify ship ${layout.name} --stage\` assembles the tree anyway; packing it is what ` +
1082
- 'the milestone that stages an interpreter is for (#1354 M2b).');
1140
+ ` Set \`gjsify.ship.app.${layout.os}\` to ` +
1141
+ `"${unusable[0]?.interpreters.join('" / "')}" — it overrides \`gjsify.app\` for THIS target and\n` +
1142
+ ' leaves every other one alone, which is what lets one project be GJS on Linux and Node here.\n' +
1143
+ ` \`gjsify ship ${layout.name} --stage\` assembles the tree either way.`);
1083
1144
  }
1084
1145
  // A THIRD emptiness, and the one the last throw in this function used to
1085
1146
  // mis-diagnose. A layout that HAS formats still reaches here with none
@@ -1136,66 +1197,13 @@ function assertPackable(formats, layout, chosenBy, unusable = []) {
1136
1197
  ' Every layout this gjsify knows has one (ADR 0024 stages 2-5), so this is a layout added ' +
1137
1198
  'without a `FORMATS` row.');
1138
1199
  }
1139
- /**
1140
- * Refuse a build target this command cannot package correctly.
1141
- *
1142
- * `gjs` and `node` are packageable: `renderLauncher` execs the one
1143
- * `settings.app` names and `deriveDepends` declares the same one — `gjs >= …`,
1144
- * or `nodejs (>= 24)` / `nodejs(engine) >= 24`. Until #1354's M0 this said "only
1145
- * `gjs` can be packaged today", which was true: the launcher execed gjs
1146
- * unconditionally, so a `--app node` package would have installed and died at
1147
- * startup.
1148
- *
1149
- * `browser` and `nativescript` stay refused, and not for want of a launcher
1150
- * line: a browser bundle has no process to start, and NativeScript ships as an
1151
- * APK/IPA through a different pipeline entirely (ADR 0024 § 4).
1152
- *
1153
- * DELIBERATELY NOT PER LAYOUT, and the first cut of the layout axis got this
1154
- * exactly backwards. Reading ADR § 4's runtime table as a per-layout `app`
1155
- * requirement refused `gjsify.app: "gjs"` for the macOS layout — i.e. refused the
1156
- * entire audience of this command from assembling a `.app` — while a project with
1157
- * no `gjsify.app` key sailed through and staged a launcher naming `node` in front
1158
- * of a GJS bundle. Both halves came from the same mistake: § 4 derives the runtime
1159
- * a SHIPPED ARTIFACT carries, which is `Layout.shippedRuntime` and is printed as
1160
- * `Layout.runtimeGap`. What the launcher execs is what the PAYLOAD was built for,
1161
- * and that is `settings.app` on every layout.
1162
- *
1163
- * An undeclared target is the common case for a GJS app and is allowed. It must
1164
- * NOT be resolved through `Config.forBuild`, whose fallback is the HOST runtime
1165
- * — that would refuse, or worse silently re-target, a perfectly good GJS project
1166
- * merely for running this command under Node.
1167
- *
1168
- * WHY THIS REFUSES AND `Layout.runtimeGap` ONLY WARNS, since the two describe
1169
- * neighbouring predicaments and get opposite treatment. A target this command
1170
- * cannot package would leave here as an ARTIFACT — a `.deb` a stranger installs,
1171
- * which then fails at startup, with no gate between here and their machine. The
1172
- * `runtimeGap` warns about something that is not an artifact: it is printed over a
1173
- * staged tree, and only when that tree carries no interpreter of its own.
1174
- *
1175
- * This paragraph used to end "the day a Windows format exists, that warning has to
1176
- * become this refusal". #1354 M3 is that day, and the refusal it became is
1177
- * {@link assertFormatCanRunInterpreter} — per FORMAT rather than per layout,
1178
- * because what a runtime can execute is a property of the container and not of the
1179
- * tree it wraps. This function stayed exactly as wide as it was.
1180
- *
1181
- * Phase one only. Phase two does not re-read `gjsify.app` — it has no project —
1182
- * but it is not unchecked either: `assertLauncherMatchesInterpreter` compares
1183
- * the staged `bin/<name>`'s own `exec` line against the dependency about to be
1184
- * written.
1185
- */
1186
- function assertShippableTarget(app) {
1187
- if (app === undefined || app === 'gjs' || app === 'node')
1188
- return;
1189
- throw new Error(`gjsify ship: this project declares \`gjsify.app: "${app}"\`, and only \`gjs\` and \`node\` can be ` +
1190
- 'packaged. A browser bundle has no process to launch, and a NativeScript app ships as an APK/IPA ' +
1191
- 'through a different pipeline. ADR 0024 § 4 has the runtime-per-OS table.');
1192
- }
1193
1200
  /**
1194
1201
  * Refuse a format whose runtime cannot provide the interpreter the launcher execs.
1195
1202
  *
1196
1203
  * THE HOLE THIS CLOSES, and it was opened by the commit that made `--app node`
1197
- * packageable. `assertShippableTarget` used to refuse `app: 'node'` outright, so
1198
- * no format ever saw one. Lifting that made deb and rpm correct and left Flatpak
1204
+ * packageable. The shippable-target refusal — `resolveShipApp` in
1205
+ * `utils/ship/settings.ts`, a free function in this file until #1486 — used to
1206
+ * refuse `app: 'node'` outright, so no format ever saw one. Lifting that made deb and rpm correct and left Flatpak
1199
1207
  * silently wrong: measured at exit 0, a `--target flatpak --stage` with
1200
1208
  * `app: 'node'` emitted a manifest with `runtime: org.gnome.Platform`,
1201
1209
  * `runtime-version: 50`, no `sdk-extensions` and no `append-path`, beside a
@@ -1213,14 +1221,19 @@ function assertShippableTarget(app) {
1213
1221
  function assertFormatCanRunInterpreter(format, app) {
1214
1222
  if (format.interpreters.includes(app))
1215
1223
  return;
1216
- throw new Error(`gjsify ship: this project is \`gjsify.app: "${app}"\`, and the ${format.id} runtime cannot run it — ` +
1217
- `it provides ${format.interpreters.join(', ')}.\n` +
1224
+ throw new Error(
1225
+ // The TARGET's runtime, not "this project is": since #1486 the two can
1226
+ // differ, and a message naming `gjsify.app` for a per-target answer sends
1227
+ // the reader to the key whose edit moves every OTHER target's `Depends:`.
1228
+ `gjsify ship: this ${format.layoutOs} target runs \`${app}\`, and the ${format.id} runtime cannot ` +
1229
+ `run it — it provides ${format.interpreters.join(', ')}.\n` +
1218
1230
  // The ROW's own sentence, not one written here. Hardcoded, this
1219
1231
  // paragraph told the first `.app` author about
1220
1232
  // `org.freedesktop.Sdk.Extension.node2x` and the GNOME runtime.
1221
1233
  ` ${format.interpreterGap}.\n` +
1222
- ' Build the formats that wrap this layout and can run it, or ship this app as ' +
1223
- `\`gjsify.app: "${format.interpreters.join('" / "')}"\`.`);
1234
+ ' Build the formats that wrap this layout and can run it, or set ' +
1235
+ `\`gjsify.ship.app.${format.layoutOs}: "${format.interpreters.join('" / "')}"\` ` +
1236
+ '(which overrides `gjsify.app` for this target alone).');
1224
1237
  }
1225
1238
  /**
1226
1239
  * Run the project's own `build` script, as a CHILD process.
@@ -1,3 +1,4 @@
1
+ import { type DependencyGroup } from '../utils/dep-aggregation.js';
1
2
  import type { Command } from '../types/index.js';
2
3
  interface UpgradeOptions {
3
4
  latest?: boolean;
@@ -8,10 +9,32 @@ interface UpgradeOptions {
8
9
  excludeWorkspace?: string;
9
10
  align?: boolean;
10
11
  check?: boolean;
12
+ exact?: boolean;
11
13
  dryRun?: boolean;
12
14
  cwd?: string;
13
15
  verbose?: boolean;
14
16
  yes?: boolean;
15
17
  }
16
18
  export declare const upgradeCommand: Command<unknown, UpgradeOptions>;
19
+ /**
20
+ * `--exact` half of the CI gate: does every matched dep carry a bare version?
21
+ *
22
+ * Consistency and exactness are different questions, and the first cannot answer the
23
+ * second: a repository where every manifest agrees on `^4.1.0` is perfectly consistent
24
+ * and still lets a minor release move a subpath under a lockfile-less install. That is
25
+ * the hazard ADR 0029 § Risks 1 names for `@girs/*`, whose `./vocabulary` export is what
26
+ * `@gjsify/gtk-host` consumes — so the pin has to be the whole version, and something
27
+ * has to say so on every PR.
28
+ *
29
+ * Returns the number of offending declarations so the caller can exit once for both arms.
30
+ */
31
+ export declare function reportInexactRanges(groups: readonly DependencyGroup[]): number;
32
+ /**
33
+ * The range a dependency will be WRITTEN at.
34
+ *
35
+ * Preview and write share this deliberately. They were two expressions for one decision
36
+ * for exactly one revision, and `--dry-run --exact` announced `^4.3.0` while the writer
37
+ * would have produced `4.3.0` — a tool describing a change it was not about to make.
38
+ */
39
+ export declare function targetRange(prefix: string, version: string, exact: boolean): string;
17
40
  export {};
@@ -79,6 +79,11 @@ export const upgradeCommand = {
79
79
  description: 'CI gate: exit non-zero when any dep is declared inconsistently across workspaces. Implies offline (no registry calls). Pairs with `--align` as the fix.',
80
80
  type: 'boolean',
81
81
  default: false,
82
+ })
83
+ .option('exact', {
84
+ description: 'Pin without a range operator. When writing, emits `1.2.3` instead of `^1.2.3`; with `--check`, fails on any matched dep that carries one. Pair with `--filter` — a repository-wide exactness check fails on every ordinary caret dep by design.',
85
+ type: 'boolean',
86
+ default: false,
82
87
  })
83
88
  .option('dry-run', {
84
89
  description: 'Print the upgrade plan without writing package.json files.',
@@ -118,7 +123,7 @@ export const upgradeCommand = {
118
123
  const groups = groupByDependency(decls);
119
124
  // --check: exit non-zero if any inconsistency.
120
125
  if (args.check) {
121
- return runCheckMode(groups);
126
+ return runCheckMode(groups, args.exact);
122
127
  }
123
128
  // --align: offline consistency-only, no registry calls.
124
129
  if (args.align) {
@@ -163,10 +168,10 @@ export const upgradeCommand = {
163
168
  if (args.dryRun) {
164
169
  const fileCount = uniqueLocations(selected).length;
165
170
  console.log(`[gjsify upgrade] --dry-run: would update ${selected.length} deps across ${fileCount} package.json file(s).`);
166
- printChangePlan(selected);
171
+ printChangePlan(selected, args.exact);
167
172
  return;
168
173
  }
169
- const files = applyToFiles(selected);
174
+ const files = applyToFiles(selected, args.exact);
170
175
  console.log(`✏️ updated ${selected.length} dep(s) across ${files} package.json file(s). Run \`gjsify install\` to apply.`);
171
176
  },
172
177
  };
@@ -281,10 +286,53 @@ function splitRange(range) {
281
286
  return { prefix, version: parsed?.version ?? null };
282
287
  }
283
288
  // ─── Modes: --check / --align ──────────────────────────────────────────
284
- function runCheckMode(groups) {
289
+ /**
290
+ * `--exact` half of the CI gate: does every matched dep carry a bare version?
291
+ *
292
+ * Consistency and exactness are different questions, and the first cannot answer the
293
+ * second: a repository where every manifest agrees on `^4.1.0` is perfectly consistent
294
+ * and still lets a minor release move a subpath under a lockfile-less install. That is
295
+ * the hazard ADR 0029 § Risks 1 names for `@girs/*`, whose `./vocabulary` export is what
296
+ * `@gjsify/gtk-host` consumes — so the pin has to be the whole version, and something
297
+ * has to say so on every PR.
298
+ *
299
+ * Returns the number of offending declarations so the caller can exit once for both arms.
300
+ */
301
+ export function reportInexactRanges(groups) {
302
+ const offenders = groups
303
+ .map((g) => ({ name: g.name, loose: g.occurrences.filter((o) => o.prefix !== '') }))
304
+ .filter((entry) => entry.loose.length > 0);
305
+ if (offenders.length === 0)
306
+ return 0;
307
+ const total = offenders.reduce((n, entry) => n + entry.loose.length, 0);
308
+ console.error(`gjsify upgrade --check --exact: FAIL. ${total} declaration(s) across ${offenders.length} dep(s) carry a range operator:\n`);
309
+ for (const entry of offenders) {
310
+ const byRange = new Map();
311
+ for (const occ of entry.loose) {
312
+ const list = byRange.get(occ.currentRange) ?? [];
313
+ list.push(occ.workspace);
314
+ byRange.set(occ.currentRange, list);
315
+ }
316
+ console.error(` ${ANSI.bold}${entry.name}${ANSI.reset}`);
317
+ for (const [range, holders] of byRange.entries()) {
318
+ const shown = holders.length > 6 ? `${holders.slice(0, 6).join(', ')} … +${holders.length - 6}` : holders.join(', ');
319
+ console.error(` ${range.padEnd(16)} — ${shown}`);
320
+ }
321
+ }
322
+ return total;
323
+ }
324
+ function runCheckMode(groups, exact = false) {
325
+ const inexact = exact ? reportInexactRanges(groups) : 0;
285
326
  const inconsistencies = findInconsistencies(groups);
327
+ if (inconsistencies.length === 0 && inexact > 0) {
328
+ console.error(`\nFix: run \`gjsify upgrade --align --exact\` (offline; drops the operator and keeps the version).`);
329
+ // `return`, not a bare call: under GJS the process does not halt here, and the
330
+ // consistency report below would run on top of the exactness one.
331
+ return process.exit(1);
332
+ }
286
333
  if (inconsistencies.length === 0) {
287
- console.log(`gjsify upgrade --check: OK. ${groups.length} dep(s) consistently declared across workspaces.`);
334
+ console.log(`gjsify upgrade --check: OK. ${groups.length} dep(s) consistently declared across workspaces` +
335
+ (exact ? `, every declaration pinned exactly.` : `.`));
288
336
  return;
289
337
  }
290
338
  console.error(`gjsify upgrade --check: FAIL. ${inconsistencies.length} dep(s) declared at inconsistent ranges:\n`);
@@ -334,13 +382,13 @@ function runAlignMode(groups, args) {
334
382
  console.log(`gjsify upgrade --align: aligning ${updates.length} inconsistent dep(s) to their highest declared version:\n`);
335
383
  for (const u of updates) {
336
384
  const newPrefix = u.occurrences[0]?.prefix ?? '^';
337
- console.log(` ${u.name.padEnd(28)} ranges: ${[...u.declaredRanges].join(', ')} → ${newPrefix}${u.latestVersion}`);
385
+ console.log(` ${u.name.padEnd(28)} ranges: ${[...u.declaredRanges].join(', ')} → ${targetRange(newPrefix, u.latestVersion, args.exact ?? false)}`);
338
386
  }
339
387
  if (args.dryRun) {
340
388
  console.log('\n[gjsify upgrade --align] --dry-run: no files changed.');
341
389
  return;
342
390
  }
343
- const files = applyToFiles(updates);
391
+ const files = applyToFiles(updates, args.exact);
344
392
  console.log(`\n✏️ updated ${updates.length} dep(s) across ${files} package.json file(s).`);
345
393
  }
346
394
  // ─── Registry resolution (group-aware) ─────────────────────────────────
@@ -491,10 +539,10 @@ function renderDeclaredCell(c) {
491
539
  return joined;
492
540
  return joined.slice(0, 29) + '…';
493
541
  }
494
- function printChangePlan(selected) {
542
+ function printChangePlan(selected, exact = false) {
495
543
  for (const u of selected) {
496
544
  for (const occ of u.occurrences) {
497
- console.log(` ${occ.workspace.padEnd(38)} ${u.name.padEnd(28)} ${occ.currentRange.padEnd(12)} → ${occ.prefix}${u.latestVersion}`);
545
+ console.log(` ${occ.workspace.padEnd(38)} ${u.name.padEnd(28)} ${occ.currentRange.padEnd(12)} → ${targetRange(occ.prefix, u.latestVersion, exact)}`);
498
546
  }
499
547
  }
500
548
  }
@@ -543,7 +591,17 @@ async function promptSelection(candidates) {
543
591
  }
544
592
  }
545
593
  // ─── Write-back across all affected workspaces ─────────────────────────
546
- function applyToFiles(updates) {
594
+ /**
595
+ * The range a dependency will be WRITTEN at.
596
+ *
597
+ * Preview and write share this deliberately. They were two expressions for one decision
598
+ * for exactly one revision, and `--dry-run --exact` announced `^4.3.0` while the writer
599
+ * would have produced `4.3.0` — a tool describing a change it was not about to make.
600
+ */
601
+ export function targetRange(prefix, version, exact) {
602
+ return exact ? version : `${prefix}${version}`;
603
+ }
604
+ function applyToFiles(updates, exact = false) {
547
605
  // Group by workspace.location so each package.json is read + written once.
548
606
  const byLocation = new Map();
549
607
  for (const u of updates) {
@@ -559,25 +617,50 @@ function applyToFiles(updates) {
559
617
  const raw = readFileSync(pkgJsonPath, 'utf-8');
560
618
  const pkg = JSON.parse(raw);
561
619
  let changed = false;
620
+ const changedNames = [];
562
621
  for (const g of groups) {
563
622
  const occ = g.occurrences[0];
564
623
  const map = pkg[occ.field];
565
624
  if (!map)
566
625
  continue;
567
- const next = `${occ.prefix}${g.latestVersion}`;
626
+ const next = targetRange(occ.prefix, g.latestVersion, exact);
568
627
  if (map[g.name] !== next) {
569
628
  map[g.name] = next;
570
629
  changed = true;
630
+ changedNames.push(g.name);
571
631
  }
572
632
  }
573
633
  if (changed) {
574
634
  const indent = detectIndent(raw);
575
- writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : ''), 'utf-8');
635
+ const rewritten = JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : '');
636
+ writeFileSync(pkgJsonPath, keepUntouchedLines(raw, rewritten, changedNames), 'utf-8');
576
637
  touched++;
577
638
  }
578
639
  }
579
640
  return touched;
580
641
  }
642
+ /**
643
+ * Take the re-serialised file, but keep every line that is not about a changed dep.
644
+ *
645
+ * A `JSON.parse` → `JSON.stringify` round trip rewrites the WHOLE file, and re-encoding
646
+ * is not identity: measured on this repository, pinning `@girs/*` decoded `\u2014` into
647
+ * an em dash inside three `description` fields nobody asked to touch, so a dependency
648
+ * bump arrived as a diff over unrelated prose. Semantically equal, and still noise a
649
+ * reviewer has to read past.
650
+ *
651
+ * Line-for-line only. Adding or removing a key changes the line count, and then the
652
+ * mapping is not sound — so that case falls back to the plain re-serialisation rather
653
+ * than guessing an alignment.
654
+ */
655
+ function keepUntouchedLines(raw, rewritten, changedNames) {
656
+ const before = raw.split('\n');
657
+ const after = rewritten.split('\n');
658
+ if (before.length !== after.length)
659
+ return rewritten;
660
+ return after
661
+ .map((line, i) => (changedNames.some((name) => line.includes(`"${name}"`)) ? line : before[i]))
662
+ .join('\n');
663
+ }
581
664
  function uniqueLocations(updates) {
582
665
  const set = new Set();
583
666
  for (const u of updates) {
@@ -18,8 +18,8 @@
18
18
  * GJSIFY_INSTALL_BOOTSTRAP_CACHE.
19
19
  */
20
20
 
21
- import GLib from 'gi://GLib';
22
- import Gio from 'gi://Gio';
21
+ import GLib from 'gi://GLib?version=2.0';
22
+ import Gio from 'gi://Gio?version=2.0';
23
23
  import system, { exit } from 'system';
24
24
 
25
25
  /**
@@ -430,6 +430,25 @@ export interface ConfigDataShip extends AppMetadata {
430
430
  release?: string;
431
431
  /** `Maintainer:` / `Packager:` as `Name <email>`. Default: `package.json#author`. */
432
432
  maintainer?: string;
433
+ /**
434
+ * The runtime a shipped artifact runs, PER TARGET — `gjsify.app` is the
435
+ * default and a key here overrides it for one layout.
436
+ *
437
+ * KEYED IN THE `process.platform` SPELLING, like {@link ShipSignOptions} and
438
+ * for the same reason: the value that resolves is chosen by the LAYOUT being
439
+ * assembled — `FormatDescriptor.layoutOs` and the stage manifest's `target.os`
440
+ * — never by the host doing the assembling. `macos` and `windows` are the
441
+ * `gjsify ship <os>` POSITIONAL's spelling, and both `gjsify ship` and
442
+ * `manifest-conformance`'s `ship` rule refuse them BY NAME, because a runtime
443
+ * under a key nothing reads is a target that silently keeps the project-wide
444
+ * answer.
445
+ *
446
+ * `resolveShipApp` (`utils/ship/settings.ts`) is the only reader of this key
447
+ * or of `gjsify.app`, and holds the rest: what one field could not answer,
448
+ * the measurement behind it, and why an absent key means `gjsify.app` and
449
+ * never `Layout.shippedRuntime`.
450
+ */
451
+ app?: ShipAppOptions;
433
452
  /**
434
453
  * Formats to build when `--target` is not given. Default `['deb', 'rpm']`.
435
454
  *
@@ -526,6 +545,30 @@ export interface ConfigDataShip extends AppMetadata {
526
545
  * produces a package that installs its translations and shows none of them.
527
546
  */
528
547
  localeDir?: string;
548
+ /**
549
+ * A font file, or a directory of them, the application SHIPS (ADR 0038).
550
+ * Default: `data/fonts` when it exists.
551
+ *
552
+ * Staged into `share/fonts/<appId>/`, which is where fontconfig looks: under a
553
+ * `/usr` prefix through the stock `fonts.conf`'s unconditional
554
+ * `<dir>/usr/share/fonts</dir>`, and under every other prefix through
555
+ * `<dir prefix="xdg">fonts</dir>` plus the `XDG_DATA_DIRS` the launcher already
556
+ * exports. The app names the FAMILY in its styles and needs no install-layout
557
+ * knowledge, exactly as it needs none for icons or schemas.
558
+ *
559
+ * This is what a branded application needs: on a GNOME host the family is
560
+ * usually installed anyway, and on macOS, on Windows and on any Linux that is
561
+ * not GNOME it is not — so without shipping it Pango silently substitutes a
562
+ * fallback and the app merely looks wrong, at exit 0.
563
+ *
564
+ * `.woff` / `.woff2` / `.eot` are REFUSED. Whether FreeType opens a web-font
565
+ * wrapper is a BUILD option of the FreeType the app runs against (brotli for
566
+ * woff2, zlib for woff), so it is a property of the host's or the bundle's
567
+ * closure rather than of the file — and a font that resolves on the developer's
568
+ * box and not in the shipped bundle is the exact silent substitution this key
569
+ * exists to prevent. Ship the desktop face.
570
+ */
571
+ fonts?: string;
529
572
  /** Extra payload entries: prefix-relative destination → project-relative source. */
530
573
  extraFiles?: Record<string, string>;
531
574
  /** Arguments the launcher appends before the user's own. */
@@ -547,6 +590,27 @@ export interface ConfigDataShip extends AppMetadata {
547
590
  */
548
591
  sign?: ShipSignOptions;
549
592
  }
593
+ /**
594
+ * `gjsify.ship.app` — the shipped runtime per OS, overriding `gjsify.app`.
595
+ *
596
+ * All three keys are present, which is where this differs from
597
+ * {@link ShipSignOptions}: `linux` is absent there because a `.deb` carries no
598
+ * per-file signature, while a Linux artifact very much has a runtime — it is the
599
+ * one that emits `Depends: gjs (>= 1.86)` or `Depends: nodejs (>= 24)`, and the
600
+ * whole point of this table is that stating darwin's answer must not move it.
601
+ *
602
+ * Only `gjs` and `node` are shippable. `browser` has no process to launch and
603
+ * `nativescript` ships as an APK/IPA through another pipeline (ADR 0024 § 4), so
604
+ * either one is refused here exactly as it is on `gjsify.app`.
605
+ */
606
+ export interface ShipAppOptions {
607
+ /** What a `.deb`/`.rpm`/Flatpak depends on and what its launcher execs. */
608
+ linux?: 'gjs' | 'node';
609
+ /** What a `<App>.app` carries. `node` is the only value the macOS formats accept. */
610
+ darwin?: 'gjs' | 'node';
611
+ /** What a Windows program directory carries. `node` is the only value its formats accept. */
612
+ win32?: 'gjs' | 'node';
613
+ }
550
614
  /**
551
615
  * `gjsify.ship.sign` — an IDENTITY per OS, and never a certificate.
552
616
  *
@@ -36,9 +36,17 @@ import { detectNativePackages } from './detect-native-packages.js';
36
36
  *
37
37
  * `globalThis.imports?.gi` is both the sanctioned GJS probe spelling
38
38
  * (`.oxlintrc.json`) and the correct CONDITION, so no `isGjs()` beside it: the
39
- * question is "is there a GIRepository to prepend to", and a Node host answers
40
- * no by itself — the required no-op, since on Node the CLI uses the npm
41
- * `rolldown` crate and never wants a prebuild.
39
+ * question is "is there a GIRepository to prepend to", and a plain Node process has
40
+ * none — nothing here to write to, which is the required no-op.
41
+ *
42
+ * NOT because a Node host wants no prebuild. That justification stood here and is
43
+ * false exactly where it matters: on darwin `@gjsify/webkit-native`'s prebuild is
44
+ * the ONLY WebKit any host has (ADR 0022), and ADR 0024 § 4 puts macOS and Windows
45
+ * applications on Node + node-gi. What is true is narrower — the CLI on Node uses
46
+ * the npm `rolldown` crate — and generalising it to "a Node host never wants a
47
+ * prebuild" is what kept every OTHER Node prebuild env-bound until
48
+ * `@gjsify/node-gi`'s `native-prebuilds.js` (ADR 0021 § The Node host). The Node
49
+ * host is covered THERE, in the package that owns a GIRepository, not here.
42
50
  *
43
51
  * `dup_default()` is GLib >= 2.86, probed as a capability rather than caught:
44
52
  * an older GLib simply keeps today's behaviour (the launcher's environment, or