@awak-app/simy-cli 0.5.7 → 0.5.10

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.
@@ -15,6 +15,12 @@ import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { promisify } from "node:util";
17
17
 
18
+ import { openExternalUrl } from "./browser.js";
19
+ import {
20
+ installMacosInstallerRelease,
21
+ readCurrentInstallerVersion,
22
+ } from "./install-update.js";
23
+
18
24
  const execFileAsync = promisify(execFile);
19
25
 
20
26
  export const CLI_PACKAGE_NAME = "@awak-app/simy-cli";
@@ -26,6 +32,8 @@ const AUTO_UPDATE_IDLE_RETRY_MS = 30_000;
26
32
  const UPDATE_LOCK_STALE_MS = 30 * 60 * 1_000;
27
33
  const RESTART_HANDOFF_TIMEOUT_MS = 30_000;
28
34
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
35
+ const INSTALLATION_MARKER_FILE = "installation.json";
36
+ const MACOS_INSTALLER_MODE = "macos_installer";
29
37
  const NON_IDLE_STATES = new Set([
30
38
  "queued",
31
39
  "risk_classifying",
@@ -73,7 +81,8 @@ export class CliAutoUpdater extends EventEmitter {
73
81
  automatic: Boolean(
74
82
  !options.disabled &&
75
83
  options.daemon &&
76
- options.installMode === "global_npm" &&
84
+ (options.installMode === "global_npm" ||
85
+ (options.installMode === MACOS_INSTALLER_MODE && options.installerMarker?.teamId)) &&
77
86
  options.requestedPort === 0
78
87
  ),
79
88
  checked_at: null,
@@ -89,6 +98,17 @@ export class CliAutoUpdater extends EventEmitter {
89
98
  return structuredClone(this.#snapshot);
90
99
  }
91
100
 
101
+ async performAction() {
102
+ const action = this.#snapshot.action;
103
+ if (action?.id === "download_macos_installer" && action.url) {
104
+ return this.#options.openUrl(action.url);
105
+ }
106
+ if (action?.command) {
107
+ throw new Error(`Run this command after leaving SIMY: ${action.command}`);
108
+ }
109
+ throw new Error("No SIMY CLI update action is currently available.");
110
+ }
111
+
92
112
  acceptsNewWork() {
93
113
  return !["updating", "restarting"].includes(this.#snapshot.state);
94
114
  }
@@ -151,9 +171,10 @@ export class CliAutoUpdater extends EventEmitter {
151
171
  });
152
172
  try {
153
173
  const release = await this.#options.checkLatestVersion();
174
+ this.#options.lastRelease = release;
154
175
  const targetVersion = String(release?.version || "").trim();
155
176
  if (!parseStableVersion(targetVersion)) {
156
- throw new Error("The npm registry returned an invalid stable CLI version.");
177
+ throw new Error("The update service returned an invalid stable CLI version.");
157
178
  }
158
179
  const checkedAt = new Date(this.#options.now()).toISOString();
159
180
  if (compareStableVersions(targetVersion, this.#options.currentVersion) <= 0) {
@@ -169,8 +190,11 @@ export class CliAutoUpdater extends EventEmitter {
169
190
  }
170
191
 
171
192
  const change = classifyVersionChange(this.#options.currentVersion, targetVersion);
172
- const automatic = this.#snapshot.automatic && ["patch", "minor"].includes(change);
173
- const action = updateAction(targetVersion);
193
+ const automatic =
194
+ this.#snapshot.automatic &&
195
+ ["patch", "minor"].includes(change) &&
196
+ (this.#options.installMode !== MACOS_INSTALLER_MODE || Boolean(release?.artifact?.updater));
197
+ const action = updateAction(targetVersion, this.#options);
174
198
  if (!automatic) {
175
199
  this.#clearIdleTimer();
176
200
  this.#setState("update_available", {
@@ -241,6 +265,7 @@ export class CliAutoUpdater extends EventEmitter {
241
265
  }
242
266
 
243
267
  let releaseLock = null;
268
+ let installation = null;
244
269
  try {
245
270
  releaseLock = await this.#options.acquireUpdateLock();
246
271
  if (!releaseLock) {
@@ -256,9 +281,10 @@ export class CliAutoUpdater extends EventEmitter {
256
281
  return this.snapshot();
257
282
  }
258
283
 
259
- await installAndVerifyVersion({
284
+ installation = await installAndVerifyVersion({
260
285
  currentVersion: this.#options.currentVersion,
261
286
  targetVersion,
287
+ release: this.#options.lastRelease,
262
288
  installVersion: this.#options.installVersion,
263
289
  readInstalledVersion: this.#options.readInstalledVersion,
264
290
  });
@@ -269,7 +295,7 @@ export class CliAutoUpdater extends EventEmitter {
269
295
  message: `SIMY CLI ${targetVersion} is installed. Verifying the restarted daemon...`,
270
296
  });
271
297
  this.#announce();
272
- await this.#restartDaemon?.(targetVersion);
298
+ await this.#restartDaemon?.(targetVersion, installation);
273
299
  this.#clearIdleTimer();
274
300
  this.#setState("updated", {
275
301
  message: `SIMY CLI ${targetVersion} is running.`,
@@ -278,10 +304,19 @@ export class CliAutoUpdater extends EventEmitter {
278
304
  });
279
305
  return this.snapshot();
280
306
  } catch (error) {
307
+ if (installation?.rollback) {
308
+ try {
309
+ await installation.rollback();
310
+ } catch (rollbackError) {
311
+ error = new Error(
312
+ `${errorMessage(error)} Automatic rollback failed (${errorMessage(rollbackError)}).`,
313
+ );
314
+ }
315
+ }
281
316
  this.#clearIdleTimer();
282
317
  this.#setState("failed", {
283
318
  message: `Automatic update failed. SIMY ${this.#options.currentVersion} is still running.`,
284
- action: updateAction(targetVersion),
319
+ action: updateAction(targetVersion, this.#options),
285
320
  error: errorMessage(error),
286
321
  });
287
322
  this.#announce();
@@ -315,9 +350,11 @@ export class CliAutoUpdater extends EventEmitter {
315
350
  if (this.#options.quiet) return;
316
351
  const suffix =
317
352
  ["update_available", "failed"].includes(this.#snapshot.state) &&
318
- this.#snapshot.action?.command
319
- ? ` Run manually: ${this.#snapshot.action.command}`
320
- : "";
353
+ (this.#snapshot.action?.command || this.#snapshot.action?.url)
354
+ ? this.#snapshot.action.url
355
+ ? ` Open: ${this.#snapshot.action.url}`
356
+ : ` Run manually: ${this.#snapshot.action.command}`
357
+ : "";
321
358
  const detail = this.#snapshot.error ? ` (${this.#snapshot.error})` : "";
322
359
  this.#options.logger(`${this.#snapshot.message}${suffix}${detail}`);
323
360
  }
@@ -329,6 +366,7 @@ export async function createCliAutoUpdater({
329
366
  requestedPort = 0,
330
367
  disabled = false,
331
368
  packageRoot = PACKAGE_ROOT,
369
+ webOrigin,
332
370
  dependencies = {},
333
371
  } = {}) {
334
372
  const packageInfo = dependencies.packageInfo || (await readCliPackageInfo(packageRoot));
@@ -337,6 +375,14 @@ export async function createCliAutoUpdater({
337
375
  execFileCommand: dependencies.execFileCommand,
338
376
  }));
339
377
  const stateRoot = dependencies.stateRoot || defaultStateRoot();
378
+ const installerMarker = installMode === MACOS_INSTALLER_MODE
379
+ ? dependencies.installerMarker || (await readInstallerMarker(packageRoot))
380
+ : null;
381
+ const checkRelease = dependencies.checkLatestVersion || (
382
+ installMode === MACOS_INSTALLER_MODE
383
+ ? () => checkInstallerRelease({ webOrigin, fetchImpl: dependencies.fetchImpl })
384
+ : checkLatestVersion
385
+ );
340
386
  return new CliAutoUpdater({
341
387
  daemon: Boolean(daemon),
342
388
  interactive: Boolean(interactive),
@@ -344,15 +390,37 @@ export async function createCliAutoUpdater({
344
390
  disabled,
345
391
  currentVersion: packageInfo.version,
346
392
  installMode,
347
- checkLatestVersion: dependencies.checkLatestVersion || checkLatestVersion,
393
+ installerMarker,
394
+ webOrigin,
395
+ checkLatestVersion: checkRelease,
348
396
  installVersion:
349
397
  dependencies.installVersion ||
350
- ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
398
+ (installMode === MACOS_INSTALLER_MODE
399
+ ? ((version, release) => installMacosInstallerRelease({
400
+ version,
401
+ release,
402
+ marker: installerMarker,
403
+ stateRoot,
404
+ fetchImpl: dependencies.fetchImpl,
405
+ execFileCommand: dependencies.execFileCommand,
406
+ }))
407
+ : ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand }))),
351
408
  readInstalledVersion:
352
409
  dependencies.readInstalledVersion ||
353
- (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
410
+ (installMode === MACOS_INSTALLER_MODE
411
+ ? (() => readCurrentInstallerVersion({ stateRoot }))
412
+ : (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand }))),
354
413
  acquireUpdateLock:
355
- dependencies.acquireUpdateLock || (() => acquireGlobalUpdateLock(stateRoot, dependencies)),
414
+ dependencies.acquireUpdateLock || (() => acquireGlobalUpdateLock(stateRoot, {
415
+ ...dependencies,
416
+ installMode,
417
+ })),
418
+ openUrl:
419
+ dependencies.openUrl ||
420
+ ((url) => openExternalUrl(url, {
421
+ description: "SIMY installer download page",
422
+ logger: { log() {}, error() {} },
423
+ })),
356
424
  logger: dependencies.logger || console.log,
357
425
  quiet: Boolean(interactive),
358
426
  now: dependencies.now || Date.now,
@@ -372,8 +440,13 @@ export async function readCliPackageInfo(packageRoot = PACKAGE_ROOT) {
372
440
  export async function detectInstallMode({
373
441
  packageRoot = PACKAGE_ROOT,
374
442
  execFileCommand = execFileAsync,
443
+ platform = process.platform,
444
+ arch = process.arch,
375
445
  } = {}) {
376
446
  const resolvedRoot = await realpathOrResolve(packageRoot);
447
+ if (await readInstallerMarker(resolvedRoot, { platform, arch })) {
448
+ return MACOS_INSTALLER_MODE;
449
+ }
377
450
  if (resolvedRoot.split(path.sep).includes("_npx")) return "npx";
378
451
 
379
452
  try {
@@ -390,6 +463,38 @@ export async function detectInstallMode({
390
463
  return resolvedRoot.split(path.sep).includes("node_modules") ? "package" : "source";
391
464
  }
392
465
 
466
+ export async function readInstallerMarker(
467
+ packageRoot = PACKAGE_ROOT,
468
+ { platform = process.platform, arch = process.arch } = {},
469
+ ) {
470
+ if (platform !== "darwin" || arch !== "arm64") return null;
471
+ let payload;
472
+ try {
473
+ payload = JSON.parse(
474
+ await readFile(path.join(packageRoot, INSTALLATION_MARKER_FILE), "utf8"),
475
+ );
476
+ } catch (error) {
477
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return null;
478
+ throw error;
479
+ }
480
+ if (
481
+ !payload ||
482
+ typeof payload !== "object" ||
483
+ Array.isArray(payload) ||
484
+ payload.schemaVersion !== 1 ||
485
+ payload.channel !== "macos-installer" ||
486
+ payload.platform !== "darwin" ||
487
+ payload.arch !== "arm64" ||
488
+ !(payload.teamId === null || /^[A-Z0-9]{10}$/.test(payload.teamId)) ||
489
+ payload.bundleId !== "com.awak.simy.cli" ||
490
+ Object.keys(payload).sort().join(",") !==
491
+ "arch,bundleId,channel,platform,schemaVersion,teamId"
492
+ ) {
493
+ return null;
494
+ }
495
+ return payload;
496
+ }
497
+
393
498
  export async function checkLatestVersion({ fetchImpl = fetch } = {}) {
394
499
  const response = await fetchImpl(
395
500
  "https://registry.npmjs.org/@awak-app%2fsimy-cli/latest",
@@ -407,6 +512,74 @@ export async function checkLatestVersion({ fetchImpl = fetch } = {}) {
407
512
  };
408
513
  }
409
514
 
515
+ export async function checkInstallerRelease({ webOrigin, fetchImpl = fetch } = {}) {
516
+ const origin = new URL(webOrigin).origin;
517
+ const response = await fetchImpl(new URL("/api/local-cli/release", origin), {
518
+ headers: { Accept: "application/json" },
519
+ signal: AbortSignal.timeout(10_000),
520
+ });
521
+ if (!response.ok) {
522
+ throw new Error(`SIMY Web installer release endpoint returned HTTP ${response.status}`);
523
+ }
524
+ const payload = await response.json();
525
+ return validateInstallerRelease(payload);
526
+ }
527
+
528
+ export function validateInstallerRelease(payload) {
529
+ if (
530
+ !payload ||
531
+ typeof payload !== "object" ||
532
+ Array.isArray(payload) ||
533
+ payload.schemaVersion !== 1 ||
534
+ !parseStableVersion(payload.version) ||
535
+ typeof payload.publishedAt !== "string" ||
536
+ !Number.isFinite(Date.parse(payload.publishedAt)) ||
537
+ !Array.isArray(payload.artifacts)
538
+ ) {
539
+ throw new Error("SIMY Web returned an invalid installer release manifest.");
540
+ }
541
+ const artifacts = payload.artifacts.filter(
542
+ (artifact) => artifact?.platform === "darwin" && artifact?.arch === "arm64",
543
+ );
544
+ if (artifacts.length !== 1) {
545
+ throw new Error("SIMY Web returned an invalid Apple Silicon installer artifact.");
546
+ }
547
+ const artifact = artifacts[0];
548
+ let artifactUrl;
549
+ try {
550
+ artifactUrl = new URL(artifact.url);
551
+ } catch {
552
+ throw new Error("SIMY Web returned an invalid Apple Silicon installer artifact.");
553
+ }
554
+ if (
555
+ artifactUrl.protocol !== "https:" ||
556
+ !/^[a-f0-9]{64}$/.test(String(artifact.sha256 || "")) ||
557
+ !Number.isSafeInteger(artifact.size) ||
558
+ artifact.size <= 0 ||
559
+ artifact.signed !== true ||
560
+ artifact.notarized !== true
561
+ ) {
562
+ throw new Error("SIMY Web returned an invalid Apple Silicon installer artifact.");
563
+ }
564
+ if (artifact.updater != null) {
565
+ let updaterUrl;
566
+ try {
567
+ updaterUrl = new URL(artifact.updater.url);
568
+ } catch {
569
+ throw new Error("SIMY Web returned an invalid automatic updater artifact.");
570
+ }
571
+ if (
572
+ updaterUrl.protocol !== "https:" ||
573
+ !/^[a-f0-9]{64}$/.test(String(artifact.updater.sha256 || "")) ||
574
+ !Number.isSafeInteger(artifact.updater.size) ||
575
+ artifact.updater.size <= 0
576
+ ) {
577
+ throw new Error("SIMY Web returned an invalid automatic updater artifact.");
578
+ }
579
+ }
580
+ return { version: payload.version, publishedAt: payload.publishedAt, artifact };
581
+ }
582
+
410
583
  export async function installGlobalNpmVersion(
411
584
  version,
412
585
  { execFileCommand = execFileAsync } = {},
@@ -437,20 +610,25 @@ export async function readGlobalNpmVersion({ execFileCommand = execFileAsync } =
437
610
  export async function installAndVerifyVersion({
438
611
  currentVersion,
439
612
  targetVersion,
613
+ release,
440
614
  installVersion = installGlobalNpmVersion,
441
615
  readInstalledVersion = readGlobalNpmVersion,
442
616
  }) {
617
+ let installation = null;
443
618
  try {
444
- await installVersion(targetVersion);
619
+ installation = await installVersion(targetVersion, release);
445
620
  const installedVersion = await readInstalledVersion();
446
621
  if (installedVersion !== targetVersion) {
447
- throw new Error(`npm reported success, but installed SIMY ${installedVersion}; expected ${targetVersion}.`);
622
+ throw new Error(`The updater selected SIMY ${installedVersion}; expected ${targetVersion}.`);
448
623
  }
449
- return installedVersion;
624
+ return installation && typeof installation === "object"
625
+ ? { ...installation, installedVersion }
626
+ : installedVersion;
450
627
  } catch (error) {
451
628
  let recoveryError = null;
452
629
  try {
453
- await installVersion(currentVersion);
630
+ if (installation?.rollback) await installation.rollback();
631
+ else await installVersion(currentVersion);
454
632
  const restoredVersion = await readInstalledVersion();
455
633
  if (restoredVersion !== currentVersion) {
456
634
  throw new Error(`restored version is ${restoredVersion || "unknown"}`);
@@ -475,6 +653,7 @@ export async function runCliUpdateGate({
475
653
  incompatibleRetry = false,
476
654
  packageRoot = PACKAGE_ROOT,
477
655
  entryPath,
656
+ webOrigin,
478
657
  env = process.env,
479
658
  logger = console.log,
480
659
  errorLogger = console.error,
@@ -513,10 +692,18 @@ export async function runCliUpdateGate({
513
692
  }
514
693
 
515
694
  let release;
695
+ let releaseInfo;
516
696
  try {
517
- const releaseInfo = await (dependencies.checkLatestVersion || checkLatestVersion)();
697
+ const checkRelease = dependencies.checkLatestVersion || (
698
+ installMode === MACOS_INSTALLER_MODE
699
+ ? () => checkInstallerRelease({ webOrigin, fetchImpl: dependencies.fetchImpl })
700
+ : checkLatestVersion
701
+ );
702
+ releaseInfo = await checkRelease();
518
703
  release = String(releaseInfo?.version || "").trim();
519
- if (!parseStableVersion(release)) throw new Error("The npm registry returned an invalid stable CLI version.");
704
+ if (!parseStableVersion(release)) {
705
+ throw new Error("The update service returned an invalid stable CLI version.");
706
+ }
520
707
  } catch (error) {
521
708
  if (mode !== "startup") errorLogger(`Update check failed: ${errorMessage(error)}`);
522
709
  result.error = errorMessage(error);
@@ -525,8 +712,31 @@ export async function runCliUpdateGate({
525
712
 
526
713
  if (compareStableVersions(release, info.version) <= 0) return report("up_to_date", release);
527
714
  const change = classifyVersionChange(info.version, release);
528
- const action = updateAction(release);
529
- result.recoveryCommand = action.command;
715
+ const action = updateAction(release, { installMode, webOrigin });
716
+ result.action = action;
717
+ if (action.command) result.recoveryCommand = action.command;
718
+ if (action.url) result.recoveryUrl = action.url;
719
+
720
+ const stateRoot = dependencies.stateRoot || defaultStateRoot();
721
+ const installerMarker = installMode === MACOS_INSTALLER_MODE
722
+ ? dependencies.installerMarker || (await readInstallerMarker(packageRoot))
723
+ : null;
724
+ const installerAutomatic = Boolean(installerMarker?.teamId && releaseInfo?.artifact?.updater);
725
+ if (installMode === MACOS_INSTALLER_MODE) {
726
+ logger(`SIMY CLI ${release} is available for Apple Silicon.`);
727
+ logger(
728
+ change === "major"
729
+ ? "Major installer updates require a manual signed installer download."
730
+ : installerAutomatic
731
+ ? "The background agent will install it after active work is finished."
732
+ : "This release requires a manual installer download.",
733
+ );
734
+ logger(`Installer download: ${action.url}`);
735
+ return report(
736
+ mode === "update" ? "installer_update_deferred" : "update_available",
737
+ release,
738
+ );
739
+ }
530
740
 
531
741
  if (installMode !== "global_npm") {
532
742
  logger(`SIMY CLI ${release} is available. This ${installMode} installation is read-only; run: ${action.command}`);
@@ -548,12 +758,15 @@ export async function runCliUpdateGate({
548
758
 
549
759
  if (change === "minor") logger(`Updating SIMY CLI ${info.version} → ${release}...`);
550
760
  if (change === "major") logger(`Installing confirmed major SIMY CLI update ${info.version} → ${release}...`);
551
- const stateRoot = dependencies.stateRoot || defaultStateRoot();
552
761
  const acquire = dependencies.acquireUpdateLock || (() => acquireGlobalUpdateLock(stateRoot, dependencies));
762
+ const installVersion = dependencies.installVersion ||
763
+ ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand }));
764
+ const readInstalledVersion = dependencies.readInstalledVersion ||
765
+ (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand }));
553
766
  let releaseLock = await acquire();
554
767
  if (!releaseLock) {
555
768
  const installed = await waitForInstalledVersion(release, {
556
- readInstalledVersion: dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
769
+ readInstalledVersion,
557
770
  timeoutMs: dependencies.lockWaitTimeoutMs,
558
771
  pollMs: dependencies.lockPollMs,
559
772
  });
@@ -566,8 +779,8 @@ export async function runCliUpdateGate({
566
779
  await installAndVerifyVersion({
567
780
  currentVersion: info.version,
568
781
  targetVersion: release,
569
- installVersion: dependencies.installVersion || ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
570
- readInstalledVersion: dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
782
+ installVersion,
783
+ readInstalledVersion,
571
784
  });
572
785
  } catch (error) {
573
786
  errorLogger(`SIMY update failed: ${errorMessage(error)}`);
@@ -589,12 +802,15 @@ export async function runCliUpdateGate({
589
802
  if (incompatibleRetry) childEnv[INCOMPATIBLE_RETRY_ENV] = "1";
590
803
  let exitCode;
591
804
  try {
592
- exitCode = await reexecute({ entryPath, argv, env: childEnv, spawnProcess: dependencies.spawnProcess });
805
+ exitCode = await reexecute({
806
+ entryPath,
807
+ argv,
808
+ env: childEnv,
809
+ spawnProcess: dependencies.spawnProcess,
810
+ });
593
811
  } catch (error) {
594
812
  let rollbackDetail = `Recovery: npm install -g ${CLI_PACKAGE_NAME}@${info.version}`;
595
813
  try {
596
- const installVersion = dependencies.installVersion || ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand }));
597
- const readInstalledVersion = dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand }));
598
814
  await installVersion(info.version);
599
815
  const restored = await readInstalledVersion();
600
816
  if (restored !== info.version) throw new Error(`installed version is ${restored || "unknown"}`);
@@ -616,12 +832,13 @@ export async function runCliUpdateGate({
616
832
 
617
833
  export async function reexecuteCliCommand({
618
834
  entryPath,
835
+ executablePath = process.execPath,
619
836
  argv,
620
837
  env = process.env,
621
838
  spawnProcess = spawn,
622
839
  } = {}) {
623
840
  return new Promise((resolve, reject) => {
624
- const child = spawnProcess(process.execPath, [entryPath, ...argv], {
841
+ const child = spawnProcess(executablePath, [entryPath, ...argv], {
625
842
  env,
626
843
  stdio: "inherit",
627
844
  });
@@ -683,8 +900,11 @@ export function shouldRetryIncompatibleCli(error, env = process.env) {
683
900
 
684
901
  export async function restartDaemonProcess({
685
902
  entryPath,
903
+ executablePath = process.execPath,
686
904
  argv,
687
905
  targetVersion,
906
+ expectedWebOrigin = null,
907
+ expectedInstallMode = null,
688
908
  stateRoot = defaultStateRoot(),
689
909
  spawnProcess = spawn,
690
910
  timeoutMs = RESTART_HANDOFF_TIMEOUT_MS,
@@ -694,7 +914,7 @@ export async function restartDaemonProcess({
694
914
  await mkdir(handoffDirectory, { recursive: true, mode: 0o700 });
695
915
  const nonce = randomBytes(24).toString("hex");
696
916
  const handoffPath = path.join(handoffDirectory, `${process.pid}-${nonce}.json`);
697
- const child = spawnProcess(process.execPath, [entryPath, ...argv], {
917
+ const child = spawnProcess(executablePath, [entryPath, ...argv], {
698
918
  detached: true,
699
919
  stdio: "ignore",
700
920
  env: {
@@ -719,7 +939,16 @@ export async function restartDaemonProcess({
719
939
  `Restarted SIMY reported version ${handoff.version || "unknown"}, expected ${targetVersion}.`,
720
940
  );
721
941
  }
942
+ if (expectedWebOrigin && handoff.web_origin !== expectedWebOrigin) {
943
+ throw new Error("Restarted SIMY reported a different Web origin.");
944
+ }
945
+ if (expectedInstallMode && handoff.install_mode !== expectedInstallMode) {
946
+ throw new Error("Restarted SIMY reported a different installation channel.");
947
+ }
722
948
  return handoff;
949
+ } catch (error) {
950
+ await terminateCandidate(child);
951
+ throw error;
723
952
  } finally {
724
953
  await rm(handoffPath, { force: true });
725
954
  }
@@ -728,6 +957,8 @@ export async function restartDaemonProcess({
728
957
  export async function writeUpdateHandoffFromEnvironment({
729
958
  version,
730
959
  port,
960
+ webOrigin = null,
961
+ installMode = null,
731
962
  env = process.env,
732
963
  stateRoot = defaultStateRoot(),
733
964
  } = {}) {
@@ -741,13 +972,28 @@ export async function writeUpdateHandoffFromEnvironment({
741
972
  await mkdir(handoffDirectory, { recursive: true, mode: 0o700 });
742
973
  await writeFile(
743
974
  handoffPath,
744
- `${JSON.stringify({ nonce, version, port, pid: process.pid, ready_at: new Date().toISOString() })}\n`,
975
+ `${JSON.stringify({
976
+ nonce,
977
+ version,
978
+ port,
979
+ pid: process.pid,
980
+ web_origin: webOrigin,
981
+ install_mode: installMode,
982
+ ready_at: new Date().toISOString(),
983
+ })}\n`,
745
984
  { encoding: "utf8", mode: 0o600 },
746
985
  );
747
986
  return true;
748
987
  }
749
988
 
750
- export function autoUpdateHelpText() {
989
+ export function autoUpdateHelpText({ installMode = null, webOrigin = null } = {}) {
990
+ if (installMode === MACOS_INSTALLER_MODE) {
991
+ return [
992
+ "Apple Silicon installer updates:",
993
+ " Signed patch and minor updates install in the background after active work finishes.",
994
+ ` Major updates use the signed installer: ${new URL("/local-cli/download", webOrigin)}`,
995
+ ].join("\n");
996
+ }
751
997
  return [
752
998
  "Recommended installation (enables verified CLI auto-update):",
753
999
  ` npm install -g ${CLI_PACKAGE_NAME}`,
@@ -762,6 +1008,9 @@ function manualUpdateMessage({
762
1008
  daemon,
763
1009
  requestedPort,
764
1010
  }) {
1011
+ if (installMode === MACOS_INSTALLER_MODE) {
1012
+ return `SIMY CLI ${targetVersion} is available. Download the signed Apple Silicon installer; your tasks and settings will be preserved.`;
1013
+ }
765
1014
  if (installMode !== "global_npm") {
766
1015
  return `SIMY CLI ${targetVersion} is available. Install the global version to enable automatic updates.`;
767
1016
  }
@@ -774,7 +1023,14 @@ function manualUpdateMessage({
774
1023
  return `SIMY CLI ${targetVersion} is available. This version change requires manual confirmation.`;
775
1024
  }
776
1025
 
777
- export function updateAction(version) {
1026
+ export function updateAction(version, { installMode = "global_npm", webOrigin } = {}) {
1027
+ if (installMode === MACOS_INSTALLER_MODE) {
1028
+ return {
1029
+ id: "download_macos_installer",
1030
+ label: "Download the Apple Silicon installer",
1031
+ url: new URL("/local-cli/download", webOrigin).toString(),
1032
+ };
1033
+ }
778
1034
  return {
779
1035
  id: "install_global_cli",
780
1036
  label: "Install and restart SIMY",
@@ -790,7 +1046,10 @@ function parseStableVersion(value) {
790
1046
  export async function acquireGlobalUpdateLock(stateRoot = defaultStateRoot(), dependencies = {}) {
791
1047
  const now = dependencies.now || Date.now;
792
1048
  const lockDirectory = path.join(stateRoot, "updates");
793
- const lockPath = path.join(lockDirectory, "global-npm-update.lock");
1049
+ const lockName = dependencies.installMode === MACOS_INSTALLER_MODE
1050
+ ? "macos-installer-update.lock"
1051
+ : "global-npm-update.lock";
1052
+ const lockPath = path.join(lockDirectory, lockName);
794
1053
  await mkdir(lockDirectory, { recursive: true, mode: 0o700 });
795
1054
 
796
1055
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -857,6 +1116,24 @@ async function waitForHandoff(handoffPath, nonce, timeoutMs) {
857
1116
  throw new Error("The updated SIMY daemon did not become healthy in time.");
858
1117
  }
859
1118
 
1119
+ async function terminateCandidate(child) {
1120
+ if (!child?.pid || child.exitCode != null || child.signalCode != null) return;
1121
+ let resolveExit;
1122
+ const exited = new Promise((resolve) => {
1123
+ resolveExit = resolve;
1124
+ child.once?.("exit", resolve);
1125
+ });
1126
+ child.kill?.("SIGTERM");
1127
+ if (await Promise.race([exited.then(() => true), delay(2_000, false)])) return;
1128
+ child.kill?.("SIGKILL");
1129
+ await Promise.race([exited, delay(2_000)]);
1130
+ resolveExit?.();
1131
+ }
1132
+
1133
+ function delay(milliseconds, value = undefined) {
1134
+ return new Promise((resolve) => setTimeout(() => resolve(value), milliseconds));
1135
+ }
1136
+
860
1137
  async function realpathOrResolve(value) {
861
1138
  try {
862
1139
  return await realpath(value);
package/src/browser.js CHANGED
@@ -8,14 +8,31 @@ export async function openAuthorizationUrl(
8
8
  logger = console,
9
9
  } = {},
10
10
  ) {
11
- if (!authorizationUrl) return { opened: false, reason: "not_required" };
11
+ return openExternalUrl(authorizationUrl, {
12
+ platform,
13
+ spawn,
14
+ logger,
15
+ description: "SIMY CLI authorization page",
16
+ });
17
+ }
18
+
19
+ export async function openExternalUrl(
20
+ url,
21
+ {
22
+ platform = process.platform,
23
+ spawn = spawnProcess,
24
+ logger = console,
25
+ description = "page",
26
+ } = {},
27
+ ) {
28
+ if (!url) return { opened: false, reason: "not_required" };
12
29
 
13
- const target = browserCommand(platform, authorizationUrl);
14
- logger.log(`Authorize SIMY CLI: ${authorizationUrl}`);
30
+ const target = browserCommand(platform, url);
31
+ logger.log(`Open ${description}: ${url}`);
15
32
 
16
33
  if (!target) {
17
34
  logger.error(`SIMY could not open a browser on platform ${platform}.`);
18
- logger.error(`Open this URL manually: ${authorizationUrl}`);
35
+ logger.error(`Open this URL manually: ${url}`);
19
36
  return { opened: false, reason: "unsupported_platform" };
20
37
  }
21
38
 
@@ -26,11 +43,11 @@ export async function openAuthorizationUrl(
26
43
  });
27
44
  await waitForSpawn(child);
28
45
  child.unref();
29
- logger.log("Opened the SIMY CLI authorization page in your browser.");
46
+ logger.log(`Opened the ${description} in your browser.`);
30
47
  return { opened: true };
31
48
  } catch (error) {
32
- logger.error(`SIMY could not open the authorization page: ${errorMessage(error)}`);
33
- logger.error(`Open this URL manually: ${authorizationUrl}`);
49
+ logger.error(`SIMY could not open the ${description}: ${errorMessage(error)}`);
50
+ logger.error(`Open this URL manually: ${url}`);
34
51
  return { opened: false, reason: "launch_failed", error };
35
52
  }
36
53
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.7";
16
+ export const CLI_VERSION = "0.5.10";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(