@nativedesktop/cli 0.1.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.
@@ -0,0 +1,87 @@
1
+ // Linux packaging: assembles a self-contained AppDir (generated AppRun +
2
+ // .desktop + icons + mime XML, the gtk host binary, bundled Bun, the app
3
+ // payload), then packs it into an AppImage with appimagetool, falling back to
4
+ // mksquashfs when appimagetool isn't on PATH.
5
+ import { $ } from "bun";
6
+ import { chmodSync, cpSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { resolveHostBinary } from "@nativedesktop/host";
9
+ import type { NativeDesktopConfig } from "../config.ts";
10
+ import { installLinuxIcon } from "./icons.ts";
11
+ import { buildAppRun, buildDesktopEntry, buildMimeInfoXml, type ResolvedIdentity } from "./identity.ts";
12
+ import { assemblePayload } from "./payload.ts";
13
+ import { maybePublishUpdate } from "./updates.ts";
14
+ import type { PackageOptions, PackageResult } from "./index.ts";
15
+
16
+ export async function packageLinuxApp(
17
+ appDir: string,
18
+ config: NativeDesktopConfig,
19
+ identity: ResolvedIdentity,
20
+ outDir: string,
21
+ options: PackageOptions,
22
+ ): Promise<PackageResult> {
23
+ const linux = config.package?.linux;
24
+ const dist = join(outDir, "linux");
25
+ const appdir = join(dist, "AppDir");
26
+ if (linux?.appDirTemplate) {
27
+ cpSync(resolve(appDir, linux.appDirTemplate), appdir, { recursive: true });
28
+ }
29
+ mkdirSync(join(appdir, "usr", "bin"), { recursive: true });
30
+
31
+ const payload = await assemblePayload({
32
+ appDir,
33
+ config,
34
+ identity,
35
+ appRoot: join(appdir, "app"),
36
+ entry: options.entry,
37
+ compile: options.compile,
38
+ });
39
+
40
+ writeFileSync(join(appdir, "AppRun"), buildAppRun({
41
+ entry: payload.entry,
42
+ cwd: payload.cwd,
43
+ slug: identity.slug,
44
+ appId: identity.id,
45
+ pluginPaths: payload.pluginPaths,
46
+ }));
47
+ chmodSync(join(appdir, "AppRun"), 0o755);
48
+ writeFileSync(join(appdir, `${identity.slug}.desktop`), buildDesktopEntry(identity, linux));
49
+ await installLinuxIcon(identity, appDir, appdir);
50
+ const mimeXml = buildMimeInfoXml({ fileAssociations: identity.fileAssociations });
51
+ if (mimeXml) {
52
+ if (!identity.id) throw new Error("nd: mimeType'd file associations need app.id (the shared-mime-info package name)");
53
+ mkdirSync(join(appdir, "usr/share/mime/packages"), { recursive: true });
54
+ writeFileSync(join(appdir, `usr/share/mime/packages/${identity.id}.xml`), mimeXml);
55
+ }
56
+
57
+ const hostBinary = await resolveHostBinary({ backend: "gtk" });
58
+ const exe = join(appdir, "usr", "bin", identity.slug);
59
+ cpSync(hostBinary, exe, { dereference: true });
60
+ chmodSync(exe, 0o755);
61
+ const bunPath = config.package?.bunPath ?? Bun.which("bun");
62
+ if (!bunPath) throw new Error("nd: bun not found on PATH");
63
+ // dereference: true - `bun` on PATH is frequently a nix-store symlink.
64
+ cpSync(bunPath, join(appdir, "usr", "bin", "bun"), { dereference: true });
65
+ chmodSync(join(appdir, "usr", "bin", "bun"), 0o755);
66
+
67
+ let bundlePath = appdir;
68
+ const format = options.format ?? linux?.format ?? "appimage";
69
+ if (format === "appimage") {
70
+ const appImage = join(dist, `${identity.name}-${identity.version}.AppImage`);
71
+ await $`appimagetool ${appdir} ${appImage}`.quiet()
72
+ .catch(async () => { await $`mksquashfs ${appdir} ${appImage} -root-owned -noappend`.quiet(); });
73
+ console.error(`ND_PACKAGE_APPIMAGE ${appImage}`);
74
+ bundlePath = appImage;
75
+ }
76
+
77
+ const update = await maybePublishUpdate(config.package?.updates, {
78
+ identity,
79
+ platform: "linux",
80
+ distDir: dist,
81
+ member: "AppDir",
82
+ outDir,
83
+ });
84
+
85
+ console.error(`ND_PACKAGE_OK ${bundlePath}`);
86
+ return { bundlePath, updateManifest: update?.manifestPath, publicKey: update?.publicKey };
87
+ }
@@ -0,0 +1,18 @@
1
+ // Notarization gating: an explicit flag/config wins; the auto default must
2
+ // not submit an unsigned bundle even with Apple credentials exported.
3
+ import { describe, expect, test } from "bun:test";
4
+ import { resolveNotarize } from "./mac.ts";
5
+
6
+ describe("resolveNotarize", () => {
7
+ test("auto notarizes only with credentials AND a signed bundle", () => {
8
+ expect(resolveNotarize(undefined, true, true)).toBe(true);
9
+ expect(resolveNotarize(undefined, true, false)).toBe(false);
10
+ expect(resolveNotarize(undefined, false, true)).toBe(false);
11
+ expect(resolveNotarize(undefined, false, false)).toBe(false);
12
+ });
13
+
14
+ test("an explicit request or refusal wins over the auto default", () => {
15
+ expect(resolveNotarize(true, false, false)).toBe(true);
16
+ expect(resolveNotarize(false, true, true)).toBe(false);
17
+ });
18
+ });
@@ -0,0 +1,113 @@
1
+ // macOS packaging: assembles <Name>.app around the resolved host binary +
2
+ // bundled Bun + the app payload, deep-signs inside-out (ad-hoc by default;
3
+ // Developer-ID via config/APPLE_SIGN_IDENTITY) with the hardened runtime +
4
+ // allow-jit entitlements, and gates notarization on Apple credential presence.
5
+ import { $ } from "bun";
6
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { resolveHostBinary } from "@nativedesktop/host";
9
+ import type { NativeDesktopConfig } from "../config.ts";
10
+ import { installMacIcon } from "./icons.ts";
11
+ import { buildInfoPlist, type ResolvedIdentity } from "./identity.ts";
12
+ import { assemblePayload } from "./payload.ts";
13
+ import { ENTITLEMENTS_PLIST } from "./templates.ts";
14
+ import { maybePublishUpdate } from "./updates.ts";
15
+ import type { PackageOptions, PackageResult } from "./index.ts";
16
+
17
+ /** Notarization decision: an explicit flag/config wins; the auto default needs credentials AND a signed bundle. */
18
+ export function resolveNotarize(explicit: boolean | undefined, creds: boolean, signed: boolean): boolean {
19
+ return explicit ?? (creds && signed);
20
+ }
21
+
22
+ export async function packageMacApp(
23
+ appDir: string,
24
+ config: NativeDesktopConfig,
25
+ identity: ResolvedIdentity,
26
+ outDir: string,
27
+ options: PackageOptions,
28
+ ): Promise<PackageResult> {
29
+ const mac = config.package?.mac;
30
+ const dist = join(outDir, "mac");
31
+ const app = join(dist, `${identity.name}.app`);
32
+ const c = join(app, "Contents");
33
+ mkdirSync(join(c, "MacOS"), { recursive: true });
34
+ mkdirSync(join(c, "Resources"), { recursive: true });
35
+ mkdirSync(join(c, "Frameworks"), { recursive: true });
36
+
37
+ await assemblePayload({
38
+ appDir,
39
+ config,
40
+ identity,
41
+ appRoot: join(c, "Resources", "app"),
42
+ entry: options.entry,
43
+ compile: options.compile,
44
+ });
45
+
46
+ const hostBinary = await resolveHostBinary({ backend: mac?.backend ?? "appkit" });
47
+ const exe = join(c, "MacOS", identity.name);
48
+ cpSync(hostBinary, exe, { dereference: true });
49
+ chmodSync(exe, 0o755);
50
+ const bunPath = config.package?.bunPath ?? Bun.which("bun");
51
+ if (!bunPath) throw new Error("nd: bun not found on PATH");
52
+ // dereference: true - `bun` on PATH is frequently a symlink into a read-only
53
+ // nix store; copying the link itself would leave a dangling/unwritable entry
54
+ // once relocated into the .app (chmod EPERM on the nix store target).
55
+ cpSync(bunPath, join(c, "MacOS", "bun"), { dereference: true });
56
+ chmodSync(join(c, "MacOS", "bun"), 0o755);
57
+
58
+ const iconFile = await installMacIcon(identity, appDir, join(c, "Resources"));
59
+ const customXml = mac?.infoPlist ? readFileSync(resolve(appDir, mac.infoPlist), "utf8") : undefined;
60
+ writeFileSync(join(c, "Info.plist"), buildInfoPlist(identity, mac, customXml, iconFile));
61
+
62
+ // Signing identity: --sign wins, then config, then env, then ad-hoc.
63
+ // --no-sign (signIdentity === null) skips codesign entirely.
64
+ const sign = options.signIdentity !== undefined
65
+ ? options.signIdentity
66
+ : mac?.signIdentity ?? process.env.APPLE_SIGN_IDENTITY ?? "-";
67
+ if (sign !== null) {
68
+ const ent = mac?.entitlements ? resolve(appDir, mac.entitlements) : join(dist, "entitlements.plist");
69
+ if (!mac?.entitlements) writeFileSync(ent, ENTITLEMENTS_PLIST);
70
+ // Deep-sign inside-out: nested Mach-O first, the .app last. `bun` is the
71
+ // process that actually needs allow-jit.
72
+ const nativeDir = join(c, "Resources", "app", "native");
73
+ const plugins = existsSync(nativeDir)
74
+ ? [...new Bun.Glob("*.dylib").scanSync({ cwd: nativeDir, absolute: true })].sort()
75
+ : [];
76
+ for (const nested of [join(c, "MacOS", "bun"), exe, ...plugins]) {
77
+ await $`codesign --force --options runtime --entitlements ${ent} --sign ${sign} ${nested}`;
78
+ }
79
+ await $`codesign --force --deep --options runtime --entitlements ${ent} --sign ${sign} ${app}`;
80
+ await $`codesign --verify --strict ${app}`;
81
+ console.error(`ND_PACKAGE_APP_SIGNED ${app} identity=${sign === "-" ? "adhoc" : "developer-id"}`);
82
+ }
83
+
84
+ // Notarization: explicit --notarize/--no-notarize/config wins; the default
85
+ // is auto (run only when all three Apple credentials are present AND the
86
+ // bundle was signed - Apple rejects unsigned submissions outright).
87
+ const { APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD } = process.env;
88
+ const creds = !!(APPLE_ID && APPLE_TEAM_ID && APPLE_APP_PASSWORD);
89
+ const notarize = resolveNotarize(options.notarize ?? mac?.notarize, creds, sign !== null);
90
+ if (notarize && sign === null) throw new Error("nd: notarize requested but signing is disabled (--no-sign)");
91
+ if (notarize && !creds) throw new Error("nd: notarize requested but APPLE_ID/APPLE_TEAM_ID/APPLE_APP_PASSWORD are not all set");
92
+ if (notarize) {
93
+ const zip = join(dist, `${identity.name}.zip`);
94
+ await $`ditto -c -k --keepParent ${app} ${zip}`;
95
+ await $`xcrun notarytool submit ${zip} --apple-id ${APPLE_ID} --team-id ${APPLE_TEAM_ID} --password ${APPLE_APP_PASSWORD} --wait`;
96
+ await $`xcrun stapler staple ${app}`;
97
+ console.error(`ND_PACKAGE_NOTARIZE_OK ${app}`);
98
+ } else {
99
+ const reason = options.notarize === false || mac?.notarize === false ? "disabled" : sign === null ? "unsigned" : "no-credentials";
100
+ console.error(`ND_PACKAGE_NOTARIZE_SKIPPED reason=${reason}`);
101
+ }
102
+
103
+ const update = await maybePublishUpdate(config.package?.updates, {
104
+ identity,
105
+ platform: "mac",
106
+ distDir: dist,
107
+ member: `${identity.name}.app`,
108
+ outDir,
109
+ });
110
+
111
+ console.error(`ND_PACKAGE_OK ${app}`);
112
+ return { bundlePath: app, updateManifest: update?.manifestPath, publicKey: update?.publicKey };
113
+ }
@@ -0,0 +1,145 @@
1
+ // flattenRuntimeModules against a Bun-isolated-store-shaped fixture: packages
2
+ // live under node_modules/.bun/<pkg>@<ver>/node_modules/<pkg> with sibling
3
+ // symlinks for their deps, and the app's node_modules only symlinks its direct
4
+ // deps. The flattener must produce one flat, real-file node_modules.
5
+ import { describe, expect, test } from "bun:test";
6
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { assertResolvableEntries, flattenRuntimeModules } from "./modules.ts";
10
+
11
+ interface FixturePkg {
12
+ name: string;
13
+ version: string;
14
+ dependencies?: Record<string, string>;
15
+ peerDependencies?: Record<string, string>;
16
+ main?: string;
17
+ }
18
+
19
+ /** Creates <root>/node_modules/.bun/<name>@<version>/node_modules/<name>. */
20
+ function storePackage(root: string, pkg: FixturePkg): string {
21
+ const dir = join(root, "node_modules", ".bun", `${pkg.name}@${pkg.version}`, "node_modules", pkg.name);
22
+ mkdirSync(dir, { recursive: true });
23
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ main: "index.js", ...pkg }));
24
+ writeFileSync(join(dir, pkg.main ?? "index.js"), `module.exports = "${pkg.name}@${pkg.version}";\n`);
25
+ return dir;
26
+ }
27
+
28
+ /** Symlinks depName next to the store entry of `fromPkg` (Bun's sibling layout). */
29
+ function storeSibling(root: string, fromPkg: string, depName: string, depDir: string): void {
30
+ const link = join(root, "node_modules", ".bun", fromPkg, "node_modules", depName);
31
+ mkdirSync(join(link, ".."), { recursive: true });
32
+ symlinkSync(depDir, link);
33
+ }
34
+
35
+ function makeFixture(): { appDir: string; root: string } {
36
+ const root = mkdtempSync(join(tmpdir(), "nd-modules-"));
37
+ const appDir = join(root, "app");
38
+ mkdirSync(appDir, { recursive: true });
39
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({
40
+ name: "fixture-app",
41
+ dependencies: { "dep-a": "1.0.0", "dep-c": "1.0.0" },
42
+ }));
43
+
44
+ const depA = storePackage(root, { name: "dep-a", version: "1.0.0", dependencies: { "dep-b": "1.0.0" } });
45
+ const depB1 = storePackage(root, { name: "dep-b", version: "1.0.0" });
46
+ const depB2 = storePackage(root, { name: "dep-b", version: "2.0.0" });
47
+ const depC = storePackage(root, { name: "dep-c", version: "1.0.0", dependencies: { "dep-b": "2.0.0" } });
48
+ storeSibling(root, "dep-a@1.0.0", "dep-b", depB1);
49
+ storeSibling(root, "dep-c@1.0.0", "dep-b", depB2);
50
+
51
+ mkdirSync(join(appDir, "node_modules"), { recursive: true });
52
+ symlinkSync(depA, join(appDir, "node_modules", "dep-a"));
53
+ symlinkSync(depC, join(appDir, "node_modules", "dep-c"));
54
+ return { appDir, root };
55
+ }
56
+
57
+ describe("flattenRuntimeModules", () => {
58
+ test("flattens the isolated store into real directories and nests version conflicts", () => {
59
+ const { appDir, root } = makeFixture();
60
+ const dest = join(root, "out", "node_modules");
61
+ const flat = flattenRuntimeModules({ appDir, dest });
62
+
63
+ expect(flat.sort()).toEqual(["dep-a", "dep-b", "dep-c"]);
64
+ for (const name of ["dep-a", "dep-b", "dep-c"]) {
65
+ const dir = join(dest, name);
66
+ expect(existsSync(join(dir, "package.json"))).toBe(true);
67
+ expect(lstatSync(dir).isSymbolicLink()).toBe(false);
68
+ }
69
+ // dep-a claimed the flat dep-b@1 slot first; dep-c's dep-b@2 nests.
70
+ expect(JSON.parse(readFileSync(join(dest, "dep-b", "package.json"), "utf8")).version).toBe("1.0.0");
71
+ const nested = join(dest, "dep-c", "node_modules", "dep-b", "package.json");
72
+ expect(existsSync(nested)).toBe(true);
73
+ expect(JSON.parse(readFileSync(nested, "utf8")).version).toBe("2.0.0");
74
+ });
75
+
76
+ test("terminates on a dependency cycle with version conflicts and reuses reachable copies", () => {
77
+ // a@1 <-> b@1 at the top, with b@1 -> a@2 -> b@2 -> a@2 cycling on
78
+ // conflicting versions. Nesting must stop once the needed version already
79
+ // resolves from an ancestor slot.
80
+ const root = mkdtempSync(join(tmpdir(), "nd-modules-"));
81
+ const appDir = join(root, "app");
82
+ mkdirSync(appDir, { recursive: true });
83
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({
84
+ name: "fixture-app",
85
+ dependencies: { "dep-a": "1.0.0", "dep-b": "1.0.0" },
86
+ }));
87
+ const a1 = storePackage(root, { name: "dep-a", version: "1.0.0", dependencies: { "dep-b": "1.0.0" } });
88
+ const b1 = storePackage(root, { name: "dep-b", version: "1.0.0", dependencies: { "dep-a": "2.0.0" } });
89
+ const a2 = storePackage(root, { name: "dep-a", version: "2.0.0", dependencies: { "dep-b": "2.0.0" } });
90
+ const b2 = storePackage(root, { name: "dep-b", version: "2.0.0", dependencies: { "dep-a": "2.0.0" } });
91
+ storeSibling(root, "dep-a@1.0.0", "dep-b", b1);
92
+ storeSibling(root, "dep-b@1.0.0", "dep-a", a2);
93
+ storeSibling(root, "dep-a@2.0.0", "dep-b", b2);
94
+ storeSibling(root, "dep-b@2.0.0", "dep-a", a2);
95
+ mkdirSync(join(appDir, "node_modules"), { recursive: true });
96
+ symlinkSync(a1, join(appDir, "node_modules", "dep-a"));
97
+ symlinkSync(b1, join(appDir, "node_modules", "dep-b"));
98
+
99
+ const dest = join(root, "out", "node_modules");
100
+ const flat = flattenRuntimeModules({ appDir, dest });
101
+ expect(flat.sort()).toEqual(["dep-a", "dep-b"]);
102
+ const version = (p: string) => JSON.parse(readFileSync(join(dest, p, "package.json"), "utf8")).version;
103
+ expect(version("dep-a")).toBe("1.0.0");
104
+ expect(version("dep-b")).toBe("1.0.0");
105
+ expect(version("dep-b/node_modules/dep-a")).toBe("2.0.0");
106
+ expect(version("dep-b/node_modules/dep-a/node_modules/dep-b")).toBe("2.0.0");
107
+ // b@2's dep-a@2 resolves via the copy two levels up: no deeper nesting.
108
+ expect(existsSync(join(dest, "dep-b/node_modules/dep-a/node_modules/dep-b/node_modules/dep-a"))).toBe(false);
109
+ });
110
+
111
+ test("skips unresolvable peer dependencies but throws on a missing hard dependency", () => {
112
+ const root = mkdtempSync(join(tmpdir(), "nd-modules-"));
113
+ const appDir = join(root, "app");
114
+ mkdirSync(appDir, { recursive: true });
115
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({ name: "a", dependencies: { "dep-p": "1.0.0" } }));
116
+ const depP = storePackage(root, { name: "dep-p", version: "1.0.0", peerDependencies: { "not-installed": "*" } });
117
+ mkdirSync(join(appDir, "node_modules"), { recursive: true });
118
+ symlinkSync(depP, join(appDir, "node_modules", "dep-p"));
119
+
120
+ const dest = join(root, "out", "node_modules");
121
+ expect(flattenRuntimeModules({ appDir, dest }).sort()).toEqual(["dep-p"]);
122
+
123
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({ name: "a", dependencies: { "dep-p": "1.0.0", missing: "*" } }));
124
+ expect(() => flattenRuntimeModules({ appDir, dest: join(root, "out2", "node_modules") }))
125
+ .toThrow('cannot resolve runtime dependency "missing"');
126
+ });
127
+ });
128
+
129
+ describe("assertResolvableEntries", () => {
130
+ test("throws a build hint when a copied package's entry target is missing", () => {
131
+ const root = mkdtempSync(join(tmpdir(), "nd-modules-"));
132
+ const broken = join(root, "node_modules", "needs-build");
133
+ mkdirSync(broken, { recursive: true });
134
+ writeFileSync(join(broken, "package.json"), JSON.stringify({ name: "needs-build", main: "./dist/index.js" }));
135
+ expect(() => assertResolvableEntries(root, ["needs-build"])).toThrow("bun run build");
136
+ });
137
+
138
+ test("tolerates a package that declares no entry point (binary carrier)", () => {
139
+ const root = mkdtempSync(join(tmpdir(), "nd-modules-"));
140
+ const carrier = join(root, "node_modules", "host-binary");
141
+ mkdirSync(carrier, { recursive: true });
142
+ writeFileSync(join(carrier, "package.json"), JSON.stringify({ name: "host-binary", files: ["bin"] }));
143
+ expect(() => assertResolvableEntries(root, ["host-binary"])).not.toThrow();
144
+ });
145
+ });
@@ -0,0 +1,129 @@
1
+ // Runtime node_modules flattening: BFS over the real module graph, copying
2
+ // every package the app can reach at runtime into one flat node_modules that
3
+ // an ordinary upward walk resolves. Subsumes Bun's isolated store
4
+ // (node_modules/.bun/<pkg>/node_modules/<pkg> siblings), workspace symlinks,
5
+ // and file: deps; replaces `bun install` inside the bundle.
6
+ import { cpSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
7
+ import { basename, dirname, join, sep } from "node:path";
8
+
9
+ interface QueueEntry {
10
+ spec: string;
11
+ /** Directory the spec resolves from (the requiring package's real root). */
12
+ anchor: string;
13
+ /** Dest-relative dir of the requiring package, for version-conflict nesting. */
14
+ requirer?: string;
15
+ optional: boolean;
16
+ }
17
+
18
+ const skipNodeModules = (src: string) => basename(src) !== "node_modules";
19
+
20
+ function packageRoot(spec: string, anchor: string): string {
21
+ // dereference to the real store/workspace directory so two symlinked routes
22
+ // to the same package compare equal.
23
+ return realpathSync(dirname(Bun.resolveSync(`${spec}/package.json`, anchor)));
24
+ }
25
+
26
+ export interface FlattenOptions {
27
+ /** The app directory whose package.json dependencies seed the walk. */
28
+ appDir: string;
29
+ /** Extra seed specifiers (PackageConfig.runtimeDependencies), resolved from appDir. */
30
+ extraRoots?: string[];
31
+ /** The flat node_modules directory to populate. */
32
+ dest: string;
33
+ }
34
+
35
+ /** Copies the app's runtime dependency closure into `dest`. Returns the flat package names. */
36
+ export function flattenRuntimeModules({ appDir, extraRoots = [], dest }: FlattenOptions): string[] {
37
+ const appPkg = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8")) as {
38
+ dependencies?: Record<string, string>;
39
+ };
40
+ const anchor = realpathSync(appDir);
41
+ const queue: QueueEntry[] = [
42
+ ...Object.keys(appPkg.dependencies ?? {}).map((spec) => ({ spec, anchor, optional: false })),
43
+ ...extraRoots.map((spec) => ({ spec, anchor, optional: false })),
44
+ ];
45
+ // First claim of a name wins the flat slot; a later claim with a different
46
+ // realpath nests under the requiring package's own node_modules.
47
+ const claimed = new Map<string, string>();
48
+ // Conflict copies already made, keyed by dest-relative slot. Consulted up
49
+ // the requirer chain (Node resolution order) so a dependency cycle whose
50
+ // version is already reachable stops instead of nesting forever.
51
+ const nested = new Map<string, string>();
52
+ const resolvedInDest = (spec: string, requirer: string): string | undefined => {
53
+ for (let dir = requirer; ; ) {
54
+ const hit = nested.get(join(dir, "node_modules", spec));
55
+ if (hit) return hit;
56
+ const cut = dir.lastIndexOf(`${sep}node_modules${sep}`);
57
+ if (cut === -1) break;
58
+ dir = dir.slice(0, cut);
59
+ }
60
+ return claimed.get(spec);
61
+ };
62
+ mkdirSync(dest, { recursive: true });
63
+
64
+ while (queue.length) {
65
+ const entry = queue.shift()!;
66
+ let root: string;
67
+ try {
68
+ root = packageRoot(entry.spec, entry.anchor);
69
+ } catch (err) {
70
+ if (entry.optional) continue;
71
+ throw new Error(`nd: cannot resolve runtime dependency "${entry.spec}" from ${entry.anchor}: ${err}`);
72
+ }
73
+
74
+ const existing = claimed.get(entry.spec);
75
+ let destDir: string;
76
+ if (existing === undefined) {
77
+ destDir = entry.spec;
78
+ claimed.set(entry.spec, root);
79
+ } else if (existing === root) {
80
+ continue;
81
+ } else {
82
+ if (!entry.requirer) throw new Error(`nd: conflicting versions of "${entry.spec}" among the app's direct dependencies`);
83
+ if (resolvedInDest(entry.spec, entry.requirer) === root) continue;
84
+ destDir = join(entry.requirer, "node_modules", entry.spec);
85
+ nested.set(destDir, root);
86
+ }
87
+ cpSync(root, join(dest, destDir), { recursive: true, dereference: true, filter: skipNodeModules });
88
+
89
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
90
+ dependencies?: Record<string, string>;
91
+ optionalDependencies?: Record<string, string>;
92
+ peerDependencies?: Record<string, string>;
93
+ };
94
+ for (const spec of Object.keys(pkg.dependencies ?? {})) {
95
+ queue.push({ spec, anchor: root, requirer: destDir, optional: false });
96
+ }
97
+ for (const spec of [...Object.keys(pkg.optionalDependencies ?? {}), ...Object.keys(pkg.peerDependencies ?? {})]) {
98
+ queue.push({ spec, anchor: root, requirer: destDir, optional: true });
99
+ }
100
+ }
101
+
102
+ return [...claimed.keys()];
103
+ }
104
+
105
+ /**
106
+ * Asserts each flat package's main/exports entry resolves inside the bundle.
107
+ * Catches a copied-but-unbuilt package (e.g. @nativedesktop/react without its
108
+ * dist/) before it ships as a broken bundle.
109
+ */
110
+ export function assertResolvableEntries(appRoot: string, names: string[]): void {
111
+ for (const name of names) {
112
+ try {
113
+ Bun.resolveSync(name, appRoot);
114
+ } catch {
115
+ // A package that declares no entry point at all (binary/asset carriers
116
+ // like the platform host packages) is legitimately unresolvable.
117
+ const pkg = JSON.parse(readFileSync(join(appRoot, "node_modules", name, "package.json"), "utf8")) as {
118
+ main?: string;
119
+ module?: string;
120
+ exports?: unknown;
121
+ };
122
+ if (pkg.main === undefined && pkg.module === undefined && pkg.exports === undefined) continue;
123
+ throw new Error(
124
+ `nd: bundled package "${name}" has no resolvable entry (its build output is missing). ` +
125
+ `Run \`bun run build\` in ${name} and package again.`,
126
+ );
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,150 @@
1
+ // Payload assembly units: entry rewriting, workspace mapping, `nd package`
2
+ // arg parsing, and an end-to-end assemblePayload run against a temp fixture.
3
+ import { describe, expect, test } from "bun:test";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { parsePackageArgs } from "../index.ts";
8
+ import { assemblePayload, compiledEntryFor, workspaceRelative } from "./payload.ts";
9
+ import type { ResolvedIdentity } from "./identity.ts";
10
+
11
+ describe("compiledEntryFor", () => {
12
+ test("replaces the first path segment with the compile outDir", () => {
13
+ expect(compiledEntryFor("src/main.tsx", "dist")).toBe("dist/main.tsx");
14
+ expect(compiledEntryFor("src/app/main.tsx", "out")).toBe("out/app/main.tsx");
15
+ });
16
+
17
+ test("rejects a root-level entry (nothing to substitute)", () => {
18
+ expect(() => compiledEntryFor("main.tsx", "dist")).toThrow("compiled entry");
19
+ });
20
+ });
21
+
22
+ describe("workspaceRelative", () => {
23
+ test("maps the app dir to its workspace-relative path", () => {
24
+ expect(workspaceRelative("/repo", "/repo/examples/gallery")).toBe("examples/gallery");
25
+ expect(workspaceRelative("/repo/app", "/repo/app")).toBe("");
26
+ });
27
+
28
+ test("rejects an app dir outside the workspace root", () => {
29
+ expect(() => workspaceRelative("/repo/app", "/elsewhere")).toThrow("outside workspaceRoot");
30
+ });
31
+ });
32
+
33
+ describe("parsePackageArgs", () => {
34
+ test("platform defaults to the host and flags map onto PackageOptions", () => {
35
+ expect(parsePackageArgs([], "darwin").platform).toBe("mac");
36
+ expect(parsePackageArgs([], "linux").platform).toBe("linux");
37
+ const opts = parsePackageArgs(
38
+ ["linux", "--out", "build", "--entry", "app.tsx", "--version", "2.0.0", "--cwd", "apps/x", "--no-compile", "--format", "appdir"],
39
+ "darwin",
40
+ );
41
+ expect(opts).toEqual({
42
+ platform: "linux",
43
+ outDir: "build",
44
+ entry: "app.tsx",
45
+ version: "2.0.0",
46
+ cwd: "apps/x",
47
+ compile: false,
48
+ format: "appdir",
49
+ });
50
+ });
51
+
52
+ test("--sign/--no-sign and --notarize/--no-notarize resolve to identity overrides", () => {
53
+ expect(parsePackageArgs(["mac", "--sign", "Developer ID"], "darwin").signIdentity).toBe("Developer ID");
54
+ expect(parsePackageArgs(["mac", "--no-sign"], "darwin").signIdentity).toBeNull();
55
+ expect(parsePackageArgs(["mac", "--notarize"], "darwin").notarize).toBe(true);
56
+ expect(parsePackageArgs(["mac", "--no-notarize"], "darwin").notarize).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("assemblePayload", () => {
61
+ const identity: ResolvedIdentity = {
62
+ id: "com.example.fixture",
63
+ name: "Fixture",
64
+ displayName: "Fixture",
65
+ slug: "fixture",
66
+ version: "1.0.0",
67
+ categories: ["Utility"],
68
+ };
69
+
70
+ test("copies the entry dir, writes nd-app.json, and mirrors the workspace layout", async () => {
71
+ const root = mkdtempSync(join(tmpdir(), "nd-payload-"));
72
+ const appDir = join(root, "apps", "fixture");
73
+ mkdirSync(join(appDir, "src"), { recursive: true });
74
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" }));
75
+ writeFileSync(join(appDir, "src", "main.tsx"), "export {};\n");
76
+ mkdirSync(join(root, "shared"), { recursive: true });
77
+ writeFileSync(join(root, "shared", "util.ts"), "export {};\n");
78
+
79
+ const appRoot = join(root, "out", "app");
80
+ const result = await assemblePayload({
81
+ appDir,
82
+ config: { package: { workspaceRoot: "../..", include: ["shared"], compile: false } },
83
+ identity,
84
+ appRoot,
85
+ });
86
+
87
+ expect(result).toEqual({ entry: "apps/fixture/src/main.tsx", cwd: "apps/fixture", pluginPaths: [] });
88
+ expect(existsSync(join(appRoot, "apps/fixture/src/main.tsx"))).toBe(true);
89
+ expect(existsSync(join(appRoot, "apps/fixture/package.json"))).toBe(true);
90
+ expect(existsSync(join(appRoot, "shared/util.ts"))).toBe(true);
91
+ const manifest = JSON.parse(readFileSync(join(appRoot, "nd-app.json"), "utf8"));
92
+ expect(manifest).toEqual({
93
+ id: "com.example.fixture",
94
+ name: "Fixture",
95
+ version: "1.0.0",
96
+ entry: "apps/fixture/src/main.tsx",
97
+ cwd: "apps/fixture",
98
+ pluginPaths: [],
99
+ });
100
+ });
101
+
102
+ test("default config: bundle output nested in the compile outDir is not copied into itself", async () => {
103
+ // The template shape: no `package` block, a `compile` script emitting
104
+ // dist/, so the bundle output (<appDir>/dist/mac/...) nests inside the
105
+ // copied entry dir (<appDir>/dist).
106
+ const appDir = mkdtempSync(join(tmpdir(), "nd-payload-default-"));
107
+ mkdirSync(join(appDir, "src"), { recursive: true });
108
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({
109
+ name: "fixture",
110
+ version: "1.0.0",
111
+ scripts: { compile: "mkdir -p dist && cp src/main.tsx dist/main.tsx" },
112
+ }));
113
+ writeFileSync(join(appDir, "src", "main.tsx"), "export {};\n");
114
+
115
+ // appRoot exactly as mac.ts derives it from the default outDir.
116
+ const contents = join(appDir, "dist", "mac", "Fixture.app", "Contents");
117
+ for (const dir of ["MacOS", "Resources", "Frameworks"]) mkdirSync(join(contents, dir), { recursive: true });
118
+ const appRoot = join(contents, "Resources", "app");
119
+
120
+ const result = await assemblePayload({ appDir, config: {}, identity, appRoot });
121
+
122
+ expect(result).toEqual({ entry: "dist/main.tsx", cwd: ".", pluginPaths: [] });
123
+ expect(existsSync(join(appRoot, "dist/main.tsx"))).toBe(true);
124
+ expect(existsSync(join(appRoot, "package.json"))).toBe(true);
125
+ expect(existsSync(join(appRoot, "dist", "mac"))).toBe(false);
126
+ });
127
+
128
+ test("root-level entry ships the whole app dir minus node_modules", async () => {
129
+ const root = mkdtempSync(join(tmpdir(), "nd-payload-"));
130
+ const appDir = join(root, "app");
131
+ mkdirSync(join(appDir, "assets"), { recursive: true });
132
+ mkdirSync(join(appDir, "node_modules", "junk"), { recursive: true });
133
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({ name: "flat", version: "1.0.0" }));
134
+ writeFileSync(join(appDir, "main.tsx"), "export {};\n");
135
+ writeFileSync(join(appDir, "assets", "a.txt"), "a\n");
136
+
137
+ const appRoot = join(root, "out", "app");
138
+ const result = await assemblePayload({
139
+ appDir,
140
+ config: { package: { entry: "main.tsx", compile: false } },
141
+ identity,
142
+ appRoot,
143
+ });
144
+
145
+ expect(result).toEqual({ entry: "main.tsx", cwd: ".", pluginPaths: [] });
146
+ expect(existsSync(join(appRoot, "main.tsx"))).toBe(true);
147
+ expect(existsSync(join(appRoot, "assets/a.txt"))).toBe(true);
148
+ expect(existsSync(join(appRoot, "node_modules", "junk"))).toBe(false);
149
+ });
150
+ });