@gjsify/cli 0.42.0 → 0.44.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 (51) hide show
  1. package/dist/cli.gjs.mjs +444 -415
  2. package/lib/actions/build.d.ts +6 -0
  3. package/lib/actions/build.js +66 -9
  4. package/lib/commands/affected-classify.js +7 -5
  5. package/lib/commands/build.js +6 -0
  6. package/lib/commands/flatpak/ci.js +10 -2
  7. package/lib/commands/flatpak/init.js +15 -16
  8. package/lib/commands/flatpak/utils.d.ts +0 -26
  9. package/lib/commands/flatpak/utils.js +4 -29
  10. package/lib/commands/ship.d.ts +2 -0
  11. package/lib/commands/ship.js +358 -104
  12. package/lib/config.js +25 -0
  13. package/lib/types/cli-build-options.d.ts +11 -0
  14. package/lib/types/config-data.d.ts +106 -2
  15. package/lib/utils/flatpak-runtime.d.ts +41 -0
  16. package/lib/utils/flatpak-runtime.js +54 -0
  17. package/lib/utils/gjs-bundle-guard.d.ts +57 -3
  18. package/lib/utils/gjs-bundle-guard.js +122 -13
  19. package/lib/utils/jsx-config.d.ts +45 -0
  20. package/lib/utils/jsx-config.js +98 -0
  21. package/lib/utils/onboard-probe.d.ts +23 -0
  22. package/lib/utils/onboard-probe.js +65 -0
  23. package/lib/utils/ship/deb.d.ts +3 -3
  24. package/lib/utils/ship/deb.js +6 -2
  25. package/lib/utils/ship/depends.d.ts +13 -13
  26. package/lib/utils/ship/depends.js +25 -3
  27. package/lib/utils/ship/discover.js +72 -6
  28. package/lib/utils/ship/flatpak-config.d.ts +66 -0
  29. package/lib/utils/ship/flatpak-config.js +132 -0
  30. package/lib/utils/ship/flatpak.d.ts +45 -0
  31. package/lib/utils/ship/flatpak.js +148 -0
  32. package/lib/utils/ship/formats.d.ts +35 -0
  33. package/lib/utils/ship/formats.js +152 -5
  34. package/lib/utils/ship/mime.d.ts +19 -0
  35. package/lib/utils/ship/mime.js +89 -0
  36. package/lib/utils/ship/payload.d.ts +76 -0
  37. package/lib/utils/ship/payload.js +91 -1
  38. package/lib/utils/ship/plan.d.ts +30 -1
  39. package/lib/utils/ship/plan.js +54 -2
  40. package/lib/utils/ship/rpm.d.ts +3 -3
  41. package/lib/utils/ship/rpm.js +20 -3
  42. package/lib/utils/ship/scripts.d.ts +14 -4
  43. package/lib/utils/ship/scripts.js +26 -8
  44. package/lib/utils/ship/settings.js +20 -0
  45. package/lib/utils/ship/stage-manifest.d.ts +163 -0
  46. package/lib/utils/ship/stage-manifest.js +445 -0
  47. package/lib/utils/ship/stage-writer.d.ts +30 -3
  48. package/lib/utils/ship/stage-writer.js +76 -9
  49. package/lib/utils/ship/types.d.ts +203 -27
  50. package/lib/utils/ship/types.js +5 -0
  51. package/package.json +19 -19
@@ -5,12 +5,23 @@
5
5
  // thin on purpose: every decision worth testing lives in `utils/ship/*` as a
6
6
  // pure function, and what is left here is filesystem work and printing.
7
7
  //
8
+ // TWO PHASES, one verb each (ADR 0024 § A2). Phase one ASSEMBLES: it resolves
9
+ // the settings, plans the payload, writes `stage/`, and writes the closure that
10
+ // makes that tree packable somewhere else (`.gjsify-ship-stage.json`, see
11
+ // `utils/ship/stage-manifest.ts`). Phase two FINISHES: `--from-stage <dir>`
12
+ // packs a stage that arrived from another host, with no project, no config and
13
+ // no built bundle in reach. With neither flag both phases run in one process,
14
+ // which is what a developer on one machine wants and what the e2e suite
15
+ // compares the split against byte for byte.
16
+ //
8
17
  // Scope today is Linux and `--app gjs`: no runtime is bundled, because GJS and
9
18
  // GTK come from the distribution and shipping ~100 MiB of them would be cargo
10
19
  // 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';
20
+ // this command, are the later stages of the same ADR — and they are the reason
21
+ // the split exists at all: a `.dmg` is a UDIF image over a real HFS+/APFS
22
+ // volume and no Linux host in this tree can write one (§ A1).
23
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
+ import { basename, dirname, join, relative, resolve } from 'node:path';
14
25
  import { Config } from '../config.js';
15
26
  import { renderDesktopEntry, renderMetainfoApp, renderMetainfoCli, validateAppMetadata, } from '../utils/app-metadata.js';
16
27
  import { readPackageJson } from '../utils/pkg-json.js';
@@ -18,23 +29,31 @@ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
18
29
  import { buildDeb } from '../utils/ship/deb.js';
19
30
  import { deriveDepends, warnAboutGjsFloor } from '../utils/ship/depends.js';
20
31
  import { discoverPayload } from '../utils/ship/discover.js';
21
- import { resolveFormats, FORMAT_IDS } from '../utils/ship/formats.js';
32
+ import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
33
+ import { assertHostCanFinish, assertToolsInstalled, resolveFormats, DEFAULT_FORMAT_IDS, FORMAT_IDS, } from '../utils/ship/formats.js';
22
34
  import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
23
- import { assertPayloadMatchesArch, buildTimestamp, isArchIndependent } from '../utils/ship/payload.js';
24
- import { planOverlay, planStage } from '../utils/ship/plan.js';
35
+ import { assertPayloadMatchesArch, buildTimestamp, isArchIndependent, readPayloadFacts, } from '../utils/ship/payload.js';
36
+ import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
25
37
  import { renderLauncher } from '../utils/ship/launcher.js';
26
38
  import { buildRpm } from '../utils/ship/rpm.js';
27
39
  import { declaredBundlePath, resolveShipSettings } from '../utils/ship/settings.js';
40
+ import { assertExpectedTarget, assertFormatsStaged, formatTarget, overlayFiles, readStageManifest, resolveStageMtime, warnOnToolSkew, writeStageManifest, STAGE_MANIFEST_FILE, } from '../utils/ship/stage-manifest.js';
28
41
  import { readStage, writeStage } from '../utils/ship/stage-writer.js';
29
42
  const LOG = '[gjsify ship]';
30
43
  export const shipCommand = {
31
44
  command: 'ship',
32
45
  description: `Build installable artifacts (${FORMAT_IDS.join(', ')}) from the current project.`,
46
+ // `DEFAULT_FORMAT_IDS` in the flag text, `FORMAT_IDS` in the description:
47
+ // one of them answers "what can this build", the other "what will it build
48
+ // if I say nothing", and they stopped being the same list when a host-bound
49
+ // format arrived.
33
50
  builder: (yargs) => yargs
34
51
  .option('target', {
35
52
  type: 'string',
36
53
  array: true,
37
- description: `Formats to build, comma-separated or repeated. Default: ${FORMAT_IDS.join(',')}.`,
54
+ description: `Formats to build, comma-separated or repeated. Default: ${DEFAULT_FORMAT_IDS.join(',')}. ` +
55
+ `Also available, and opt-in because it needs tooling this CLI does not carry: ` +
56
+ `${FORMAT_IDS.filter((id) => !DEFAULT_FORMAT_IDS.includes(id)).join(', ')}.`,
38
57
  })
39
58
  .option('out', {
40
59
  type: 'string',
@@ -45,6 +64,17 @@ export const shipCommand = {
45
64
  type: 'boolean',
46
65
  default: false,
47
66
  description: 'Produce the staged payload and stop, without packing anything.',
67
+ })
68
+ .option('from-stage', {
69
+ type: 'string',
70
+ normalize: true,
71
+ description: 'Pack a staged payload written by an earlier `--stage` run. Needs no project: the stage ' +
72
+ 'carries its own closure.',
73
+ })
74
+ .option('expect-target', {
75
+ type: 'string',
76
+ description: 'With --from-stage: refuse unless the stage was assembled for this `<os>-<arch>`, e.g. ' +
77
+ '`linux-arm64`.',
48
78
  })
49
79
  .option('skip-build', {
50
80
  type: 'boolean',
@@ -58,116 +88,336 @@ export const shipCommand = {
58
88
  .option('verbose', {
59
89
  type: 'boolean',
60
90
  default: false,
61
- description: 'Print each staged file and the GI namespaces the bundle imports.',
91
+ description: 'Print each staged file, the GI namespaces the bundle imports, and every tool a ' +
92
+ 'host-bound packer invokes.',
62
93
  }),
63
94
  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,
95
+ // The two phases share nothing but `packOne`, and the branch is first
96
+ // so that `--from-stage` never touches the cwd: a finishing host has no
97
+ // project there, and reading one would make the artifact depend on
98
+ // whatever directory the job happened to start in.
99
+ if (args['from-stage'] !== undefined)
100
+ return await finishFromStage(args, args['from-stage']);
101
+ await assemble(args);
102
+ },
103
+ };
104
+ /**
105
+ * Phase one: resolve, plan, stage, and write the closure — then, unless
106
+ * `--stage` was passed, pack right here.
107
+ */
108
+ async function assemble(args) {
109
+ const projectDir = process.cwd();
110
+ const config = new Config();
111
+ // No `.catch(() => ({}))` here, unlike the flatpak commands: a config
112
+ // file that fails to load would silently become "no config", and the
113
+ // result is a shipped artifact carrying default metadata the author
114
+ // never chose. An unreadable config is an error worth printing.
115
+ const configData = await config.forCommand(projectDir);
116
+ const ship = configData.ship ?? {};
117
+ const flatpak = configData.flatpak ?? {};
118
+ assertShippableTarget(configData.app);
119
+ const pkg = (readPackageJson(join(projectDir, 'package.json')) ?? {});
120
+ // Resolved before anything is built or written: a typo'd `--target`
121
+ // should not cost a build and leave a half-written `ship/stage/`.
122
+ const formats = resolveFormats(args.target ?? ship.targets ?? DEFAULT_FORMAT_IDS);
123
+ // BEFORE the build, not before the pack. `gjsify ship` runs the project's
124
+ // own `build` script first, so discovering a missing `flatpak-builder`
125
+ // afterwards costs the whole build for a refusal that was knowable up
126
+ // front. Skipped under `--stage`, which is precisely the phase that does
127
+ // NOT need the format's tooling — that asymmetry is the point of the split.
128
+ if (!args.stage)
129
+ for (const format of formats)
130
+ assertCanPack(format);
131
+ if (!args['skip-build'])
132
+ await runProjectBuild(projectDir);
133
+ const discovered = discoverPayload({
134
+ projectDir,
135
+ pkg,
136
+ ship,
137
+ flatpakIcon: flatpak.icon,
138
+ declaredBundle: declaredBundlePath(pkg, ship),
139
+ });
140
+ const { settings, metadata, warnings } = resolveShipSettings({
141
+ projectDir,
142
+ pkg,
143
+ ship,
144
+ flatpak,
145
+ cli: { outDir: args.out, arch: args.arch },
146
+ discovered,
147
+ });
148
+ for (const warning of warnings)
149
+ console.warn(`${LOG} ${warning}`);
150
+ const mtime = buildTimestamp(settings.bundlePath);
151
+ const metadataInputs = {
152
+ appId: settings.appId,
153
+ name: settings.name,
154
+ command: settings.binaryName,
155
+ kind: settings.kind,
156
+ metadata,
157
+ configKey: 'gjsify.ship',
158
+ copyrightYear: new Date(mtime * 1000).getUTCFullYear(),
159
+ };
160
+ // Warn rather than fail: an incomplete AppStream component still
161
+ // installs and still runs. It is app STORES that will reject it, and
162
+ // that is a different day's problem than getting the package built.
163
+ for (const missing of validateAppMetadata(metadataInputs)) {
164
+ console.warn(`${LOG} ${missing.field} is not set — ${missing.hint}`);
165
+ }
166
+ // Printed, not assumed. The text may come from an ancestor — one `LICENSE`
167
+ // at a monorepo root covers every package under it (see `discoverLicense`)
168
+ // — and which file was baked into the copyright is not recoverable from the
169
+ // artifact afterwards without unpacking it.
170
+ if (settings.licenseFile !== undefined && dirname(settings.licenseFile) !== projectDir) {
171
+ console.log(`${LOG} licence text from ${relative(projectDir, settings.licenseFile)} ` +
172
+ '(this package carries none of its own)');
173
+ }
174
+ const stageInputs = {
175
+ bundleFiles: discovered.bundleFiles,
176
+ launcher: renderLauncher(settings, basename(settings.bundlePath)),
177
+ metainfo: settings.kind === 'app' ? renderMetainfoApp(metadataInputs) : renderMetainfoCli(metadataInputs),
178
+ desktopEntry: settings.kind === 'app' ? renderDesktopEntry(metadataInputs) : undefined,
179
+ licenseText: settings.licenseFile === undefined ? undefined : readFileSync(settings.licenseFile, 'utf-8'),
180
+ };
181
+ const outRoot = resolve(projectDir, settings.outDir);
182
+ const stageDir = join(outRoot, 'stage');
183
+ const staged = planStage(settings, stageInputs);
184
+ writeStage(stageDir, staged);
185
+ console.log(`${LOG} staged ${staged.length} file(s) in ${relative(projectDir, stageDir)}/`);
186
+ if (args.verbose)
187
+ for (const file of staged)
188
+ console.log(`${LOG} ${file.path}`);
189
+ // Scanned from the BUILD TREE's bundle, which is why it has to be recorded:
190
+ // `gi://` specifiers are what the emitted `Depends:` is derived from
191
+ // (ADR 0024 § 6), and the packing host has the staged copy but no way to
192
+ // tell which staged file is the entry.
193
+ const namespaces = scanGiNamespaces(readFileSync(settings.bundlePath, 'utf-8'));
194
+ if (args.verbose)
195
+ console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
196
+ const overlay = new Map();
197
+ for (const format of formats)
198
+ overlay.set(format.id, planOverlay(settings, format, stageInputs));
199
+ // The dependency derivation runs HERE as well, and its result is thrown
200
+ // away. It is the only check in the pipeline that can refuse a project
201
+ // (`gi://Nautilus` with no known package, ADR 0024 § 6), and this is the
202
+ // host that has the `package.json` the refusal tells you to edit — letting
203
+ // it fire two jobs later, on a machine with no project, would name a fix
204
+ // nobody there can apply. Facts come from the PLAN, which has the same
205
+ // paths the payload will have.
206
+ const facts = readPayloadFacts(staged);
207
+ for (const format of formats) {
208
+ // `format.depends`, not `format.id`: a Flatpak has no package
209
+ // dependency list at all, and asking for one would either invent a
210
+ // third column in the typelib table or silently reuse rpm's — which is
211
+ // the exact defect `check-ship-format-vocabulary.mjs`'s header records
212
+ // from the other direction (a third format taking rpm's package name
213
+ // into a Debian `Depends:`, at exit 0).
214
+ if (format.depends === null)
215
+ continue;
216
+ deriveDepends(format.depends, {
217
+ namespaces,
218
+ hasIcons: facts.hasIcons,
219
+ hasSchemas: facts.hasSchemas,
220
+ extra: settings.extraDepends[format.depends],
221
+ typelibPackages: settings.typelibPackages,
222
+ bundledTypelibs: facts.bundledTypelibs,
223
+ minGjsVersion: settings.minGjsVersion,
94
224
  });
95
- for (const warning of warnings)
225
+ for (const warning of warnAboutGjsFloor(format.depends, settings.minGjsVersion)) {
96
226
  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
227
  }
137
- for (const artifact of artifacts) {
138
- console.log(`${LOG} ${artifact.format}: ${relative(projectDir, artifact.path)} (${artifact.size} bytes)`);
228
+ }
229
+ // Written unconditionally, not only under `--stage`: a `ship/stage/` that
230
+ // is sometimes packable elsewhere and sometimes not is a worse contract
231
+ // than one that always is, and it is what lets the e2e suite compare the
232
+ // one-process artifact with the two-phase one.
233
+ const manifest = writeStageManifest({ stageDir, settings, formats, mtime, namespaces, staged, overlay });
234
+ if (args.stage) {
235
+ console.log(`${LOG} stage manifest: ${formatTarget(manifest.target)}, formats ${manifest.formats.join(', ')}`);
236
+ return;
237
+ }
238
+ const artifacts = [];
239
+ for (const format of formats) {
240
+ artifacts.push(await packOne({
241
+ format,
242
+ settings,
243
+ arch: settings.arch,
244
+ staged,
245
+ overlay: overlay.get(format.id) ?? [],
246
+ stageDir,
247
+ outRoot,
248
+ namespaces,
249
+ mtime,
250
+ verbose: args.verbose,
251
+ }));
252
+ }
253
+ printArtifacts(projectDir, artifacts);
254
+ }
255
+ /**
256
+ * Phase two: pack a stage that was assembled somewhere else.
257
+ *
258
+ * Everything this needs is in the stage. Nothing under the cwd is read, and
259
+ * that is the property under test — `tests/e2e/ship-from-stage` deletes the
260
+ * project tree between the two phases, because without the deletion every
261
+ * accidental reach-back succeeds and the suite proves nothing.
262
+ */
263
+ async function finishFromStage(args, fromStage) {
264
+ if (args.stage) {
265
+ throw new Error('gjsify ship: --stage and --from-stage are the two halves of one split and cannot run together. ' +
266
+ '`--stage` assembles a payload and stops; `--from-stage <dir>` packs one that is already ' +
267
+ 'assembled. Run them as two commands, on the host each belongs on (ADR 0024 § A2).');
268
+ }
269
+ if (args['skip-build']) {
270
+ throw new Error('gjsify ship: --from-stage never runs a build — the payload it packs was built and staged by an ' +
271
+ 'earlier run — so --skip-build has nothing to skip. Drop it.');
272
+ }
273
+ if (args.arch !== undefined) {
274
+ throw new Error('gjsify ship: --arch is decided when the stage is assembled, not when it is packed: the launcher, ' +
275
+ 'the payload and the recorded target were all fixed then. Pass --arch to ' +
276
+ '`gjsify ship --stage`, and use `--expect-target <os>-<arch>` here to assert which leg you are ' +
277
+ 'packing.');
278
+ }
279
+ const cwd = process.cwd();
280
+ const stageDir = resolve(cwd, fromStage);
281
+ const manifest = readStageManifest(stageDir);
282
+ assertExpectedTarget(manifest, args['expect-target']);
283
+ const formats = resolveFormats(args.target ?? manifest.formats);
284
+ assertFormatsStaged(manifest, formats);
285
+ for (const format of formats)
286
+ assertCanPack(format);
287
+ for (const warning of warnOnToolSkew(manifest))
288
+ console.warn(`${LOG} ${warning}`);
289
+ const { mtime, warnings } = resolveStageMtime(manifest);
290
+ for (const warning of warnings)
291
+ console.warn(`${LOG} ${warning}`);
292
+ console.log(`${LOG} packing ${manifest.settings.binaryName} ${manifest.settings.version}-${manifest.settings.release} ` +
293
+ `for ${formatTarget(manifest.target)} from ${relative(cwd, stageDir) || '.'}/`);
294
+ if (args.verbose) {
295
+ for (const file of manifest.staged)
296
+ console.log(`${LOG} ${file.path}`);
297
+ console.log(`${LOG} gi namespaces: ${manifest.namespaces.join(', ') || '(none)'}`);
298
+ }
299
+ // The stage's own directory is not written to — `writeStage` wipes what it
300
+ // is pointed at, and pointing it at an arriving stage would delete the
301
+ // payload. The overlay is the only thing this phase materialises, and it
302
+ // goes beside the artifacts.
303
+ const outRoot = args.out === undefined ? dirname(stageDir) : resolve(cwd, args.out);
304
+ const artifacts = [];
305
+ for (const format of formats) {
306
+ if (format.depends !== null) {
307
+ for (const warning of warnAboutGjsFloor(format.depends, manifest.settings.minGjsVersion)) {
308
+ console.warn(`${LOG} ${warning}`);
309
+ }
139
310
  }
140
- },
141
- };
311
+ artifacts.push(await packOne({
312
+ format,
313
+ settings: manifest.settings,
314
+ arch: manifest.target.arch,
315
+ staged: manifest.staged,
316
+ overlay: overlayFiles(manifest, format.id),
317
+ stageDir,
318
+ outRoot,
319
+ namespaces: manifest.namespaces,
320
+ mtime,
321
+ verbose: args.verbose,
322
+ }));
323
+ }
324
+ printArtifacts(cwd, artifacts);
325
+ }
142
326
  async function packOne(input) {
143
327
  const { format, settings, stageDir, outRoot, mtime } = input;
144
- const overlay = planOverlay(settings, format, input.stageInputs);
328
+ assertOverlayIsLicensed(format.id, settings.binaryName, input.overlay);
145
329
  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
- bundledTypelibs: settings.typelibFiles,
155
- minGjsVersion: settings.minGjsVersion,
156
- });
157
- for (const warning of warnAboutGjsFloor(format.id, settings.minGjsVersion))
158
- console.warn(`${LOG} ${warning}`);
159
- // Before the label is chosen, not after: `archName` collapses an
160
- // arch-independent payload to `all`/`noarch`, and a mismatch only matters
161
- // for a payload that IS architecture-specific.
162
- assertPayloadMatchesArch(payload, settings.arch);
163
- const archLabel = format.archName(settings.arch, isArchIndependent(payload));
330
+ writeStage(overlayDir, input.overlay);
331
+ const payload = readStage([stageDir, overlayDir], [...input.staged, ...input.overlay]);
332
+ // What the package installs, read off what it installs. The three questions
333
+ // this answers used to be answered from the project's file lists, which are
334
+ // absolute paths on the build host and therefore unavailable here.
335
+ const facts = readPayloadFacts(payload);
336
+ const depends = format.depends === null
337
+ ? []
338
+ : deriveDepends(format.depends, {
339
+ namespaces: input.namespaces,
340
+ hasIcons: facts.hasIcons,
341
+ hasSchemas: facts.hasSchemas,
342
+ extra: settings.extraDepends[format.depends],
343
+ typelibPackages: settings.typelibPackages,
344
+ bundledTypelibs: facts.bundledTypelibs,
345
+ minGjsVersion: settings.minGjsVersion,
346
+ });
347
+ // `--arch` is a CLAIM about the payload and nothing else compared it to one:
348
+ // an x86-64 `.so` staged with `--arch arm64` packed at exit 0 and `rpm -qp
349
+ // --qf %{ARCH}` confirmed `aarch64`, because rpm reads the header and the
350
+ // header was written from the claim. Read the two bytes further that settle
351
+ // it before the label reaches a header. Payload-versus-LABEL, never
352
+ // payload-versus-HOST — a host check would refuse the cross-host pack this
353
+ // whole split exists for.
354
+ //
355
+ // And BEFORE the label is chosen, not after — this ordering is load-bearing:
356
+ // `archName` collapses an arch-independent payload to `all`/`noarch`, so
357
+ // asserting afterwards would compare against a label that no longer names an
358
+ // architecture, and the mismatch that matters is exactly the one on a payload
359
+ // that IS architecture-specific.
360
+ assertPayloadMatchesArch(payload, input.arch);
361
+ const archLabel = format.archName(input.arch, isArchIndependent(payload));
164
362
  const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
165
- const bytes = format.id === 'deb' ? await buildDeb(common) : await buildRpm(common);
166
363
  const outDir = join(outRoot, 'out');
167
364
  mkdirSync(outDir, { recursive: true });
168
365
  const target = join(outDir, format.fileName(settings, archLabel));
169
- writeFileSync(target, bytes);
170
- return { format: format.id, path: target, size: bytes.byteLength };
366
+ // A SWITCH with a `never` guard, not a ternary. `format.id === 'deb' ? deb : rpm`
367
+ // read as a dispatch and behaved as one only while there were exactly two
368
+ // formats: a third would have taken the else-branch and written an RPM under a
369
+ // `.dmg` name, at exit 0. This is the same closed-vocabulary hazard the format
370
+ // list has, one level worse — a wrong ARTIFACT rather than a rejected
371
+ // declaration — so the compiler owns it now.
372
+ //
373
+ // Each branch WRITES `target` rather than returning bytes. It used to return
374
+ // them, which is the right shape only while every packer is a byte writer: a
375
+ // Flatpak is produced by `flatpak build-bundle`, so its bytes exist as a file
376
+ // before this process could hold them, and reading a bundle back into memory
377
+ // to hand it to one `writeFileSync` would be a copy for the sake of a
378
+ // signature. The size is stat'ed off what landed, for every format.
379
+ switch (format.id) {
380
+ case 'deb':
381
+ writeFileSync(target, await buildDeb(common));
382
+ break;
383
+ case 'rpm':
384
+ writeFileSync(target, await buildRpm(common));
385
+ break;
386
+ case 'flatpak':
387
+ await buildFlatpakBundle({
388
+ settings,
389
+ stageDir,
390
+ overlayDir,
391
+ stageManifestFile: STAGE_MANIFEST_FILE,
392
+ workDir: join(outRoot, 'flatpak'),
393
+ target,
394
+ archLabel,
395
+ verbose: input.verbose,
396
+ });
397
+ break;
398
+ default: {
399
+ const unhandled = format.id;
400
+ throw new Error(`gjsify ship: no packer is wired for format "${String(unhandled)}".`);
401
+ }
402
+ }
403
+ return { format: format.id, path: target, size: statSync(target).size };
404
+ }
405
+ function printArtifacts(base, artifacts) {
406
+ for (const artifact of artifacts) {
407
+ console.log(`${LOG} ${artifact.format}: ${relative(base, artifact.path)} (${artifact.size} bytes)`);
408
+ }
409
+ }
410
+ /**
411
+ * Refuse a format this host cannot finish, and say which half is missing.
412
+ *
413
+ * Two checks and not one, because the fixes differ: the wrong OS needs another
414
+ * machine, an absent tool needs a package. Both run on the pack path only —
415
+ * `--stage` deliberately needs neither, which is what makes a Flatpak
416
+ * assemblable on a host that cannot build one (ADR 0024 § A1).
417
+ */
418
+ function assertCanPack(format) {
419
+ assertHostCanFinish(format);
420
+ assertToolsInstalled(format);
171
421
  }
172
422
  /**
173
423
  * Refuse a build target this command cannot package correctly.
@@ -181,6 +431,10 @@ async function packOne(input) {
181
431
  * An undeclared target is the common case for a GJS app and is allowed: `app`
182
432
  * otherwise defaults to the HOST runtime, which would refuse a perfectly good
183
433
  * GJS project merely for running this command under Node.
434
+ *
435
+ * Phase one only, and nothing checks it again in phase two: a stage can only
436
+ * exist because this passed, and the staged `bin/<name>` carries the
437
+ * `exec gjs -m` line that proves it.
184
438
  */
185
439
  function assertShippableTarget(app) {
186
440
  if (app === undefined || app === 'gjs')
package/lib/config.js CHANGED
@@ -4,6 +4,7 @@ import { basename, dirname } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
6
6
  import { buildAppForRuntime } from './utils/runtimes.js';
7
+ import { DIALECT_APPS, isSourceDialect, SOURCE_DIALECTS } from '@gjsify/rolldown-plugin-gjsify';
7
8
  /**
8
9
  * Does the failed `import()` of a config look like a MODULE-RESOLUTION failure
9
10
  * (the config imports something GJS's ESM loader can't resolve for an external
@@ -268,6 +269,18 @@ export class Config {
268
269
  configData.exclude = cliArgs.exclude || [];
269
270
  if (cliArgs.consoleShim !== undefined)
270
271
  configData.consoleShim = cliArgs.consoleShim;
272
+ // `--dialect` narrows here rather than at the call site. yargs `choices`
273
+ // already rejects an unknown value typed on the command line, but a
274
+ // `gjsify.dialect` in package.json or `.gjsifyrc.*` never passes through
275
+ // yargs — so the one place both spellings meet is the one place that
276
+ // checks, and it names the alternatives instead of silently ignoring.
277
+ if (cliArgs.dialect !== undefined) {
278
+ if (!isSourceDialect(cliArgs.dialect)) {
279
+ throw new Error(`gjsify build: "${cliArgs.dialect}" is not a source dialect. Known: ${SOURCE_DIALECTS.join(', ')}. ` +
280
+ 'This is the dialect the SOURCE is written in — not gjsify.runtimes, which declares the runtime a package RUNS on.');
281
+ }
282
+ configData.dialect = cliArgs.dialect;
283
+ }
271
284
  // Default `--app` FOLLOWS the host runtime the CLI executes in: gjs when
272
285
  // run under gjs, node when run under node/bun/deno (both consume the
273
286
  // `--app node` bundle). Applied post-merge (not as a yargs `default:`)
@@ -278,6 +291,18 @@ export class Config {
278
291
  if (cliArgs.app !== undefined)
279
292
  configData.app = cliArgs.app;
280
293
  configData.app ??= buildAppForRuntime(hostRuntime());
294
+ // A dialect only composes on the targets that have a plugin for it, and
295
+ // saying so is the whole point: `--dialect react-native --app browser`
296
+ // parses, resolves and then composes NOTHING, so the alias never happens,
297
+ // the support gate never runs, and the build succeeds while doing none of
298
+ // what was asked. That is the shape this repo keeps paying for — a flag
299
+ // accepted and ignored. Checked here rather than in the plugin because the
300
+ // plugin only ever sees the targets it was composed on.
301
+ if (configData.dialect !== undefined && !DIALECT_APPS.has(configData.app)) {
302
+ throw new Error(`gjsify build: --dialect ${configData.dialect} has no effect on --app ${configData.app}. ` +
303
+ `It composes on ${[...DIALECT_APPS].join(' and ')} only. ` +
304
+ 'Drop the dialect, or build one of those targets.');
305
+ }
281
306
  if (cliArgs.globals !== undefined)
282
307
  configData.globals = cliArgs.globals;
283
308
  // Fallback applied post-merge (not as a yargs `default:`) so a
@@ -25,6 +25,17 @@ export interface CliBuildOptions {
25
25
  * working ANSI colors). Disable with `--no-console-shim`. Default: true.
26
26
  */
27
27
  consoleShim?: boolean;
28
+ /**
29
+ * The dialect the SOURCE is written in — `--dialect react-native` (ADR 0032):
30
+ * alias `react-native` onto `@gjsify/react-native` and fail the build on an
31
+ * import whose support-table status is not `supported` or `partial`. `gjs` and
32
+ * `node` targets. Default: unset — a tree with a phone leg has the real
33
+ * `react-native` on purpose.
34
+ *
35
+ * A `string` here and a `SourceDialect` in `ConfigData`: yargs hands back
36
+ * whatever was typed, and the narrowing happens once, where the value is read.
37
+ */
38
+ dialect?: string;
28
39
  /**
29
40
  * Comma-separated global identifiers the code needs, e.g.
30
41
  * `"fetch,Buffer,process,URL,crypto"`. Each maps to the corresponding