opensocrates 1.1.0 → 1.1.3

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.
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "node:child_process";
4
- import { createHash, randomUUID } from "node:crypto";
5
- import { createReadStream, createWriteStream, realpathSync } from "node:fs";
4
+ import { createHash, randomInt, randomUUID } from "node:crypto";
5
+ import { constants as fsConstants, createReadStream, createWriteStream, realpathSync } from "node:fs";
6
6
  import {
7
7
  access,
8
+ chmod,
8
9
  cp,
9
10
  lstat,
10
11
  mkdir,
11
12
  mkdtemp,
13
+ open,
12
14
  readFile,
13
15
  readdir,
14
16
  rename,
@@ -22,13 +24,15 @@ import { Readable } from "node:stream";
22
24
  import { pipeline } from "node:stream/promises";
23
25
  import { fileURLToPath } from "node:url";
24
26
 
25
- export const PRODUCT_VERSION = "1.1.0";
27
+ export const PRODUCT_VERSION = "1.1.3";
26
28
  export const REPOSITORY = "ParkerHwang/OpenSocrates";
27
29
  export const MARKETPLACE_NAME = "opensocrates";
28
30
  export const PLUGIN_NAME = "opensocrates";
29
31
  export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
30
32
  export const DEFAULT_HOST = "codex";
31
33
  export const SUPPORTED_HOSTS = Object.freeze(["claude", "codex"]);
34
+ export const ALL_HOST = "all";
35
+ export const HOST_CHOICES = Object.freeze([ALL_HOST, ...SUPPORTED_HOSTS]);
32
36
  export function assetNameFor(host = DEFAULT_HOST) {
33
37
  if (!SUPPORTED_HOSTS.includes(host)) {
34
38
  fail(`unsupported host ${JSON.stringify(host)}`);
@@ -64,6 +68,14 @@ const HOST_LAYOUTS = Object.freeze({
64
68
  });
65
69
  const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
66
70
  const MAX_ARCHIVE_ENTRIES = 10_000;
71
+ const DESIRED_STATE_SCHEMA = "opensocrates.desired-state/1.0.0";
72
+ const RECEIPT_SCHEMA = "opensocrates.auto-update-receipt/1.0.0";
73
+ const AUTO_UPDATE_LABEL = "com.opensocrates.auto-update";
74
+ const AUTO_UPDATE_MIN_INTERVAL_HOURS = 1;
75
+ const AUTO_UPDATE_MAX_INTERVAL_HOURS = 24 * 7;
76
+ const AUTO_UPDATE_DEFAULT_INTERVAL_HOURS = 24;
77
+ const AUTO_UPDATE_POLL_SECONDS = 60 * 60;
78
+ const LOCK_STALE_MILLISECONDS = 2 * 60 * 60 * 1000;
67
79
 
68
80
  export class InstallerError extends Error {}
69
81
 
@@ -71,6 +83,10 @@ function fail(message) {
71
83
  throw new InstallerError(message);
72
84
  }
73
85
 
86
+ function shellQuote(value) {
87
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
88
+ }
89
+
74
90
  async function exists(target) {
75
91
  try {
76
92
  await access(target);
@@ -80,6 +96,273 @@ async function exists(target) {
80
96
  }
81
97
  }
82
98
 
99
+ export function statePaths() {
100
+ const stateDirectory = resolve(
101
+ process.env.OPENSOCRATES_STATE_DIR || join(homedir(), ".opensocrates"),
102
+ );
103
+ const launchAgentsDirectory = resolve(
104
+ process.env.OPENSOCRATES_LAUNCH_AGENTS_DIR || join(homedir(), "Library", "LaunchAgents"),
105
+ );
106
+ return {
107
+ directory: stateDirectory,
108
+ desiredState: join(stateDirectory, "desired-state.json"),
109
+ receipt: join(stateDirectory, "auto-update-receipt.json"),
110
+ lock: join(stateDirectory, "lifecycle.lock"),
111
+ launchAgentsDirectory,
112
+ launchAgent: join(launchAgentsDirectory, `${AUTO_UPDATE_LABEL}.plist`),
113
+ };
114
+ }
115
+
116
+ function defaultDesiredState() {
117
+ return {
118
+ schema: DESIRED_STATE_SCHEMA,
119
+ channel: "stable",
120
+ installedHosts: [],
121
+ activeVersion: null,
122
+ updatePolicy: {
123
+ intervalHours: AUTO_UPDATE_DEFAULT_INTERVAL_HOURS,
124
+ allowMajor: false,
125
+ },
126
+ autoUpdate: {
127
+ enabled: false,
128
+ hosts: [],
129
+ nextCheckAt: null,
130
+ },
131
+ availableVersion: null,
132
+ lastCheckAt: null,
133
+ lastSuccessfulUpdateAt: null,
134
+ };
135
+ }
136
+
137
+ function normalizeDesiredState(value) {
138
+ if (
139
+ value === null ||
140
+ typeof value !== "object" ||
141
+ Array.isArray(value) ||
142
+ value.schema !== DESIRED_STATE_SCHEMA
143
+ ) {
144
+ fail("the OpenSocrates desired-state manifest has an unsupported schema");
145
+ }
146
+ const installedHosts = value.installedHosts;
147
+ if (
148
+ !Array.isArray(installedHosts) ||
149
+ installedHosts.some((host) => !SUPPORTED_HOSTS.includes(host)) ||
150
+ new Set(installedHosts).size !== installedHosts.length
151
+ ) {
152
+ fail("the OpenSocrates desired-state manifest has an invalid host set");
153
+ }
154
+ const channel = value.channel;
155
+ if (!new Set(["stable", "next"]).has(channel)) {
156
+ fail("the OpenSocrates desired-state manifest has an invalid update channel");
157
+ }
158
+ const intervalHours = value.updatePolicy?.intervalHours;
159
+ if (
160
+ !Number.isInteger(intervalHours) ||
161
+ intervalHours < AUTO_UPDATE_MIN_INTERVAL_HOURS ||
162
+ intervalHours > AUTO_UPDATE_MAX_INTERVAL_HOURS ||
163
+ typeof value.updatePolicy?.allowMajor !== "boolean" ||
164
+ typeof value.autoUpdate?.enabled !== "boolean"
165
+ ) {
166
+ fail("the OpenSocrates desired-state manifest has an invalid update policy");
167
+ }
168
+ const autoUpdateHosts =
169
+ value.autoUpdate.hosts === undefined
170
+ ? value.autoUpdate.enabled
171
+ ? installedHosts
172
+ : []
173
+ : value.autoUpdate.hosts;
174
+ if (
175
+ !Array.isArray(autoUpdateHosts) ||
176
+ autoUpdateHosts.some(
177
+ (host) => !SUPPORTED_HOSTS.includes(host) || !installedHosts.includes(host),
178
+ ) ||
179
+ new Set(autoUpdateHosts).size !== autoUpdateHosts.length ||
180
+ (value.autoUpdate.enabled && autoUpdateHosts.length === 0) ||
181
+ (!value.autoUpdate.enabled && autoUpdateHosts.length > 0)
182
+ ) {
183
+ fail("the OpenSocrates desired-state manifest has an invalid automatic-update host set");
184
+ }
185
+ return {
186
+ ...defaultDesiredState(),
187
+ ...value,
188
+ installedHosts: [...installedHosts].sort(),
189
+ updatePolicy: {
190
+ intervalHours,
191
+ allowMajor: value.updatePolicy.allowMajor,
192
+ },
193
+ autoUpdate: {
194
+ enabled: value.autoUpdate.enabled,
195
+ hosts: [...autoUpdateHosts].sort(),
196
+ nextCheckAt: value.autoUpdate.nextCheckAt ?? null,
197
+ },
198
+ };
199
+ }
200
+
201
+ export async function readDesiredState() {
202
+ const { desiredState } = statePaths();
203
+ if (!(await exists(desiredState))) {
204
+ return defaultDesiredState();
205
+ }
206
+ let value;
207
+ try {
208
+ value = JSON.parse(await readFile(desiredState, "utf8"));
209
+ } catch (error) {
210
+ fail(`cannot read the OpenSocrates desired-state manifest: ${error.message}`);
211
+ }
212
+ return normalizeDesiredState(value);
213
+ }
214
+
215
+ async function ensurePrivateDirectory(directory) {
216
+ if (await exists(directory)) {
217
+ const info = await lstat(directory);
218
+ if (!info.isDirectory() || info.isSymbolicLink()) {
219
+ fail(`refusing to use a non-directory or symbolic-link state path: ${directory}`);
220
+ }
221
+ await chmod(directory, 0o700);
222
+ return;
223
+ }
224
+ await mkdir(directory, { recursive: true, mode: 0o700 });
225
+ const info = await lstat(directory);
226
+ if (!info.isDirectory() || info.isSymbolicLink()) {
227
+ fail(`refusing to use an unsafe state path: ${directory}`);
228
+ }
229
+ await chmod(directory, 0o700);
230
+ }
231
+
232
+ async function atomicWritePrivateFile(target, contents) {
233
+ const parent = dirname(target);
234
+ await ensurePrivateDirectory(parent);
235
+ const temporary = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
236
+ try {
237
+ await writeFile(temporary, contents, { encoding: "utf8", mode: 0o600, flag: "wx" });
238
+ await chmod(temporary, 0o600);
239
+ await rename(temporary, target);
240
+ } finally {
241
+ if (await exists(temporary)) {
242
+ await rm(temporary, { force: true });
243
+ }
244
+ }
245
+ }
246
+
247
+ export async function writeDesiredState(value) {
248
+ const normalized = normalizeDesiredState(value);
249
+ await atomicWritePrivateFile(
250
+ statePaths().desiredState,
251
+ `${JSON.stringify(normalized, null, 2)}\n`,
252
+ );
253
+ return normalized;
254
+ }
255
+
256
+ async function writeAutoUpdateReceipt({ version, checkedAt, hosts, result, errorCategory }) {
257
+ const receipt = {
258
+ schema: RECEIPT_SCHEMA,
259
+ version,
260
+ checkedAt,
261
+ hosts: [...hosts]
262
+ .sort((left, right) => left.host.localeCompare(right.host))
263
+ .map(({ host, result: hostResult }) => ({ host, result: hostResult })),
264
+ result,
265
+ errorCategory: errorCategory ?? null,
266
+ };
267
+ await atomicWritePrivateFile(
268
+ statePaths().receipt,
269
+ `${JSON.stringify(receipt, null, 2)}\n`,
270
+ );
271
+ }
272
+
273
+ function nowIso() {
274
+ return new Date().toISOString();
275
+ }
276
+
277
+ function nextCheckAt(intervalHours) {
278
+ const intervalMilliseconds = intervalHours * 60 * 60 * 1000;
279
+ const jitterBasisPoints = randomInt(-1500, 1501);
280
+ return new Date(Date.now() + intervalMilliseconds * (1 + jitterBasisPoints / 10_000)).toISOString();
281
+ }
282
+
283
+ function majorVersion(value) {
284
+ const match = String(value ?? "").match(/^(\d+)\./u);
285
+ return match ? Number(match[1]) : null;
286
+ }
287
+
288
+ function errorCategory(error) {
289
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
290
+ if (/checksum|manifest|archive|symbolic link/u.test(message)) return "verification";
291
+ if (/download|fetch|network|offline|timed? ?out/u.test(message)) return "network";
292
+ if (/preflight|not logged|auth|version is required|could not run/u.test(message)) return "preflight";
293
+ if (/rollback|restore/u.test(message)) return "rollback";
294
+ if (/lock|already running/u.test(message)) return "locked";
295
+ if (/register|plugin|marketplace/u.test(message)) return "activation";
296
+ return "internal";
297
+ }
298
+
299
+ async function acquireOperationLock() {
300
+ const paths = statePaths();
301
+ await ensurePrivateDirectory(paths.directory);
302
+ for (let attempt = 0; attempt < 2; attempt += 1) {
303
+ try {
304
+ const handle = await open(paths.lock, "wx", 0o600);
305
+ try {
306
+ await handle.writeFile(
307
+ `${JSON.stringify({ pid: process.pid, startedAt: nowIso() })}\n`,
308
+ "utf8",
309
+ );
310
+ } catch (writeError) {
311
+ await handle.close().catch(() => undefined);
312
+ await rm(paths.lock, { force: true }).catch(() => undefined);
313
+ throw writeError;
314
+ }
315
+ return async () => {
316
+ try {
317
+ await handle.close();
318
+ } finally {
319
+ await rm(paths.lock, { force: true });
320
+ }
321
+ };
322
+ } catch (error) {
323
+ if (error?.code !== "EEXIST") {
324
+ throw error;
325
+ }
326
+ let stale = false;
327
+ try {
328
+ const lock = JSON.parse(await readFile(paths.lock, "utf8"));
329
+ const started = Date.parse(lock.startedAt);
330
+ let ownerAlive = null;
331
+ if (Number.isInteger(lock.pid) && lock.pid > 0) {
332
+ try {
333
+ process.kill(lock.pid, 0);
334
+ ownerAlive = true;
335
+ } catch (processError) {
336
+ ownerAlive = processError?.code === "ESRCH" ? false : true;
337
+ }
338
+ }
339
+ stale =
340
+ ownerAlive === false ||
341
+ (ownerAlive === null &&
342
+ Number.isFinite(started) &&
343
+ Date.now() - started > LOCK_STALE_MILLISECONDS);
344
+ } catch {
345
+ const info = await lstat(paths.lock);
346
+ stale = Date.now() - info.mtimeMs > LOCK_STALE_MILLISECONDS;
347
+ }
348
+ if (!stale || attempt > 0) {
349
+ fail("another OpenSocrates lifecycle operation is already running");
350
+ }
351
+ await rm(paths.lock, { force: true });
352
+ }
353
+ }
354
+ fail("could not acquire the OpenSocrates lifecycle lock");
355
+ }
356
+
357
+ export async function withOperationLock(action) {
358
+ const release = await acquireOperationLock();
359
+ try {
360
+ return await action();
361
+ } finally {
362
+ await release();
363
+ }
364
+ }
365
+
83
366
  function jsonEqual(left, right) {
84
367
  const leftKeys = Object.keys(left).sort();
85
368
  const rightKeys = Object.keys(right).sort();
@@ -139,7 +422,15 @@ export function parseChecksumText(text, expectedName = ASSET_NAME) {
139
422
  }
140
423
 
141
424
  export function parseCli(argv) {
142
- const actions = new Set(["install", "status", "update", "remove", "verify", "help"]);
425
+ const actions = new Set([
426
+ "install",
427
+ "status",
428
+ "update",
429
+ "remove",
430
+ "verify",
431
+ "auto-update",
432
+ "help",
433
+ ]);
143
434
  const args = [...argv];
144
435
  let action = "install";
145
436
  if (args[0] && !args[0].startsWith("-")) {
@@ -148,7 +439,34 @@ export function parseCli(argv) {
148
439
  if (!actions.has(action)) {
149
440
  fail(`unknown action ${JSON.stringify(action)}`);
150
441
  }
151
- const options = { action, host: DEFAULT_HOST, asset: null, checksum: null };
442
+ let autoUpdateAction = null;
443
+ if (action === "auto-update") {
444
+ if (["--help", "-h"].includes(args[0])) {
445
+ args.shift();
446
+ action = "help";
447
+ } else {
448
+ autoUpdateAction = args.shift() ?? "status";
449
+ if (!new Set(["enable", "status", "disable", "run"]).has(autoUpdateAction)) {
450
+ fail(`unknown auto-update action ${JSON.stringify(autoUpdateAction)}`);
451
+ }
452
+ }
453
+ }
454
+ const options = {
455
+ action,
456
+ autoUpdateAction,
457
+ host: action === "auto-update" ? ALL_HOST : DEFAULT_HOST,
458
+ asset: null,
459
+ checksum: null,
460
+ hostAssets: {
461
+ claude: { asset: null, checksum: null },
462
+ codex: { asset: null, checksum: null },
463
+ },
464
+ channel: "stable",
465
+ intervalHours: AUTO_UPDATE_DEFAULT_INTERVAL_HOURS,
466
+ allowMajor: false,
467
+ force: false,
468
+ };
469
+ const seenOptions = new Set();
152
470
  while (args.length > 0) {
153
471
  const flag = args.shift();
154
472
  if (flag === "--help" || flag === "-h") {
@@ -156,13 +474,60 @@ export function parseCli(argv) {
156
474
  continue;
157
475
  }
158
476
  if (flag === "--host") {
477
+ seenOptions.add("host");
159
478
  const host = args.shift();
160
- if (!SUPPORTED_HOSTS.includes(host)) {
161
- fail(`--host must be one of: ${SUPPORTED_HOSTS.join(", ")}`);
479
+ if (!HOST_CHOICES.includes(host)) {
480
+ fail(`--host must be one of: ${HOST_CHOICES.join(", ")}`);
162
481
  }
163
482
  options.host = host;
164
483
  continue;
165
484
  }
485
+ if (flag === "--allow-major") {
486
+ seenOptions.add("allow-major");
487
+ options.allowMajor = true;
488
+ continue;
489
+ }
490
+ if (flag === "--force") {
491
+ seenOptions.add("force");
492
+ options.force = true;
493
+ continue;
494
+ }
495
+ if (flag === "--channel") {
496
+ seenOptions.add("channel");
497
+ const channel = args.shift();
498
+ if (!new Set(["stable", "next"]).has(channel)) {
499
+ fail("--channel must be one of: stable, next");
500
+ }
501
+ options.channel = channel;
502
+ continue;
503
+ }
504
+ if (flag === "--interval-hours") {
505
+ seenOptions.add("interval-hours");
506
+ const raw = args.shift();
507
+ const value = Number(raw);
508
+ if (
509
+ !Number.isInteger(value) ||
510
+ value < AUTO_UPDATE_MIN_INTERVAL_HOURS ||
511
+ value > AUTO_UPDATE_MAX_INTERVAL_HOURS
512
+ ) {
513
+ fail(
514
+ `--interval-hours must be an integer from ${AUTO_UPDATE_MIN_INTERVAL_HOURS} ` +
515
+ `through ${AUTO_UPDATE_MAX_INTERVAL_HOURS}`,
516
+ );
517
+ }
518
+ options.intervalHours = value;
519
+ continue;
520
+ }
521
+ const qualified = flag?.match(/^--(asset|checksum)-(claude|codex)$/u);
522
+ if (qualified) {
523
+ seenOptions.add(qualified[1]);
524
+ const value = args.shift();
525
+ if (!value || value.startsWith("-")) {
526
+ fail(`${flag} requires a file path`);
527
+ }
528
+ options.hostAssets[qualified[2]][qualified[1]] = resolve(value);
529
+ continue;
530
+ }
166
531
  if (flag !== "--asset" && flag !== "--checksum") {
167
532
  fail(`unknown option ${JSON.stringify(flag)}`);
168
533
  }
@@ -171,13 +536,71 @@ export function parseCli(argv) {
171
536
  fail(`${flag} requires a file path`);
172
537
  }
173
538
  options[flag === "--asset" ? "asset" : "checksum"] = resolve(value);
539
+ seenOptions.add(flag.slice(2));
174
540
  }
175
541
  if ((options.asset === null) !== (options.checksum === null)) {
176
542
  fail("--asset and --checksum must be supplied together");
177
543
  }
544
+ for (const host of SUPPORTED_HOSTS) {
545
+ const inputs = options.hostAssets[host];
546
+ if ((inputs.asset === null) !== (inputs.checksum === null)) {
547
+ fail(`--asset-${host} and --checksum-${host} must be supplied together`);
548
+ }
549
+ }
550
+ if (options.host === ALL_HOST && options.asset !== null) {
551
+ fail("--host all requires host-qualified --asset-<host> and --checksum-<host> options");
552
+ }
553
+ if (
554
+ options.host === ALL_HOST &&
555
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null) &&
556
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset === null)
557
+ ) {
558
+ fail("local --host all verification requires asset and checksum pairs for both hosts");
559
+ }
560
+ if (
561
+ options.host !== ALL_HOST &&
562
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)
563
+ ) {
564
+ fail("host-qualified asset options are only valid with --host all");
565
+ }
178
566
  if (["status", "remove", "help"].includes(options.action) && options.asset !== null) {
179
567
  fail(`${options.action} does not accept --asset or --checksum`);
180
568
  }
569
+ if (
570
+ ["status", "remove", "help"].includes(options.action) &&
571
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)
572
+ ) {
573
+ fail(`${options.action} does not accept host-qualified asset options`);
574
+ }
575
+ if (
576
+ options.action === "auto-update" &&
577
+ options.autoUpdateAction !== "run" &&
578
+ (options.asset !== null ||
579
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null))
580
+ ) {
581
+ fail(`auto-update ${options.autoUpdateAction} does not accept local asset options`);
582
+ }
583
+ const autoUpdateEnable =
584
+ options.action === "auto-update" && options.autoUpdateAction === "enable";
585
+ const autoUpdateRun = options.action === "auto-update" && options.autoUpdateAction === "run";
586
+ for (const policyOption of ["allow-major", "channel", "interval-hours"]) {
587
+ if (seenOptions.has(policyOption) && !autoUpdateEnable) {
588
+ fail(`--${policyOption} is only valid with auto-update enable`);
589
+ }
590
+ }
591
+ if (seenOptions.has("force") && !autoUpdateRun) {
592
+ fail("--force is only valid with auto-update run");
593
+ }
594
+ if (
595
+ seenOptions.has("host") &&
596
+ options.action === "auto-update" &&
597
+ new Set(["status", "disable"]).has(options.autoUpdateAction)
598
+ ) {
599
+ fail(`auto-update ${options.autoUpdateAction} does not accept --host`);
600
+ }
601
+ if (autoUpdateRun && seenOptions.has("host") && options.host !== ALL_HOST) {
602
+ fail("auto-update run always reconciles desired state and only accepts --host all");
603
+ }
181
604
  return options;
182
605
  }
183
606
 
@@ -185,14 +608,21 @@ function showHelp() {
185
608
  console.log(`OpenSocrates ${PRODUCT_VERSION}
186
609
 
187
610
  Usage:
188
- opensocrates install [--host codex|claude] [--asset ZIP --checksum SHA256]
189
- opensocrates status [--host codex|claude]
190
- opensocrates update [--host codex|claude] [--asset ZIP --checksum SHA256]
191
- opensocrates remove [--host codex|claude]
192
- opensocrates verify [--host codex|claude] [--asset ZIP --checksum SHA256]
611
+ opensocrates install [--host all|codex|claude] [--asset ZIP --checksum SHA256]
612
+ opensocrates status [--host all|codex|claude]
613
+ opensocrates update [--host all|codex|claude] [--asset ZIP --checksum SHA256]
614
+ opensocrates remove [--host all|codex|claude]
615
+ opensocrates verify [--host all|codex|claude] [--asset ZIP --checksum SHA256]
616
+ opensocrates auto-update enable [--host all|codex|claude]
617
+ [--channel stable|next] [--interval-hours ${AUTO_UPDATE_DEFAULT_INTERVAL_HOURS}]
618
+ [--allow-major]
619
+ opensocrates auto-update status
620
+ opensocrates auto-update disable
193
621
 
194
622
  Without --asset, install, update, and verify download the v${PRODUCT_VERSION}
195
- package and checksum from GitHub Releases. The default host is codex.
623
+ package and checksum from GitHub Releases. The default lifecycle host is codex.
624
+ For offline --host all verification, use --asset-claude/--checksum-claude
625
+ and --asset-codex/--checksum-codex together. Automatic updates are opt-in.
196
626
  `);
197
627
  }
198
628
 
@@ -277,6 +707,58 @@ function runClaudeJson(args) {
277
707
  return runJson(claudeBinary(), args, "Claude Code");
278
708
  }
279
709
 
710
+ function claudeListEntries(payload, wrapper, label) {
711
+ if (Array.isArray(payload)) {
712
+ return payload;
713
+ }
714
+ if (
715
+ payload !== null &&
716
+ typeof payload === "object" &&
717
+ !Array.isArray(payload) &&
718
+ Object.keys(payload).length === 1 &&
719
+ Array.isArray(payload[wrapper])
720
+ ) {
721
+ return payload[wrapper];
722
+ }
723
+ fail(`Claude Code ${label} returned an unexpected schema`);
724
+ }
725
+
726
+ function requireUniqueClaudeEntries(entries, identity, label) {
727
+ const seen = new Set();
728
+ for (const entry of entries) {
729
+ if (
730
+ entry === null ||
731
+ typeof entry !== "object" ||
732
+ Array.isArray(entry) ||
733
+ typeof entry[identity] !== "string" ||
734
+ entry[identity].trim().length === 0
735
+ ) {
736
+ fail(`Claude Code ${label} returned a malformed entry`);
737
+ }
738
+ if (seen.has(entry[identity])) {
739
+ fail(`Claude Code ${label} returned duplicate entries for ${entry[identity]}`);
740
+ }
741
+ seen.add(entry[identity]);
742
+ }
743
+ return entries;
744
+ }
745
+
746
+ function claudeMarketplaceEntries(payload) {
747
+ return requireUniqueClaudeEntries(
748
+ claudeListEntries(payload, "marketplaces", "marketplace list"),
749
+ "name",
750
+ "marketplace list",
751
+ );
752
+ }
753
+
754
+ function claudePluginEntries(payload) {
755
+ return requireUniqueClaudeEntries(
756
+ claudeListEntries(payload, "plugins", "plugin list"),
757
+ "id",
758
+ "plugin list",
759
+ );
760
+ }
761
+
280
762
  function versionAtLeast(value, minimum) {
281
763
  const match = String(value).match(/(\d+)\.(\d+)\.(\d+)/u);
282
764
  if (!match) {
@@ -290,7 +772,7 @@ function versionAtLeast(value, minimum) {
290
772
  return true;
291
773
  }
292
774
 
293
- function requireHost(host) {
775
+ function requireHostCli(host, { authenticated = false } = {}) {
294
776
  if (host === "claude") {
295
777
  const result = run(claudeBinary(), ["--version"]);
296
778
  if (!versionAtLeast(result.stdout, [2, 1, 205])) {
@@ -298,9 +780,15 @@ function requireHost(host) {
298
780
  `Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`,
299
781
  );
300
782
  }
783
+ if (authenticated) {
784
+ run(claudeBinary(), ["auth", "status"]);
785
+ }
301
786
  return;
302
787
  }
303
788
  run(codexBinary(), ["--version"]);
789
+ if (authenticated) {
790
+ run(codexBinary(), ["login", "status"]);
791
+ }
304
792
  }
305
793
 
306
794
  function marketplaceEntries(host) {
@@ -308,7 +796,7 @@ function marketplaceEntries(host) {
308
796
  host === "claude"
309
797
  ? runClaudeJson(["plugin", "marketplace", "list", "--json"])
310
798
  : runCodexJson(["plugin", "marketplace", "list", "--json"]).marketplaces;
311
- const entries = payload;
799
+ const entries = host === "claude" ? claudeMarketplaceEntries(payload) : payload;
312
800
  if (!Array.isArray(entries)) {
313
801
  fail(`${host} marketplace list returned an unexpected schema`);
314
802
  }
@@ -326,17 +814,22 @@ function marketplaceEntry(host) {
326
814
 
327
815
  function pluginState(host) {
328
816
  if (host === "claude") {
329
- const installed = runClaudeJson(["plugin", "list", "--json"]);
330
- if (!Array.isArray(installed)) {
331
- fail("Claude Code plugin list returned an unexpected schema");
332
- }
817
+ const installed = claudePluginEntries(runClaudeJson(["plugin", "list", "--json"]));
333
818
  const matches = installed.filter((entry) => entry?.id === PLUGIN_ID);
334
- if (matches.length > 1) {
335
- fail(`Claude Code reported duplicate entries for ${PLUGIN_ID}`);
819
+ if (matches.length === 0) return { kind: "missing", version: null };
820
+ const entry = matches[0];
821
+ if (
822
+ (entry.version !== undefined &&
823
+ entry.version !== null &&
824
+ (typeof entry.version !== "string" || entry.version.trim().length === 0)) ||
825
+ typeof entry.enabled !== "boolean"
826
+ ) {
827
+ fail(`Claude Code reported an invalid state for ${PLUGIN_ID}`);
336
828
  }
337
- return matches.length === 1
338
- ? { kind: "installed", version: matches[0].version ?? null }
339
- : { kind: "missing", version: null };
829
+ return {
830
+ kind: entry.enabled ? "installed" : "disabled",
831
+ version: entry.version ?? null,
832
+ };
340
833
  }
341
834
  const payload = runCodexJson([
342
835
  "plugin", "list", "--marketplace", MARKETPLACE_NAME, "--available", "--json",
@@ -367,15 +860,12 @@ function detectLegacyClaudeInstallation() {
367
860
  entry.name !== MARKETPLACE_NAME &&
368
861
  entry.name.toLowerCase() === MARKETPLACE_NAME,
369
862
  );
370
- const plugins = runClaudeJson(["plugin", "list", "--json"]);
371
- const legacyPlugins = Array.isArray(plugins)
372
- ? plugins.filter(
373
- (entry) =>
374
- typeof entry?.id === "string" &&
375
- entry.id !== PLUGIN_ID &&
376
- entry.id.toLowerCase() === PLUGIN_ID,
377
- )
378
- : [];
863
+ const plugins = claudePluginEntries(runClaudeJson(["plugin", "list", "--json"]));
864
+ const legacyPlugins = plugins.filter(
865
+ (entry) =>
866
+ entry.id !== PLUGIN_ID &&
867
+ entry.id.toLowerCase() === PLUGIN_ID,
868
+ );
379
869
  const names = marketplaces.map((entry) => entry.name).join(", ") || "OpenSocrates";
380
870
  return { found: marketplaces.length > 0 || legacyPlugins.length > 0, names };
381
871
  }
@@ -465,15 +955,23 @@ async function downloadFile(url, destination) {
465
955
  }
466
956
  }
467
957
 
468
- async function resolveAssetInputs(options, scratch) {
469
- if (options.asset !== null) {
470
- if (!(await exists(options.asset)) || !(await exists(options.checksum))) {
958
+ function localAssetInputs(options, host) {
959
+ if (options.host === ALL_HOST) {
960
+ return options.hostAssets[host];
961
+ }
962
+ return { asset: options.asset, checksum: options.checksum };
963
+ }
964
+
965
+ async function resolveAssetInputs(options, scratch, host) {
966
+ const local = localAssetInputs(options, host);
967
+ if (local.asset !== null) {
968
+ if (!(await exists(local.asset)) || !(await exists(local.checksum))) {
471
969
  fail("the supplied asset or checksum file does not exist");
472
970
  }
473
- return { asset: options.asset, checksum: options.checksum };
971
+ return local;
474
972
  }
475
973
  const base = `https://github.com/${REPOSITORY}/releases/download/v${PRODUCT_VERSION}`;
476
- const assetName = assetNameFor(options.host);
974
+ const assetName = assetNameFor(host);
477
975
  const checksumName = `${assetName}.sha256`;
478
976
  const asset = join(scratch, assetName);
479
977
  const checksum = join(scratch, checksumName);
@@ -604,21 +1102,41 @@ async function verifyExtractedPackage(pluginRoot, host) {
604
1102
  return verifyPackageChecksums(pluginRoot);
605
1103
  }
606
1104
 
607
- async function prepareVerifiedPackage(options) {
1105
+ async function prepareVerifiedPackage(options, host = options.host) {
1106
+ if (!SUPPORTED_HOSTS.includes(host)) {
1107
+ fail(`cannot prepare a package for unsupported host ${JSON.stringify(host)}`);
1108
+ }
608
1109
  const scratch = await mkdtemp(join(tmpdir(), "opensocrates-install-"));
609
1110
  try {
610
- const { asset, checksum } = await resolveAssetInputs(options, scratch);
611
- await verifyOuterChecksum(asset, checksum, options.host);
1111
+ const { asset, checksum } = await resolveAssetInputs(options, scratch, host);
1112
+ await verifyOuterChecksum(asset, checksum, host);
612
1113
  const pluginRoot = join(scratch, "plugin");
613
1114
  await extractArchive(asset, pluginRoot);
614
- const checkedFiles = await verifyExtractedPackage(pluginRoot, options.host);
615
- return { scratch, pluginRoot, checkedFiles };
1115
+ const checkedFiles = await verifyExtractedPackage(pluginRoot, host);
1116
+ return { scratch, pluginRoot, checkedFiles, host };
616
1117
  } catch (error) {
617
1118
  await rm(scratch, { recursive: true, force: true });
618
1119
  throw error;
619
1120
  }
620
1121
  }
621
1122
 
1123
+ async function prepareVerifiedPackages(options, hosts) {
1124
+ const settled = await Promise.allSettled(
1125
+ hosts.map((host) => prepareVerifiedPackage(options, host)),
1126
+ );
1127
+ const prepared = settled
1128
+ .filter((result) => result.status === "fulfilled")
1129
+ .map((result) => result.value);
1130
+ const failure = settled.find((result) => result.status === "rejected");
1131
+ if (failure) {
1132
+ await Promise.all(
1133
+ prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
1134
+ );
1135
+ throw failure.reason;
1136
+ }
1137
+ return prepared;
1138
+ }
1139
+
622
1140
  function expectedMarketplace(host) {
623
1141
  if (host === "claude") {
624
1142
  return {
@@ -633,7 +1151,7 @@ function expectedMarketplace(host) {
633
1151
  name: PLUGIN_NAME,
634
1152
  source: `./plugins/${PLUGIN_NAME}`,
635
1153
  description:
636
- "Local reasoning-system selection for Claude Code and Cowork, plus reusable Claude skills.",
1154
+ "Local reasoning-system selection for Claude Code and Cowork, plus one /opensocrates entry.",
637
1155
  category: "workflow",
638
1156
  },
639
1157
  ],
@@ -688,7 +1206,34 @@ function entryRoot(entry, host) {
688
1206
  if (entry === null) {
689
1207
  return null;
690
1208
  }
691
- const value = host === "claude" ? entry.path ?? entry.installLocation : entry.root;
1209
+ let value;
1210
+ if (host === "claude") {
1211
+ const fields = ["path", "installLocation"].filter(
1212
+ (field) => entry[field] !== undefined && entry[field] !== null,
1213
+ );
1214
+ if (
1215
+ fields.length === 0 ||
1216
+ fields.some(
1217
+ (field) => typeof entry[field] !== "string" || entry[field].trim().length === 0,
1218
+ )
1219
+ ) {
1220
+ fail(`${host} marketplace ${MARKETPLACE_NAME} has no usable root`);
1221
+ }
1222
+ const roots = fields.map((field) => {
1223
+ const candidate = resolve(entry[field]);
1224
+ try {
1225
+ return realpathSync(candidate);
1226
+ } catch {
1227
+ return candidate;
1228
+ }
1229
+ });
1230
+ if (new Set(roots).size !== 1) {
1231
+ fail(`${host} marketplace ${MARKETPLACE_NAME} reported conflicting roots`);
1232
+ }
1233
+ [value] = roots;
1234
+ } else {
1235
+ value = entry.root;
1236
+ }
692
1237
  if (typeof value !== "string" || value.trim().length === 0) {
693
1238
  fail(`${host} marketplace ${MARKETPLACE_NAME} has no usable root`);
694
1239
  }
@@ -706,7 +1251,7 @@ function entryRoot(entry, host) {
706
1251
 
707
1252
  function removeRegistration(host, entry, state) {
708
1253
  if (host === "claude") {
709
- if (state.kind === "installed") {
1254
+ if (["installed", "disabled"].includes(state.kind)) {
710
1255
  run(claudeBinary(), ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]);
711
1256
  }
712
1257
  if (entry !== null) {
@@ -724,11 +1269,14 @@ function removeRegistration(host, entry, state) {
724
1269
  }
725
1270
  }
726
1271
 
727
- function addRegistration(host, root, installPlugin) {
1272
+ function addRegistration(host, root, installPlugin, { enabled = true } = {}) {
728
1273
  if (host === "claude") {
729
1274
  run(claudeBinary(), ["plugin", "marketplace", "add", root, "--scope", "user"]);
730
1275
  if (installPlugin) {
731
1276
  run(claudeBinary(), ["plugin", "install", PLUGIN_ID, "--scope", "user"]);
1277
+ if (!enabled) {
1278
+ run(claudeBinary(), ["plugin", "disable", PLUGIN_ID, "--scope", "user"]);
1279
+ }
732
1280
  }
733
1281
  return null;
734
1282
  }
@@ -752,7 +1300,7 @@ function removeRegistrationBestEffort(host) {
752
1300
  }
753
1301
  try {
754
1302
  const state = pluginState(host);
755
- if (state.kind === "installed") {
1303
+ if (["installed", "disabled"].includes(state.kind)) {
756
1304
  const binary = host === "claude" ? claudeBinary() : codexBinary();
757
1305
  const args =
758
1306
  host === "claude"
@@ -783,14 +1331,16 @@ async function recoveryStep(label, action) {
783
1331
  }
784
1332
  }
785
1333
 
786
- async function installVerifiedPackage(pluginSource, action, host) {
787
- requireHost(host);
1334
+ async function preflightHost(host, action) {
1335
+ requireHostCli(host, { authenticated: ["install", "update"].includes(action) });
788
1336
  if (host === "claude") {
789
- requireNoLegacyClaudeInstallation();
1337
+ if (["install", "update"].includes(action)) {
1338
+ requireNoLegacyClaudeInstallation();
1339
+ } else {
1340
+ warnLegacyClaudeInstallation();
1341
+ }
790
1342
  }
791
1343
  const paths = managedPaths(host);
792
- await mkdir(paths.parent, { recursive: true, mode: 0o700 });
793
-
794
1344
  const previousEntry = marketplaceEntry(host);
795
1345
  if (previousEntry !== null && entryRoot(previousEntry, host) !== paths.root) {
796
1346
  fail(
@@ -798,189 +1348,931 @@ async function installVerifiedPackage(pluginSource, action, host) {
798
1348
  "refusing to overwrite an unmanaged location",
799
1349
  );
800
1350
  }
801
- const previousState =
802
- previousEntry === null ? { kind: "missing", version: null } : pluginState(host);
803
- if (await exists(paths.root)) {
1351
+ const rootExists = await exists(paths.root);
1352
+ if (rootExists) {
804
1353
  await requireOwnedRoot(paths.root, host);
805
1354
  }
1355
+ if (previousEntry !== null && !rootExists) {
1356
+ fail(`${host} has a managed registration whose root is missing: ${paths.root}`);
1357
+ }
1358
+ const previousState =
1359
+ previousEntry === null ? { kind: "missing", version: null } : pluginState(host);
1360
+ return { host, paths, previousEntry, previousState, rootExists };
1361
+ }
806
1362
 
807
- const staging = await buildStagingTree(paths.parent, pluginSource, host);
808
- const backup = join(paths.parent, `.opensocrates.backup-${randomUUID()}`);
809
- let registrationRemoved = false;
810
- let backupCreated = false;
811
- let newRootActive = false;
812
- try {
813
- if (previousEntry !== null) {
814
- registrationRemoved = true;
815
- removeRegistration(host, previousEntry, previousState);
816
- }
817
- if (await exists(paths.root)) {
818
- await rename(paths.root, backup);
819
- backupCreated = true;
1363
+ async function preflightSelectedHosts(options, action, desiredState) {
1364
+ if (options.host !== ALL_HOST) {
1365
+ return [await preflightHost(options.host, action)];
1366
+ }
1367
+ const desiredHosts = new Set(desiredState.installedHosts);
1368
+ const rootPresence = Object.fromEntries(
1369
+ await Promise.all(
1370
+ SUPPORTED_HOSTS.map(async (host) => [host, await exists(managedPaths(host).root)]),
1371
+ ),
1372
+ );
1373
+ const settled = await Promise.allSettled(
1374
+ SUPPORTED_HOSTS.map((host) => preflightHost(host, action)),
1375
+ );
1376
+ const successes = new Map();
1377
+ const failures = new Map();
1378
+ settled.forEach((result, index) => {
1379
+ const host = SUPPORTED_HOSTS[index];
1380
+ if (result.status === "fulfilled") successes.set(host, result.value);
1381
+ else failures.set(host, result.reason);
1382
+ });
1383
+
1384
+ const required = new Set();
1385
+ if (action === "install") {
1386
+ for (const host of SUPPORTED_HOSTS) {
1387
+ if (desiredHosts.has(host) || rootPresence[host]) required.add(host);
820
1388
  }
821
- await rename(staging, paths.root);
822
- newRootActive = true;
823
- const result = addRegistration(host, paths.root, true);
824
- const state = pluginState(host);
825
- if (
826
- (host === "codex" &&
827
- (result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
828
- state.kind !== "installed" ||
829
- state.version !== PRODUCT_VERSION
830
- ) {
831
- fail(`${host} did not confirm the expected installed plugin and version`);
1389
+ } else if (desiredHosts.size > 0) {
1390
+ for (const host of desiredHosts) required.add(host);
1391
+ } else if (action === "remove") {
1392
+ for (const host of SUPPORTED_HOSTS) {
1393
+ if (rootPresence[host]) required.add(host);
832
1394
  }
833
- if (backupCreated) {
834
- await requireOwnedRoot(backup, host);
835
- await rm(backup, { recursive: true });
1395
+ }
1396
+ for (const host of required) {
1397
+ if (failures.has(host)) {
1398
+ const reason = failures.get(host);
1399
+ fail(`preflight failed for ${host}: ${reason instanceof Error ? reason.message : reason}`);
836
1400
  }
837
- const verb = action === "update" ? "updated" : "installed";
838
- console.log(`OpenSocrates ${PRODUCT_VERSION} ${verb} successfully.`);
839
- console.log(`Managed marketplace: ${paths.root}`);
840
- console.log(
841
- host === "claude"
842
- ? "Start a new Claude Code or Cowork task to load the updated skills and hooks."
843
- : "Start a new Codex task to load the updated skills and hooks.",
1401
+ }
1402
+
1403
+ let selected;
1404
+ if (action === "update" && desiredHosts.size > 0) {
1405
+ selected = [...desiredHosts].map((host) => successes.get(host)).filter(Boolean);
1406
+ } else if (action === "remove") {
1407
+ selected = [...successes.values()].filter(
1408
+ (item) => desiredHosts.has(item.host) || item.rootExists || item.previousEntry !== null,
844
1409
  );
845
- } catch (error) {
846
- // Every recovery stage is independently guarded: one failing stage must
847
- // never prevent the later stages from running, because the stage that
848
- // restores the user's previous installation comes last.
849
- let restored = false;
850
- await recoveryStep("unregister the failed installation", async () => {
851
- removeRegistrationBestEffort(host);
852
- });
853
- if (newRootActive && (await exists(paths.root))) {
854
- await recoveryStep("remove the failed installation root", async () => {
855
- // This run created paths.root by renaming staging into place, so its
856
- // provenance is known directly and the ownership marker is redundant
857
- // here. Requiring the marker would let a host that corrupted or
858
- // removed it while failing block recovery entirely, because the
859
- // surviving directory then also blocks the backup rename below.
860
- // Roots this run did not create remain guarded by requireOwnedRoot
861
- // before staging begins, and the backup restore below still verifies
862
- // ownership.
863
- await rm(paths.root, { recursive: true, force: true });
864
- });
865
- }
866
- if (backupCreated && (await exists(backup))) {
867
- restored = await recoveryStep("restore the previous installation", async () => {
868
- await requireOwnedRoot(backup, host);
869
- await rename(backup, paths.root);
870
- });
871
- }
872
- if (registrationRemoved && previousEntry !== null && (restored || !backupCreated)) {
873
- await recoveryStep("re-register the previous installation", async () => {
874
- addRegistration(host, paths.root, previousState.kind === "installed");
875
- });
1410
+ } else {
1411
+ selected = [...successes.values()];
1412
+ }
1413
+ for (const [host, reason] of failures) {
1414
+ if (!required.has(host)) {
1415
+ console.log(
1416
+ `${host}: skipped (host is unavailable or not authenticated: ` +
1417
+ `${reason instanceof Error ? reason.message : reason})`,
1418
+ );
876
1419
  }
877
- if (backupCreated && !restored) {
878
- console.error(
879
- "error: the previous OpenSocrates installation could not be restored automatically.",
1420
+ }
1421
+ if (selected.length === 0 && action !== "remove") {
1422
+ fail(`preflight found no ready hosts for ${action} --host all`);
1423
+ }
1424
+ return selected.sort((left, right) => left.host.localeCompare(right.host));
1425
+ }
1426
+
1427
+ async function stageInstallation(preflight, pluginSource) {
1428
+ const { host, paths } = preflight;
1429
+ await mkdir(paths.parent, { recursive: true, mode: 0o700 });
1430
+ const staging = await buildStagingTree(paths.parent, pluginSource, host);
1431
+ return {
1432
+ ...preflight,
1433
+ staging,
1434
+ backup: join(paths.parent, `.opensocrates.backup-${randomUUID()}`),
1435
+ registrationRemoved: false,
1436
+ backupCreated: false,
1437
+ newRootActive: false,
1438
+ activationStarted: false,
1439
+ };
1440
+ }
1441
+
1442
+ async function activateInstallation(transaction) {
1443
+ const { host, paths, previousEntry, previousState } = transaction;
1444
+ transaction.activationStarted = true;
1445
+ if (previousEntry !== null) {
1446
+ transaction.registrationRemoved = true;
1447
+ removeRegistration(host, previousEntry, previousState);
1448
+ }
1449
+ if (await exists(paths.root)) {
1450
+ await rename(paths.root, transaction.backup);
1451
+ transaction.backupCreated = true;
1452
+ }
1453
+ await rename(transaction.staging, paths.root);
1454
+ transaction.newRootActive = true;
1455
+ const result = addRegistration(host, paths.root, true);
1456
+ const state = pluginState(host);
1457
+ if (
1458
+ (host === "codex" &&
1459
+ (result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
1460
+ state.kind !== "installed" ||
1461
+ state.version !== PRODUCT_VERSION
1462
+ ) {
1463
+ fail(`${host} did not confirm the expected installed plugin and version`);
1464
+ }
1465
+ }
1466
+
1467
+ async function rollbackInstallation(transaction) {
1468
+ if (!transaction.activationStarted) return;
1469
+ const { host, paths, previousEntry, previousState } = transaction;
1470
+ let restored = false;
1471
+ await recoveryStep(`unregister the failed ${host} installation`, async () => {
1472
+ removeRegistrationBestEffort(host);
1473
+ });
1474
+ if (transaction.newRootActive && (await exists(paths.root))) {
1475
+ await recoveryStep(`remove the failed ${host} installation root`, async () => {
1476
+ // This root was created by this transaction, so deleting it cannot
1477
+ // cross the ownership boundary even if a failed host command damaged
1478
+ // its marker.
1479
+ await rm(paths.root, { recursive: true, force: true });
1480
+ });
1481
+ }
1482
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1483
+ restored = await recoveryStep(`restore the previous ${host} installation`, async () => {
1484
+ await requireOwnedRoot(transaction.backup, host);
1485
+ await rename(transaction.backup, paths.root);
1486
+ });
1487
+ }
1488
+ if (
1489
+ transaction.registrationRemoved &&
1490
+ previousEntry !== null &&
1491
+ (restored || !transaction.backupCreated)
1492
+ ) {
1493
+ await recoveryStep(`re-register the previous ${host} installation`, async () => {
1494
+ addRegistration(
1495
+ host,
1496
+ paths.root,
1497
+ ["installed", "disabled"].includes(previousState.kind),
1498
+ { enabled: previousState.kind !== "disabled" },
880
1499
  );
881
- console.error(`error: your previous files are preserved at: ${backup}`);
882
- console.error(
883
- `error: remove ${paths.root} if it still exists, move the preserved directory ` +
884
- `to ${paths.root}, then rerun ` +
885
- `\`opensocrates install --host ${host}\`.`,
1500
+ });
1501
+ }
1502
+ if (transaction.backupCreated && !restored) {
1503
+ console.error(
1504
+ `error: the previous ${host} OpenSocrates installation could not be restored automatically.`,
1505
+ );
1506
+ console.error(`error: your previous files are preserved at: ${transaction.backup}`);
1507
+ console.error(`error: recovery command: /bin/rm -rf -- ${shellQuote(paths.root)}`);
1508
+ console.error(
1509
+ `error: recovery command: /bin/mv -- ${shellQuote(transaction.backup)} ${shellQuote(paths.root)}`,
1510
+ );
1511
+ console.error(`error: recovery command: opensocrates install --host ${host}`);
1512
+ }
1513
+ }
1514
+
1515
+ async function cleanupInstallationTransaction(transaction) {
1516
+ if (await exists(transaction.staging)) {
1517
+ await rm(transaction.staging, { recursive: true, force: true });
1518
+ }
1519
+ }
1520
+
1521
+ async function commitInstallation(transaction) {
1522
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1523
+ await requireOwnedRoot(transaction.backup, transaction.host);
1524
+ await rm(transaction.backup, { recursive: true });
1525
+ transaction.backupCreated = false;
1526
+ }
1527
+ }
1528
+
1529
+ function installedDesiredState(previous, hosts) {
1530
+ return {
1531
+ ...previous,
1532
+ installedHosts: [...new Set([...previous.installedHosts, ...hosts])].sort(),
1533
+ activeVersion: PRODUCT_VERSION,
1534
+ availableVersion: PRODUCT_VERSION,
1535
+ };
1536
+ }
1537
+
1538
+ async function runInstallOrUpdate(options, action) {
1539
+ const desired = await readDesiredState();
1540
+ const preflights = await preflightSelectedHosts(options, action, desired);
1541
+ const hosts = preflights.map((item) => item.host);
1542
+ const prepared = await prepareVerifiedPackages(options, hosts);
1543
+ const packages = new Map(prepared.map((item) => [item.host, item]));
1544
+ const transactions = [];
1545
+ try {
1546
+ for (const preflight of preflights) {
1547
+ const item = packages.get(preflight.host);
1548
+ console.log(
1549
+ `${preflight.host}: verified OpenSocrates ${PRODUCT_VERSION} and ` +
1550
+ `${item.checkedFiles} package files`,
886
1551
  );
1552
+ transactions.push(await stageInstallation(preflight, item.pluginRoot));
1553
+ }
1554
+ for (const transaction of transactions) {
1555
+ await activateInstallation(transaction);
1556
+ console.log(`${transaction.host}: activated OpenSocrates ${PRODUCT_VERSION}`);
1557
+ }
1558
+ await writeDesiredState(installedDesiredState(desired, hosts));
1559
+ } catch (error) {
1560
+ for (const transaction of [...transactions].reverse()) {
1561
+ await rollbackInstallation(transaction);
887
1562
  }
888
1563
  throw error;
889
1564
  } finally {
890
- if (await exists(staging)) {
891
- await rm(staging, { recursive: true, force: true });
1565
+ await Promise.all(transactions.map(cleanupInstallationTransaction));
1566
+ await Promise.all(
1567
+ prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
1568
+ );
1569
+ }
1570
+ for (const transaction of transactions) {
1571
+ const cleaned = await recoveryStep(`remove the committed ${transaction.host} backup`, async () => {
1572
+ await commitInstallation(transaction);
1573
+ });
1574
+ if (!cleaned) {
1575
+ console.warn(`warning: ${transaction.host} is active, but its previous backup needs cleanup`);
892
1576
  }
893
1577
  }
1578
+ const verb = action === "update" ? "updated" : "installed";
1579
+ console.log(
1580
+ `OpenSocrates ${PRODUCT_VERSION} ${verb} successfully for ${hosts.join(", ")}.`,
1581
+ );
1582
+ console.log("Start new host tasks to load the updated skills and hooks.");
1583
+ return hosts;
894
1584
  }
895
1585
 
896
- async function showStatus(host) {
897
- requireHost(host);
898
- if (host === "claude") {
899
- warnLegacyClaudeInstallation();
900
- }
1586
+ async function inspectHostStatus(host) {
1587
+ requireHostCli(host);
1588
+ if (host === "claude") warnLegacyClaudeInstallation();
901
1589
  const paths = managedPaths(host);
902
1590
  const entry = marketplaceEntry(host);
903
1591
  if (entry === null) {
904
1592
  if (await exists(paths.root)) {
905
1593
  await requireOwnedRoot(paths.root, host);
906
- console.log(`OpenSocrates files are present but not registered: ${paths.root}`);
907
- } else {
908
- console.log("OpenSocrates is not installed.");
1594
+ return { host, kind: "files-only", version: null, paths };
909
1595
  }
910
- return;
1596
+ return { host, kind: "missing", version: null, paths };
911
1597
  }
912
1598
  if (entryRoot(entry, host) !== paths.root) {
913
1599
  fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry, host)}`);
914
1600
  }
915
1601
  await requireOwnedRoot(paths.root, host);
916
1602
  const state = pluginState(host);
917
- if (state.kind === "installed") {
918
- console.log(`OpenSocrates ${state.version ?? "unknown"} is installed.`);
919
- } else if (state.kind === "available") {
920
- console.log(`OpenSocrates ${state.version ?? "unknown"} is available but not installed.`);
1603
+ return { host, kind: state.kind, version: state.version, paths };
1604
+ }
1605
+
1606
+ async function showStatus(host) {
1607
+ if (host === ALL_HOST) {
1608
+ const desired = await readDesiredState();
1609
+ console.log(`Desired version: ${desired.activeVersion ?? "none"}`);
1610
+ console.log(`Available version: ${desired.availableVersion ?? "unknown"}`);
1611
+ console.log(`Last check: ${desired.lastCheckAt ?? "never"}`);
1612
+ console.log(`Last successful update: ${desired.lastSuccessfulUpdateAt ?? "never"}`);
1613
+ console.log(`Auto-update: ${desired.autoUpdate.enabled ? "enabled" : "disabled"}`);
1614
+ let drift = false;
1615
+ for (const candidate of SUPPORTED_HOSTS) {
1616
+ try {
1617
+ const status = await inspectHostStatus(candidate);
1618
+ const expected = desired.installedHosts.includes(candidate);
1619
+ const hostDrift = expected
1620
+ ? status.kind !== "installed" ||
1621
+ desired.activeVersion === null ||
1622
+ status.version !== desired.activeVersion
1623
+ : status.kind !== "missing";
1624
+ drift ||= hostDrift;
1625
+ if (status.kind === "installed") {
1626
+ console.log(
1627
+ `${candidate}: installed ${status.version ?? "unknown"}` +
1628
+ (expected
1629
+ ? hostDrift
1630
+ ? ` (drift from ${desired.activeVersion ?? "desired state"})`
1631
+ : " (in sync)"
1632
+ : " (not in desired state)"),
1633
+ );
1634
+ } else if (status.kind === "disabled") {
1635
+ console.log(
1636
+ `${candidate}: installed but disabled (${status.version ?? "unknown"})` +
1637
+ (expected ? " (drift: desired host is not active)" : " (not in desired state)"),
1638
+ );
1639
+ } else if (status.kind === "available") {
1640
+ console.log(
1641
+ `${candidate}: available but not installed (${status.version ?? "unknown"})` +
1642
+ (expected ? " (drift: desired host is not active)" : ""),
1643
+ );
1644
+ } else if (status.kind === "files-only") {
1645
+ console.log(`${candidate}: managed files present but not registered`);
1646
+ } else {
1647
+ console.log(
1648
+ `${candidate}: not installed` + (expected ? " (drift: desired host is missing)" : ""),
1649
+ );
1650
+ }
1651
+ } catch (error) {
1652
+ if (desired.installedHosts.includes(candidate)) drift = true;
1653
+ console.log(
1654
+ `${candidate}: unavailable (${errorCategory(error)})` +
1655
+ (desired.installedHosts.includes(candidate) ? " (drift unknown)" : ""),
1656
+ );
1657
+ }
1658
+ }
1659
+ console.log(`Overall: ${drift ? "drift detected" : "no detected drift"}`);
1660
+ return;
1661
+ }
1662
+ const status = await inspectHostStatus(host);
1663
+ if (status.kind === "installed") {
1664
+ console.log(`OpenSocrates ${status.version ?? "unknown"} is installed.`);
1665
+ } else if (status.kind === "disabled") {
1666
+ console.log(
1667
+ `OpenSocrates ${status.version ?? "unknown"} is installed but disabled. ` +
1668
+ "Run install or update to re-enable it.",
1669
+ );
1670
+ } else if (status.kind === "available") {
1671
+ console.log(`OpenSocrates ${status.version ?? "unknown"} is available but not installed.`);
1672
+ } else if (status.kind === "files-only") {
1673
+ console.log(`OpenSocrates files are present but not registered: ${status.paths.root}`);
921
1674
  } else {
922
- console.log("The OpenSocrates marketplace is registered, but the plugin is missing.");
1675
+ console.log("OpenSocrates is not installed.");
923
1676
  }
924
1677
  }
925
1678
 
926
- async function removeInstalled(host) {
927
- requireHost(host);
928
- if (host === "claude") {
929
- // Warn only. A legacy registration must never block removal of the root
930
- // this installer owns, and removal never touches the legacy installation.
931
- warnLegacyClaudeInstallation();
932
- }
933
- const paths = managedPaths(host);
934
- const entry = marketplaceEntry(host);
935
- if (entry !== null && entryRoot(entry, host) !== paths.root) {
936
- fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry, host)}`);
1679
+ function removalTransaction(preflight) {
1680
+ return {
1681
+ ...preflight,
1682
+ backup: join(preflight.paths.parent, `.opensocrates.removed-${randomUUID()}`),
1683
+ registrationRemoved: false,
1684
+ backupCreated: false,
1685
+ };
1686
+ }
1687
+
1688
+ async function activateRemoval(transaction) {
1689
+ const { host, previousEntry, previousState, paths } = transaction;
1690
+ if (previousEntry !== null) {
1691
+ transaction.registrationRemoved = true;
1692
+ removeRegistration(host, previousEntry, previousState);
937
1693
  }
938
- const state = entry === null ? { kind: "missing", version: null } : pluginState(host);
939
- removeRegistration(host, entry, state);
940
1694
  if (await exists(paths.root)) {
941
- await requireOwnedRoot(paths.root, host);
942
- await rm(paths.root, { recursive: true });
1695
+ await rename(paths.root, transaction.backup);
1696
+ transaction.backupCreated = true;
943
1697
  }
944
- console.log(`OpenSocrates was removed from ${host === "claude" ? "Claude" : "Codex"}.`);
945
1698
  }
946
1699
 
947
- export async function main(argv = process.argv.slice(2)) {
948
- const options = parseCli(argv);
949
- if (options.action === "help") {
950
- showHelp();
951
- return 0;
1700
+ async function rollbackRemoval(transaction) {
1701
+ let restored = !transaction.backupCreated;
1702
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1703
+ restored = await recoveryStep(`restore the removed ${transaction.host} files`, async () => {
1704
+ await requireOwnedRoot(transaction.backup, transaction.host);
1705
+ await rename(transaction.backup, transaction.paths.root);
1706
+ });
952
1707
  }
953
- if (options.action === "status") {
954
- await showStatus(options.host);
955
- return 0;
1708
+ if (transaction.registrationRemoved && transaction.previousEntry !== null && restored) {
1709
+ await recoveryStep(`restore the removed ${transaction.host} registration`, async () => {
1710
+ addRegistration(
1711
+ transaction.host,
1712
+ transaction.paths.root,
1713
+ ["installed", "disabled"].includes(transaction.previousState.kind),
1714
+ { enabled: transaction.previousState.kind !== "disabled" },
1715
+ );
1716
+ });
956
1717
  }
957
- if (options.action === "remove") {
958
- await removeInstalled(options.host);
959
- return 0;
1718
+ }
1719
+
1720
+ async function commitRemoval(transaction) {
1721
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1722
+ await requireOwnedRoot(transaction.backup, transaction.host);
1723
+ await rm(transaction.backup, { recursive: true });
1724
+ }
1725
+ }
1726
+
1727
+ function xmlEscape(value) {
1728
+ return String(value)
1729
+ .replaceAll("&", "&amp;")
1730
+ .replaceAll("<", "&lt;")
1731
+ .replaceAll(">", "&gt;")
1732
+ .replaceAll('"', "&quot;")
1733
+ .replaceAll("'", "&apos;");
1734
+ }
1735
+
1736
+ async function executablePath(name, environmentOverride) {
1737
+ const override = process.env[environmentOverride];
1738
+ if (override) {
1739
+ const candidate = resolve(override);
1740
+ await access(candidate, fsConstants.X_OK);
1741
+ return candidate;
1742
+ }
1743
+ const which = run("/usr/bin/which", [name], { allowFailure: true });
1744
+ const candidate = which.status === 0 ? which.stdout.trim() : "";
1745
+ if (!candidate || !candidate.startsWith("/")) {
1746
+ fail(`could not find an executable ${name} for the automatic updater`);
1747
+ }
1748
+ await access(candidate, fsConstants.X_OK);
1749
+ return candidate;
1750
+ }
1751
+
1752
+ function launchctlBinary() {
1753
+ return process.env.OPENSOCRATES_LAUNCHCTL_BIN || "/bin/launchctl";
1754
+ }
1755
+
1756
+ function launchctlDomain() {
1757
+ if (typeof process.getuid !== "function") {
1758
+ fail("automatic updates require a user launchd domain");
1759
+ }
1760
+ return `gui/${process.getuid()}`;
1761
+ }
1762
+
1763
+ function launchAgentDocument(npx, channel, environment) {
1764
+ const packageTag = channel === "next" ? "next" : "latest";
1765
+ const arguments_ = [
1766
+ npx,
1767
+ "--yes",
1768
+ `opensocrates@${packageTag}`,
1769
+ "auto-update",
1770
+ "run",
1771
+ ];
1772
+ const argumentsXml = arguments_
1773
+ .map((argument) => ` <string>${xmlEscape(argument)}</string>`)
1774
+ .join("\n");
1775
+ const environmentEntries = Object.entries(environment).sort(([left], [right]) =>
1776
+ left.localeCompare(right),
1777
+ );
1778
+ const environmentXml =
1779
+ environmentEntries.length === 0
1780
+ ? ""
1781
+ : `\n <key>EnvironmentVariables</key>\n <dict>\n` +
1782
+ environmentEntries
1783
+ .map(
1784
+ ([key, value]) =>
1785
+ ` <key>${xmlEscape(key)}</key>\n` +
1786
+ ` <string>${xmlEscape(value)}</string>`,
1787
+ )
1788
+ .join("\n") +
1789
+ "\n </dict>";
1790
+ return `<?xml version="1.0" encoding="UTF-8"?>
1791
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1792
+ <plist version="1.0">
1793
+ <dict>
1794
+ <key>Label</key>
1795
+ <string>${AUTO_UPDATE_LABEL}</string>
1796
+ <key>ProgramArguments</key>
1797
+ <array>
1798
+ ${argumentsXml}
1799
+ </array>
1800
+ <key>RunAtLoad</key>
1801
+ <true/>
1802
+ <key>StartInterval</key>
1803
+ <integer>${AUTO_UPDATE_POLL_SECONDS}</integer>
1804
+ <key>ProcessType</key>
1805
+ <string>Background</string>
1806
+ <key>StandardOutPath</key>
1807
+ <string>/dev/null</string>
1808
+ <key>StandardErrorPath</key>
1809
+ <string>/dev/null</string>${environmentXml}
1810
+ </dict>
1811
+ </plist>
1812
+ `;
1813
+ }
1814
+
1815
+ function launchAgentTarget() {
1816
+ return `${launchctlDomain()}/${AUTO_UPDATE_LABEL}`;
1817
+ }
1818
+
1819
+ function launchAgentLoaded() {
1820
+ return run(launchctlBinary(), ["print", launchAgentTarget()], { allowFailure: true }).status === 0;
1821
+ }
1822
+
1823
+ function stopLoadedLaunchAgent() {
1824
+ if (!launchAgentLoaded()) return;
1825
+ const stopped = run(launchctlBinary(), ["bootout", launchAgentTarget()], {
1826
+ allowFailure: true,
1827
+ });
1828
+ if (stopped.status !== 0 && launchAgentLoaded()) {
1829
+ const detail = stopped.stderr?.trim() || stopped.stdout?.trim() || `exit ${stopped.status}`;
1830
+ fail(`could not stop the OpenSocrates LaunchAgent: ${detail}`);
1831
+ }
1832
+ }
1833
+
1834
+ async function updaterEnvironment(hosts, npx) {
1835
+ const node = await executablePath("node", "OPENSOCRATES_NODE_BIN");
1836
+ const environment = {
1837
+ PATH: [...new Set([dirname(npx), dirname(node), "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(
1838
+ ":",
1839
+ ),
1840
+ };
1841
+ for (const host of hosts) {
1842
+ const key = host === "claude" ? "CLAUDE_BIN" : "CODEX_BIN";
1843
+ const executable = await executablePath(host, key);
1844
+ environment[key] = executable;
1845
+ environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
1846
+ }
1847
+ for (const key of ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "OPENSOCRATES_STATE_DIR"]) {
1848
+ if (process.env[key]) environment[key] = resolve(process.env[key]);
1849
+ }
1850
+ return environment;
1851
+ }
1852
+
1853
+ async function installLaunchAgent(channel, hosts) {
1854
+ if (process.platform !== "darwin" || process.arch !== "arm64") {
1855
+ fail(
1856
+ `automatic updates currently support darwin-arm64 only; detected ` +
1857
+ `${process.platform}-${process.arch}`,
1858
+ );
1859
+ }
1860
+ const paths = statePaths();
1861
+ const npx = await executablePath("npx", "OPENSOCRATES_NPX_BIN");
1862
+ const environment = await updaterEnvironment(hosts, npx);
1863
+ let previousDocument = null;
1864
+ if (await exists(paths.launchAgent)) {
1865
+ const info = await lstat(paths.launchAgent);
1866
+ if (!info.isFile() || info.isSymbolicLink()) {
1867
+ fail(`refusing to replace an unsafe LaunchAgent path: ${paths.launchAgent}`);
1868
+ }
1869
+ previousDocument = await readFile(paths.launchAgent, "utf8");
1870
+ }
1871
+ const document = launchAgentDocument(npx, channel, environment);
1872
+ if (process.env.OPENSOCRATES_SKIP_LAUNCHCTL === "1") {
1873
+ await atomicWritePrivateFile(paths.launchAgent, document);
1874
+ return;
960
1875
  }
1876
+ const wasLoaded = launchAgentLoaded();
1877
+ if (wasLoaded) stopLoadedLaunchAgent();
1878
+ await atomicWritePrivateFile(paths.launchAgent, document);
1879
+ const launched = run(
1880
+ launchctlBinary(),
1881
+ ["bootstrap", launchctlDomain(), paths.launchAgent],
1882
+ { allowFailure: true },
1883
+ );
1884
+ if (launched.status !== 0) {
1885
+ if (previousDocument === null) {
1886
+ await rm(paths.launchAgent, { force: true });
1887
+ } else {
1888
+ await atomicWritePrivateFile(paths.launchAgent, previousDocument);
1889
+ if (wasLoaded) {
1890
+ const restored = run(
1891
+ launchctlBinary(),
1892
+ ["bootstrap", launchctlDomain(), paths.launchAgent],
1893
+ { allowFailure: true },
1894
+ );
1895
+ if (restored.status !== 0) {
1896
+ console.error("warning: the previous OpenSocrates LaunchAgent could not be reloaded");
1897
+ }
1898
+ }
1899
+ }
1900
+ const detail = launched.stderr?.trim() || launched.stdout?.trim() || `exit ${launched.status}`;
1901
+ fail(`could not enable the OpenSocrates LaunchAgent: ${detail}`);
1902
+ }
1903
+ }
1904
+
1905
+ async function disableLaunchAgent() {
1906
+ const paths = statePaths();
1907
+ const agentPresent = await exists(paths.launchAgent);
1908
+ if (agentPresent) {
1909
+ const info = await lstat(paths.launchAgent);
1910
+ if (!info.isFile() || info.isSymbolicLink()) {
1911
+ fail(`refusing to remove an unsafe LaunchAgent path: ${paths.launchAgent}`);
1912
+ }
1913
+ }
1914
+ if (process.env.OPENSOCRATES_SKIP_LAUNCHCTL !== "1") {
1915
+ stopLoadedLaunchAgent();
1916
+ }
1917
+ if (agentPresent) await rm(paths.launchAgent);
1918
+ }
1919
+
1920
+ async function disableAutoUpdateState(desired) {
1921
+ await disableLaunchAgent();
1922
+ return {
1923
+ ...desired,
1924
+ autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1925
+ };
1926
+ }
1927
+
1928
+ async function runRemove(options) {
1929
+ const desired = await readDesiredState();
1930
+ const preflights = await preflightSelectedHosts(options, "remove", desired);
1931
+ const removedHosts = preflights.map((item) => item.host);
1932
+ const remainingHosts = desired.installedHosts.filter((host) => !removedHosts.includes(host));
1933
+ const remainingAutoUpdateHosts = desired.autoUpdate.hosts.filter(
1934
+ (host) => !removedHosts.includes(host),
1935
+ );
1936
+ const keepAutoUpdate = desired.autoUpdate.enabled && remainingAutoUpdateHosts.length > 0;
1937
+ const nextDesired = {
1938
+ ...desired,
1939
+ installedHosts: remainingHosts,
1940
+ activeVersion: remainingHosts.length === 0 ? null : desired.activeVersion,
1941
+ autoUpdate: keepAutoUpdate
1942
+ ? { ...desired.autoUpdate, hosts: remainingAutoUpdateHosts }
1943
+ : { enabled: false, hosts: [], nextCheckAt: null },
1944
+ };
1945
+ const launchAgentPresent = await exists(statePaths().launchAgent);
1946
+ const schedulerNeedsChange =
1947
+ options.host === ALL_HOST ||
1948
+ remainingHosts.length === 0 ||
1949
+ launchAgentPresent !== keepAutoUpdate ||
1950
+ desired.autoUpdate.hosts.some((host) => removedHosts.includes(host));
1951
+ const transactions = preflights.map(removalTransaction);
1952
+ let schedulerTouched = false;
1953
+ try {
1954
+ for (const transaction of transactions) await activateRemoval(transaction);
1955
+ if (schedulerNeedsChange) {
1956
+ schedulerTouched = true;
1957
+ if (keepAutoUpdate) {
1958
+ await installLaunchAgent(desired.channel, remainingAutoUpdateHosts);
1959
+ } else {
1960
+ await disableLaunchAgent();
1961
+ }
1962
+ }
1963
+ await writeDesiredState(nextDesired);
1964
+ } catch (error) {
1965
+ for (const transaction of [...transactions].reverse()) await rollbackRemoval(transaction);
1966
+ if (schedulerTouched) {
1967
+ const schedulerRestored = await recoveryStep(
1968
+ "restore the automatic updater after removal failure",
1969
+ async () => {
1970
+ if (desired.autoUpdate.enabled) {
1971
+ await installLaunchAgent(desired.channel, desired.autoUpdate.hosts);
1972
+ } else {
1973
+ await disableLaunchAgent();
1974
+ }
1975
+ },
1976
+ );
1977
+ if (!schedulerRestored && desired.autoUpdate.enabled) {
1978
+ await recoveryStep("record the disabled updater after removal failure", async () => {
1979
+ await writeDesiredState({
1980
+ ...desired,
1981
+ autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1982
+ });
1983
+ });
1984
+ }
1985
+ } else if (!nextDesired.autoUpdate.enabled && desired.autoUpdate.enabled) {
1986
+ await recoveryStep("record the disabled updater after removal failure", async () => {
1987
+ await writeDesiredState({
1988
+ ...desired,
1989
+ autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1990
+ });
1991
+ });
1992
+ }
1993
+ throw error;
1994
+ }
1995
+ for (const transaction of transactions) await commitRemoval(transaction);
1996
+ for (const host of removedHosts) {
1997
+ console.log(`OpenSocrates was removed from ${host === "claude" ? "Claude" : "Codex"}.`);
1998
+ }
1999
+ if (removedHosts.length === 0) console.log("OpenSocrates is not installed on any managed host.");
2000
+ }
2001
+
2002
+ async function enableAutoUpdate(options) {
2003
+ const previous = await readDesiredState();
2004
+ const preflights = new Map();
2005
+ let installedHosts = [...previous.installedHosts];
2006
+ if (installedHosts.length === 0) {
2007
+ const discoveryOptions = { ...options, host: ALL_HOST };
2008
+ const discovered = await preflightSelectedHosts(discoveryOptions, "update", previous);
2009
+ for (const item of discovered) {
2010
+ if (item.previousState.kind === "installed") {
2011
+ preflights.set(item.host, item);
2012
+ installedHosts.push(item.host);
2013
+ }
2014
+ }
2015
+ }
2016
+ let updateHosts = options.host === ALL_HOST ? [...installedHosts] : [options.host];
2017
+ if (updateHosts.length === 0) {
2018
+ fail("install OpenSocrates on at least one host before enabling automatic updates");
2019
+ }
2020
+ for (const host of updateHosts) {
2021
+ const preflight = preflights.get(host) ?? (await preflightHost(host, "update"));
2022
+ if (preflight.previousState.kind !== "installed") {
2023
+ fail(`OpenSocrates is not installed on ${host}`);
2024
+ }
2025
+ if (typeof preflight.previousState.version !== "string") {
2026
+ fail(`cannot determine the installed OpenSocrates version on ${host}`);
2027
+ }
2028
+ preflights.set(host, preflight);
2029
+ installedHosts.push(host);
2030
+ }
2031
+ installedHosts = [...new Set(installedHosts)].sort();
2032
+ updateHosts = [...new Set(updateHosts)].sort();
2033
+ let activeVersion = previous.activeVersion;
2034
+ if (activeVersion === null) {
2035
+ const observedVersions = new Set();
2036
+ for (const host of installedHosts) {
2037
+ const preflight = preflights.get(host) ?? (await preflightHost(host, "update"));
2038
+ if (
2039
+ preflight.previousState.kind !== "installed" ||
2040
+ typeof preflight.previousState.version !== "string"
2041
+ ) {
2042
+ fail(`cannot determine the installed OpenSocrates version on ${host}`);
2043
+ }
2044
+ preflights.set(host, preflight);
2045
+ observedVersions.add(preflight.previousState.version);
2046
+ }
2047
+ if (observedVersions.size !== 1) {
2048
+ fail(
2049
+ "installed hosts do not share one known version; run update --host all before enabling automatic updates",
2050
+ );
2051
+ }
2052
+ [activeVersion] = observedVersions;
2053
+ }
2054
+ const desired = {
2055
+ ...previous,
2056
+ channel: options.channel,
2057
+ installedHosts,
2058
+ activeVersion,
2059
+ updatePolicy: {
2060
+ intervalHours: options.intervalHours,
2061
+ allowMajor: options.allowMajor,
2062
+ },
2063
+ autoUpdate: {
2064
+ enabled: true,
2065
+ hosts: updateHosts,
2066
+ nextCheckAt: nowIso(),
2067
+ },
2068
+ };
2069
+ await writeDesiredState(desired);
2070
+ try {
2071
+ await installLaunchAgent(options.channel, desired.autoUpdate.hosts);
2072
+ } catch (error) {
2073
+ await writeDesiredState(previous);
2074
+ throw error;
2075
+ }
2076
+ console.log(
2077
+ `Automatic updates enabled for ${desired.autoUpdate.hosts.join(", ")} on the ` +
2078
+ `${desired.channel} channel every ${desired.updatePolicy.intervalHours} hours with jitter.`,
2079
+ );
2080
+ console.log(
2081
+ desired.updatePolicy.allowMajor
2082
+ ? "Automatic major-version upgrades are enabled."
2083
+ : "Automatic major-version upgrades remain disabled.",
2084
+ );
2085
+ }
2086
+
2087
+ async function showAutoUpdateStatus() {
2088
+ const desired = await readDesiredState();
2089
+ const agentPresent = await exists(statePaths().launchAgent);
2090
+ console.log(`Automatic updates: ${desired.autoUpdate.enabled ? "enabled" : "disabled"}`);
2091
+ console.log(`LaunchAgent: ${agentPresent ? "installed" : "not installed"}`);
2092
+ console.log(`Channel: ${desired.channel}`);
2093
+ console.log(`Installed hosts: ${desired.installedHosts.join(", ") || "none"}`);
2094
+ console.log(`Automatic-update hosts: ${desired.autoUpdate.hosts.join(", ") || "none"}`);
2095
+ console.log(`Interval: ${desired.updatePolicy.intervalHours} hours with jitter`);
2096
+ console.log(`Major upgrades: ${desired.updatePolicy.allowMajor ? "allowed" : "blocked"}`);
2097
+ console.log(`Next check: ${desired.autoUpdate.nextCheckAt ?? "not scheduled"}`);
2098
+ console.log(`Last check: ${desired.lastCheckAt ?? "never"}`);
2099
+ console.log(`Last successful update: ${desired.lastSuccessfulUpdateAt ?? "never"}`);
2100
+ }
2101
+
2102
+ async function disableAutoUpdate() {
2103
+ const desired = await readDesiredState();
2104
+ await writeDesiredState(await disableAutoUpdateState(desired));
2105
+ console.log("Automatic updates are disabled and the LaunchAgent was removed.");
2106
+ }
2107
+
2108
+ async function runScheduledUpdate(options) {
2109
+ let desired = await readDesiredState();
2110
+ if (!desired.autoUpdate.enabled) {
2111
+ console.log("Automatic updates are disabled.");
2112
+ return;
2113
+ }
2114
+ const scheduledAt = Date.parse(desired.autoUpdate.nextCheckAt ?? "");
2115
+ if (!options.force && Number.isFinite(scheduledAt) && scheduledAt > Date.now()) {
2116
+ console.log(`The next automatic update check is scheduled for ${desired.autoUpdate.nextCheckAt}.`);
2117
+ return;
2118
+ }
2119
+ const checkedAt = nowIso();
2120
+ const hosts = desired.autoUpdate.hosts;
2121
+ if (hosts.length === 0) {
2122
+ fail("the automatic updater has no selected hosts in desired state");
2123
+ }
2124
+ const next = nextCheckAt(desired.updatePolicy.intervalHours);
2125
+ const currentMajor = majorVersion(desired.activeVersion);
2126
+ const availableMajor = majorVersion(PRODUCT_VERSION);
961
2127
  if (
962
- ["install", "update"].includes(options.action) &&
963
- (process.platform !== "darwin" || process.arch !== "arm64")
2128
+ !desired.updatePolicy.allowMajor &&
2129
+ currentMajor !== null &&
2130
+ availableMajor !== null &&
2131
+ availableMajor > currentMajor
964
2132
  ) {
2133
+ desired = await writeDesiredState({
2134
+ ...desired,
2135
+ availableVersion: PRODUCT_VERSION,
2136
+ lastCheckAt: checkedAt,
2137
+ autoUpdate: { ...desired.autoUpdate, nextCheckAt: next },
2138
+ });
2139
+ await writeAutoUpdateReceipt({
2140
+ version: PRODUCT_VERSION,
2141
+ checkedAt,
2142
+ hosts: hosts.map((host) => ({ host, result: "blocked-major" })),
2143
+ result: "blocked",
2144
+ errorCategory: "major-policy",
2145
+ });
2146
+ console.log(`OpenSocrates ${PRODUCT_VERSION} is available but blocked by the major-version policy.`);
2147
+ return;
2148
+ }
2149
+ try {
2150
+ const ready = [];
2151
+ for (const host of hosts) ready.push(await preflightHost(host, "update"));
2152
+ const alreadyCurrent =
2153
+ desired.activeVersion === PRODUCT_VERSION &&
2154
+ ready.every(
2155
+ (item) =>
2156
+ item.previousState.kind === "installed" && item.previousState.version === PRODUCT_VERSION,
2157
+ );
2158
+ if (!alreadyCurrent) {
2159
+ if (hosts.length === SUPPORTED_HOSTS.length) {
2160
+ await runInstallOrUpdate({ ...options, host: ALL_HOST }, "update");
2161
+ } else {
2162
+ const [host] = hosts;
2163
+ const local = localAssetInputs(options, host);
2164
+ await runInstallOrUpdate(
2165
+ { ...options, host, asset: local.asset, checksum: local.checksum },
2166
+ "update",
2167
+ );
2168
+ }
2169
+ }
2170
+ const refreshed = await readDesiredState();
2171
+ desired = await writeDesiredState({
2172
+ ...refreshed,
2173
+ availableVersion: PRODUCT_VERSION,
2174
+ lastCheckAt: checkedAt,
2175
+ lastSuccessfulUpdateAt: alreadyCurrent
2176
+ ? refreshed.lastSuccessfulUpdateAt
2177
+ : checkedAt,
2178
+ autoUpdate: { ...refreshed.autoUpdate, nextCheckAt: next },
2179
+ });
2180
+ await writeAutoUpdateReceipt({
2181
+ version: PRODUCT_VERSION,
2182
+ checkedAt,
2183
+ hosts: hosts.map((host) => ({ host, result: alreadyCurrent ? "current" : "updated" })),
2184
+ result: alreadyCurrent ? "no-update" : "updated",
2185
+ errorCategory: null,
2186
+ });
2187
+ console.log(
2188
+ alreadyCurrent
2189
+ ? `OpenSocrates ${PRODUCT_VERSION} is already current on every managed host.`
2190
+ : `OpenSocrates ${PRODUCT_VERSION} was reconciled across ${hosts.join(", ")}.`,
2191
+ );
2192
+ } catch (error) {
2193
+ await recoveryStep("record the failed automatic update", async () => {
2194
+ desired = await writeDesiredState({
2195
+ ...(await readDesiredState()),
2196
+ availableVersion: PRODUCT_VERSION,
2197
+ lastCheckAt: checkedAt,
2198
+ autoUpdate: { ...desired.autoUpdate, nextCheckAt: next },
2199
+ });
2200
+ await writeAutoUpdateReceipt({
2201
+ version: PRODUCT_VERSION,
2202
+ checkedAt,
2203
+ hosts: hosts.map((host) => ({ host, result: "failed" })),
2204
+ result: "failed",
2205
+ errorCategory: errorCategory(error),
2206
+ });
2207
+ });
2208
+ throw error;
2209
+ }
2210
+ }
2211
+
2212
+ function requireSupportedPlatform() {
2213
+ if (process.platform !== "darwin" || process.arch !== "arm64") {
965
2214
  fail(
966
2215
  `OpenSocrates ${PRODUCT_VERSION} prebuilt installation supports darwin-arm64 only; ` +
967
2216
  `detected ${process.platform}-${process.arch}`,
968
2217
  );
969
2218
  }
2219
+ }
970
2220
 
971
- const prepared = await prepareVerifiedPackage(options);
2221
+ async function verifyPackages(options) {
2222
+ const hosts = options.host === ALL_HOST ? SUPPORTED_HOSTS : [options.host];
2223
+ const prepared = await prepareVerifiedPackages(options, hosts);
972
2224
  try {
973
- console.log(
974
- `Verified OpenSocrates ${PRODUCT_VERSION} release and ${prepared.checkedFiles} package files.`,
2225
+ for (const item of prepared) {
2226
+ console.log(
2227
+ `${item.host}: verified OpenSocrates ${PRODUCT_VERSION} release and ` +
2228
+ `${item.checkedFiles} package files.`,
2229
+ );
2230
+ }
2231
+ } finally {
2232
+ await Promise.all(
2233
+ prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
975
2234
  );
976
- if (options.action === "verify") {
2235
+ }
2236
+ }
2237
+
2238
+ export async function main(argv = process.argv.slice(2)) {
2239
+ const options = parseCli(argv);
2240
+ if (options.action === "help") {
2241
+ showHelp();
2242
+ return 0;
2243
+ }
2244
+ if (options.action === "status") {
2245
+ await showStatus(options.host);
2246
+ return 0;
2247
+ }
2248
+ if (options.action === "verify") {
2249
+ await verifyPackages(options);
2250
+ return 0;
2251
+ }
2252
+ if (options.action === "auto-update" && options.autoUpdateAction === "status") {
2253
+ await showAutoUpdateStatus();
2254
+ return 0;
2255
+ }
2256
+ return withOperationLock(async () => {
2257
+ if (options.action === "remove") {
2258
+ await runRemove(options);
2259
+ return 0;
2260
+ }
2261
+ if (options.action === "auto-update") {
2262
+ if (options.autoUpdateAction === "enable") {
2263
+ await enableAutoUpdate(options);
2264
+ } else if (options.autoUpdateAction === "disable") {
2265
+ await disableAutoUpdate();
2266
+ } else {
2267
+ requireSupportedPlatform();
2268
+ await runScheduledUpdate(options);
2269
+ }
977
2270
  return 0;
978
2271
  }
979
- await installVerifiedPackage(prepared.pluginRoot, options.action, options.host);
2272
+ requireSupportedPlatform();
2273
+ await runInstallOrUpdate(options, options.action);
980
2274
  return 0;
981
- } finally {
982
- await rm(prepared.scratch, { recursive: true, force: true });
983
- }
2275
+ });
984
2276
  }
985
2277
 
986
2278
  let invokedDirectly = false;