@gjsify/cli 0.64.9 → 0.64.11

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.
@@ -62,7 +62,7 @@ system.exit(await runDevtoolsCli(system.programArgs));
62
62
  * `instances` — so anything a rig does by hand over `gdbus` has a scriptable spelling.
63
63
  */
64
64
  export const devtoolsCommand = {
65
- command: 'devtools',
65
+ command: 'devtools <operation> [operands..]',
66
66
  description: 'Drive a running devtools-enabled app from a script: capture a screenshot to a file, find a widget, ' +
67
67
  'activate it, send a key. Peer of `gjsify debug`, which speaks MCP for an AI agent.',
68
68
  builder: (yargs) => yargs
@@ -1,5 +1,6 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  import { type ParsedSpec } from '../utils/parse-spec.js';
3
+ import { type PlatformTarget } from '../utils/platform-check.js';
3
4
  interface DlxOptions {
4
5
  spec: string;
5
6
  binOrArg?: string;
@@ -31,6 +32,12 @@ export interface EnsureOpts {
31
32
  * Part of the cache key, so the gjs and node trees never share an entry.
32
33
  */
33
34
  extraSpecs?: readonly string[];
35
+ /**
36
+ * Install the tree for this platform rather than the host's: a package that is
37
+ * copied into another machine's artifact (`gjsify ship` fetching a darwin GTK
38
+ * closure on Linux). Part of the cache key.
39
+ */
40
+ platform?: PlatformTarget;
34
41
  }
35
42
  /**
36
43
  * Resolve (installing + caching when remote) the on-disk directory of a package
@@ -16,6 +16,7 @@ import { resolveGjsEntry } from '../utils/resolve-gjs-entry.js';
16
16
  import { doubleDashArgs } from '../utils/double-dash-args.js';
17
17
  import { cacheDirFor, createCacheKey, getValidCachedPkg, makePrepareDir, resolveInstalledPkgDir, symlinkSwap, } from '../utils/dlx-cache.js';
18
18
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
19
+ import { describePlatformTarget } from '../utils/platform-check.js';
19
20
  export const dlxCommand = {
20
21
  command: 'dlx <spec> [binOrArg] [extraArgs..]',
21
22
  description: 'Run the GJS bundle of an npm-published package without installing it locally.',
@@ -118,7 +119,10 @@ export async function ensurePkgDir(parsed, opts) {
118
119
  return { pkgDir: parsed.path, cachedPkgName: null };
119
120
  }
120
121
  const specs = [parsed.spec, ...(opts.extraSpecs ?? [])];
121
- const cacheKey = createCacheKey({ packages: specs });
122
+ const cacheKey = createCacheKey({
123
+ packages: specs,
124
+ ...(opts.platform ? { platform: describePlatformTarget(opts.platform) } : {}),
125
+ });
122
126
  const cacheDir = cacheDirFor(cacheKey);
123
127
  const cached = opts.cacheMaxAge > 0 ? getValidCachedPkg(cacheDir, opts.cacheMaxAge) : undefined;
124
128
  if (cached) {
@@ -145,6 +149,7 @@ export async function ensurePkgDir(parsed, opts) {
145
149
  lockfile: true,
146
150
  frozen: opts.frozen,
147
151
  progress,
152
+ ...(opts.platform ? { platform: opts.platform } : {}),
148
153
  });
149
154
  const liveTarget = symlinkSwap(cacheDir, prepareDir);
150
155
  return {
@@ -48,7 +48,7 @@ import { buildPublishHeaders, escapePackageName } from '../utils/publish-headers
48
48
  import { packWorkspace } from './pack.js';
49
49
  import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
50
50
  import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
51
- import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, probePackageName, verifyPublishedVersion, } from '../utils/publish-readback.js';
51
+ import { budgetForPayload, DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, probePackageName, verifyPublishedVersion, } from '../utils/publish-readback.js';
52
52
  import { collectWorkspacePins, REQUIRED_BLOCK } from '../utils/publish-pins.js';
53
53
  import { appendLedger, DEFAULT_HOLD_WAIT_MS, findUnavailablePins, holdsPublish, markHeldPublished, pendingEntries, readLedger, recordUnserved, renderLedgerSummary, settleUnserved, summariseReasons, } from '../utils/publish-hold.js';
54
54
  import { describeClass, statusTokenFromEnv } from '../utils/publish-status.js';
@@ -584,9 +584,10 @@ export async function publishWorkspace(input) {
584
584
  * `publish-unconfirmed`. Same origin, same credential, one request later.
585
585
  */
586
586
  const readBack = async () => {
587
- const budgetMs = input.verifyBudgetMs ?? DEFAULT_VERIFY_BUDGET_MS;
588
- if (budgetMs <= 0)
587
+ const configuredMs = input.verifyBudgetMs ?? DEFAULT_VERIFY_BUDGET_MS;
588
+ if (configuredMs <= 0)
589
589
  return undefined;
590
+ const budgetMs = budgetForPayload(configuredMs, bodyStr.length);
590
591
  return verifyPublishedVersion({
591
592
  registry: registryClean,
592
593
  name: packed.name,
@@ -35,6 +35,7 @@ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
35
35
  import { buildDeb } from '../utils/ship/deb.js';
36
36
  import { deriveDepends, warnAboutGjsFloor, warnAboutNodeFloor } from '../utils/ship/depends.js';
37
37
  import { isGtkRuntimeTarget, stageAppRuntime } from '../utils/ship/app-runtime.js';
38
+ import { resolveNodeModulesClosure, stageNodeModules } from '../utils/ship/node-modules.js';
38
39
  import { acquireGtkRuntime } from '../utils/ship/gtk-runtime-acquire.js';
39
40
  import { discoverPayload } from '../utils/ship/discover.js';
40
41
  import { appDirFor, appDirPayload, appImageHostRequirements, appImageRuntimeNotice, buildAppImage, findPinnedRuntime, stampAppDirTimes, } from '../utils/ship/appimage.js';
@@ -44,7 +45,7 @@ import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
44
45
  import { iconSizes, resolveAppIcon } from '../utils/ship/icons.js';
45
46
  import { localizeMetadata } from '../utils/ship/localize-metadata.js';
46
47
  import { assertHostCanFinish, assertKindCanPack, assertToolsInstalled, canCarryTicket, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
47
- import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, windowsGuiLauncherPath, LAYOUT_NAMES, LAYOUTS, } from '../utils/ship/layout.js';
48
+ import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, payloadArchExemptions, LAYOUT_NAMES, } from '../utils/ship/layout.js';
48
49
  import { isNodeRuntimeTarget, resolveNodeRuntime } from '../utils/ship/node-runtime.js';
49
50
  import { isUnder, SHARE } from '../utils/ship/share-dirs.js';
50
51
  import { compileSchemasForPayload, compileSchemasForStage } from '../utils/ship/schemas.js';
@@ -416,6 +417,7 @@ async function assemble(args) {
416
417
  // carrying a second copy inside the payload would be a private interpreter no
417
418
  // distribution update ever patches.
418
419
  const runtime = await resolveCarriedRuntime(layout, settings, projectDir);
420
+ carryNodeModules(runtime, layout, settings, projectDir, ship.nodeModules, ship.nodeModulesExclude);
419
421
  const stageInputs = {
420
422
  bundleFiles: discovered.bundleFiles,
421
423
  launcher: renderLauncher(settings, basename(settings.bundlePath), layout, runtime.launcher),
@@ -510,7 +512,7 @@ async function assemble(args) {
510
512
  // stub unconditionally (it used to skip arm64 entirely), so an arm64 stage
511
513
  // legitimately carries one x64 PE beside its arm64 payload.
512
514
  assertPayloadMatchesArch(readStage([stageDir], staged), settings.arch, {
513
- exempt: layout === LAYOUTS.windows ? new Set([windowsGuiLauncherPath(settings)]) : undefined,
515
+ exempt: payloadArchExemptions(layout.os, settings),
514
516
  });
515
517
  // What the map carried into a layout that has no install step for it.
516
518
  //
@@ -784,12 +786,19 @@ async function resolveCarriedRuntime(layout, settings, projectDir) {
784
786
  return empty;
785
787
  // The closure is fetched, not installed: a project that lists it puts a second
786
788
  // GLib next to Homebrew's in its dev tree (see `gtk-runtime-acquire.ts`).
789
+ // Fetched FOR THE TARGET: on a Linux host the package's own `os`/`cpu` gate
790
+ // refused it as EBADPLATFORM, and every macOS and Windows artifact assembled
791
+ // on Linux shipped without GTK, starting only where Homebrew happened to have it.
787
792
  const acquired = await acquireGtkRuntime({
788
793
  target,
789
794
  version: cliVersion(),
790
795
  discovery: { cwd: projectDir },
791
- ensure: async (spec) => (await ensurePkgDir(parseSpec(spec), { verbose: false, cacheMaxAge: DLX_CACHE_MAX_AGE_MIN, frozen: false }))
792
- .pkgDir,
796
+ ensure: async (spec) => (await ensurePkgDir(parseSpec(spec), {
797
+ verbose: false,
798
+ cacheMaxAge: DLX_CACHE_MAX_AGE_MIN,
799
+ frozen: false,
800
+ platform: { os: layout.os, cpu: settings.arch },
801
+ })).pkgDir,
793
802
  log: (message) => console.log(`${LOG} ${message}`),
794
803
  });
795
804
  return stageAppRuntime({
@@ -802,6 +811,35 @@ async function resolveCarriedRuntime(layout, settings, projectDir) {
802
811
  ...(acquired.kind === 'failed' ? { gtkFetchFailure: acquired.reason } : {}),
803
812
  });
804
813
  }
814
+ /**
815
+ * `gjsify.ship.nodeModules`: the npm packages an `--app node` bundle imports and cannot contain,
816
+ * staged beside the bundle with what they need at runtime (`utils/ship/node-modules.ts`).
817
+ *
818
+ * Only for a node payload: a GJS bundle inlines or rewrites them, so one declaration can serve a
819
+ * project that ships GJS on Linux and Node on macOS and Windows. Mutates `runtime` because the
820
+ * files take the same route as the carried interpreter — already stage-relative, past the planner.
821
+ */
822
+ function carryNodeModules(runtime, layout, settings, projectDir, declared, exclude) {
823
+ if (declared === undefined || declared.length === 0 || settings.app !== 'node')
824
+ return;
825
+ const closure = resolveNodeModulesClosure(declared, projectDir, {
826
+ target: { os: layout.os, cpu: settings.arch },
827
+ exclude,
828
+ });
829
+ runtime.files.push(...stageNodeModules(closure, layout.dirs(settings).bundle));
830
+ runtime.found.push(`node_modules: ${closure.packages.length} package(s) for ${declared.join(', ')}`);
831
+ if (closure.excluded.length > 0) {
832
+ console.log(`${LOG} node_modules: left out by gjsify.ship.nodeModulesExclude: ${closure.excluded.join(', ')}`);
833
+ }
834
+ if (closure.skippedForTarget.length > 0) {
835
+ console.log(`${LOG} node_modules: left out, installed but built for another platform than ${layout.os}-${settings.arch}: ` +
836
+ closure.skippedForTarget.join(', '));
837
+ }
838
+ if (closure.skippedOptional.length > 0) {
839
+ console.log(`${LOG} node_modules: ${closure.skippedOptional.length} optional dependenc(ies) not installed, ` +
840
+ `so not carried (other platforms' builds): ${closure.skippedOptional.join(', ')}`);
841
+ }
842
+ }
805
843
  /** How long a fetched runtime stays in the dlx cache — the same week `gjsify showcase` uses. */
806
844
  const DLX_CACHE_MAX_AGE_MIN = 60 * 24 * 7;
807
845
  /**
@@ -979,7 +1017,9 @@ async function packOne(input) {
979
1017
  // asserting afterwards would compare against a label that no longer names an
980
1018
  // architecture, and the mismatch that matters is exactly the one on a payload
981
1019
  // that IS architecture-specific.
982
- assertPayloadMatchesArch(assembled, input.arch);
1020
+ assertPayloadMatchesArch(assembled, input.arch, {
1021
+ exempt: payloadArchExemptions(format.layoutOs, settings),
1022
+ });
983
1023
  // The second half of the same idea, on the other label this artifact carries:
984
1024
  // something declares which interpreter runs the app, and until now nothing
985
1025
  // compared it to the launcher that will. They CAN disagree — a stage assembled
@@ -606,6 +606,31 @@ export interface ConfigDataShip extends AppMetadata {
606
606
  * bundle seal and `dlopen` both refuse. A target with no key stages nothing.
607
607
  */
608
608
  bundledTypelibs?: string[] | ShipBundledTypelibsOptions;
609
+ /**
610
+ * npm packages the `--app node` bundle imports but cannot contain, by NAME — e.g.
611
+ * `["node-llama-cpp"]`.
612
+ *
613
+ * An `--app node` build keeps every Node-API addon package external (an addon finds its
614
+ * `.node` relative to its own files), so the bundle `import`s it by name at runtime. A `.app`
615
+ * or program directory has no `npm install`; without this entry the artifact fails with
616
+ * `ERR_MODULE_NOT_FOUND` before the program starts. Each name is staged into the bundle's
617
+ * `node_modules/` with its `dependencies` and `optionalDependencies`, resolved from the
618
+ * project the way Node would, at the paths they are installed at (two versions of one package
619
+ * stay nested). An optional dependency that is not installed is skipped, which is how a
620
+ * per-platform addon package is chosen: `gjsify install --os <os> --cpu <cpu>` before `ship`.
621
+ * A required one that is missing is an error.
622
+ *
623
+ * Ignored for a GJS payload, so one declaration serves a project that ships GJS on Linux and
624
+ * Node on macOS and Windows. `@gjsify/node-gi` is not listed here; `ship` carries it itself.
625
+ */
626
+ nodeModules?: string[];
627
+ /**
628
+ * Packages {@link ConfigDataShip.nodeModules} leaves out wherever they turn up in the closure —
629
+ * for an optional dependency the project does not want to ship, e.g. the CUDA and Vulkan
630
+ * builds of an inference addon (`@node-llama-cpp/win-x64-cuda`, hundreds of MB) when the
631
+ * program only runs on the CPU. Names a declared package itself are not affected.
632
+ */
633
+ nodeModulesExclude?: string[];
609
634
  /**
610
635
  * Additions for ONE package format — `deb` or `rpm` — on top of everything above.
611
636
  *
@@ -1,6 +1,12 @@
1
1
  interface CacheKeyOpts {
2
2
  packages: string[];
3
3
  registries?: Record<string, string>;
4
+ /**
5
+ * The platform the tree was installed FOR (`darwin-arm64`), when not the host's.
6
+ * The same specs filter to a different tree per platform. Absent, the key is
7
+ * the one it always was, so existing cache entries stay valid.
8
+ */
9
+ platform?: string;
4
10
  }
5
11
  /** Stable, sorted JSON hash of inputs. */
6
12
  export declare function createCacheKey(opts: CacheKeyOpts): string;
@@ -19,7 +19,7 @@ function lexCompare(a, b) {
19
19
  export function createCacheKey(opts) {
20
20
  const sortedPkgs = [...opts.packages].sort(lexCompare);
21
21
  const sortedRegs = Object.entries(opts.registries ?? {}).sort(([a], [b]) => lexCompare(a, b));
22
- const payload = JSON.stringify([sortedPkgs, sortedRegs]);
22
+ const payload = JSON.stringify(opts.platform === undefined ? [sortedPkgs, sortedRegs] : [sortedPkgs, sortedRegs, opts.platform]);
23
23
  return createHash('sha256').update(payload).digest('hex');
24
24
  }
25
25
  /** $XDG_CACHE_HOME/gjsify/dlx — created if missing. */
@@ -143,8 +143,9 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
143
143
  // The triple this install materialises FOR. Normally the running host;
144
144
  // `--os/--cpu/--libc` (npm's config keys, read from the environment by
145
145
  // `resolveHostPlatform`) override it, which is what makes the darwin/win32/musl
146
- // branches reviewable from one machine.
147
- const target = resolveHostPlatform();
146
+ // branches reviewable from one machine. A caller that installs for another
147
+ // machine's artifact (`opts.platform`) wins over both.
148
+ const target = opts.platform ?? resolveHostPlatform();
148
149
  const force = readPlatformForce(process.env);
149
150
  log('install: platform target %s%s', describePlatformTarget(target), force ? ' (--force: checks bypassed)' : '');
150
151
  let nodes;
@@ -1,4 +1,5 @@
1
1
  import type { ProgressReporter } from './install-progress.js';
2
+ import type { PlatformTarget } from './platform-check.js';
2
3
  export type { ProgressEvent, ProgressPhase, ProgressReporter } from './install-progress.js';
3
4
  export { makeProgressReporter } from './install-progress.js';
4
5
  export interface InstallOptions {
@@ -79,6 +80,15 @@ export interface InstallOptions {
79
80
  * and the warning falls back to bare ranges.
80
81
  */
81
82
  specOrigins?: Map<string, string[]>;
83
+ /**
84
+ * The `os`/`cpu`/`libc` triple to install FOR, instead of the host's (and of
85
+ * `npm_config_os`/`_cpu`/`_libc`). For a tree that is copied into an artifact
86
+ * for another machine and never run here — `gjsify ship` assembling a darwin or
87
+ * win32 bundle on Linux fetches that target's GTK closure, whose `os`/`cpu`
88
+ * gate would otherwise refuse it as EBADPLATFORM (ADR 0024 § A1). The npm
89
+ * backend passes it on as `--os`/`--cpu`/`--libc`.
90
+ */
91
+ platform?: PlatformTarget;
82
92
  }
83
93
  export interface InstallResult {
84
94
  /** Requested top-level packages with the version each resolved to. Empty for
@@ -24,7 +24,7 @@ export async function installPackages(opts) {
24
24
  const installed = await installPackagesNative(opts);
25
25
  return { installed };
26
26
  }
27
- async function installViaNpm({ prefix, specs, verbose, registry }) {
27
+ async function installViaNpm({ prefix, specs, verbose, registry, platform }) {
28
28
  if (specs.length === 0) {
29
29
  throw new Error('installPackages: empty specs list');
30
30
  }
@@ -42,6 +42,8 @@ async function installViaNpm({ prefix, specs, verbose, registry }) {
42
42
  '--prefix',
43
43
  prefix,
44
44
  ...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
45
+ ...(platform ? ['--os', platform.os, '--cpu', platform.cpu] : []),
46
+ ...(platform?.libc ? ['--libc', platform.libc] : []),
45
47
  ...specs,
46
48
  ];
47
49
  // Through `spawnToCompletion` because a bare `spawn('npm', …)` is ENOENT on
@@ -146,6 +146,8 @@ export declare function probeTimeoutFor(configuredMs: number, remainingMs: numbe
146
146
  * state is what the `recorded-not-served` verdict names.
147
147
  */
148
148
  export declare const DEFAULT_VERIFY_BUDGET_MS = 600000;
149
+ /** The read-back budget for a payload: `configuredMs`, longer if it is the default and the upload is large. */
150
+ export declare function budgetForPayload(configuredMs: number, payloadBytes: number): number;
149
151
  /**
150
152
  * Poll the registry until `name@version` resolves, or the budget runs out, then
151
153
  * say WHICH of the three states it found (see {@link ReadbackVerdict}).
@@ -138,6 +138,26 @@ export function probeTimeoutFor(configuredMs, remainingMs) {
138
138
  * state is what the `recorded-not-served` verdict names.
139
139
  */
140
140
  export const DEFAULT_VERIFY_BUDGET_MS = 600_000;
141
+ /**
142
+ * Payload size above which the DEFAULT window starts to grow, and the growth per MiB.
143
+ *
144
+ * v0.64.9, run 40145205912: `@gjsify/gtk-runtime-win32-arm64` PUT 69.7 MB, got a 202,
145
+ * and the read-back gave up after 600 s with `not-published`; the registry's `time`
146
+ * map recorded the version 20 s later. The lag scales with what npm has to ingest,
147
+ * and the 600 s ceiling was measured on packages of a few MB. So the window grows by
148
+ * 10 s per MiB past 32 MiB (that package: ~945 s). Only the PATIENT default grows —
149
+ * an explicit short budget (the sweep's `--verify-timeout 5`) is a caller's decision.
150
+ * An upload npm accepted but never recorded still ends in `not-published`.
151
+ */
152
+ const LARGE_PAYLOAD_BYTES = 32 * 1024 * 1024;
153
+ const EXTRA_BUDGET_PER_MIB_MS = 10_000;
154
+ /** The read-back budget for a payload: `configuredMs`, longer if it is the default and the upload is large. */
155
+ export function budgetForPayload(configuredMs, payloadBytes) {
156
+ if (configuredMs < DEFAULT_VERIFY_BUDGET_MS || payloadBytes <= LARGE_PAYLOAD_BYTES)
157
+ return configuredMs;
158
+ const extraMiB = (payloadBytes - LARGE_PAYLOAD_BYTES) / (1024 * 1024);
159
+ return configuredMs + Math.ceil(extraMiB) * EXTRA_BUDGET_PER_MIB_MS;
160
+ }
141
161
  /**
142
162
  * Make one probe's URL uncacheable, by giving it a key no cache holds.
143
163
  *
@@ -40,6 +40,12 @@ export async function acquireGtkRuntime(input) {
40
40
  return { kind: 'present' };
41
41
  const name = gtkRuntimePackageName(input.target);
42
42
  const spec = `${name}@${input.version}`;
43
+ // An offline build, or a suite that must not download 100 MB per run, stages what is
44
+ // reachable and nothing more. The e2e suites that assert "a project without the packages
45
+ // stages no runtime" held only while the fetch failed on Linux for every foreign target.
46
+ if ((discovery.env ?? process.env)['GJSIFY_GTK_RUNTIME_FETCH'] === '0') {
47
+ return { kind: 'failed', reason: `GJSIFY_GTK_RUNTIME_FETCH=0 turned off the fetch of ${spec}` };
48
+ }
43
49
  input.log?.(`${name} is not installed here, and is not meant to be — fetching ${spec} into the cache ` +
44
50
  '(about 100 MB, once)');
45
51
  let dir;
@@ -330,6 +330,16 @@ export declare function launcherPath(layout: Layout, identity: LayoutIdentity):
330
330
  * is not there, at exit 0, with a window that never appears.
331
331
  */
332
332
  export declare function windowsGuiLauncherPath(identity: LayoutIdentity): string;
333
+ /**
334
+ * The payload paths exempt from the architecture check for one OS (`assertPayloadMatchesArch`).
335
+ *
336
+ * On windows that is the GUI-launcher stub, x86-64 machine code on every windows arch by design.
337
+ * ONE function for the two places that read the stage back — the assemble phase and the pack
338
+ * phase — because the pack phase once had its own call without the exemption: an arm64 stage
339
+ * assembled fine and every package refused it with "stamped.exe is built for x64". `undefined`
340
+ * for an OS with no such file.
341
+ */
342
+ export declare function payloadArchExemptions(os: HostOs, identity: LayoutIdentity): ReadonlySet<string> | undefined;
333
343
  /**
334
344
  * What the windows GUI launcher calls the file it writes under `%TEMP%`.
335
345
  *
@@ -552,6 +552,18 @@ export function windowsGuiLauncherPath(identity) {
552
552
  }
553
553
  return posix.join(layout.dirs(identity).launcher, `${identity.binaryName}.exe`);
554
554
  }
555
+ /**
556
+ * The payload paths exempt from the architecture check for one OS (`assertPayloadMatchesArch`).
557
+ *
558
+ * On windows that is the GUI-launcher stub, x86-64 machine code on every windows arch by design.
559
+ * ONE function for the two places that read the stage back — the assemble phase and the pack
560
+ * phase — because the pack phase once had its own call without the exemption: an arm64 stage
561
+ * assembled fine and every package refused it with "stamped.exe is built for x64". `undefined`
562
+ * for an OS with no such file.
563
+ */
564
+ export function payloadArchExemptions(os, identity) {
565
+ return os === 'win32' ? new Set([windowsGuiLauncherPath(identity)]) : undefined;
566
+ }
555
567
  /**
556
568
  * What the windows GUI launcher calls the file it writes under `%TEMP%`.
557
569
  *
@@ -0,0 +1,39 @@
1
+ import type { StagedFile } from './types.js';
2
+ /** One package of the closure, ready to stage. */
3
+ export interface NodeModulePackage {
4
+ name: string;
5
+ version: string;
6
+ /** Where the files are read from — the installed directory. */
7
+ dir: string;
8
+ /** POSIX path below the staged `node_modules/`: `yargs`, `a/node_modules/b`, `@scope/pkg`. */
9
+ dest: string;
10
+ }
11
+ /** The platform the artifact is for, spelled like npm's `os` / `cpu` fields (`darwin`, `win32`; `arm64`, `x64`). */
12
+ export interface NodeModulesTarget {
13
+ os: string;
14
+ cpu: string;
15
+ }
16
+ export interface NodeModulesOptions {
17
+ /** Platform filter, see {@link NodeModulesTarget}. Without one nothing is filtered by `os` / `cpu`. */
18
+ target?: NodeModulesTarget;
19
+ /** Package names left out wherever they appear below a declared one — `gjsify.ship.nodeModulesExclude`. */
20
+ exclude?: readonly string[];
21
+ }
22
+ export interface NodeModulesClosure {
23
+ packages: NodeModulePackage[];
24
+ /** Optional dependencies that are not installed — expected for other platforms, listed for the log. */
25
+ skippedOptional: string[];
26
+ /** Dependencies installed here but built for another platform, named by their `os` / `cpu` fields. */
27
+ skippedForTarget: string[];
28
+ /** Dependencies the project asked to leave out, and that were reached. */
29
+ excluded: string[];
30
+ }
31
+ /**
32
+ * Resolve `names` and everything they need at runtime, from `cwd`.
33
+ *
34
+ * Throws on a name that is not installed, a required dependency that is not, and two different
35
+ * installed copies that would land on one staged path.
36
+ */
37
+ export declare function resolveNodeModulesClosure(names: readonly string[], cwd: string, options?: NodeModulesOptions): NodeModulesClosure;
38
+ /** The closure as stage-relative files, under `<bundleDir>/node_modules/`. */
39
+ export declare function stageNodeModules(closure: NodeModulesClosure, bundleDir: string): StagedFile[];
@@ -0,0 +1,192 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // npm packages an `--app node` bundle imports but cannot contain: found, then staged as a TREE.
3
+ //
4
+ // WHY THIS EXISTS. `rolldown-plugin-gjsify`'s `nodeNativeExternalPlugin` keeps every Node-API
5
+ // addon package EXTERNAL in an `--app node` bundle, because an addon finds its `.node` relative
6
+ // to its own files and a bundle moves them ("an `--app node` bundle of a native dependency needs
7
+ // `node_modules` at runtime, like any `npm install`-ed program"). A shipped `.app` or program
8
+ // directory has no `npm install`. Until this module the only package `ship` carried was
9
+ // `@gjsify/node-gi`, by name — so a project with one more native dependency produced an artifact
10
+ // that fails at its first `import` on every machine but the author's. Measured on a real consumer
11
+ // (Stamped, `node-llama-cpp`): the released macOS `.app` exited with
12
+ // `ERR_MODULE_NOT_FOUND: Cannot find package 'node-llama-cpp'` before opening a window, and no
13
+ // CI step noticed because nothing ever started the artifact.
14
+ //
15
+ // THE DECLARATION. `gjsify.ship.nodeModules` lists package NAMES, nothing else. The names are the
16
+ // entry points the bundle imports; this module adds everything they need at runtime.
17
+ //
18
+ // THE CLOSURE is node's own resolution rule applied to the installed tree: for each package,
19
+ // `dependencies` and `optionalDependencies`, each found by walking `node_modules` upward from the
20
+ // package that asks for it. A missing optional dependency is skipped silently — that is the
21
+ // per-platform filter (`@node-llama-cpp/mac-arm64-metal` is optional; `gjsify install --os
22
+ // darwin --cpu arm64` is what puts the right one on disk; one that IS on disk but whose `os` /
23
+ // `cpu` fields exclude the artifact's target is left out too, since installs accumulate) — and a missing required one is an
24
+ // error naming the chain, because a package without its dependency is the very artifact this
25
+ // exists to prevent.
26
+ //
27
+ // THE LAYOUT IS THE INSTALLED ONE. Each package lands at the path it was FOUND at, relative to
28
+ // the first `node_modules` in that path: `node_modules/a/node_modules/b` stays nested, which is
29
+ // how two versions of `yargs` coexist. Flattening would be a guess about which one wins.
30
+ //
31
+ // NOT INCLUDED: `@gjsify/node-gi`, which `app-runtime.ts` stages as a trimmed tree of its own, and
32
+ // any nested `node_modules` that is not reached through a declared dependency (an npm install
33
+ // leaves dev-only trees there).
34
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
35
+ import { dirname, join, posix, relative, sep } from 'node:path';
36
+ import { isExecutableAsset } from './plan.js';
37
+ /** The package this module leaves to `app-runtime.ts`. */
38
+ const LEFT_TO_APP_RUNTIME = '@gjsify/node-gi';
39
+ function readManifest(dir) {
40
+ try {
41
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** `<from>/node_modules/<name>`, then each ancestor's — node's lookup, on the file system alone. */
48
+ function findInstalled(name, from) {
49
+ let dir = from;
50
+ for (;;) {
51
+ const candidate = join(dir, 'node_modules', ...name.split('/'));
52
+ if (existsSync(join(candidate, 'package.json')))
53
+ return candidate;
54
+ const parent = dirname(dir);
55
+ if (parent === dir)
56
+ return null;
57
+ dir = parent;
58
+ }
59
+ }
60
+ /** npm's rule for `os` / `cpu`: a `!x` entry blocks `x`; otherwise, if any plain entry exists, `value` must be one. */
61
+ function allows(field, value) {
62
+ const list = typeof field === 'string' ? [field] : (field ?? []);
63
+ if (list.includes(`!${value}`))
64
+ return false;
65
+ const positive = list.filter((entry) => !entry.startsWith('!'));
66
+ return positive.length === 0 || positive.includes(value);
67
+ }
68
+ /** The path below the first `node_modules` segment, POSIX-separated, or `null` when there is none. */
69
+ function destinationOf(found) {
70
+ const parts = found.split(sep);
71
+ const at = parts.indexOf('node_modules');
72
+ return at === -1 || at === parts.length - 1 ? null : parts.slice(at + 1).join('/');
73
+ }
74
+ /**
75
+ * Resolve `names` and everything they need at runtime, from `cwd`.
76
+ *
77
+ * Throws on a name that is not installed, a required dependency that is not, and two different
78
+ * installed copies that would land on one staged path.
79
+ */
80
+ export function resolveNodeModulesClosure(names, cwd, options = {}) {
81
+ const { target, exclude = [] } = options;
82
+ const packages = new Map();
83
+ const skippedOptional = [];
84
+ const skippedForTarget = [];
85
+ const excluded = [];
86
+ const visit = (name, from, via, optional) => {
87
+ if (name === LEFT_TO_APP_RUNTIME)
88
+ return;
89
+ if (via.length > 0 && exclude.includes(name)) {
90
+ if (!excluded.includes(name))
91
+ excluded.push(name);
92
+ return;
93
+ }
94
+ const found = findInstalled(name, from);
95
+ if (found === null) {
96
+ if (optional) {
97
+ if (!skippedOptional.includes(name))
98
+ skippedOptional.push(name);
99
+ return;
100
+ }
101
+ throw new Error(`gjsify ship: \`${name}\` is not installed${via.length > 0 ? ` (needed by ${via.join(' > ')})` : ''}. ` +
102
+ (via.length === 0
103
+ ? '`gjsify.ship.nodeModules` names it, so the artifact must carry it — install it, or remove the entry.'
104
+ : 'A package without its required dependency is an artifact that fails at its first import.'));
105
+ }
106
+ const dest = destinationOf(found);
107
+ if (dest === null) {
108
+ throw new Error(`gjsify ship: ${found} is not inside a node_modules directory, so it has no place to be staged.`);
109
+ }
110
+ const dir = realpathSync(found);
111
+ const manifest = readManifest(dir);
112
+ if (manifest === null)
113
+ throw new Error(`gjsify ship: ${join(dir, 'package.json')} is unreadable.`);
114
+ // A dependency built for another platform may well be on disk: `gjsify install --os` adds to a
115
+ // tree, it does not clear it. Carrying it would put a foreign binary in the artifact.
116
+ if (via.length > 0 &&
117
+ target !== undefined &&
118
+ !(allows(manifest.os, target.os) && allows(manifest.cpu, target.cpu))) {
119
+ if (!skippedForTarget.includes(name))
120
+ skippedForTarget.push(name);
121
+ return;
122
+ }
123
+ const existing = packages.get(dest);
124
+ if (existing !== undefined) {
125
+ if (existing.dir !== dir) {
126
+ throw new Error(`gjsify ship: two installed copies of \`${name}\` would both be staged at node_modules/${dest} ` +
127
+ `(${existing.dir} and ${dir}).`);
128
+ }
129
+ return;
130
+ }
131
+ packages.set(dest, { name, version: manifest.version ?? '0.0.0', dir, dest });
132
+ const chain = [...via, name];
133
+ for (const dep of Object.keys(manifest.dependencies ?? {}))
134
+ visit(dep, found, chain, false);
135
+ for (const dep of Object.keys(manifest.optionalDependencies ?? {}))
136
+ visit(dep, found, chain, true);
137
+ };
138
+ for (const name of names)
139
+ visit(name, cwd, [], false);
140
+ return {
141
+ packages: [...packages.values()].sort((a, b) => (a.dest < b.dest ? -1 : 1)),
142
+ skippedOptional: skippedOptional.sort(),
143
+ skippedForTarget: skippedForTarget.sort(),
144
+ excluded: excluded.sort(),
145
+ };
146
+ }
147
+ /**
148
+ * Every file of one package, package-relative, without the `node_modules` directories inside it —
149
+ * those are separate packages of the closure, or not wanted at all.
150
+ */
151
+ function listPackageFiles(root) {
152
+ const out = [];
153
+ const walk = (current) => {
154
+ for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
155
+ const full = join(current, entry.name);
156
+ if (entry.isDirectory()) {
157
+ if (entry.name !== 'node_modules')
158
+ walk(full);
159
+ }
160
+ else if (entry.isSymbolicLink()) {
161
+ throw new Error(`gjsify ship: ${full} is a symlink. The payload has to be self-contained — a package that ` +
162
+ 'links inside itself needs the link replaced by the file.');
163
+ }
164
+ else if (entry.isFile()) {
165
+ out.push(posix.join(...relative(root, full).split(sep)));
166
+ }
167
+ }
168
+ };
169
+ walk(root);
170
+ return out;
171
+ }
172
+ /** 0755 for what the source marks executable or is a shared library, 0644 for the rest. */
173
+ function modeOf(rel, abs) {
174
+ if (isExecutableAsset(rel))
175
+ return 0o755;
176
+ return (statSync(abs).mode & 0o111) !== 0 ? 0o755 : 0o644;
177
+ }
178
+ /** The closure as stage-relative files, under `<bundleDir>/node_modules/`. */
179
+ export function stageNodeModules(closure, bundleDir) {
180
+ const files = [];
181
+ for (const pkg of closure.packages) {
182
+ for (const rel of listPackageFiles(pkg.dir)) {
183
+ const abs = join(pkg.dir, ...rel.split('/'));
184
+ files.push({
185
+ path: posix.join(bundleDir, 'node_modules', pkg.dest, rel),
186
+ mode: modeOf(rel, abs),
187
+ source: { kind: 'file', path: abs },
188
+ });
189
+ }
190
+ }
191
+ return files;
192
+ }