@nissyai/desktop 0.2.36 → 0.2.39

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 CHANGED
@@ -33,10 +33,36 @@ import {
33
33
  unsafeArchiveEntries,
34
34
  UnsupportedPlatformError,
35
35
  } from "../src/resolve.mjs";
36
+ import { confirmHealthyStart, prepareStore } from "../src/store.mjs";
37
+ import { launcherCommand, runLauncherCommand } from "../src/cli.mjs";
36
38
  import { DISTRIBUTION } from "../src/distribution.mjs";
37
39
 
38
40
  const require = createRequire(import.meta.url);
39
41
 
42
+ /** npm's copy of the payload version, or null when it cannot be read — an
43
+ * uninstalled or half-installed payload, which is precisely the state
44
+ * `--version` exists to describe rather than fall over on. */
45
+ function readPayloadVersion() {
46
+ try {
47
+ const spec = resolvePayloadSpec(process.platform, process.arch);
48
+ return JSON.parse(readFileSync(require.resolve(`${spec.pkg}/package.json`), "utf8")).version;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** This launcher package's own version. Published from `0.0.0-template`, which
55
+ * the pipeline substitutes, so a `-template` here means a working copy. */
56
+ const LAUNCHER_VERSION = (() => {
57
+ try {
58
+ return JSON.parse(
59
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
60
+ ).version;
61
+ } catch {
62
+ return "unknown";
63
+ }
64
+ })();
65
+
40
66
  function fail(message) {
41
67
  console.error(`[${DISTRIBUTION.npmCommand}] ${message}`);
42
68
  process.exit(1);
@@ -95,6 +121,38 @@ function isExtractedBundleInside(tempDir, bundlePath) {
95
121
  }
96
122
  }
97
123
 
124
+ // `--version` / `--rollback` answer and exit FIRST — before the payload is
125
+ // resolved, before the store is prepared, before anything is spawned.
126
+ //
127
+ // Before the payload, because these are support commands and the moment you
128
+ // need them is the moment the install is broken. A `--version` that refuses
129
+ // with "platform payload is not installed" is useless exactly when someone is
130
+ // trying to find out what state their machine is in, and a `--rollback` that
131
+ // needs a healthy install cannot rescue an unhealthy one.
132
+ //
133
+ // Before the store, because preparing it would let `--version` apply a pending
134
+ // update as a side effect of asking a question, and would let `--rollback` undo
135
+ // a swap the same command had just performed.
136
+ const launcherOwnCommand = launcherCommand(process.argv.slice(2));
137
+ if (launcherOwnCommand !== null) {
138
+ process.exit(
139
+ runLauncherCommand(launcherOwnCommand, {
140
+ platform: process.platform,
141
+ home: homedir(),
142
+ env: process.env,
143
+ cacheName: DISTRIBUTION.cacheName,
144
+ // Null when npm's copy cannot be read. The store is the authority for what
145
+ // RUNS; the payload version is a second opinion these commands report
146
+ // alongside it, not something they depend on.
147
+ payloadVersion: readPayloadVersion(),
148
+ launcherVersion: LAUNCHER_VERSION,
149
+ log: (message) => {
150
+ console.log(message);
151
+ },
152
+ }),
153
+ );
154
+ }
155
+
98
156
  let spec;
99
157
  try {
100
158
  spec = resolvePayloadSpec(process.platform, process.arch);
@@ -127,6 +185,17 @@ try {
127
185
  // lifecycle scripts on cached installs still self-heal.
128
186
  const payloadRoot = dirname(payloadPkgJson);
129
187
  const archivePath = join(payloadRoot, "app.tar.gz");
188
+ // The version npm currently has on disk. On Windows this is what the store
189
+ // seeds from; it goes STALE the moment the app updates itself, which is why the
190
+ // store only ever seeds from it when it is genuinely newer than `current`.
191
+ const payloadVersionForStore = (() => {
192
+ try {
193
+ return JSON.parse(readFileSync(payloadPkgJson, "utf8")).version;
194
+ } catch {
195
+ return "";
196
+ }
197
+ })();
198
+
130
199
  let exePath;
131
200
  // The installed macOS `.app` bundle, when this is the macOS archive payload —
132
201
  // the anchor we hand to `open` so LaunchServices dedups. null on Windows
@@ -209,6 +278,61 @@ if (!existsSync(exePath)) {
209
278
  fail(`payload executable missing at: ${exePath}`);
210
279
  }
211
280
 
281
+ // ── the versioned store (Windows npm channel) ─────────────────────────────
282
+ //
283
+ // Run from `<store>/current/...` rather than from the npm payload in place, so
284
+ // an update can be staged beside the running copy and applied by moving a
285
+ // pointer. `npm i -g` cannot do that: it renames the installed package aside
286
+ // before unpacking and rolls that rename back only on a best-effort basis, so a
287
+ // failed update over a locked, running exe can leave nothing installed at all.
288
+ //
289
+ // EVERY failure here falls back to `exePath` — the payload in place, exactly as
290
+ // this launcher behaved before the store existed. A bug in the store must cost
291
+ // a user their update, never their ability to start the app.
292
+ /** The npm payload's own executable, captured before the store may replace
293
+ * `exePath`, so a spawn failure can retreat to it instead of taking the whole
294
+ * launcher down. */
295
+ const payloadExePath = exePath;
296
+
297
+ /**
298
+ * How long to watch a newly applied version before calling it started.
299
+ *
300
+ * Short on purpose. The launcher is a CLI: every millisecond here is a
301
+ * millisecond the user's terminal is held, and this only buys confidence
302
+ * against a process that dies straight away. The attempt counter in the store
303
+ * is what covers the slower failures.
304
+ */
305
+ const HEALTH_WAIT_MS = 3000;
306
+
307
+ /** Is the process we started still there? Signal 0 checks without delivering. */
308
+ const stillRunning = (pid) => {
309
+ try {
310
+ process.kill(pid, 0);
311
+ return true;
312
+ } catch {
313
+ return false;
314
+ }
315
+ };
316
+ let storeExePath = null;
317
+ const storeVerbose = process.env.NISSY_STORE_DEBUG === "1";
318
+ const storeLog = (message) => {
319
+ if (storeVerbose) console.error(`[${DISTRIBUTION.npmCommand}] ${message}`);
320
+ };
321
+ const staged = prepareStore({
322
+ platform: process.platform,
323
+ home: homedir(),
324
+ env: process.env,
325
+ cacheName: DISTRIBUTION.cacheName,
326
+ payloadVersion: payloadVersionForStore,
327
+ payloadRoot,
328
+ exeSegments: spec.exe,
329
+ log: storeLog,
330
+ });
331
+ if (staged !== null) {
332
+ exePath = staged.exePath;
333
+ storeExePath = staged.exePath;
334
+ }
335
+
212
336
  // By default we launch the app detached and return immediately — `npx`/`nissy`
213
337
  // frees the terminal instead of streaming the app's stdio. The app writes its
214
338
  // own persistent log (electron-log → ~/Library/Logs/Nissy/main.log on macOS),
@@ -264,6 +388,22 @@ if (foreground) {
264
388
  child.on("error", (err) => {
265
389
  fail(`failed to spawn payload: ${err.message}`);
266
390
  });
391
+ // Same handshake as the detached path, but this process is sticking around
392
+ // anyway, so it can be a timer instead of a blocking wait. Without it,
393
+ // debugging with --foreground would spend an attempt on every run and
394
+ // eventually roll a perfectly good version back.
395
+ if (staged !== null && staged.awaitingConfirmation === true) {
396
+ setTimeout(() => {
397
+ confirmHealthyStart({
398
+ platform: process.platform,
399
+ home: homedir(),
400
+ env: process.env,
401
+ cacheName: DISTRIBUTION.cacheName,
402
+ confirm: child.exitCode === null && child.signalCode === null,
403
+ log: storeLog,
404
+ });
405
+ }, HEALTH_WAIT_MS).unref();
406
+ }
267
407
  child.on("exit", (code, signal) => {
268
408
  if (signal !== null) {
269
409
  // Re-raise the signal so the parent's exit reflects how the child died.
@@ -276,7 +416,34 @@ if (foreground) {
276
416
  // `open` exits as soon as it hands off to LaunchServices; the direct-exec
277
417
  // path needs the detach so this launcher can exit while the app keeps
278
418
  // running. Either way, don't tie the app's lifetime to this process.
279
- const child = spawn(command, args, { detached: true, stdio: "ignore", env: childEnv });
419
+ let child;
420
+ try {
421
+ child = spawn(command, args, { detached: true, stdio: "ignore", env: childEnv });
422
+ } catch (err) {
423
+ // `spawn` throws synchronously for a malformed or unreadable executable
424
+ // (EFTYPE, ENOEXEC), so the "error" handler below never sees it and the
425
+ // launcher dies with a Node stack trace. Observed with a corrupt staged
426
+ // version. If we were running from the store, retreat to the npm payload
427
+ // rather than leaving the user with nothing; `pending` stays set, so the
428
+ // next launch rolls the bad version back.
429
+ if (storeExePath !== null && exePath !== payloadExePath) {
430
+ storeLog(`spawn failed from the store (${err.code || err.message}); using the npm payload`);
431
+ try {
432
+ // The retreat can throw too — the payload itself may be the thing that
433
+ // is broken. Then there is genuinely nothing left to run, and saying so
434
+ // beats a raw Node stack trace.
435
+ child = spawn(payloadExePath, args, { detached: true, stdio: "ignore", env: childEnv });
436
+ } catch (fallbackErr) {
437
+ fail(
438
+ `neither the staged version nor the npm payload could be started ` +
439
+ `(${err.code || err.message} / ${fallbackErr.code || fallbackErr.message}). ` +
440
+ `Reinstall with: npm i -g ${DISTRIBUTION.npmLauncherPackage}`,
441
+ );
442
+ }
443
+ } else {
444
+ fail(`failed to spawn payload: ${err.message}`);
445
+ }
446
+ }
280
447
  child.on("error", (err) => {
281
448
  fail(
282
449
  viaLaunchServices
@@ -285,4 +452,31 @@ if (foreground) {
285
452
  );
286
453
  });
287
454
  child.unref();
455
+
456
+ // The app is away. Two different jobs now, and conflating them is what made
457
+ // the first version of this wrong.
458
+ //
459
+ // A version we have already proven just needs its dead siblings dropped, and
460
+ // the launcher should get out of the way immediately.
461
+ //
462
+ // A version on trial needs evidence that it STARTED, and `spawn` returning a
463
+ // pid is not that — Windows creates the process before the loader has resolved
464
+ // a single import, so a build missing a DLL "spawns" perfectly and is gone a
465
+ // moment later. So wait, then ask whether it is still there. Cheap, and it
466
+ // catches the failure that actually happens. It is paid once per update, never
467
+ // on an ordinary launch.
468
+ //
469
+ // This is still the weak version of the handshake — see confirmHealthyStart.
470
+ const onTrial = staged !== null && staged.awaitingConfirmation === true;
471
+ if (onTrial) {
472
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, HEALTH_WAIT_MS);
473
+ }
474
+ confirmHealthyStart({
475
+ platform: process.platform,
476
+ home: homedir(),
477
+ env: process.env,
478
+ cacheName: DISTRIBUTION.cacheName,
479
+ confirm: onTrial && stillRunning(child.pid),
480
+ log: storeLog,
481
+ });
288
482
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nissyai/desktop",
3
- "version": "0.2.36",
3
+ "version": "0.2.39",
4
4
  "type": "module",
5
5
  "description": "Launcher for the Nissy desktop app. 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
6
  "license": "SEE LICENSE IN LICENSE",
@@ -20,8 +20,8 @@
20
20
  "src/"
21
21
  ],
22
22
  "optionalDependencies": {
23
- "@nissyai/app-win32-x64": "0.2.36",
24
- "@nissyai/app-darwin-arm64": "0.2.36",
25
- "@nissyai/app-darwin-x64": "0.2.36"
23
+ "@nissyai/app-win32-x64": "0.2.39",
24
+ "@nissyai/app-darwin-arm64": "0.2.39",
25
+ "@nissyai/app-darwin-x64": "0.2.39"
26
26
  }
27
27
  }
package/src/cli.mjs ADDED
@@ -0,0 +1,169 @@
1
+ // The launcher's own commands: `nissy --version` and `nissy --rollback`.
2
+ //
3
+ // Both exist for support rather than for users. The AUTOMATIC rollback — a
4
+ // version that never confirms a start, after three attempts — is what protects
5
+ // people, and it is in `store.mjs`. These two are for the conversation that
6
+ // starts "it updated and now something is wrong", where the useful questions are
7
+ // "which version is actually running?" and "put me back", and today the only way
8
+ // to answer either is to read `store.log`.
9
+ //
10
+ // Kept out of `bin/nissy.mjs` because that file is a straight line — resolve the
11
+ // payload, prepare the store, spawn — and a command that answers and exits does
12
+ // not belong in the middle of it. Pure apart from the store it is handed, so the
13
+ // tests do not need a real install.
14
+
15
+ import {
16
+ compareVersions,
17
+ isCompleteVersion,
18
+ listVersions,
19
+ pointCurrentAt,
20
+ readCurrentVersion,
21
+ readPreviousVersion,
22
+ storeLayout,
23
+ versionDir,
24
+ clearPending,
25
+ } from "./store.mjs";
26
+
27
+ export const VERSION_FLAG = "--version";
28
+ export const ROLLBACK_FLAG = "--rollback";
29
+
30
+ /** Which command, if any, the argv asks for. */
31
+ export function launcherCommand(argv) {
32
+ if (argv.includes(ROLLBACK_FLAG)) return "rollback";
33
+ if (argv.includes(VERSION_FLAG)) return "version";
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * What `--version` should print.
39
+ *
40
+ * Three versions, because they are genuinely three different things and the
41
+ * confusion between them is why this exists:
42
+ *
43
+ * - the STORE's current version, which is what actually runs
44
+ * - the npm PAYLOAD's version, which is what `npm i -g` last installed and
45
+ * which goes stale the moment the app updates itself
46
+ * - the LAUNCHER's own version
47
+ *
48
+ * A user reading "0.2.37" off `npm ls -g` while running 0.2.41 out of the store
49
+ * is the exact support call this is meant to shorten.
50
+ */
51
+ export function describeVersions({ storeVersion, payloadVersion, launcherVersion, previousVersion }) {
52
+ const lines = [];
53
+ if (storeVersion !== null) {
54
+ lines.push(`running ${storeVersion}`);
55
+ } else if (payloadVersion !== null) {
56
+ lines.push(`running ${payloadVersion} (npm payload, no store yet)`);
57
+ } else {
58
+ // Neither readable. That IS the broken install this command exists to
59
+ // describe, so it reports what it found rather than refusing to answer.
60
+ lines.push("running unknown - no store, and npm's payload could not be read");
61
+ }
62
+ if (storeVersion !== null && previousVersion !== null && previousVersion !== storeVersion) {
63
+ lines.push(`previous ${previousVersion}`);
64
+ }
65
+ if (storeVersion !== null && payloadVersion !== null && payloadVersion !== storeVersion) {
66
+ // Not a warning. The store being AHEAD of npm is the normal steady state
67
+ // after a self-update, and saying so stops it looking like a fault.
68
+ lines.push(`npm has ${payloadVersion}`);
69
+ }
70
+ if (storeVersion !== null && payloadVersion === null) {
71
+ lines.push("npm has nothing readable - reinstall to repair the payload");
72
+ }
73
+ lines.push(`launcher ${launcherVersion}`);
74
+ return lines.join("\n");
75
+ }
76
+
77
+ /**
78
+ * Decide what `--rollback` can do, without doing it.
79
+ *
80
+ * Separated from the action so every refusal is a value with a reason, and so
81
+ * the reasons are testable. A rollback that silently does nothing is worse than
82
+ * one that explains itself: the user reruns it, concludes the tool is broken,
83
+ * and reinstalls by hand — which is the thing this whole line of work exists to
84
+ * stop people having to do.
85
+ */
86
+ export function planRollback({ currentVersion, previousVersion, versionExists }) {
87
+ if (currentVersion === null) {
88
+ return { ok: false, reason: "there is no versioned store yet, so there is nothing to roll back from" };
89
+ }
90
+ if (previousVersion === null) {
91
+ return { ok: false, reason: `${currentVersion} is the only version ever installed here` };
92
+ }
93
+ if (previousVersion === currentVersion) {
94
+ return { ok: false, reason: `${currentVersion} is already the previous version` };
95
+ }
96
+ if (compareVersions(previousVersion, currentVersion) > 0) {
97
+ // `previous` is NEWER. That happens the moment you roll back at all:
98
+ // pointing `current` at the old version demotes the new one to `previous`,
99
+ // so a second `--rollback` would put you straight back on the build you just
100
+ // escaped — while printing "rolled back", which is the opposite of true.
101
+ //
102
+ // A rollback only ever goes backwards. Going forwards is an update, and it
103
+ // has its own path.
104
+ return {
105
+ ok: false,
106
+ reason:
107
+ `${currentVersion} is already older than ${previousVersion}. ` +
108
+ `Rollback only goes backwards; use the app's update to move forward.`,
109
+ };
110
+ }
111
+ if (!versionExists(previousVersion)) {
112
+ // Pruned, or deleted by hand. Naming it matters: "reinstall to get it back"
113
+ // is actionable, "rollback failed" is not.
114
+ return {
115
+ ok: false,
116
+ reason:
117
+ `${previousVersion} is no longer on disk, so it cannot be restored. ` +
118
+ `Reinstall a specific version instead.`,
119
+ };
120
+ }
121
+ return { ok: true, from: currentVersion, to: previousVersion };
122
+ }
123
+
124
+ /**
125
+ * Run the command. Returns the process exit code.
126
+ *
127
+ * `log` rather than `console.log` directly so the tests can read what a user
128
+ * would see, which is the whole output of these commands.
129
+ */
130
+ export function runLauncherCommand(
131
+ command,
132
+ { platform, home, env, cacheName, payloadVersion, launcherVersion, log },
133
+ ) {
134
+ const layout = storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) });
135
+
136
+ if (command === "version") {
137
+ log(
138
+ describeVersions({
139
+ storeVersion: readCurrentVersion(layout),
140
+ previousVersion: readPreviousVersion(layout),
141
+ payloadVersion,
142
+ launcherVersion,
143
+ }),
144
+ );
145
+ return 0;
146
+ }
147
+
148
+ const plan = planRollback({
149
+ currentVersion: readCurrentVersion(layout),
150
+ previousVersion: readPreviousVersion(layout),
151
+ versionExists: (version) =>
152
+ listVersions(layout).includes(version) && isCompleteVersion(versionDir(layout, version)),
153
+ });
154
+ if (!plan.ok) {
155
+ log(`cannot roll back: ${plan.reason}`);
156
+ return 1;
157
+ }
158
+ try {
159
+ pointCurrentAt(layout, plan.to);
160
+ // Whatever was pending was asking for the version we have just moved away
161
+ // from. Leaving it would let the next launch apply it again and undo this.
162
+ clearPending(layout);
163
+ } catch (err) {
164
+ log(`rollback failed: ${(err && err.message) || String(err)}`);
165
+ return 1;
166
+ }
167
+ log(`rolled back ${plan.from} -> ${plan.to}. Start Nissy again to use it.`);
168
+ return 0;
169
+ }
@@ -0,0 +1,14 @@
1
+ // Types for the parts of the resolver the DESKTOP consumes.
2
+ //
3
+ // The launcher is plain ESM with JSDoc and has no build step. This declaration
4
+ // exists so `apps/desktop`'s parity test can import the payload table and be
5
+ // type-checked rather than silently typed `any` — which would let the test pass
6
+ // while comparing `undefined` to `undefined`.
7
+
8
+ /** Host key (`${platform}-${arch}`) → payload package + the executable within
9
+ * it, as path segments. Mirrored by the app's `payloadExeSegments`; enforced by
10
+ * `payload-exe-parity.test.ts`. */
11
+ export declare const PAYLOADS: Record<
12
+ string,
13
+ { pkg: string; exe: readonly string[] }
14
+ >;
@@ -0,0 +1,38 @@
1
+ // Types for the parts of the store the DESKTOP consumes.
2
+ //
3
+ // The launcher is plain ESM with JSDoc and has no build step; this declaration
4
+ // exists so `apps/desktop`'s parity test can import `compareVersions` and be
5
+ // type-checked rather than silently typed `any`. It deliberately declares only
6
+ // what crosses that boundary, not the whole module — nothing else outside the
7
+ // launcher should be importing from here.
8
+
9
+ /** -1 / 0 / 1. Must agree with the app's `compareVersions`; enforced by
10
+ * `apps/desktop/src/main/features/auto-update/version-parity.test.ts`. */
11
+ export declare function compareVersions(a: string, b: string): number;
12
+
13
+ /** Absolute paths of everything in the store. Mirrored by the app's
14
+ * `npmStoreLayout`; enforced by `npm-store.parity.test.ts`. */
15
+ export declare function storeLayout(input: {
16
+ platform: string;
17
+ home: string;
18
+ env: Record<string, string | undefined>;
19
+ cacheName?: string;
20
+ }): {
21
+ root: string;
22
+ versionsDir: string;
23
+ currentLink: string;
24
+ previousLink: string;
25
+ pendingFile: string;
26
+ healthFile: string;
27
+ logFile: string;
28
+ lockDir: string;
29
+ };
30
+
31
+ /** Semver-ish, and free of anything that means something to a path. */
32
+ export declare function isSafeVersion(version: string): boolean;
33
+
34
+ /** Written last into a version directory; the only thing that makes it eligible. */
35
+ export declare const SEED_COMPLETE_MARKER: string;
36
+
37
+ /** Launches an applied version gets to prove it starts. */
38
+ export declare const DEFAULT_TRIES: number;