@nissyai/desktop 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/bin/nissy.mjs ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // `nissy` launcher entry point (internal npx dev channel).
4
+ //
5
+ // Picks the payload package for the host platform/arch, finds where npm
6
+ // installed it, and spawns the self-contained app executable inside it,
7
+ // inheriting stdio and propagating the exit code / signal.
8
+
9
+ import { execFileSync, spawn } from "node:child_process";
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import {
25
+ cacheRootSegments,
26
+ needsMacReinstall,
27
+ npxChannelEnv,
28
+ resolveLaunchCommand,
29
+ resolvePayloadSpec,
30
+ UnsupportedPlatformError,
31
+ } from "../src/resolve.mjs";
32
+
33
+ const require = createRequire(import.meta.url);
34
+
35
+ function fail(message) {
36
+ console.error(`[nissy] ${message}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ let spec;
41
+ try {
42
+ spec = resolvePayloadSpec(process.platform, process.arch);
43
+ } catch (err) {
44
+ if (err instanceof UnsupportedPlatformError) {
45
+ fail(`unsupported platform: ${err.key}`);
46
+ }
47
+ throw err;
48
+ }
49
+
50
+ let payloadPkgJson;
51
+ try {
52
+ payloadPkgJson = require.resolve(`${spec.pkg}/package.json`);
53
+ } catch {
54
+ fail(
55
+ `platform payload "${spec.pkg}" is not installed for ${spec.key}. ` +
56
+ `Reinstall so the matching optional dependency is fetched.`,
57
+ );
58
+ }
59
+
60
+ // Resolve the executable to launch. Two payload layouts:
61
+ // • app/ — Forge output copied verbatim (Windows). Run in place.
62
+ // • app.tar.gz — symlink-preserving archive (macOS; npm pack would strip the
63
+ // .app's framework symlinks). INSTALL it into ~/Applications
64
+ // — a standard, Spotlight-indexed location — so Nissy shows
65
+ // in Spotlight / Launchpad / Finder and carries its Dock
66
+ // icon, rather than running hidden from a per-user cache.
67
+ // Refreshed in place when the payload version changes. Done
68
+ // here (not a postinstall hook) so package managers that skip
69
+ // lifecycle scripts on cached installs still self-heal.
70
+ const payloadRoot = dirname(payloadPkgJson);
71
+ const archivePath = join(payloadRoot, "app.tar.gz");
72
+ let exePath;
73
+ // The installed macOS `.app` bundle, when this is the macOS archive payload —
74
+ // the anchor we hand to `open` so LaunchServices dedups. null on Windows
75
+ // (run-in-place), where we exec the binary directly.
76
+ let macBundlePath = null;
77
+ if (existsSync(join(payloadRoot, "app"))) {
78
+ exePath = join(payloadRoot, ...spec.exe);
79
+ } else if (existsSync(archivePath)) {
80
+ const { version } = JSON.parse(readFileSync(payloadPkgJson, "utf8"));
81
+ const appsDir = join(homedir(), "Applications");
82
+ const bundleName = spec.exe[1]; // e.g. "Nissy.app" (spec.exe[0] is "app")
83
+ const installedApp = join(appsDir, bundleName);
84
+ macBundlePath = installedApp;
85
+ // The exe inside the installed bundle (drop the payload's leading "app/").
86
+ exePath = join(appsDir, ...spec.exe.slice(1));
87
+ const cacheRoot = join(
88
+ ...cacheRootSegments({ platform: process.platform, home: homedir(), env: process.env }),
89
+ );
90
+ const markerPath = join(cacheRoot, "applications-darwin.version");
91
+ const markerVersion = existsSync(markerPath)
92
+ ? readFileSync(markerPath, "utf8").trim()
93
+ : null;
94
+
95
+ if (needsMacReinstall({ markerVersion, payloadVersion: version, exeExists: existsSync(exePath) })) {
96
+ const tmp = `${installedApp}.tmp.${process.pid}`;
97
+ try {
98
+ rmSync(tmp, { recursive: true, force: true });
99
+ mkdirSync(tmp, { recursive: true });
100
+ execFileSync("tar", ["-xzf", archivePath, "-C", tmp], { stdio: "inherit" });
101
+ const tmpApp = join(tmp, bundleName);
102
+ if (!existsSync(tmpApp)) {
103
+ rmSync(tmp, { recursive: true, force: true });
104
+ fail(`payload archive ${archivePath} did not contain ${bundleName}`);
105
+ }
106
+ mkdirSync(appsDir, { recursive: true });
107
+ // Swap: move any existing install aside, rename the new one into place,
108
+ // then best-effort remove the old. RENAMING the old bundle (rather than
109
+ // deleting it) keeps a currently-running instance's mapped files valid
110
+ // until it quits; the new version is used on next launch.
111
+ const old = `${installedApp}.old.${process.pid}`;
112
+ rmSync(old, { recursive: true, force: true });
113
+ if (existsSync(installedApp)) renameSync(installedApp, old);
114
+ renameSync(tmpApp, installedApp);
115
+ rmSync(tmp, { recursive: true, force: true });
116
+ rmSync(old, { recursive: true, force: true });
117
+ mkdirSync(dirname(markerPath), { recursive: true });
118
+ writeFileSync(markerPath, version);
119
+ // Best-effort: index the new bundle now so Spotlight/Launchpad pick it up
120
+ // without waiting for the periodic scan. Never block launch.
121
+ try {
122
+ execFileSync("/usr/bin/mdimport", [installedApp], { stdio: "ignore" });
123
+ } catch {
124
+ // ignore — Spotlight indexes ~/Applications on its own schedule too
125
+ }
126
+ } catch (err) {
127
+ rmSync(tmp, { recursive: true, force: true });
128
+ if (!existsSync(exePath)) {
129
+ fail(`failed to install ${installedApp}: ${err.message}`);
130
+ }
131
+ }
132
+ }
133
+
134
+ // Best-effort: reclaim space from the OLD version-keyed cache layout earlier
135
+ // launcher versions used (~140 MB each). Never block launch.
136
+ try {
137
+ for (const entry of readdirSync(cacheRoot)) {
138
+ if (entry.startsWith("nissy-app-darwin-")) {
139
+ rmSync(join(cacheRoot, entry), { recursive: true, force: true });
140
+ }
141
+ }
142
+ } catch {
143
+ // ignore — cleanup is non-essential
144
+ }
145
+ } else {
146
+ fail(`payload "${spec.pkg}" contains neither app/ nor app.tar.gz`);
147
+ }
148
+
149
+ if (!existsSync(exePath)) {
150
+ fail(`payload executable missing at: ${exePath}`);
151
+ }
152
+
153
+ // By default we launch the app detached and return immediately — `npx`/`nissy`
154
+ // frees the terminal instead of streaming the app's stdio. The app writes its
155
+ // own persistent log (electron-log → ~/Library/Logs/Nissy/main.log on macOS),
156
+ // so dropping stdio here loses nothing. Pass --foreground to stay attached and
157
+ // mirror the app's stdio + exit code, which is what you want when debugging.
158
+ const forwardedArgs = process.argv.slice(2).filter((a) => a !== "--foreground");
159
+ const foreground = forwardedArgs.length !== process.argv.slice(2).length;
160
+
161
+ // On macOS, launch through `/usr/bin/open <bundle>` so LaunchServices enforces
162
+ // single-instance: a `nissy`/`npx` run while Nissy is already up just activates
163
+ // the running instance instead of spawning a second mascot. Direct-exec of the
164
+ // inner binary (Windows, or `--foreground` debugging on macOS) bypasses that OS
165
+ // dedup and leans solely on Electron's userData lock — which fails open after a
166
+ // stale-lock-leaving crash. See resolveLaunchCommand for the full rationale.
167
+ const { command, args, viaLaunchServices } = resolveLaunchCommand({
168
+ platform: process.platform,
169
+ bundlePath: macBundlePath,
170
+ exePath,
171
+ foreground,
172
+ forwardedArgs,
173
+ });
174
+
175
+ // Stamp the spawned app with this channel + the STABLE launcher command
176
+ // (absolute node + this script). On Windows the app reads the channel marker to
177
+ // pick its autostart/update strategy; on macOS the channel is platform-derived
178
+ // (and `open` launches via LaunchServices, which does NOT inherit this env
179
+ // anyway), so the stamp is a harmless no-op there.
180
+ const childEnv = {
181
+ ...process.env,
182
+ ...npxChannelEnv(process.execPath, fileURLToPath(import.meta.url)),
183
+ };
184
+
185
+ if (foreground) {
186
+ const child = spawn(command, args, { stdio: "inherit", env: childEnv });
187
+ child.on("error", (err) => {
188
+ fail(`failed to spawn payload: ${err.message}`);
189
+ });
190
+ child.on("exit", (code, signal) => {
191
+ if (signal !== null) {
192
+ // Re-raise the signal so the parent's exit reflects how the child died.
193
+ process.kill(process.pid, signal);
194
+ return;
195
+ }
196
+ process.exit(code ?? 0);
197
+ });
198
+ } else {
199
+ // `open` exits as soon as it hands off to LaunchServices; the direct-exec
200
+ // path needs the detach so this launcher can exit while the app keeps
201
+ // running. Either way, don't tie the app's lifetime to this process.
202
+ const child = spawn(command, args, { detached: true, stdio: "ignore", env: childEnv });
203
+ child.on("error", (err) => {
204
+ fail(
205
+ viaLaunchServices
206
+ ? `failed to launch via 'open': ${err.message}`
207
+ : `failed to spawn payload: ${err.message}`,
208
+ );
209
+ });
210
+ child.unref();
211
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@nissyai/desktop",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Launcher for the Nissy desktop app (internal npx dev channel). Resolves the host's app payload and spawns it. Run via `npx @nissyai/desktop`; a global install (`npm i -g @nissyai/desktop`) exposes the `nissy` command.",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "author": {
8
+ "name": "Cleverbit",
9
+ "email": "hello@cleverbit.software"
10
+ },
11
+ "homepage": "https://nissy.ai",
12
+ "bin": {
13
+ "nissy": "bin/nissy.mjs"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "src/"
21
+ ],
22
+ "optionalDependencies": {
23
+ "@nissyai/app-win32-x64": "0.1.0",
24
+ "@nissyai/app-darwin-arm64": "0.1.0",
25
+ "@nissyai/app-darwin-x64": "0.1.0"
26
+ }
27
+ }
@@ -0,0 +1,181 @@
1
+ // Pure platform → payload mapping for the `nissy` launcher.
2
+ //
3
+ // No IO here so it is trivially unit-testable. The bin entry
4
+ // (../bin/nissy.mjs) layers `require.resolve` + `spawn` on top.
5
+ //
6
+ // The Electron Forge `package` output is self-contained (it bundles
7
+ // Electron), so each payload exposes a single executable we spawn
8
+ // directly — there is no dependency on the `electron` npm package.
9
+
10
+ /** Error thrown when the host platform/arch has no payload mapping. */
11
+ export class UnsupportedPlatformError extends Error {
12
+ /** @param {string} key host key, e.g. "linux-x64" */
13
+ constructor(key) {
14
+ super(`unsupported platform: ${key}`);
15
+ this.name = "UnsupportedPlatformError";
16
+ this.key = key;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Host key (`${platform}-${arch}`) → payload package name + the executable
22
+ * within it, expressed as path segments (joined with the OS separator at
23
+ * the call site so this stays platform-neutral and testable).
24
+ *
25
+ * @type {Record<string, { pkg: string, exe: readonly string[] }>}
26
+ */
27
+ export const PAYLOADS = {
28
+ "win32-x64": {
29
+ pkg: "@nissyai/app-win32-x64",
30
+ exe: ["app", "nissy.exe"],
31
+ },
32
+ "darwin-arm64": {
33
+ pkg: "@nissyai/app-darwin-arm64",
34
+ exe: ["app", "Nissy.app", "Contents", "MacOS", "nissy"],
35
+ },
36
+ "darwin-x64": {
37
+ pkg: "@nissyai/app-darwin-x64",
38
+ exe: ["app", "Nissy.app", "Contents", "MacOS", "nissy"],
39
+ },
40
+ };
41
+
42
+ /**
43
+ * Resolve the payload spec for a host platform/arch.
44
+ * @param {NodeJS.Platform | string} platform
45
+ * @param {string} arch
46
+ * @returns {{ key: string, pkg: string, exe: readonly string[] }}
47
+ * @throws {UnsupportedPlatformError} when no mapping exists
48
+ */
49
+ export function resolvePayloadSpec(platform, arch) {
50
+ const key = `${platform}-${arch}`;
51
+ const spec = PAYLOADS[key];
52
+ if (spec === undefined) {
53
+ throw new UnsupportedPlatformError(key);
54
+ }
55
+ return { key, pkg: spec.pkg, exe: spec.exe };
56
+ }
57
+
58
+ /**
59
+ * Path segments for the per-user cache root where a payload that ships as an
60
+ * archive (macOS — npm pack strips the .app's framework symlinks) is extracted
61
+ * on first run. Pure (env injected) so it stays unit-testable; the caller joins
62
+ * with the OS separator.
63
+ *
64
+ * @param {{ platform: NodeJS.Platform | string, home: string, env: Record<string, string | undefined> }} host
65
+ * @returns {string[]} path segments of the cache root
66
+ */
67
+ export function cacheRootSegments({ platform, home, env }) {
68
+ if (platform === "darwin") return [home, "Library", "Caches", "nissy"];
69
+ if (platform === "win32") {
70
+ return [env.LOCALAPPDATA || env.TEMP || home, "nissy", "Cache"];
71
+ }
72
+ return env.XDG_CACHE_HOME ? [env.XDG_CACHE_HOME, "nissy"] : [home, ".cache", "nissy"];
73
+ }
74
+
75
+ /**
76
+ * Filesystem-safe, version-pinned cache key for a payload package, e.g.
77
+ * ("@nissyai/app-darwin-x64", "1.2.3") → "nissyai-app-darwin-x64@1.2.3". Version
78
+ * pinning means a new app version extracts to a fresh dir (no stale binaries).
79
+ *
80
+ * @param {string} pkg payload package name
81
+ * @param {string} version
82
+ * @returns {string}
83
+ */
84
+ export function payloadCacheKey(pkg, version) {
85
+ const safe = pkg.replace(/^@/, "").replace(/\//g, "-");
86
+ return `${safe}@${version}`;
87
+ }
88
+
89
+ /**
90
+ * Given the entry names in the cache root and the current cache key, return the
91
+ * stale extracted-payload dirs to prune (older versions of the SAME payload).
92
+ * Keeps the current version; ignores unrelated dirs and in-flight `.tmp.<pid>`
93
+ * extraction dirs (a concurrent first-run may still own those). Pure so the
94
+ * keep/prune decision is unit-testable; the caller does the filesystem removal.
95
+ *
96
+ * @param {string[]} entries dir names directly under the cache root
97
+ * @param {string} currentKey the current `payloadCacheKey(pkg, version)`
98
+ * @returns {string[]} entry names safe to remove
99
+ */
100
+ export function stalePayloadCaches(entries, currentKey) {
101
+ const at = currentKey.lastIndexOf("@");
102
+ if (at <= 0) return [];
103
+ const pkgPrefix = currentKey.slice(0, at + 1); // e.g. "nissyai-app-darwin-x64@"
104
+ return entries.filter(
105
+ (name) =>
106
+ name !== currentKey &&
107
+ name.startsWith(pkgPrefix) &&
108
+ !name.includes(".tmp."),
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Environment the launcher stamps on the spawned app so it can tell it was
114
+ * started via this channel (vs the native Velopack install) and knows the
115
+ * STABLE launcher command to register for launch-at-login. A macOS LaunchAgent
116
+ * must point at an absolute node + script, NOT the version-keyed payload cache
117
+ * exe (which is pruned on update). Pure so the mapping is unit-testable.
118
+ *
119
+ * @param {string} nodePath absolute path to the node binary running the launcher (`process.execPath`)
120
+ * @param {string} launcherScript absolute path to this launcher script
121
+ * @returns {{ NISSY_CHANNEL: string, NISSY_LAUNCHER_NODE: string, NISSY_LAUNCHER_SCRIPT: string }}
122
+ */
123
+ export function npxChannelEnv(nodePath, launcherScript) {
124
+ return {
125
+ NISSY_CHANNEL: "npx",
126
+ NISSY_LAUNCHER_NODE: nodePath,
127
+ NISSY_LAUNCHER_SCRIPT: launcherScript,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Decide HOW to launch the resolved payload — the command + argv to spawn.
133
+ *
134
+ * On macOS we go through `/usr/bin/open <bundle>` so that **LaunchServices**
135
+ * enforces its single-instance rule: launching an app that's already running
136
+ * just activates the existing instance instead of spawning a second process.
137
+ * Exec'ing the inner Mach-O binary directly (`…/Contents/MacOS/nissy`, the
138
+ * historical path) bypasses that OS-level dedup entirely — leaving Electron's
139
+ * userData `requestSingleInstanceLock()` as the *only* guard, which fails open
140
+ * when a prior instance crashed and left a stale `SingletonLock` (this is how
141
+ * two mascots ended up running at once). Note: plain `open` — NOT `open -n`,
142
+ * which would FORCE a new instance, the opposite of what we want.
143
+ *
144
+ * `--foreground` (a debugging mode that mirrors the app's stdio + exit code)
145
+ * keeps the direct-exec path, since `open` detaches and can't stream stdio.
146
+ * Windows has no `open` equivalent here and runs the payload in place, so it
147
+ * also keeps the direct-exec path.
148
+ *
149
+ * Pure (no IO) so the decision is unit-testable; the caller does the spawn.
150
+ *
151
+ * @param {{
152
+ * platform: NodeJS.Platform | string,
153
+ * bundlePath: string | null,
154
+ * exePath: string,
155
+ * foreground: boolean,
156
+ * forwardedArgs: readonly string[],
157
+ * }} input bundlePath is the installed `.app` (macOS only; null elsewhere).
158
+ * @returns {{ command: string, args: string[], viaLaunchServices: boolean }}
159
+ */
160
+ export function resolveLaunchCommand({ platform, bundlePath, exePath, foreground, forwardedArgs }) {
161
+ if (platform === "darwin" && !foreground && bundlePath !== null) {
162
+ const args = [bundlePath];
163
+ // `open` forwards extra argv to the app only after a `--args` separator.
164
+ if (forwardedArgs.length > 0) args.push("--args", ...forwardedArgs);
165
+ return { command: "/usr/bin/open", args, viaLaunchServices: true };
166
+ }
167
+ return { command: exePath, args: [...forwardedArgs], viaLaunchServices: false };
168
+ }
169
+
170
+ /**
171
+ * Whether the macOS ~/Applications install needs (re)writing: when the recorded
172
+ * install-version marker doesn't match the payload version, or the installed
173
+ * bundle's executable is missing. Pure so the decision is unit-testable; the
174
+ * launcher does the filesystem work.
175
+ *
176
+ * @param {{ markerVersion: string | null, payloadVersion: string, exeExists: boolean }} input
177
+ * @returns {boolean}
178
+ */
179
+ export function needsMacReinstall({ markerVersion, payloadVersion, exeExists }) {
180
+ return !exeExists || markerVersion !== payloadVersion;
181
+ }