@gjsify/cli 0.64.15 → 0.64.17-next.292

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.
@@ -2,6 +2,7 @@ import type { ConfigData } from '../types/index.js';
2
2
  import type { App } from '@gjsify/rolldown-plugin-gjsify';
3
3
  import type { RolldownOutput } from 'rolldown';
4
4
  import { type RunBundleOptions } from '../bundler-pick.js';
5
+ import { WorkerEntries } from '@gjsify/rolldown-plugin-gjsify';
5
6
  export declare class BuildAction {
6
7
  readonly configData: ConfigData;
7
8
  constructor(configData?: ConfigData);
@@ -229,7 +230,16 @@ export declare class BuildAction {
229
230
  preserveDefaultExport?: boolean;
230
231
  toolchainAnchor?: string;
231
232
  onWatchFiles?: RunBundleOptions['onWatchFiles'];
233
+ /** Set by the top-level build for its nested worker builds, so names and "already built" are shared. */
234
+ workers?: WorkerEntries;
232
235
  }): Promise<RolldownOutput[]>;
236
+ /**
237
+ * Build every worker script the main bundle (or an earlier worker) names by
238
+ * `new Worker(new URL('./x', import.meta.url))`, each as its own app build beside the
239
+ * output, under the main build's config. Rounds, not recursion: a nested build registers
240
+ * what ITS workers need, and the shared registry hands each source out once.
241
+ */
242
+ private buildWorkers;
233
243
  /**
234
244
  * Drive `rolldown.watch(...)`: rebuild on source change, apply the
235
245
  * post-bundle shebang hook on each successful build, surface errors
@@ -1,5 +1,5 @@
1
1
  import { runBundle, runWatch, bundleToChunks } from '../bundler-pick.js';
2
- import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG, createGjsExternalsPredicate, globToEntryPoints, scanGiNamespaces, planGiSelection, } from '@gjsify/rolldown-plugin-gjsify';
2
+ import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG, createGjsExternalsPredicate, globToEntryPoints, scanGiNamespaces, planGiSelection, WorkerEntries, bundleSuffix, } from '@gjsify/rolldown-plugin-gjsify';
3
3
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
4
4
  import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
5
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectNodeGiGlobals, } from '@gjsify/rolldown-plugin-gjsify/globals';
@@ -715,6 +715,15 @@ export class BuildAction {
715
715
  ...(opts.preserveDefaultExport ? { preserveDefaultExport: true } : {}),
716
716
  ...(opts.toolchainAnchor !== undefined ? { toolchainAnchor: opts.toolchainAnchor } : {}),
717
717
  };
718
+ // `new Worker(new URL('./x', import.meta.url))` targets are built beside the bundle after it
719
+ // (see `buildWorkers`). Needs a concrete output location and a one-file app target.
720
+ const ownsWorkers = opts.workers === undefined;
721
+ const workers = opts.workers ??
722
+ (app !== 'nativescript' && !opts.watch && (outfile || outdir)
723
+ ? new WorkerEntries(outfile ? bundleSuffix(outfile) : '.js')
724
+ : undefined);
725
+ if (workers)
726
+ pluginOpts.workers = workers;
718
727
  const { autoMode, extras } = this.parseGlobalsValue(globals);
719
728
  const pnp = await buildPnpPlugin();
720
729
  const pnpPlugins = pnp ? [pnp] : [];
@@ -916,8 +925,33 @@ export class BuildAction {
916
925
  if (app === 'browser' && htmlEntry && outfile) {
917
926
  await this.applyBrowserHtml(htmlEntry, outfile, verbose);
918
927
  }
928
+ if (workers && ownsWorkers) {
929
+ await this.buildWorkers(workers, app, dirname(resolve(outfile ?? join(outdir, 'x'))), opts);
930
+ }
919
931
  return [writeResult];
920
932
  }
933
+ /**
934
+ * Build every worker script the main bundle (or an earlier worker) names by
935
+ * `new Worker(new URL('./x', import.meta.url))`, each as its own app build beside the
936
+ * output, under the main build's config. Rounds, not recursion: a nested build registers
937
+ * what ITS workers need, and the shared registry hands each source out once.
938
+ */
939
+ async buildWorkers(workers, app, outDir, opts) {
940
+ const { esbuild: _legacy, ...rest } = this.configData;
941
+ const bundler = normalizeBundlerOptions(this.configData);
942
+ for (let round = workers.takePending(); round.length > 0; round = workers.takePending()) {
943
+ for (const { source, fileName } of round) {
944
+ if (this.configData.verbose)
945
+ console.debug(`[gjsify] bundling worker ${source} → ${fileName}`);
946
+ const { dir: _dir, ...output } = bundler.output ?? {};
947
+ await new BuildAction({
948
+ ...rest,
949
+ shebang: false,
950
+ bundler: { ...bundler, input: source, output: { ...output, file: join(outDir, fileName) } },
951
+ }).buildApp(app, { ...opts, workers });
952
+ }
953
+ }
954
+ }
921
955
  /**
922
956
  * Drive `rolldown.watch(...)`: rebuild on source change, apply the
923
957
  * post-bundle shebang hook on each successful build, surface errors
@@ -39,7 +39,7 @@ import { resolveNodeModulesClosure, stageNodeModules } from '../utils/ship/node-
39
39
  import { acquireGtkRuntime } from '../utils/ship/gtk-runtime-acquire.js';
40
40
  import { discoverPayload } from '../utils/ship/discover.js';
41
41
  import { appDirFor, appDirPayload, appImageHostRequirements, appImageRuntimeNotice, buildAppImage, findPinnedRuntime, stampAppDirTimes, } from '../utils/ship/appimage.js';
42
- import { addApplicationsLink, buildDmgImage, dmgVolumeDir, dmgVolumeName, signDmgImage } from '../utils/ship/dmg.js';
42
+ import { addApplicationsLink, buildDmgImage, dmgVolumeDir, dmgVolumeName, signDmgImage, stapleBundleBeforeImage, } from '../utils/ship/dmg.js';
43
43
  import { assertNoDroppedFlatpakModules } from '../utils/ship/flatpak-config.js';
44
44
  import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
45
45
  import { iconSizes, resolveAppIcon } from '../utils/ship/icons.js';
@@ -422,6 +422,10 @@ async function assemble(args) {
422
422
  const stageInputs = {
423
423
  bundleFiles: discovered.bundleFiles,
424
424
  launcher: renderLauncher(settings, basename(settings.bundlePath), layout, runtime.launcher),
425
+ executableLaunchers: Object.fromEntries(settings.executables.map((executable) => [
426
+ executable.name,
427
+ renderLauncher(settings, executable.entry, layout, runtime.launcher),
428
+ ])),
425
429
  metainfo: translated.metainfo,
426
430
  desktopEntry: translated.desktopEntry,
427
431
  licenseText: settings.licenseFile === undefined ? undefined : readFileSync(settings.licenseFile, 'utf-8'),
@@ -1262,6 +1266,29 @@ async function packOne(input) {
1262
1266
  // mounts showing `Contents/` — a window with no application in it.
1263
1267
  const volumeDir = dmgVolumeDir(outRoot);
1264
1268
  writePayload(volumeDir, payload, '');
1269
+ // THE BUNDLE'S OWN TICKET, before the image exists (#162): a `.app` dragged
1270
+ // out of a volume carries only what was stapled to IT. Nothing signs after
1271
+ // this point except the image, which is a different object.
1272
+ if (input.notarize.kind === 'notarize') {
1273
+ const notarizePlan = input.notarize;
1274
+ const bundleDir = join(volumeDir, `${settings.name}.app`);
1275
+ const archive = join(dirname(volumeDir), 'bundle-notarize.zip');
1276
+ await stapleBundleBeforeImage({
1277
+ writeArchive: () => writeFileSync(archive, buildZip(zipEntriesFromPayload(payload), mtime)),
1278
+ notarize: () => notarizeArtifact({
1279
+ plan: notarizePlan,
1280
+ artifact: archive,
1281
+ verbose: input.verbose,
1282
+ log: (line) => console.log(`${LOG} ${line}`),
1283
+ }),
1284
+ staple: () => stapleArtifact({
1285
+ notary: notarizePlan.notary,
1286
+ artifact: bundleDir,
1287
+ verbose: input.verbose,
1288
+ log: (line) => console.log(`${LOG} ${line}`),
1289
+ }),
1290
+ });
1291
+ }
1265
1292
  addApplicationsLink(volumeDir);
1266
1293
  await buildDmgImage({
1267
1294
  volumeName: dmgVolumeName(settings),
@@ -702,6 +702,15 @@ export interface ConfigDataShip extends AppMetadata {
702
702
  extraFiles?: Record<string, string>;
703
703
  /** Arguments the launcher appends before the user's own. */
704
704
  execArgs?: string[];
705
+ /**
706
+ * Further executables of the SAME package, beside the primary one `gjsify.main` names.
707
+ *
708
+ * Each is one more launcher over the one shared bundle directory: the primary stays
709
+ * `bin/<binaryName>` (and `CFBundleExecutable` on macOS), these are `bin/<name>`.
710
+ * Absent means one executable, which is what every package shipped before this key.
711
+ * See `docs/ship-formats.md` for what each format does with them.
712
+ */
713
+ executables?: ShipExecutableOptions[];
705
714
  /**
706
715
  * How `--target flatpak` builds the app: the runtime it links against and
707
716
  * what the finished app is allowed to do.
@@ -719,6 +728,22 @@ export interface ConfigDataShip extends AppMetadata {
719
728
  */
720
729
  sign?: ShipSignOptions;
721
730
  }
731
+ /** One entry of `gjsify.ship.executables`. */
732
+ export interface ShipExecutableOptions {
733
+ /**
734
+ * The command name — `bin/<name>`, `Contents/MacOS/<name>`, `<name>.cmd`. Lower-case
735
+ * `[a-z0-9][a-z0-9._+-]*`, unique, and not the package's own `binaryName`.
736
+ */
737
+ name: string;
738
+ /** The built entry, project-relative, inside the directory `gjsify.main` lives in. */
739
+ entry: string;
740
+ /**
741
+ * Windows only: also stage a GUI-subsystem `<name>.exe` beside the `<name>.cmd`, so a
742
+ * double-click or shortcut opens no console window. Default `false` — a command-line
743
+ * tool wants the console.
744
+ */
745
+ gui?: boolean;
746
+ }
722
747
  /**
723
748
  * `gjsify.ship.app` — the shipped runtime per OS, overriding `gjsify.app`.
724
749
  *
@@ -919,8 +944,8 @@ export interface ConfigDataFlatpak extends AppMetadata {
919
944
  lockfile?: string;
920
945
  /**
921
946
  * GitHub-Actions container image override for `gjsify flatpak ci`. Default is
922
- * derived from runtime + runtimeVersion, e.g. gnome+50 →
923
- * `ghcr.io/flathub-infra/flatpak-github-actions:gnome-50`.
947
+ * derived from runtime + runtimeVersion, e.g. gnome+51 →
948
+ * `ghcr.io/flathub-infra/flatpak-github-actions:gnome-51`.
924
949
  */
925
950
  ciContainer?: string;
926
951
  /** Branches the generated workflow triggers on. Default `['main']`. */
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Default GNOME-Platform runtime version. Bumped per release window.
3
- * GNOME 50 = April 2026 stable; tracked in
3
+ * GNOME 51 = September 2026 stable (on Freedesktop 26.08); tracked in
4
4
  * https://docs.flathub.org/docs/for-app-authors/requirements.
5
5
  */
6
- export declare const DEFAULT_GNOME_RUNTIME_VERSION = "50";
6
+ export declare const DEFAULT_GNOME_RUNTIME_VERSION = "51";
7
7
  /** Default Freedesktop-Platform runtime version (LTS-ish). */
8
8
  export declare const DEFAULT_FREEDESKTOP_RUNTIME_VERSION = "24.08";
9
9
  /** Permissive GUI defaults for GTK4 + Adwaita apps. */
@@ -9,10 +9,10 @@
9
9
  // GNOME releases from the same `package.json`.
10
10
  /**
11
11
  * Default GNOME-Platform runtime version. Bumped per release window.
12
- * GNOME 50 = April 2026 stable; tracked in
12
+ * GNOME 51 = September 2026 stable (on Freedesktop 26.08); tracked in
13
13
  * https://docs.flathub.org/docs/for-app-authors/requirements.
14
14
  */
15
- export const DEFAULT_GNOME_RUNTIME_VERSION = '50';
15
+ export const DEFAULT_GNOME_RUNTIME_VERSION = '51';
16
16
  /** Default Freedesktop-Platform runtime version (LTS-ish). */
17
17
  export const DEFAULT_FREEDESKTOP_RUNTIME_VERSION = '24.08';
18
18
  /** Permissive GUI defaults for GTK4 + Adwaita apps. */
@@ -117,3 +117,26 @@ export declare function signDmgImage(input: DmgSignInput & {
117
117
  verbose: boolean;
118
118
  workDir: string;
119
119
  }): Promise<void>;
120
+ export interface BundleTicketSteps {
121
+ /** Write the archive `notarytool` submits (it takes no directory). */
122
+ writeArchive: () => void;
123
+ /** Submit the archive and wait for the verdict. */
124
+ notarize: () => Promise<void>;
125
+ /** Attach the ticket to the bundle ON DISK, the one the image is built from. */
126
+ staple: () => Promise<void>;
127
+ }
128
+ /**
129
+ * Notarise and staple the `.app` BEFORE the image is built from it (#162).
130
+ *
131
+ * Notarising only the `.dmg` clears the image and, online, the app inside it —
132
+ * but the ticket is stapled to the container, so an `.app` dragged out of the
133
+ * volume carries none and asks Gatekeeper over the network on every first launch.
134
+ * Apple's rule for the order (`stapler(1)`): staple after signing and notarising,
135
+ * and never `codesign` afterwards — stapling does not invalidate the signature,
136
+ * re-signing does invalidate the ticket. So the sequence is fixed:
137
+ * sign bundle → notarise bundle → staple bundle → build image → sign image →
138
+ * notarise image → staple image. This function owns the middle three and is a
139
+ * sequence of injected steps so the ORDER is unit-testable off macOS, where none
140
+ * of the tools run.
141
+ */
142
+ export declare function stapleBundleBeforeImage(steps: BundleTicketSteps): Promise<void>;
@@ -243,3 +243,22 @@ export async function signDmgImage(input) {
243
243
  throw new Error(`gjsify ship: ${DMG_SIGN_TOOL} failed on the image with ${describeExit(result)}.`);
244
244
  }
245
245
  }
246
+ /**
247
+ * Notarise and staple the `.app` BEFORE the image is built from it (#162).
248
+ *
249
+ * Notarising only the `.dmg` clears the image and, online, the app inside it —
250
+ * but the ticket is stapled to the container, so an `.app` dragged out of the
251
+ * volume carries none and asks Gatekeeper over the network on every first launch.
252
+ * Apple's rule for the order (`stapler(1)`): staple after signing and notarising,
253
+ * and never `codesign` afterwards — stapling does not invalidate the signature,
254
+ * re-signing does invalidate the ticket. So the sequence is fixed:
255
+ * sign bundle → notarise bundle → staple bundle → build image → sign image →
256
+ * notarise image → staple image. This function owns the middle three and is a
257
+ * sequence of injected steps so the ORDER is unit-testable off macOS, where none
258
+ * of the tools run.
259
+ */
260
+ export async function stapleBundleBeforeImage(steps) {
261
+ steps.writeArchive();
262
+ await steps.notarize();
263
+ await steps.staple();
264
+ }
@@ -18,6 +18,14 @@ export interface LayoutIdentity {
18
18
  binaryName: string;
19
19
  /** Human-readable display name — the `<App>.app` directory a user sees in Finder. */
20
20
  name: string;
21
+ /**
22
+ * The package's further executables (`gjsify.ship.executables`), absent or empty for one.
23
+ * `bin/<name>` is each one's planned launcher; the layout decides where it lands.
24
+ */
25
+ executables?: readonly {
26
+ name: string;
27
+ gui: boolean;
28
+ }[];
21
29
  }
22
30
  /**
23
31
  * What a layout needs to render the metadata it OWNS.
@@ -509,6 +509,20 @@ export const LAYOUTS = {
509
509
  }),
510
510
  },
511
511
  },
512
+ // A further executable that asked for a GUI subsystem gets the same stub
513
+ // under its own name, so its `.exe` finds its own `.cmd` and logs to its
514
+ // own leaf. The application's icon, since it is one application.
515
+ ...guiExecutables(input).map((executable) => ({
516
+ path: windowsGuiLauncherPath({ ...input, binaryName: executable.name }),
517
+ mode: 0o755,
518
+ source: {
519
+ kind: 'bytes',
520
+ data: buildGuiLauncher({
521
+ logLeaf: windowsLaunchLogLeaf({ ...input, binaryName: executable.name }),
522
+ ...(icon === undefined ? {} : { icon: iconResources(icon) }),
523
+ }),
524
+ },
525
+ })),
512
526
  ];
513
527
  },
514
528
  // TWO now. `wingtk/gvsbuild` still hardcodes `self.platform = "x64"` and
@@ -570,7 +584,16 @@ export function windowsGuiLauncherPath(identity) {
570
584
  * for an OS with no such file.
571
585
  */
572
586
  export function payloadArchExemptions(os, identity) {
573
- return os === 'win32' ? new Set([windowsGuiLauncherPath(identity)]) : undefined;
587
+ if (os !== 'win32')
588
+ return undefined;
589
+ return new Set([
590
+ windowsGuiLauncherPath(identity),
591
+ ...guiExecutables(identity).map((executable) => windowsGuiLauncherPath({ ...identity, binaryName: executable.name })),
592
+ ]);
593
+ }
594
+ /** The further executables that want a GUI-subsystem stub. */
595
+ function guiExecutables(identity) {
596
+ return (identity.executables ?? []).filter((executable) => executable.gui);
574
597
  }
575
598
  /**
576
599
  * What the windows GUI launcher calls the file it writes under `%TEMP%`.
@@ -676,6 +699,12 @@ export function place(layout, identity, rel) {
676
699
  const dirs = layout.dirs(identity);
677
700
  if (rel === `bin/${identity.binaryName}`)
678
701
  return launcherPath(layout, identity);
702
+ // A further executable's launcher is placed exactly like the primary one — in the
703
+ // launcher directory, with the layout's extension — and not by the generic `bin/*` rule
704
+ // below, which has no extension: a windows `<name>` without `.cmd` is not runnable.
705
+ const further = identity.executables?.find((executable) => rel === `bin/${executable.name}`);
706
+ if (further !== undefined)
707
+ return launcherPath(layout, { ...identity, binaryName: further.name });
679
708
  const libPrefix = `lib/${identity.binaryName}/`;
680
709
  const giPrefix = `${libPrefix}gi/`;
681
710
  if (rel.startsWith(giPrefix))
@@ -396,6 +396,12 @@ export function renderWxs(input) {
396
396
  // behind empty by an uninstall.
397
397
  ` <Directory Id="INSTALLDIR" Name="${xmlEscape(programDirName)}">`,
398
398
  ...lines,
399
+ ` <Component Id="AddToPathComponent" Guid="${msiComponentGuid(settings, ':path')}" ` +
400
+ `Win64="${win64 ? 'yes' : 'no'}">`,
401
+ ' <CreateFolder />',
402
+ ' <Environment Id="GjsifyPath" Name="PATH" Action="set" Part="last" System="yes" ' +
403
+ 'Permanent="no" Value="[INSTALLDIR]" />',
404
+ ' </Component>',
399
405
  ' </Directory>',
400
406
  ' </Directory>',
401
407
  // Declared and left empty: the shortcut reaches it by `Directory=`, so this
@@ -406,6 +412,15 @@ export function renderWxs(input) {
406
412
  ` <Feature Id="Main" Title="${xmlEscape(settings.name)}" Level="1">`,
407
413
  ...componentIds.map((id) => ` <ComponentRef Id="${id}" />`),
408
414
  ' </Feature>',
415
+ // OPTIONAL and OFF by default (Level 2 is above the default install level of 1):
416
+ // appending the install directory to the MACHINE `PATH` changes every
417
+ // program on the box, so a user asks for it. Silent opt-in:
418
+ // `msiexec /i app.msi ADDTOPATH=1` or `ADDLOCAL=Main,AddToPath`. The
419
+ // `Environment` row is removed again on uninstall (`Permanent="no"`).
420
+ ` <Feature Id="AddToPath" Title="Add ${xmlEscape(settings.name)} to PATH" Level="2">`,
421
+ ' <ComponentRef Id="AddToPathComponent" />',
422
+ ' <Condition Level="1">ADDTOPATH=1</Condition>',
423
+ ' </Feature>',
409
424
  ' </Product>',
410
425
  '</Wix>',
411
426
  '',
@@ -5,6 +5,8 @@ export interface StageInputs {
5
5
  bundleFiles: readonly string[];
6
6
  /** The rendered launcher script. */
7
7
  launcher: string;
8
+ /** One rendered launcher per `settings.executables` entry, by name. Absent means none. */
9
+ executableLaunchers?: Readonly<Record<string, string>>;
8
10
  /** The rendered AppStream MetaInfo XML. */
9
11
  metainfo: string;
10
12
  /** The rendered desktop entry — required for `kind: 'app'`, unused for `'cli'`. */
@@ -27,6 +27,14 @@ export function planStage(settings, inputs) {
27
27
  const files = [];
28
28
  const libDir = `lib/${settings.binaryName}`;
29
29
  files.push({ path: `bin/${settings.binaryName}`, mode: 0o755, source: { kind: 'text', text: inputs.launcher } });
30
+ for (const executable of settings.executables) {
31
+ const text = inputs.executableLaunchers?.[executable.name];
32
+ if (text === undefined || !inputs.bundleFiles.includes(executable.entry)) {
33
+ throw new Error(`gjsify ship: \`gjsify.ship.executables\` "${executable.name}" runs ${executable.entry}, which is ` +
34
+ `not in ${settings.bundleDir} — build it before shipping, or the launcher would point at nothing.`);
35
+ }
36
+ files.push({ path: `bin/${executable.name}`, mode: 0o755, source: { kind: 'text', text } });
37
+ }
30
38
  for (const rel of inputs.bundleFiles) {
31
39
  files.push({
32
40
  path: posix.join(libDir, rel),
@@ -1,5 +1,5 @@
1
1
  import type { AppMetadata, ConfigDataFlatpak, ConfigDataShip, DescriptionBlock, ShipAppOptions } from '../../types/config-data.js';
2
- import type { HostOs, ShipSettings } from './types.js';
2
+ import type { HostOs, ShipExecutable, ShipSettings } from './types.js';
3
3
  /** The subset of `package.json` the resolver reads. */
4
4
  export interface ShipPackageManifest {
5
5
  name?: string;
@@ -161,6 +161,22 @@ export interface ResolvedSettings {
161
161
  */
162
162
  export declare function resolveShipKind(ship: ConfigDataShip, flatpak: ConfigDataFlatpak): 'app' | 'cli';
163
163
  export declare function resolveShipSettings(input: SettingsInput): ResolvedSettings;
164
+ /**
165
+ * `gjsify.ship.executables` → the further launchers of one package.
166
+ *
167
+ * REFUSED rather than normalised, for each rule: a name collision between two entries
168
+ * is one launcher silently replacing the other (`placeStage` would catch the Linux
169
+ * case and miss a Windows `Foo.cmd`/`foo.cmd` pair on a case-insensitive disk), an
170
+ * entry outside the bundle directory is a file the stage does not carry, and a name
171
+ * with a path separator or a space escapes `bin/`. All three install cleanly and
172
+ * start nothing, which is the class this command is built against.
173
+ */
174
+ export declare function resolveShipExecutables(input: {
175
+ declared: unknown;
176
+ binaryName: string;
177
+ projectDir: string;
178
+ bundleDir: string;
179
+ }): ShipExecutable[];
164
180
  /** The entry one ship target installs, which key named it, and what it must NOT carry. */
165
181
  export interface ResolvedShipBundle {
166
182
  /** The declared path as written — project-relative or absolute. */
@@ -10,7 +10,7 @@
10
10
  // only) > derived from package.json. Never a yargs `default:`, which is
11
11
  // indistinguishable from a value the user typed and would clobber the config.
12
12
  import { validateMimeTypes } from './mime.js';
13
- import { basename } from 'node:path';
13
+ import { basename, isAbsolute, posix, relative, resolve, sep } from 'node:path';
14
14
  import { DEFAULT_GJS_FLOOR, DEFAULT_NODE_FLOOR } from './depends.js';
15
15
  import { resolveShipFlatpakSettings } from './flatpak-config.js';
16
16
  import { assertRelease, normaliseVersion } from './version.js';
@@ -221,6 +221,12 @@ export function resolveShipSettings(input) {
221
221
  fontFiles: discovered.fontFiles,
222
222
  extraFiles: ship.extraFiles ?? {},
223
223
  execArgs: ship.execArgs ?? [],
224
+ executables: resolveShipExecutables({
225
+ declared: ship.executables,
226
+ binaryName,
227
+ projectDir: input.projectDir,
228
+ bundleDir: discovered.bundleDir,
229
+ }),
224
230
  outDir: input.cli.outDir ?? ship.outDir ?? 'ship',
225
231
  arch: input.cli.arch ?? process.arch,
226
232
  app,
@@ -231,6 +237,61 @@ export function resolveShipSettings(input) {
231
237
  };
232
238
  return { settings, metadata, warnings };
233
239
  }
240
+ /** A command name is a file name on every layout and a command a person types. */
241
+ const EXECUTABLE_NAME = /^[a-z0-9][a-z0-9._+-]*$/;
242
+ /**
243
+ * `gjsify.ship.executables` → the further launchers of one package.
244
+ *
245
+ * REFUSED rather than normalised, for each rule: a name collision between two entries
246
+ * is one launcher silently replacing the other (`placeStage` would catch the Linux
247
+ * case and miss a Windows `Foo.cmd`/`foo.cmd` pair on a case-insensitive disk), an
248
+ * entry outside the bundle directory is a file the stage does not carry, and a name
249
+ * with a path separator or a space escapes `bin/`. All three install cleanly and
250
+ * start nothing, which is the class this command is built against.
251
+ */
252
+ export function resolveShipExecutables(input) {
253
+ const { declared } = input;
254
+ if (declared === undefined)
255
+ return [];
256
+ if (!Array.isArray(declared)) {
257
+ throw new Error('gjsify ship: `gjsify.ship.executables` must be a list of `{ "name", "entry", "gui"? }` objects.');
258
+ }
259
+ const seen = new Set([input.binaryName]);
260
+ return declared.map((row, index) => {
261
+ const at = `gjsify.ship.executables[${index}]`;
262
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
263
+ throw new Error(`gjsify ship: \`${at}\` must be an object with \`name\` and \`entry\`.`);
264
+ }
265
+ const { name, entry, gui, ...unknown } = row;
266
+ const stray = Object.keys(unknown);
267
+ if (stray.length > 0) {
268
+ throw new Error(`gjsify ship: \`${at}\` has unknown key(s) ${stray.join(', ')}; known: name, entry, gui.`);
269
+ }
270
+ if (typeof name !== 'string' || !EXECUTABLE_NAME.test(name)) {
271
+ throw new Error(`gjsify ship: \`${at}.name\` must match ${EXECUTABLE_NAME} — it becomes \`bin/<name>\`, ` +
272
+ `\`Contents/MacOS/<name>\` and \`<name>.cmd\`, so it is lower-case with no path separator or space.`);
273
+ }
274
+ if (seen.has(name)) {
275
+ throw new Error(`gjsify ship: \`${at}.name\` "${name}" is already taken — by the package's own \`binaryName\` ` +
276
+ 'or by an earlier entry. Two launchers cannot share one file name.');
277
+ }
278
+ seen.add(name);
279
+ if (typeof entry !== 'string' || entry === '') {
280
+ throw new Error(`gjsify ship: \`${at}.entry\` must be the project-relative path of a built bundle.`);
281
+ }
282
+ if (gui !== undefined && typeof gui !== 'boolean') {
283
+ throw new Error(`gjsify ship: \`${at}.gui\` must be a boolean.`);
284
+ }
285
+ const abs = isAbsolute(entry) ? entry : resolve(input.projectDir, entry);
286
+ const rel = relative(input.bundleDir, abs);
287
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
288
+ throw new Error(`gjsify ship: \`${at}.entry\` ${entry} is outside ${input.bundleDir}, the directory \`gjsify.main\` ` +
289
+ 'lives in — only that directory is staged, so the launcher would point at a file the package ' +
290
+ 'does not carry. Build the entries into one directory.');
291
+ }
292
+ return { name, entry: rel.split(sep).join(posix.sep), gui: gui === true };
293
+ });
294
+ }
234
295
  /**
235
296
  * The ONE place a ship target's ENTRY is decided — {@link resolveShipApp}'s twin.
236
297
  *
@@ -140,6 +140,7 @@ export function toPackSettings(settings) {
140
140
  minGjsVersion: settings.minGjsVersion,
141
141
  minNodeVersion: settings.minNodeVersion,
142
142
  flatpak: settings.flatpak,
143
+ executables: settings.executables.map(({ name, gui }) => ({ name, gui })),
143
144
  };
144
145
  }
145
146
  /** Absolute path of a stage's manifest. */
@@ -499,6 +500,18 @@ function readPackSettings(data, at) {
499
500
  minGjsVersion: expectString(data.minGjsVersion, field('minGjsVersion')),
500
501
  minNodeVersion: expectString(data.minNodeVersion, field('minNodeVersion')),
501
502
  flatpak: readFlatpakSettings(record(data.flatpak, field('flatpak')), field('flatpak')),
503
+ // OPTIONAL on read and NOT a schema bump: a stage written before
504
+ // `gjsify.ship.executables` is a one-executable stage, `[]` is exactly
505
+ // right for it, and an older reader meeting a newer stage refuses the
506
+ // extra `.exe` stubs by name in its architecture check rather than
507
+ // dropping them silently.
508
+ executables: expectArray(data.executables ?? [], field('executables')).map((entry, index) => {
509
+ const row = record(entry, field(`executables[${index}]`));
510
+ if (typeof row.gui !== 'boolean') {
511
+ throw new Error(`gjsify ship: ${field(`executables[${index}].gui`)} must be a boolean.`);
512
+ }
513
+ return { name: expectString(row.name, field(`executables[${index}].name`)), gui: row.gui };
514
+ }),
502
515
  };
503
516
  }
504
517
  /**
@@ -69,7 +69,7 @@ export type FormatId = 'deb' | 'rpm' | 'flatpak' | 'appimage' | 'macos-app' | 'm
69
69
  * instead of being rediscovered per call site. A `.deb` says
70
70
  * `Depends: gir1.2-gtk-4.0`, an `.rpm` says `Requires: gtk4`, and both are
71
71
  * package names in a distribution's namespace. A Flatpak has no such field: it
72
- * declares ONE dependency, its runtime (`org.gnome.Platform//50`), in the
72
+ * declares ONE dependency, its runtime (`org.gnome.Platform//51`), in the
73
73
  * manifest — and inside that runtime a typelib is either present or nothing on
74
74
  * the system provides it, so there is no package to name and no gap a name
75
75
  * could close.
@@ -408,6 +408,24 @@ export interface PackSettings {
408
408
  minNodeVersion: string;
409
409
  /** The Flatpak manifest's non-payload half, fully defaulted at stage time. */
410
410
  flatpak: ShipFlatpakSettings;
411
+ /**
412
+ * The package's FURTHER executables (`gjsify.ship.executables`); `[]` for a single-executable
413
+ * package. Name and subsystem only: the entry each one runs is already inside its staged
414
+ * launcher, and the packing host needs the names for the Windows arch exemption.
415
+ */
416
+ executables: ShipExecutableFacts[];
417
+ }
418
+ /** What the packing host needs to know about one further executable. */
419
+ export interface ShipExecutableFacts {
420
+ /** The command name — `bin/<name>`, `Contents/MacOS/<name>`, `<name>.cmd`. */
421
+ name: string;
422
+ /** Whether the windows layout stages a GUI-subsystem `<name>.exe` for it. */
423
+ gui: boolean;
424
+ }
425
+ /** One further executable, as resolved from `gjsify.ship.executables`. */
426
+ export interface ShipExecutable extends ShipExecutableFacts {
427
+ /** Its entry inside {@link ShipSettings.bundleDir}, POSIX-separated. */
428
+ entry: string;
411
429
  }
412
430
  /**
413
431
  * What the generated Flatpak manifest says about everything that is not the payload.
@@ -507,6 +525,8 @@ export interface ShipSettings extends PackSettings {
507
525
  extraFiles: Record<string, string>;
508
526
  /** Arguments appended to the launcher's `exec` line. */
509
527
  execArgs: string[];
528
+ /** The further executables with their entries; narrows {@link PackSettings.executables}. */
529
+ executables: ShipExecutable[];
510
530
  /** Output root; `stage/`, `overlay/` and `out/` hang under it. */
511
531
  outDir: string;
512
532
  /** Target architecture in `process.arch` spelling. */