@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,160 @@
1
+ // Bundle payload assembly: the app-root tree both platform packagers embed
2
+ // (Resources/app on mac, AppDir/app on linux). The app root mirrors the
3
+ // configured workspaceRoot, so the app's own files land at their
4
+ // workspace-relative path and relative imports keep resolving packaged.
5
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { basename, join, relative, resolve, sep } from "node:path";
7
+ import { buildNativePlugins, type NativeDesktopConfig, type PackageConfig } from "../config.ts";
8
+ import type { ResolvedIdentity } from "./identity.ts";
9
+ import { assertResolvableEntries, flattenRuntimeModules } from "./modules.ts";
10
+
11
+ export const DEFAULT_ENTRY = "src/main.tsx";
12
+
13
+ /** The compiled entry: the source entry with its first path segment replaced by the compile outDir. */
14
+ export function compiledEntryFor(entry: string, outDir: string): string {
15
+ const segments = entry.split("/");
16
+ if (segments.length < 2) throw new Error(`nd: cannot derive a compiled entry from "${entry}" (set package.compile.entry)`);
17
+ segments[0] = outDir;
18
+ return segments.join("/");
19
+ }
20
+
21
+ /** POSIX-separated path of `to` relative to `from` ("" when equal). Rejects escapes above `from`. */
22
+ export function workspaceRelative(from: string, to: string): string {
23
+ const rel = relative(from, to);
24
+ if (rel.startsWith("..")) throw new Error(`nd: app dir ${to} lies outside workspaceRoot ${from}`);
25
+ return rel.split(sep).join("/");
26
+ }
27
+
28
+ export interface PayloadResult {
29
+ /** App-root-relative entry script. */
30
+ entry: string;
31
+ /** App-root-relative working directory for the packaged app. */
32
+ cwd: string;
33
+ /** App-root-relative native plugin paths. */
34
+ pluginPaths: string[];
35
+ }
36
+
37
+ export interface PayloadOptions {
38
+ appDir: string;
39
+ config: NativeDesktopConfig;
40
+ identity: ResolvedIdentity;
41
+ /** The bundle's app root (Resources/app or AppDir/app). */
42
+ appRoot: string;
43
+ /** CLI --entry override (app-relative source entry). */
44
+ entry?: string;
45
+ /** false forces raw source (--no-compile). */
46
+ compile?: boolean;
47
+ }
48
+
49
+ const skipNodeModules = (src: string) => basename(src) !== "node_modules";
50
+
51
+ /**
52
+ * Copies a payload tree without ever descending into the bundle output
53
+ * (`appRoot`), which nests inside the copied tree under the default config
54
+ * (compile outDir == package outDir == "dist"). cpSync refuses a destination
55
+ * inside its source outright, so that case walks the top level by hand.
56
+ */
57
+ function copyPayloadTree(src: string, dest: string, appRoot: string, filter: (p: string) => boolean): void {
58
+ const bundle = resolve(appRoot);
59
+ const containsBundle = (p: string) => {
60
+ const r = resolve(p);
61
+ return r === bundle || bundle.startsWith(r + sep);
62
+ };
63
+ if (!containsBundle(src)) {
64
+ cpSync(src, dest, { recursive: true, dereference: true, filter });
65
+ return;
66
+ }
67
+ mkdirSync(dest, { recursive: true });
68
+ for (const name of readdirSync(src)) {
69
+ const child = join(src, name);
70
+ if (!filter(child) || containsBundle(child)) continue;
71
+ cpSync(child, join(dest, name), { recursive: true, dereference: true, filter });
72
+ }
73
+ }
74
+
75
+ async function runCompile(appDir: string, script: string): Promise<void> {
76
+ const proc = Bun.spawn(["bun", "run", script], {
77
+ cwd: appDir,
78
+ env: process.env,
79
+ stdin: "ignore",
80
+ stdout: "inherit",
81
+ stderr: "inherit",
82
+ });
83
+ if (await proc.exited !== 0) throw new Error(`nd: compile failed (bun run ${script})`);
84
+ }
85
+
86
+ export async function assemblePayload(o: PayloadOptions): Promise<PayloadResult> {
87
+ const pkg = o.config.package ?? {};
88
+ const appDir = resolve(o.appDir);
89
+ const workspaceRoot = resolve(appDir, pkg.workspaceRoot ?? ".");
90
+ const appRel = workspaceRelative(workspaceRoot, appDir);
91
+ const destAppDir = appRel ? join(o.appRoot, appRel) : o.appRoot;
92
+ const sourceEntry = o.entry ?? pkg.entry ?? DEFAULT_ENTRY;
93
+
94
+ // Compile step: "auto" runs the app's `compile` script when declared.
95
+ const compileCfg = o.compile === false ? false : pkg.compile ?? "auto";
96
+ const appPkg = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8")) as {
97
+ scripts?: Record<string, string>;
98
+ };
99
+ let entry = sourceEntry;
100
+ if (compileCfg !== false) {
101
+ const script = typeof compileCfg === "object" ? compileCfg.script ?? "compile" : "compile";
102
+ const outDir = typeof compileCfg === "object" ? compileCfg.outDir ?? "dist" : "dist";
103
+ if (typeof compileCfg === "object" || appPkg.scripts?.[script]) {
104
+ await runCompile(appDir, script);
105
+ entry = typeof compileCfg === "object" && compileCfg.entry ? compileCfg.entry : compiledEntryFor(sourceEntry, outDir);
106
+ } else {
107
+ console.error('nd: no "compile" script - packaging raw src/ (react-compiler pass skipped)');
108
+ }
109
+ }
110
+
111
+ mkdirSync(destAppDir, { recursive: true });
112
+ const entryDir = entry.includes("/") ? entry.split("/")[0]! : null;
113
+ if (entryDir) {
114
+ copyPayloadTree(join(appDir, entryDir), join(destAppDir, entryDir), o.appRoot, skipNodeModules);
115
+ } else {
116
+ // Root-level entry: ship the whole app dir (minus node_modules and any
117
+ // build output nested inside it, which would recurse into the copy).
118
+ const bundleOut = resolve(workspaceRoot, pkg.outDir ?? "dist");
119
+ const skip = (src: string) => skipNodeModules(src) && resolve(src) !== bundleOut;
120
+ copyPayloadTree(appDir, destAppDir, o.appRoot, skip);
121
+ }
122
+ cpSync(join(appDir, "package.json"), join(destAppDir, "package.json"));
123
+
124
+ for (const inc of pkg.include ?? []) {
125
+ const src = join(workspaceRoot, inc);
126
+ if (!existsSync(src)) throw new Error(`nd: package.include path not found: ${src}`);
127
+ cpSync(src, join(o.appRoot, inc), { recursive: true, dereference: true, filter: skipNodeModules });
128
+ }
129
+
130
+ const flat = flattenRuntimeModules({
131
+ appDir,
132
+ extraRoots: pkg.runtimeDependencies,
133
+ dest: join(o.appRoot, "node_modules"),
134
+ });
135
+ assertResolvableEntries(o.appRoot, flat);
136
+
137
+ // Native plugins: build (or reuse) the app's declared outputs, ship them
138
+ // under app/native/, and record app-root-relative paths for the bootstrap.
139
+ const outputs = await buildNativePlugins(o.config, appDir);
140
+ const pluginPaths: string[] = [];
141
+ for (const output of outputs) {
142
+ const dest = join("native", basename(output));
143
+ mkdirSync(join(o.appRoot, "native"), { recursive: true });
144
+ cpSync(output, join(o.appRoot, dest), { dereference: true });
145
+ pluginPaths.push(dest);
146
+ }
147
+
148
+ const cwd = appRel || ".";
149
+ const appEntry = appRel ? `${appRel}/${entry}` : entry;
150
+ writeFileSync(join(o.appRoot, "nd-app.json"), `${JSON.stringify({
151
+ id: o.identity.id,
152
+ name: o.identity.name,
153
+ version: o.identity.version,
154
+ entry: appEntry,
155
+ cwd,
156
+ pluginPaths,
157
+ }, null, 2)}\n`);
158
+
159
+ return { entry: appEntry, cwd, pluginPaths };
160
+ }
@@ -0,0 +1,104 @@
1
+ // In-code packaging templates: default Info.plist, entitlements, AppRun, and
2
+ // .desktop entry. Strings instead of template files so packages/nd's
3
+ // `files: ["src"]` ships everything `nd package` needs.
4
+
5
+ export interface InfoPlistFields {
6
+ name: string;
7
+ displayName: string;
8
+ id: string;
9
+ executable: string;
10
+ version: string;
11
+ minimumSystemVersion: string;
12
+ category?: string;
13
+ iconFile?: string;
14
+ }
15
+
16
+ export function infoPlistSkeleton(f: InfoPlistFields): string {
17
+ let body =
18
+ ` <key>CFBundleName</key><string>${f.name}</string>\n` +
19
+ ` <key>CFBundleDisplayName</key><string>${f.displayName}</string>\n` +
20
+ ` <key>CFBundleIdentifier</key><string>${f.id}</string>\n` +
21
+ ` <key>CFBundleExecutable</key><string>${f.executable}</string>\n` +
22
+ " <key>CFBundlePackageType</key><string>APPL</string>\n" +
23
+ ` <key>CFBundleShortVersionString</key><string>${f.version}</string>\n` +
24
+ ` <key>CFBundleVersion</key><string>${f.version}</string>\n` +
25
+ ` <key>LSMinimumSystemVersion</key><string>${f.minimumSystemVersion}</string>\n` +
26
+ " <key>NSHighResolutionCapable</key><true/>\n";
27
+ if (f.category) body += ` <key>LSApplicationCategoryType</key><string>${f.category}</string>\n`;
28
+ if (f.iconFile) {
29
+ body += ` <key>CFBundleIconFile</key><string>${f.iconFile}</string>\n`;
30
+ body += ` <key>CFBundleIconName</key><string>${f.iconFile}</string>\n`;
31
+ }
32
+ return (
33
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
34
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
35
+ '<plist version="1.0">\n<dict>\n' +
36
+ body +
37
+ "</dict>\n</plist>\n"
38
+ );
39
+ }
40
+
41
+ /** Hardened-runtime entitlements. JSC-under-Bun needs allow-jit on Apple Silicon. */
42
+ export const ENTITLEMENTS_PLIST =
43
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
44
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
45
+ '<plist version="1.0">\n<dict>\n' +
46
+ " <key>com.apple.security.cs.allow-jit</key>\n <true/>\n" +
47
+ " <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n <true/>\n" +
48
+ "</dict>\n</plist>\n";
49
+
50
+ export interface AppRunSpec {
51
+ /** App-root-relative script the host boots. */
52
+ entry: string;
53
+ /** App-root-relative working directory. */
54
+ cwd: string;
55
+ /** usr/bin/<slug> executable name. */
56
+ slug: string;
57
+ appId?: string;
58
+ /** App-root-relative native plugin paths. */
59
+ pluginPaths: string[];
60
+ }
61
+
62
+ export function appRunTemplate(s: AppRunSpec): string {
63
+ let script =
64
+ "#!/usr/bin/env bash\n" +
65
+ 'HERE="$(dirname "$(readlink -f "$0")")"\n' +
66
+ `export ND_SCRIPT="$HERE/app/${s.entry}"\n` +
67
+ 'export PATH="$HERE/usr/bin:$PATH"\n';
68
+ if (s.appId) script += `export ND_APP_ID="${s.appId}"\n`;
69
+ if (s.pluginPaths.length) {
70
+ script += 'export ND_PLUGINS="1"\n';
71
+ script += `export ND_PLUGIN_PATHS="${s.pluginPaths.map((p) => `$HERE/app/${p}`).join(":")}"\n`;
72
+ }
73
+ script += `cd "$HERE/app/${s.cwd}"\n`;
74
+ script += `exec "$HERE/usr/bin/${s.slug}" "$@"\n`;
75
+ return script;
76
+ }
77
+
78
+ export interface DesktopEntrySpec {
79
+ displayName: string;
80
+ slug: string;
81
+ categories: string[];
82
+ appId?: string;
83
+ /** Extra/overriding lines from LinuxPackageConfig.desktopEntry. */
84
+ extra?: Record<string, string>;
85
+ }
86
+
87
+ export function desktopEntryTemplate(s: DesktopEntrySpec): string {
88
+ const lines = new Map<string, string>([
89
+ ["Type", "Application"],
90
+ ["Name", s.displayName],
91
+ ["Exec", "AppRun"],
92
+ ["Icon", s.slug],
93
+ ["Categories", `${s.categories.join(";")};`],
94
+ ]);
95
+ if (s.appId) lines.set("StartupWMClass", s.appId);
96
+ for (const [k, v] of Object.entries(s.extra ?? {})) lines.set(k, v);
97
+ return `[Desktop Entry]\n${[...lines].map(([k, v]) => `${k}=${v}`).join("\n")}\n`;
98
+ }
99
+
100
+ /** 1x1 RGBA PNG, the no-icon-configured placeholder (appimagetool requires a root icon). */
101
+ export const PLACEHOLDER_PNG: Uint8Array = Buffer.from(
102
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
103
+ "base64",
104
+ );
@@ -0,0 +1,118 @@
1
+ // Update artifacts: minisign-signed full archive + manifest JSON matching
2
+ // src/core/update.zig's `Manifest` struct. Shells out to the `minisign` CLI.
3
+ // Opt-in per app: no `package.updates` config, no artifacts.
4
+ import { $ } from "bun";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { basename, join } from "node:path";
7
+ import type { UpdatesConfig } from "../config.ts";
8
+ import type { ResolvedIdentity } from "./identity.ts";
9
+
10
+ export interface ManifestOpts {
11
+ appId: string;
12
+ version: string;
13
+ from: string | null;
14
+ archivePath: string;
15
+ url: string;
16
+ secKey: string;
17
+ pubKey: string;
18
+ }
19
+
20
+ // Returns { manifestPath, manifestSigPath }.
21
+ export async function buildAndSignManifest(o: ManifestOpts) {
22
+ // Sign the archive (minisign -S writes <archive>.minisig).
23
+ await $`minisign -S -s ${o.secKey} -m ${o.archivePath}`.quiet();
24
+ const archiveSig = `${o.archivePath}.minisig`;
25
+ // The second line of the .minisig is the base64 algo/keyid/sig blob the Zig
26
+ // verifier decodes directly; pass it through as full_sig_b64.
27
+ const sigB64 = readFileSync(archiveSig, "utf8").split("\n")[1]!.trim();
28
+ const manifest = {
29
+ app_id: o.appId,
30
+ version: o.version,
31
+ from: o.from,
32
+ full_url: o.url,
33
+ full_sig_b64: sigB64,
34
+ };
35
+ const manifestPath = `${o.archivePath.replace(/[^/]+$/, "")}manifest-${o.version}.json`;
36
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
37
+ await $`minisign -S -s ${o.secKey} -m ${manifestPath}`.quiet();
38
+ return { manifestPath, manifestSigPath: `${manifestPath}.minisig` };
39
+ }
40
+
41
+ // Ephemeral key helper for CI/tests (no real signing identity in this env).
42
+ // Reads ND_MINISIGN_SEC/ND_MINISIGN_PUB if set; otherwise generates a
43
+ // throwaway no-password keypair under `dir`.
44
+ export async function ensureEphemeralKey(dir: string) {
45
+ if (process.env.ND_MINISIGN_SEC && process.env.ND_MINISIGN_PUB) {
46
+ return { sec: process.env.ND_MINISIGN_SEC, pub: process.env.ND_MINISIGN_PUB };
47
+ }
48
+ const sec = `${dir}/nd-sign.sec`, pub = `${dir}/nd-sign.pub`;
49
+ if (!existsSync(sec)) await $`minisign -G -W -p ${pub} -s ${sec}`.quiet();
50
+ return { sec, pub };
51
+ }
52
+
53
+ export interface PublishOptions {
54
+ identity: ResolvedIdentity;
55
+ platform: "mac" | "linux";
56
+ /** dist/<platform> directory the archive member lives in. */
57
+ distDir: string;
58
+ /** The archive member inside distDir (Gallery.app / AppDir). */
59
+ member: string;
60
+ /** Resolved output root; update artifacts land in <outDir>/update. */
61
+ outDir: string;
62
+ }
63
+
64
+ export interface PublishResult {
65
+ manifestPath: string;
66
+ publicKey: string;
67
+ }
68
+
69
+ /**
70
+ * Archives + signs when `package.updates` is configured; otherwise prints
71
+ * ND_PACKAGE_UPDATES_SKIPPED and returns undefined. Tar invocations stay
72
+ * exactly as the update flow's consumers expect (.tar.gz mac / .tar.zst linux,
73
+ * zstd via the CLI).
74
+ */
75
+ export async function maybePublishUpdate(cfg: UpdatesConfig | undefined, o: PublishOptions): Promise<PublishResult | undefined> {
76
+ if (!cfg) {
77
+ console.error("ND_PACKAGE_UPDATES_SKIPPED reason=not-configured");
78
+ return undefined;
79
+ }
80
+ if (!o.identity.id) throw new Error("nd: package.updates needs app.id (the manifest's app_id)");
81
+
82
+ const updDir = join(o.outDir, "update");
83
+ mkdirSync(updDir, { recursive: true });
84
+ const format = cfg.format ?? (o.platform === "mac" ? "tar.gz" : "tar.zst");
85
+ const archive = join(updDir, `${o.identity.slug}-${o.identity.version}-${o.platform}.${format}`);
86
+ rmSync(archive, { force: true });
87
+ if (format === "tar.gz") {
88
+ await $`tar -C ${o.distDir} -czf ${archive} ${o.member}`;
89
+ } else {
90
+ await $`tar -C ${o.distDir} -cf - ${o.member} | zstd -q -o ${archive}`.quiet();
91
+ }
92
+
93
+ let sec: string, pub: string;
94
+ if (cfg.secretKey) {
95
+ if (!cfg.publicKey) throw new Error("nd: package.updates.secretKey needs publicKey too");
96
+ sec = cfg.secretKey;
97
+ pub = cfg.publicKey;
98
+ } else if (process.env.ND_MINISIGN_SEC && process.env.ND_MINISIGN_PUB) {
99
+ sec = process.env.ND_MINISIGN_SEC;
100
+ pub = process.env.ND_MINISIGN_PUB;
101
+ } else if (cfg.ephemeralKey) {
102
+ ({ sec, pub } = await ensureEphemeralKey(updDir));
103
+ } else {
104
+ throw new Error("nd: package.updates has no signing key (set secretKey/publicKey, ND_MINISIGN_SEC/ND_MINISIGN_PUB, or ephemeralKey: true)");
105
+ }
106
+
107
+ const { manifestPath } = await buildAndSignManifest({
108
+ appId: o.identity.id,
109
+ version: o.identity.version,
110
+ from: null,
111
+ archivePath: archive,
112
+ url: `${cfg.baseUrl.replace(/\/$/, "")}/${basename(archive)}`,
113
+ secKey: sec,
114
+ pubKey: pub,
115
+ });
116
+ console.error(`ND_PACKAGE_MANIFEST ${manifestPath} pub=${pub}`);
117
+ return { manifestPath, publicKey: pub };
118
+ }