@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.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@nativedesktop/cli",
3
+ "version": "0.1.0",
4
+ "description": "The nd CLI: dev-runs, builds, and packages NativeDesktop apps",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/FormalSnake/NativeDesktop.git",
10
+ "directory": "packages/nd"
11
+ },
12
+ "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
13
+ "bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": ["src"],
18
+ "bin": {
19
+ "nd": "src/index.ts"
20
+ },
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./config": "./src/config.ts"
24
+ },
25
+ "dependencies": {
26
+ "@nativedesktop/host": "0.1.0"
27
+ }
28
+ }
package/src/config.ts ADDED
@@ -0,0 +1,204 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ export interface NativeBuildCommand {
6
+ command: string[];
7
+ /** Files/directories whose mtimes invalidate the cached output. */
8
+ inputs?: string[];
9
+ }
10
+
11
+ export interface NativePluginConfig {
12
+ /** Shared library path, or a per-platform path. */
13
+ path?: string;
14
+ darwin?: string;
15
+ linux?: string;
16
+ build?: NativeBuildCommand | Partial<Record<"darwin" | "linux", NativeBuildCommand>>;
17
+ }
18
+
19
+ export interface FileAssociation {
20
+ /** Extension without the dot, e.g. "md". */
21
+ ext: string;
22
+ /** Human-readable document type name. */
23
+ name?: string;
24
+ /** e.g. "text/markdown" — drives the Linux .desktop MimeType entry. */
25
+ mimeType?: string;
26
+ /** macOS CFBundleTypeRole. Defaults to "editor". */
27
+ role?: "editor" | "viewer";
28
+ }
29
+
30
+ export interface UrlScheme {
31
+ scheme: string;
32
+ name?: string;
33
+ }
34
+
35
+ export interface AppIcon {
36
+ /** Shared icon source (.png/.svg/.icns path, app-relative). */
37
+ source?: string;
38
+ macos?: string;
39
+ linux?: string;
40
+ }
41
+
42
+ export interface AppIdentity {
43
+ /** Reverse-DNS bundle id. Required for icons, mime registration, and updates. */
44
+ id?: string;
45
+ /** Product name: <Name>.app, usr/bin/<slug>, AppImage basename. Default: package.json name. */
46
+ name?: string;
47
+ /** CFBundleDisplayName / .desktop Name. Default: name. */
48
+ displayName?: string;
49
+ /** Default: package.json version, then "0.0.0". ND_APP_VERSION and --version override. */
50
+ version?: string;
51
+ icon?: string | AppIcon;
52
+ /** .desktop Categories=. Default ["Utility"]. */
53
+ categories?: string[];
54
+ fileAssociations?: FileAssociation[];
55
+ urlSchemes?: UrlScheme[];
56
+ }
57
+
58
+ export interface UpdatesConfig {
59
+ baseUrl: string;
60
+ /** minisign secret key path. Falls back to ND_MINISIGN_SEC/ND_MINISIGN_PUB. */
61
+ secretKey?: string;
62
+ publicKey?: string;
63
+ /** Generate a throwaway keypair when no key is configured (CI/test path only). */
64
+ ephemeralKey?: boolean;
65
+ format?: "tar.gz" | "tar.zst";
66
+ }
67
+
68
+ export interface MacPackageConfig {
69
+ /** Info.plist override file path; the default template is generated in-code. */
70
+ infoPlist?: string;
71
+ entitlements?: string;
72
+ /** Default "26.0". */
73
+ minimumSystemVersion?: string;
74
+ /** LSApplicationCategoryType. */
75
+ category?: string;
76
+ /** Else APPLE_SIGN_IDENTITY, else ad-hoc "-". */
77
+ signIdentity?: string;
78
+ /** Default: auto (only with all three Apple creds). */
79
+ notarize?: boolean;
80
+ extraPlist?: Record<string, string | number | boolean>;
81
+ backend?: "appkit" | "gtk";
82
+ }
83
+
84
+ export interface LinuxPackageConfig {
85
+ /** AppDir seed tree copied before generated files land. */
86
+ appDirTemplate?: string;
87
+ format?: "appimage" | "appdir";
88
+ /** Extra/overriding .desktop entry lines. */
89
+ desktopEntry?: Record<string, string>;
90
+ }
91
+
92
+ export interface PackageConfig {
93
+ /** Source entry, app-relative. Default "src/main.tsx". */
94
+ entry?: string;
95
+ /** Default "auto": run the app's `compile` script when it declares one. */
96
+ compile?: "auto" | false | { script?: string; outDir?: string; entry?: string };
97
+ /** App-relative, e.g. "../..". The bundle app root mirrors this directory. Default: the app dir itself. */
98
+ workspaceRoot?: string;
99
+ /** Extra workspace-relative dirs/files copied into the bundle app root. */
100
+ include?: string[];
101
+ /** Extra roots for the runtime module-graph walk. */
102
+ runtimeDependencies?: string[];
103
+ /** Default "dist" (resolved against workspaceRoot). */
104
+ outDir?: string;
105
+ bunPath?: string;
106
+ mac?: MacPackageConfig;
107
+ linux?: LinuxPackageConfig;
108
+ updates?: UpdatesConfig;
109
+ }
110
+
111
+ export interface NativeDesktopConfig {
112
+ native?: { plugins?: NativePluginConfig[] };
113
+ app?: AppIdentity;
114
+ package?: PackageConfig;
115
+ }
116
+
117
+ export function defineConfig(config: NativeDesktopConfig): NativeDesktopConfig { return config; }
118
+
119
+ export async function loadConfig(cwd = process.cwd()): Promise<NativeDesktopConfig> {
120
+ const path = resolve(cwd, "nativedesktop.config.ts");
121
+ if (!existsSync(path)) return {};
122
+ const mod = await import(pathToFileURL(path).href);
123
+ return (mod.default ?? {}) as NativeDesktopConfig;
124
+ }
125
+
126
+ export async function buildNativePlugins(config: NativeDesktopConfig, cwd = process.cwd()): Promise<string[]> {
127
+ const platform = process.platform;
128
+ if (platform !== "darwin" && platform !== "linux") return [];
129
+ const jobs: { output: string; build?: NativeBuildCommand }[] = [];
130
+ for (const plugin of config.native?.plugins ?? []) {
131
+ const declared = plugin[platform] ?? plugin.path;
132
+ if (!declared) {
133
+ if (!plugin.path && !plugin.darwin && !plugin.linux) throw new Error("nd: native plugin requires path, darwin, or linux output");
134
+ console.error(`nd: skipping native plugin (no ${platform} output)`);
135
+ continue;
136
+ }
137
+ const output = resolve(cwd, declared);
138
+ const configured = plugin.build;
139
+ const build = configured && "command" in configured
140
+ ? configured as NativeBuildCommand
141
+ : (configured as Partial<Record<"darwin" | "linux", NativeBuildCommand>> | undefined)?.[platform];
142
+ const stale = build && needsBuild(output, ["nativedesktop.config.ts", ...(build.inputs ?? [])], cwd);
143
+ jobs.push({ output, build: stale ? build : undefined });
144
+ }
145
+ const pending = jobs.filter((job) => job.build);
146
+ if (pending.length) {
147
+ const env = buildCommandEnv(cwd, platform);
148
+ await Promise.all(pending.map(async ({ build }) => {
149
+ const proc = Bun.spawn(build!.command, { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
150
+ const [out, err, status] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
151
+ if (out) process.stdout.write(out);
152
+ if (err) process.stderr.write(err);
153
+ if (status !== 0) throw new Error(`nd: native build failed (${build!.command.join(" ")})`);
154
+ }));
155
+ }
156
+ const outputs: string[] = [];
157
+ for (const { output } of jobs) {
158
+ if (!existsSync(output)) throw new Error(`nd: native plugin output not found: ${output}`);
159
+ outputs.push(output);
160
+ }
161
+ return outputs;
162
+ }
163
+
164
+ /** Env for plugin build commands: on darwin, scrub Nix/SDK overrides that break
165
+ * xcrun/swiftc and pin DEVELOPER_DIR to the selected Xcode; everywhere, expose
166
+ * the installed @nativedesktop/native package root as ND_NATIVE_PACKAGE. */
167
+ function buildCommandEnv(cwd: string, platform: "darwin" | "linux"): Record<string, string | undefined> {
168
+ const env: Record<string, string | undefined> = { ...process.env };
169
+ if (platform === "darwin") {
170
+ delete env.SDKROOT;
171
+ delete env.DEVELOPER_SDK_DIR;
172
+ delete env.NIX_CFLAGS_COMPILE;
173
+ delete env.NIX_LDFLAGS;
174
+ // xcode-select -p echoes an inherited DEVELOPER_DIR back, so query with it
175
+ // cleared to read the machine's actual selection.
176
+ const xcode = Bun.spawnSync(["xcode-select", "-p"], { env: { ...env, DEVELOPER_DIR: undefined } });
177
+ if (xcode.exitCode === 0) env.DEVELOPER_DIR = xcode.stdout.toString().trim();
178
+ }
179
+ try {
180
+ env.ND_NATIVE_PACKAGE = dirname(Bun.resolveSync("@nativedesktop/native/package.json", cwd));
181
+ } catch {
182
+ // App has no @nativedesktop/native dependency — leave the variable unset.
183
+ }
184
+ return env;
185
+ }
186
+
187
+ function needsBuild(output: string, inputs: string[], cwd: string): boolean {
188
+ const resolved = inputs.map((input) => resolve(cwd, input));
189
+ for (const input of resolved) {
190
+ if (!existsSync(input)) throw new Error(`nd: native build input not found: ${input}`);
191
+ }
192
+ if (!existsSync(output)) return true;
193
+ const outputTime = statSync(output).mtimeMs;
194
+ return resolved.some((input) => newestMtime(input) > outputTime);
195
+ }
196
+
197
+ function newestMtime(path: string): number {
198
+ const stat = statSync(path);
199
+ if (!stat.isDirectory()) return stat.mtimeMs;
200
+ const glob = new Bun.Glob("**/*");
201
+ let newest = stat.mtimeMs;
202
+ for (const relative of glob.scanSync({ cwd: path, onlyFiles: true })) newest = Math.max(newest, statSync(resolve(path, relative)).mtimeMs);
203
+ return newest;
204
+ }
package/src/index.ts ADDED
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bun
2
+ // nd — the NativeDesktop CLI. Wraps the invocations documented in
3
+ // docs/agents/README.md / template/AGENTS.md so there is one real command
4
+ // instead of a hand-typed env-var incantation:
5
+ // `nd dev [entry]` == ND_DEV=1 ND_SCRIPT=<entry> <host-binary-from-@nativedesktop/host>
6
+ // `nd build` == bun run compile (babel + react-compiler pre-pass, see template/README.md)
7
+ // `nd package` == assemble + sign the platform bundle (packages/nd/src/package/)
8
+ // `nd doctor` == packaging/toolchain readiness checks
9
+ import { type Backend, resolveHostBinary } from "@nativedesktop/host";
10
+ import { buildNativePlugins, loadConfig } from "./config.ts";
11
+ import { packageApp, type PackageOptions } from "./package/index.ts";
12
+
13
+ const DEFAULT_ENTRY = "src/main.tsx";
14
+
15
+ async function nativeEnv(): Promise<Record<string, string>> {
16
+ const paths = await buildNativePlugins(await loadConfig());
17
+ return paths.length ? { ND_PLUGINS: "1", ND_PLUGIN_PATHS: paths.join(":") } : {};
18
+ }
19
+
20
+ async function runDev(entry: string, backend?: Backend): Promise<number> {
21
+ const hostBinary = await resolveHostBinary({ backend });
22
+ const proc = Bun.spawn([hostBinary], {
23
+ cwd: process.cwd(),
24
+ env: { ...process.env, ...(await nativeEnv()), ND_DEV: "1", ND_SCRIPT: entry },
25
+ stdin: "inherit",
26
+ stdout: "inherit",
27
+ stderr: "inherit",
28
+ });
29
+ return proc.exited;
30
+ }
31
+
32
+ async function runBuild(): Promise<number> {
33
+ // Production plugin wiring is the app's packaging responsibility; we only
34
+ // make sure the native artifacts are built and fresh.
35
+ await buildNativePlugins(await loadConfig());
36
+ const proc = Bun.spawn(["bun", "run", "compile"], {
37
+ cwd: process.cwd(),
38
+ env: process.env,
39
+ stdin: "inherit",
40
+ stdout: "inherit",
41
+ stderr: "inherit",
42
+ });
43
+ return proc.exited;
44
+ }
45
+
46
+ function usage(): void {
47
+ console.error(
48
+ [
49
+ "Usage:",
50
+ " nd dev [entry] [--backend gtk|appkit]",
51
+ " run the app in dev mode (ND_DEV=1, hot reload + crash overlay)",
52
+ ` entry defaults to "${DEFAULT_ENTRY}"`,
53
+ " backend defaults to appkit on macOS, gtk elsewhere (also honors ND_BACKEND);",
54
+ " --backend gtk cross-checks the GTK host, --backend appkit forces the macOS shell",
55
+ " nd build compile the app for production (bun run compile)",
56
+ " nd package [mac|linux] [--out <dir>] [--entry <file>] [--version <v>] [--cwd <dir>]",
57
+ " [--no-compile] [--sign <identity>|--no-sign] [--notarize|--no-notarize]",
58
+ " [--format appimage|appdir]",
59
+ " assemble + sign the platform bundle (platform defaults to the host;",
60
+ " Windows lands with M7)",
61
+ " nd doctor [--json]",
62
+ " check packaging/toolchain readiness for the current directory",
63
+ ].join("\n"),
64
+ );
65
+ }
66
+
67
+ /** Parse `nd package` args. Exits 2 on an unsupported platform (Windows unchanged). */
68
+ export function parsePackageArgs(args: string[], platform: string = process.platform): PackageOptions {
69
+ let target: PackageOptions["platform"] | undefined;
70
+ const opts: Omit<PackageOptions, "platform"> = {};
71
+ const takeValue = (flag: string, value: string | undefined): string => {
72
+ if (value === undefined) {
73
+ console.error(`nd: ${flag} expects a value\n`);
74
+ usage();
75
+ process.exit(1);
76
+ }
77
+ return value;
78
+ };
79
+ for (let i = 0; i < args.length; i++) {
80
+ const arg = args[i]!;
81
+ if (arg === "--out") opts.outDir = takeValue(arg, args[++i]);
82
+ else if (arg === "--entry") opts.entry = takeValue(arg, args[++i]);
83
+ else if (arg === "--version") opts.version = takeValue(arg, args[++i]);
84
+ else if (arg === "--cwd") opts.cwd = takeValue(arg, args[++i]);
85
+ else if (arg === "--no-compile") opts.compile = false;
86
+ else if (arg === "--sign") opts.signIdentity = takeValue(arg, args[++i]);
87
+ else if (arg === "--no-sign") opts.signIdentity = null;
88
+ else if (arg === "--notarize") opts.notarize = true;
89
+ else if (arg === "--no-notarize") opts.notarize = false;
90
+ else if (arg === "--format") {
91
+ const format = takeValue(arg, args[++i]);
92
+ if (format !== "appimage" && format !== "appdir") {
93
+ console.error(`nd: --format expects "appimage" or "appdir" (got "${format}")\n`);
94
+ usage();
95
+ process.exit(1);
96
+ }
97
+ opts.format = format;
98
+ } else if (!arg.startsWith("-") && target === undefined) {
99
+ if (arg !== "mac" && arg !== "linux") {
100
+ console.error(`nd package: unsupported platform "${arg}" (Windows lands with M7)\n`);
101
+ usage();
102
+ process.exit(2);
103
+ }
104
+ target = arg;
105
+ } else {
106
+ console.error(`nd: unexpected argument "${arg}"\n`);
107
+ usage();
108
+ process.exit(1);
109
+ }
110
+ }
111
+ if (target === undefined) {
112
+ if (platform === "darwin") target = "mac";
113
+ else if (platform === "linux") target = "linux";
114
+ else {
115
+ console.error(`nd package: unsupported host platform "${platform}" (Windows lands with M7)\n`);
116
+ usage();
117
+ process.exit(2);
118
+ }
119
+ }
120
+ return { platform: target, ...opts };
121
+ }
122
+
123
+ /** Parse `[entry] [--backend gtk|appkit]` in any order. Returns undefined backend to defer to ND_BACKEND / platform default. */
124
+ function parseDevArgs(args: string[]): { entry: string; backend?: Backend } {
125
+ let entry: string | undefined;
126
+ let backend: Backend | undefined;
127
+ for (let i = 0; i < args.length; i++) {
128
+ const arg = args[i]!;
129
+ if (arg === "--backend") backend = expectBackend(args[++i]);
130
+ else if (arg.startsWith("--backend=")) backend = expectBackend(arg.slice("--backend=".length));
131
+ else if (!arg.startsWith("-") && entry === undefined) entry = arg;
132
+ else {
133
+ console.error(`nd: unexpected argument "${arg}"\n`);
134
+ usage();
135
+ process.exit(1);
136
+ }
137
+ }
138
+ return { entry: entry ?? DEFAULT_ENTRY, backend };
139
+ }
140
+
141
+ function expectBackend(value: string | undefined): Backend {
142
+ if (value !== "gtk" && value !== "appkit") {
143
+ console.error(`nd: --backend expects "gtk" or "appkit" (got "${value ?? ""}")\n`);
144
+ usage();
145
+ process.exit(1);
146
+ }
147
+ return value;
148
+ }
149
+
150
+ async function main(): Promise<void> {
151
+ const [cmd, ...rest] = process.argv.slice(2);
152
+ switch (cmd) {
153
+ case "dev": {
154
+ const { entry, backend } = parseDevArgs(rest);
155
+ process.exit(await runDev(entry, backend));
156
+ break;
157
+ }
158
+ case "build":
159
+ process.exit(await runBuild());
160
+ break;
161
+ case "package":
162
+ await packageApp(parsePackageArgs(rest));
163
+ process.exit(0);
164
+ break;
165
+ case "doctor": {
166
+ const json = rest.includes("--json");
167
+ const extra = rest.filter((arg) => arg !== "--json");
168
+ if (extra.length) {
169
+ console.error(`nd: unexpected argument "${extra[0]}"\n`);
170
+ usage();
171
+ process.exit(1);
172
+ }
173
+ const { runDoctor } = await import("./package/doctor.ts");
174
+ process.exit(await runDoctor(process.cwd(), json));
175
+ break;
176
+ }
177
+ default:
178
+ if (cmd) console.error(`nd: unknown command "${cmd}"\n`);
179
+ usage();
180
+ process.exit(1);
181
+ }
182
+ }
183
+
184
+ if (import.meta.main) await main();
@@ -0,0 +1,110 @@
1
+ // `nd doctor`: checks the current directory's packaging readiness and the
2
+ // host toolchain. Warnings are advisory; only real gaps (a config that names
3
+ // missing files, an unresolvable host binary) exit non-zero.
4
+ import { existsSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { hostBinaryCandidates, prebuiltHostBinary, resolveBackend } from "@nativedesktop/host";
7
+ import { loadConfig, type NativeDesktopConfig } from "../config.ts";
8
+ import { DEFAULT_ENTRY } from "./payload.ts";
9
+
10
+ export type CheckStatus = "ok" | "warn" | "error";
11
+
12
+ export interface Check {
13
+ name: string;
14
+ status: CheckStatus;
15
+ detail: string;
16
+ }
17
+
18
+ function which(tool: string): boolean {
19
+ return Bun.which(tool) !== null;
20
+ }
21
+
22
+ export async function collectChecks(cwd: string): Promise<Check[]> {
23
+ const checks: Check[] = [];
24
+ const configPath = resolve(cwd, "nativedesktop.config.ts");
25
+ let config: NativeDesktopConfig = {};
26
+ if (!existsSync(configPath)) {
27
+ checks.push({ name: "config", status: "warn", detail: "no nativedesktop.config.ts here (defaults apply)" });
28
+ } else {
29
+ try {
30
+ config = await loadConfig(cwd);
31
+ checks.push({ name: "config", status: "ok", detail: "nativedesktop.config.ts loads" });
32
+ } catch (err) {
33
+ checks.push({ name: "config", status: "error", detail: `nativedesktop.config.ts failed to load: ${err}` });
34
+ return checks;
35
+ }
36
+ }
37
+
38
+ const hasConfig = existsSync(configPath);
39
+ if (config.app?.id) {
40
+ checks.push({ name: "app.id", status: "ok", detail: config.app.id });
41
+ } else {
42
+ // Icons, mime registration, and update manifests all key off app.id; a
43
+ // configured app without one is an error, a bare directory just a warning.
44
+ const status: CheckStatus = hasConfig && (config.app || config.package) ? "error" : "warn";
45
+ checks.push({ name: "app.id", status, detail: "app.id not set (required for icons, file associations, and updates)" });
46
+ }
47
+
48
+ const entry = config.package?.entry ?? DEFAULT_ENTRY;
49
+ if (existsSync(resolve(cwd, entry))) {
50
+ checks.push({ name: "entry", status: "ok", detail: entry });
51
+ } else {
52
+ checks.push({
53
+ name: "entry",
54
+ status: hasConfig ? "error" : "warn",
55
+ detail: `entry ${entry} not found (set package.entry)`,
56
+ });
57
+ }
58
+
59
+ checks.push(which("bun")
60
+ ? { name: "bun", status: "ok", detail: Bun.which("bun")! }
61
+ : { name: "bun", status: "error", detail: "bun not found on PATH" });
62
+
63
+ try {
64
+ const backend = resolveBackend(process.platform === "darwin" ? { backend: config.package?.mac?.backend ?? "appkit" } : { backend: "gtk" });
65
+ const { fresh } = hostBinaryCandidates(backend);
66
+ const found = prebuiltHostBinary(backend) ?? fresh.find(existsSync);
67
+ checks.push(found
68
+ ? { name: "host", status: "ok", detail: found }
69
+ : { name: "host", status: "warn", detail: `no ${backend} host binary built yet (nd dev / nd package builds it on first run in a source checkout)` });
70
+ } catch (err) {
71
+ checks.push({ name: "host", status: "error", detail: String(err) });
72
+ }
73
+
74
+ if (process.platform === "darwin") {
75
+ checks.push(which("codesign")
76
+ ? { name: "codesign", status: "ok", detail: "codesign available" }
77
+ : { name: "codesign", status: "error", detail: "codesign not found (install the Xcode command line tools)" });
78
+ const icon = typeof config.app?.icon === "string" ? config.app.icon : config.app?.icon?.macos ?? config.app?.icon?.source;
79
+ if (icon && icon.endsWith(".png") && !(which("sips") && which("iconutil"))) {
80
+ checks.push({ name: "icon-tools", status: "error", detail: "PNG icon configured but sips/iconutil are missing" });
81
+ }
82
+ } else if (process.platform === "linux") {
83
+ checks.push(which("appimagetool") || which("mksquashfs")
84
+ ? { name: "appimage", status: "ok", detail: which("appimagetool") ? "appimagetool available" : "mksquashfs fallback available" }
85
+ : { name: "appimage", status: "warn", detail: "neither appimagetool nor mksquashfs on PATH (only the raw AppDir can be produced)" });
86
+ }
87
+
88
+ if (config.package?.updates) {
89
+ checks.push(which("minisign")
90
+ ? { name: "updates", status: "ok", detail: "updates configured, minisign available" }
91
+ : { name: "updates", status: "error", detail: "updates configured but minisign is not on PATH" });
92
+ } else {
93
+ checks.push({ name: "updates", status: "warn", detail: "package.updates not configured: the shipped app has no updater" });
94
+ }
95
+
96
+ return checks;
97
+ }
98
+
99
+ const GLYPH: Record<CheckStatus, string> = { ok: "ok ", warn: "warn", error: "FAIL" };
100
+
101
+ /** Runs the check table against `cwd`, prints it (or JSON), returns the exit code. */
102
+ export async function runDoctor(cwd: string, json: boolean): Promise<number> {
103
+ const checks = await collectChecks(cwd);
104
+ if (json) {
105
+ console.log(JSON.stringify(checks, null, 2));
106
+ } else {
107
+ for (const check of checks) console.log(`${GLYPH[check.status]} ${check.name.padEnd(12)} ${check.detail}`);
108
+ }
109
+ return checks.some((check) => check.status === "error") ? 1 : 0;
110
+ }
@@ -0,0 +1,61 @@
1
+ // Linux icon degrade paths: resizers are probed, never hard-required, and a
2
+ // broken resizer must not abort packaging. Each scenario runs in a subprocess
3
+ // whose PATH holds only the fixture's fake tools, because Bun.which and spawn
4
+ // resolve against the process's startup PATH.
5
+ import { describe, expect, test } from "bun:test";
6
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const ICONS_MODULE = join(import.meta.dir, "icons.ts");
11
+
12
+ function fixture(tools: (root: string) => Record<string, string>): { root: string; appDir: string; appdir: string; bin: string } {
13
+ const root = mkdtempSync(join(tmpdir(), "nd-icons-"));
14
+ const appDir = join(root, "app");
15
+ const appdir = join(root, "AppDir");
16
+ mkdirSync(appDir, { recursive: true });
17
+ mkdirSync(appdir, { recursive: true });
18
+ writeFileSync(join(appDir, "icon.png"), "png bytes");
19
+ const bin = join(root, "bin");
20
+ mkdirSync(bin, { recursive: true });
21
+ for (const [name, script] of Object.entries(tools(root))) {
22
+ const file = join(bin, name);
23
+ writeFileSync(file, `#!/bin/sh\n${script}\n`);
24
+ chmodSync(file, 0o755);
25
+ }
26
+ return { root, appDir, appdir, bin };
27
+ }
28
+
29
+ function runInstall(appDir: string, appdir: string, path: string): { exitCode: number; stderr: string } {
30
+ const script = `
31
+ import { installLinuxIcon } from ${JSON.stringify(ICONS_MODULE)};
32
+ await installLinuxIcon(
33
+ { name: "Fixture", displayName: "Fixture", slug: "fixture", version: "1.0.0", categories: ["Utility"], icon: { source: "icon.png" } },
34
+ ${JSON.stringify(appDir)},
35
+ ${JSON.stringify(appdir)},
36
+ );
37
+ `;
38
+ const proc = Bun.spawnSync([process.execPath, "-e", script], { env: { PATH: path } });
39
+ return { exitCode: proc.exitCode, stderr: proc.stderr.toString() };
40
+ }
41
+
42
+ describe("installLinuxIcon", () => {
43
+ test("a failing resizer degrades to the root icon instead of aborting", () => {
44
+ const { appDir, appdir, bin } = fixture(() => ({ sips: "exit 1" }));
45
+ const { exitCode, stderr } = runInstall(appDir, appdir, bin);
46
+ expect(exitCode).toBe(0);
47
+ expect(stderr).toContain("ND_PACKAGE_ICON_SKIPPED reason=resize-failed");
48
+ expect(existsSync(join(appdir, "fixture.png"))).toBe(true);
49
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor"))).toBe(false);
50
+ });
51
+
52
+ test("rsvg-convert is never probed for a PNG source", () => {
53
+ const { root, appDir, appdir, bin } = fixture((r) => ({ "rsvg-convert": `touch "${r}/invoked"; exit 1` }));
54
+ const { exitCode, stderr } = runInstall(appDir, appdir, bin);
55
+ expect(exitCode).toBe(0);
56
+ expect(stderr).toContain("ND_PACKAGE_ICON_SKIPPED reason=no-resizer");
57
+ expect(existsSync(join(root, "invoked"))).toBe(false);
58
+ expect(existsSync(join(appdir, "fixture.png"))).toBe(true);
59
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor"))).toBe(false);
60
+ });
61
+ });