opensocrates 1.0.0 → 1.1.2

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,24 +24,58 @@ 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.0.0";
27
+ export const PRODUCT_VERSION = "1.1.2";
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
- export const ASSET_NAME = `opensocrates-${PRODUCT_VERSION}-codex-plugin.zip`;
32
+ export const DEFAULT_HOST = "codex";
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]);
36
+ export function assetNameFor(host = DEFAULT_HOST) {
37
+ if (!SUPPORTED_HOSTS.includes(host)) {
38
+ fail(`unsupported host ${JSON.stringify(host)}`);
39
+ }
40
+ return `opensocrates-${PRODUCT_VERSION}-${host}-plugin.zip`;
41
+ }
42
+ export const ASSET_NAME = assetNameFor(DEFAULT_HOST);
31
43
  export const CHECKSUM_NAME = `${ASSET_NAME}.sha256`;
32
44
 
33
45
  const MARKER_NAME = ".opensocrates-managed.json";
34
- const MARKER = Object.freeze({
46
+ const CODEX_MARKER = Object.freeze({
47
+ schemaVersion: 1,
48
+ marketplaceName: MARKETPLACE_NAME,
49
+ pluginName: PLUGIN_NAME,
50
+ });
51
+ const CLAUDE_MARKER = Object.freeze({
35
52
  schemaVersion: 1,
36
53
  marketplaceName: MARKETPLACE_NAME,
37
54
  pluginName: PLUGIN_NAME,
55
+ host: "claude",
56
+ });
57
+ const HOST_LAYOUTS = Object.freeze({
58
+ claude: {
59
+ marketplaceRelative: join(".claude-plugin", "marketplace.json"),
60
+ pluginRelative: join("plugins", PLUGIN_NAME),
61
+ manifestRelative: join(".claude-plugin", "plugin.json"),
62
+ },
63
+ codex: {
64
+ marketplaceRelative: join(".agents", "plugins", "marketplace.json"),
65
+ pluginRelative: join("build", "generated", "plugins", "codex"),
66
+ manifestRelative: join(".codex-plugin", "plugin.json"),
67
+ },
38
68
  });
39
- const MARKETPLACE_RELATIVE = join(".agents", "plugins", "marketplace.json");
40
- const PLUGIN_RELATIVE = join("build", "generated", "plugins", "codex");
41
69
  const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
42
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;
43
79
 
44
80
  export class InstallerError extends Error {}
45
81
 
@@ -56,6 +92,273 @@ async function exists(target) {
56
92
  }
57
93
  }
58
94
 
95
+ export function statePaths() {
96
+ const stateDirectory = resolve(
97
+ process.env.OPENSOCRATES_STATE_DIR || join(homedir(), ".opensocrates"),
98
+ );
99
+ const launchAgentsDirectory = resolve(
100
+ process.env.OPENSOCRATES_LAUNCH_AGENTS_DIR || join(homedir(), "Library", "LaunchAgents"),
101
+ );
102
+ return {
103
+ directory: stateDirectory,
104
+ desiredState: join(stateDirectory, "desired-state.json"),
105
+ receipt: join(stateDirectory, "auto-update-receipt.json"),
106
+ lock: join(stateDirectory, "lifecycle.lock"),
107
+ launchAgentsDirectory,
108
+ launchAgent: join(launchAgentsDirectory, `${AUTO_UPDATE_LABEL}.plist`),
109
+ };
110
+ }
111
+
112
+ function defaultDesiredState() {
113
+ return {
114
+ schema: DESIRED_STATE_SCHEMA,
115
+ channel: "stable",
116
+ installedHosts: [],
117
+ activeVersion: null,
118
+ updatePolicy: {
119
+ intervalHours: AUTO_UPDATE_DEFAULT_INTERVAL_HOURS,
120
+ allowMajor: false,
121
+ },
122
+ autoUpdate: {
123
+ enabled: false,
124
+ hosts: [],
125
+ nextCheckAt: null,
126
+ },
127
+ availableVersion: null,
128
+ lastCheckAt: null,
129
+ lastSuccessfulUpdateAt: null,
130
+ };
131
+ }
132
+
133
+ function normalizeDesiredState(value) {
134
+ if (
135
+ value === null ||
136
+ typeof value !== "object" ||
137
+ Array.isArray(value) ||
138
+ value.schema !== DESIRED_STATE_SCHEMA
139
+ ) {
140
+ fail("the OpenSocrates desired-state manifest has an unsupported schema");
141
+ }
142
+ const installedHosts = value.installedHosts;
143
+ if (
144
+ !Array.isArray(installedHosts) ||
145
+ installedHosts.some((host) => !SUPPORTED_HOSTS.includes(host)) ||
146
+ new Set(installedHosts).size !== installedHosts.length
147
+ ) {
148
+ fail("the OpenSocrates desired-state manifest has an invalid host set");
149
+ }
150
+ const channel = value.channel;
151
+ if (!new Set(["stable", "next"]).has(channel)) {
152
+ fail("the OpenSocrates desired-state manifest has an invalid update channel");
153
+ }
154
+ const intervalHours = value.updatePolicy?.intervalHours;
155
+ if (
156
+ !Number.isInteger(intervalHours) ||
157
+ intervalHours < AUTO_UPDATE_MIN_INTERVAL_HOURS ||
158
+ intervalHours > AUTO_UPDATE_MAX_INTERVAL_HOURS ||
159
+ typeof value.updatePolicy?.allowMajor !== "boolean" ||
160
+ typeof value.autoUpdate?.enabled !== "boolean"
161
+ ) {
162
+ fail("the OpenSocrates desired-state manifest has an invalid update policy");
163
+ }
164
+ const autoUpdateHosts =
165
+ value.autoUpdate.hosts === undefined
166
+ ? value.autoUpdate.enabled
167
+ ? installedHosts
168
+ : []
169
+ : value.autoUpdate.hosts;
170
+ if (
171
+ !Array.isArray(autoUpdateHosts) ||
172
+ autoUpdateHosts.some(
173
+ (host) => !SUPPORTED_HOSTS.includes(host) || !installedHosts.includes(host),
174
+ ) ||
175
+ new Set(autoUpdateHosts).size !== autoUpdateHosts.length ||
176
+ (value.autoUpdate.enabled && autoUpdateHosts.length === 0) ||
177
+ (!value.autoUpdate.enabled && autoUpdateHosts.length > 0)
178
+ ) {
179
+ fail("the OpenSocrates desired-state manifest has an invalid automatic-update host set");
180
+ }
181
+ return {
182
+ ...defaultDesiredState(),
183
+ ...value,
184
+ installedHosts: [...installedHosts].sort(),
185
+ updatePolicy: {
186
+ intervalHours,
187
+ allowMajor: value.updatePolicy.allowMajor,
188
+ },
189
+ autoUpdate: {
190
+ enabled: value.autoUpdate.enabled,
191
+ hosts: [...autoUpdateHosts].sort(),
192
+ nextCheckAt: value.autoUpdate.nextCheckAt ?? null,
193
+ },
194
+ };
195
+ }
196
+
197
+ export async function readDesiredState() {
198
+ const { desiredState } = statePaths();
199
+ if (!(await exists(desiredState))) {
200
+ return defaultDesiredState();
201
+ }
202
+ let value;
203
+ try {
204
+ value = JSON.parse(await readFile(desiredState, "utf8"));
205
+ } catch (error) {
206
+ fail(`cannot read the OpenSocrates desired-state manifest: ${error.message}`);
207
+ }
208
+ return normalizeDesiredState(value);
209
+ }
210
+
211
+ async function ensurePrivateDirectory(directory) {
212
+ if (await exists(directory)) {
213
+ const info = await lstat(directory);
214
+ if (!info.isDirectory() || info.isSymbolicLink()) {
215
+ fail(`refusing to use a non-directory or symbolic-link state path: ${directory}`);
216
+ }
217
+ await chmod(directory, 0o700);
218
+ return;
219
+ }
220
+ await mkdir(directory, { recursive: true, mode: 0o700 });
221
+ const info = await lstat(directory);
222
+ if (!info.isDirectory() || info.isSymbolicLink()) {
223
+ fail(`refusing to use an unsafe state path: ${directory}`);
224
+ }
225
+ await chmod(directory, 0o700);
226
+ }
227
+
228
+ async function atomicWritePrivateFile(target, contents) {
229
+ const parent = dirname(target);
230
+ await ensurePrivateDirectory(parent);
231
+ const temporary = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
232
+ try {
233
+ await writeFile(temporary, contents, { encoding: "utf8", mode: 0o600, flag: "wx" });
234
+ await chmod(temporary, 0o600);
235
+ await rename(temporary, target);
236
+ } finally {
237
+ if (await exists(temporary)) {
238
+ await rm(temporary, { force: true });
239
+ }
240
+ }
241
+ }
242
+
243
+ export async function writeDesiredState(value) {
244
+ const normalized = normalizeDesiredState(value);
245
+ await atomicWritePrivateFile(
246
+ statePaths().desiredState,
247
+ `${JSON.stringify(normalized, null, 2)}\n`,
248
+ );
249
+ return normalized;
250
+ }
251
+
252
+ async function writeAutoUpdateReceipt({ version, checkedAt, hosts, result, errorCategory }) {
253
+ const receipt = {
254
+ schema: RECEIPT_SCHEMA,
255
+ version,
256
+ checkedAt,
257
+ hosts: [...hosts]
258
+ .sort((left, right) => left.host.localeCompare(right.host))
259
+ .map(({ host, result: hostResult }) => ({ host, result: hostResult })),
260
+ result,
261
+ errorCategory: errorCategory ?? null,
262
+ };
263
+ await atomicWritePrivateFile(
264
+ statePaths().receipt,
265
+ `${JSON.stringify(receipt, null, 2)}\n`,
266
+ );
267
+ }
268
+
269
+ function nowIso() {
270
+ return new Date().toISOString();
271
+ }
272
+
273
+ function nextCheckAt(intervalHours) {
274
+ const intervalMilliseconds = intervalHours * 60 * 60 * 1000;
275
+ const jitterBasisPoints = randomInt(-1500, 1501);
276
+ return new Date(Date.now() + intervalMilliseconds * (1 + jitterBasisPoints / 10_000)).toISOString();
277
+ }
278
+
279
+ function majorVersion(value) {
280
+ const match = String(value ?? "").match(/^(\d+)\./u);
281
+ return match ? Number(match[1]) : null;
282
+ }
283
+
284
+ function errorCategory(error) {
285
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
286
+ if (/checksum|manifest|archive|symbolic link/u.test(message)) return "verification";
287
+ if (/download|fetch|network|offline|timed? ?out/u.test(message)) return "network";
288
+ if (/preflight|not logged|auth|version is required|could not run/u.test(message)) return "preflight";
289
+ if (/rollback|restore/u.test(message)) return "rollback";
290
+ if (/lock|already running/u.test(message)) return "locked";
291
+ if (/register|plugin|marketplace/u.test(message)) return "activation";
292
+ return "internal";
293
+ }
294
+
295
+ async function acquireOperationLock() {
296
+ const paths = statePaths();
297
+ await ensurePrivateDirectory(paths.directory);
298
+ for (let attempt = 0; attempt < 2; attempt += 1) {
299
+ try {
300
+ const handle = await open(paths.lock, "wx", 0o600);
301
+ try {
302
+ await handle.writeFile(
303
+ `${JSON.stringify({ pid: process.pid, startedAt: nowIso() })}\n`,
304
+ "utf8",
305
+ );
306
+ } catch (writeError) {
307
+ await handle.close().catch(() => undefined);
308
+ await rm(paths.lock, { force: true }).catch(() => undefined);
309
+ throw writeError;
310
+ }
311
+ return async () => {
312
+ try {
313
+ await handle.close();
314
+ } finally {
315
+ await rm(paths.lock, { force: true });
316
+ }
317
+ };
318
+ } catch (error) {
319
+ if (error?.code !== "EEXIST") {
320
+ throw error;
321
+ }
322
+ let stale = false;
323
+ try {
324
+ const lock = JSON.parse(await readFile(paths.lock, "utf8"));
325
+ const started = Date.parse(lock.startedAt);
326
+ let ownerAlive = null;
327
+ if (Number.isInteger(lock.pid) && lock.pid > 0) {
328
+ try {
329
+ process.kill(lock.pid, 0);
330
+ ownerAlive = true;
331
+ } catch (processError) {
332
+ ownerAlive = processError?.code === "ESRCH" ? false : true;
333
+ }
334
+ }
335
+ stale =
336
+ ownerAlive === false ||
337
+ (ownerAlive === null &&
338
+ Number.isFinite(started) &&
339
+ Date.now() - started > LOCK_STALE_MILLISECONDS);
340
+ } catch {
341
+ const info = await lstat(paths.lock);
342
+ stale = Date.now() - info.mtimeMs > LOCK_STALE_MILLISECONDS;
343
+ }
344
+ if (!stale || attempt > 0) {
345
+ fail("another OpenSocrates lifecycle operation is already running");
346
+ }
347
+ await rm(paths.lock, { force: true });
348
+ }
349
+ }
350
+ fail("could not acquire the OpenSocrates lifecycle lock");
351
+ }
352
+
353
+ export async function withOperationLock(action) {
354
+ const release = await acquireOperationLock();
355
+ try {
356
+ return await action();
357
+ } finally {
358
+ await release();
359
+ }
360
+ }
361
+
59
362
  function jsonEqual(left, right) {
60
363
  const leftKeys = Object.keys(left).sort();
61
364
  const rightKeys = Object.keys(right).sort();
@@ -65,12 +368,16 @@ function jsonEqual(left, right) {
65
368
  );
66
369
  }
67
370
 
68
- export function markerMatches(value) {
371
+ function markerFor(host) {
372
+ return host === "claude" ? CLAUDE_MARKER : CODEX_MARKER;
373
+ }
374
+
375
+ export function markerMatches(value, host = DEFAULT_HOST) {
69
376
  return (
70
377
  value !== null &&
71
378
  typeof value === "object" &&
72
379
  !Array.isArray(value) &&
73
- jsonEqual(value, MARKER)
380
+ jsonEqual(value, markerFor(host))
74
381
  );
75
382
  }
76
383
 
@@ -111,7 +418,15 @@ export function parseChecksumText(text, expectedName = ASSET_NAME) {
111
418
  }
112
419
 
113
420
  export function parseCli(argv) {
114
- const actions = new Set(["install", "status", "update", "remove", "verify", "help"]);
421
+ const actions = new Set([
422
+ "install",
423
+ "status",
424
+ "update",
425
+ "remove",
426
+ "verify",
427
+ "auto-update",
428
+ "help",
429
+ ]);
115
430
  const args = [...argv];
116
431
  let action = "install";
117
432
  if (args[0] && !args[0].startsWith("-")) {
@@ -120,13 +435,95 @@ export function parseCli(argv) {
120
435
  if (!actions.has(action)) {
121
436
  fail(`unknown action ${JSON.stringify(action)}`);
122
437
  }
123
- const options = { action, asset: null, checksum: null };
438
+ let autoUpdateAction = null;
439
+ if (action === "auto-update") {
440
+ if (["--help", "-h"].includes(args[0])) {
441
+ args.shift();
442
+ action = "help";
443
+ } else {
444
+ autoUpdateAction = args.shift() ?? "status";
445
+ if (!new Set(["enable", "status", "disable", "run"]).has(autoUpdateAction)) {
446
+ fail(`unknown auto-update action ${JSON.stringify(autoUpdateAction)}`);
447
+ }
448
+ }
449
+ }
450
+ const options = {
451
+ action,
452
+ autoUpdateAction,
453
+ host: action === "auto-update" ? ALL_HOST : DEFAULT_HOST,
454
+ asset: null,
455
+ checksum: null,
456
+ hostAssets: {
457
+ claude: { asset: null, checksum: null },
458
+ codex: { asset: null, checksum: null },
459
+ },
460
+ channel: "stable",
461
+ intervalHours: AUTO_UPDATE_DEFAULT_INTERVAL_HOURS,
462
+ allowMajor: false,
463
+ force: false,
464
+ };
465
+ const seenOptions = new Set();
124
466
  while (args.length > 0) {
125
467
  const flag = args.shift();
126
468
  if (flag === "--help" || flag === "-h") {
127
469
  options.action = "help";
128
470
  continue;
129
471
  }
472
+ if (flag === "--host") {
473
+ seenOptions.add("host");
474
+ const host = args.shift();
475
+ if (!HOST_CHOICES.includes(host)) {
476
+ fail(`--host must be one of: ${HOST_CHOICES.join(", ")}`);
477
+ }
478
+ options.host = host;
479
+ continue;
480
+ }
481
+ if (flag === "--allow-major") {
482
+ seenOptions.add("allow-major");
483
+ options.allowMajor = true;
484
+ continue;
485
+ }
486
+ if (flag === "--force") {
487
+ seenOptions.add("force");
488
+ options.force = true;
489
+ continue;
490
+ }
491
+ if (flag === "--channel") {
492
+ seenOptions.add("channel");
493
+ const channel = args.shift();
494
+ if (!new Set(["stable", "next"]).has(channel)) {
495
+ fail("--channel must be one of: stable, next");
496
+ }
497
+ options.channel = channel;
498
+ continue;
499
+ }
500
+ if (flag === "--interval-hours") {
501
+ seenOptions.add("interval-hours");
502
+ const raw = args.shift();
503
+ const value = Number(raw);
504
+ if (
505
+ !Number.isInteger(value) ||
506
+ value < AUTO_UPDATE_MIN_INTERVAL_HOURS ||
507
+ value > AUTO_UPDATE_MAX_INTERVAL_HOURS
508
+ ) {
509
+ fail(
510
+ `--interval-hours must be an integer from ${AUTO_UPDATE_MIN_INTERVAL_HOURS} ` +
511
+ `through ${AUTO_UPDATE_MAX_INTERVAL_HOURS}`,
512
+ );
513
+ }
514
+ options.intervalHours = value;
515
+ continue;
516
+ }
517
+ const qualified = flag?.match(/^--(asset|checksum)-(claude|codex)$/u);
518
+ if (qualified) {
519
+ seenOptions.add(qualified[1]);
520
+ const value = args.shift();
521
+ if (!value || value.startsWith("-")) {
522
+ fail(`${flag} requires a file path`);
523
+ }
524
+ options.hostAssets[qualified[2]][qualified[1]] = resolve(value);
525
+ continue;
526
+ }
130
527
  if (flag !== "--asset" && flag !== "--checksum") {
131
528
  fail(`unknown option ${JSON.stringify(flag)}`);
132
529
  }
@@ -135,13 +532,71 @@ export function parseCli(argv) {
135
532
  fail(`${flag} requires a file path`);
136
533
  }
137
534
  options[flag === "--asset" ? "asset" : "checksum"] = resolve(value);
535
+ seenOptions.add(flag.slice(2));
138
536
  }
139
537
  if ((options.asset === null) !== (options.checksum === null)) {
140
538
  fail("--asset and --checksum must be supplied together");
141
539
  }
540
+ for (const host of SUPPORTED_HOSTS) {
541
+ const inputs = options.hostAssets[host];
542
+ if ((inputs.asset === null) !== (inputs.checksum === null)) {
543
+ fail(`--asset-${host} and --checksum-${host} must be supplied together`);
544
+ }
545
+ }
546
+ if (options.host === ALL_HOST && options.asset !== null) {
547
+ fail("--host all requires host-qualified --asset-<host> and --checksum-<host> options");
548
+ }
549
+ if (
550
+ options.host === ALL_HOST &&
551
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null) &&
552
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset === null)
553
+ ) {
554
+ fail("local --host all verification requires asset and checksum pairs for both hosts");
555
+ }
556
+ if (
557
+ options.host !== ALL_HOST &&
558
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)
559
+ ) {
560
+ fail("host-qualified asset options are only valid with --host all");
561
+ }
142
562
  if (["status", "remove", "help"].includes(options.action) && options.asset !== null) {
143
563
  fail(`${options.action} does not accept --asset or --checksum`);
144
564
  }
565
+ if (
566
+ ["status", "remove", "help"].includes(options.action) &&
567
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)
568
+ ) {
569
+ fail(`${options.action} does not accept host-qualified asset options`);
570
+ }
571
+ if (
572
+ options.action === "auto-update" &&
573
+ options.autoUpdateAction !== "run" &&
574
+ (options.asset !== null ||
575
+ SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null))
576
+ ) {
577
+ fail(`auto-update ${options.autoUpdateAction} does not accept local asset options`);
578
+ }
579
+ const autoUpdateEnable =
580
+ options.action === "auto-update" && options.autoUpdateAction === "enable";
581
+ const autoUpdateRun = options.action === "auto-update" && options.autoUpdateAction === "run";
582
+ for (const policyOption of ["allow-major", "channel", "interval-hours"]) {
583
+ if (seenOptions.has(policyOption) && !autoUpdateEnable) {
584
+ fail(`--${policyOption} is only valid with auto-update enable`);
585
+ }
586
+ }
587
+ if (seenOptions.has("force") && !autoUpdateRun) {
588
+ fail("--force is only valid with auto-update run");
589
+ }
590
+ if (
591
+ seenOptions.has("host") &&
592
+ options.action === "auto-update" &&
593
+ new Set(["status", "disable"]).has(options.autoUpdateAction)
594
+ ) {
595
+ fail(`auto-update ${options.autoUpdateAction} does not accept --host`);
596
+ }
597
+ if (autoUpdateRun && seenOptions.has("host") && options.host !== ALL_HOST) {
598
+ fail("auto-update run always reconciles desired state and only accepts --host all");
599
+ }
145
600
  return options;
146
601
  }
147
602
 
@@ -149,28 +604,47 @@ function showHelp() {
149
604
  console.log(`OpenSocrates ${PRODUCT_VERSION}
150
605
 
151
606
  Usage:
152
- opensocrates install [--asset ZIP --checksum SHA256]
153
- opensocrates status
154
- opensocrates update [--asset ZIP --checksum SHA256]
155
- opensocrates remove
156
- opensocrates verify [--asset ZIP --checksum SHA256]
607
+ opensocrates install [--host all|codex|claude] [--asset ZIP --checksum SHA256]
608
+ opensocrates status [--host all|codex|claude]
609
+ opensocrates update [--host all|codex|claude] [--asset ZIP --checksum SHA256]
610
+ opensocrates remove [--host all|codex|claude]
611
+ opensocrates verify [--host all|codex|claude] [--asset ZIP --checksum SHA256]
612
+ opensocrates auto-update enable [--host all|codex|claude]
613
+ [--channel stable|next] [--interval-hours ${AUTO_UPDATE_DEFAULT_INTERVAL_HOURS}]
614
+ [--allow-major]
615
+ opensocrates auto-update status
616
+ opensocrates auto-update disable
157
617
 
158
618
  Without --asset, install, update, and verify download the v${PRODUCT_VERSION}
159
- package and checksum from GitHub Releases.
619
+ package and checksum from GitHub Releases. The default lifecycle host is codex.
620
+ For offline --host all verification, use --asset-claude/--checksum-claude
621
+ and --asset-codex/--checksum-codex together. Automatic updates are opt-in.
160
622
  `);
161
623
  }
162
624
 
163
- function managedPaths() {
164
- const configured = process.env.CODEX_HOME;
165
- const codexHome = resolve(configured ? configured : join(homedir(), ".codex"));
166
- const root = join(codexHome, "managed-marketplaces", MARKETPLACE_NAME);
625
+ function managedPaths(host) {
626
+ const configured =
627
+ host === "claude" ? process.env.CLAUDE_CONFIG_DIR : process.env.CODEX_HOME;
628
+ const configuredHome = resolve(
629
+ configured ? configured : join(homedir(), host === "claude" ? ".claude" : ".codex"),
630
+ );
631
+ let hostHome = configuredHome;
632
+ try {
633
+ hostHome = realpathSync(configuredHome);
634
+ } catch {
635
+ // The installer creates a missing host home below after validating its
636
+ // explicit, non-recursive target. Lexical resolution is the only
637
+ // available normalization until then.
638
+ }
639
+ const root = join(hostHome, "managed-marketplaces", MARKETPLACE_NAME);
640
+ const layout = HOST_LAYOUTS[host];
167
641
  return {
168
- codexHome,
642
+ hostHome,
169
643
  root,
170
644
  parent: dirname(root),
171
645
  marker: join(root, MARKER_NAME),
172
- marketplace: join(root, MARKETPLACE_RELATIVE),
173
- plugin: join(root, PLUGIN_RELATIVE),
646
+ marketplace: join(root, layout.marketplaceRelative),
647
+ plugin: join(root, layout.pluginRelative),
174
648
  };
175
649
  }
176
650
 
@@ -178,6 +652,10 @@ function codexBinary() {
178
652
  return process.env.CODEX_BIN || "codex";
179
653
  }
180
654
 
655
+ function claudeBinary() {
656
+ return process.env.CLAUDE_BIN || "claude";
657
+ }
658
+
181
659
  function run(command, args, { allowFailure = false } = {}) {
182
660
  const result = spawnSync(command, args, {
183
661
  encoding: "utf8",
@@ -197,47 +675,103 @@ function run(command, args, { allowFailure = false } = {}) {
197
675
  return result;
198
676
  }
199
677
 
200
- function runCodexJson(args) {
201
- const result = run(codexBinary(), args);
678
+ function runJson(command, args, label) {
679
+ const result = run(command, args);
202
680
  try {
203
681
  const payload = JSON.parse(result.stdout);
204
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
205
- fail("Codex returned a non-object JSON value");
682
+ if (payload === null || typeof payload !== "object") {
683
+ fail(`${label} returned a non-container JSON value`);
206
684
  }
207
685
  return payload;
208
686
  } catch (error) {
209
687
  if (error instanceof InstallerError) {
210
688
  throw error;
211
689
  }
212
- fail(`Codex returned invalid JSON for ${args.join(" ")}: ${error.message}`);
690
+ fail(`${label} returned invalid JSON for ${args.join(" ")}: ${error.message}`);
691
+ }
692
+ }
693
+
694
+ function runCodexJson(args) {
695
+ const payload = runJson(codexBinary(), args, "Codex");
696
+ if (Array.isArray(payload)) {
697
+ fail("Codex returned an unexpected JSON array");
698
+ }
699
+ return payload;
700
+ }
701
+
702
+ function runClaudeJson(args) {
703
+ return runJson(claudeBinary(), args, "Claude Code");
704
+ }
705
+
706
+ function versionAtLeast(value, minimum) {
707
+ const match = String(value).match(/(\d+)\.(\d+)\.(\d+)/u);
708
+ if (!match) {
709
+ return false;
710
+ }
711
+ const current = match.slice(1).map(Number);
712
+ for (let index = 0; index < minimum.length; index += 1) {
713
+ if (current[index] > minimum[index]) return true;
714
+ if (current[index] < minimum[index]) return false;
213
715
  }
716
+ return true;
214
717
  }
215
718
 
216
- function requireCodex() {
719
+ function requireHostCli(host, { authenticated = false } = {}) {
720
+ if (host === "claude") {
721
+ const result = run(claudeBinary(), ["--version"]);
722
+ if (!versionAtLeast(result.stdout, [2, 1, 205])) {
723
+ fail(
724
+ `Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`,
725
+ );
726
+ }
727
+ if (authenticated) {
728
+ run(claudeBinary(), ["auth", "status"]);
729
+ }
730
+ return;
731
+ }
217
732
  run(codexBinary(), ["--version"]);
733
+ if (authenticated) {
734
+ run(codexBinary(), ["login", "status"]);
735
+ }
218
736
  }
219
737
 
220
- function marketplaceEntry() {
221
- const payload = runCodexJson(["plugin", "marketplace", "list", "--json"]);
222
- const entries = payload.marketplaces;
738
+ function marketplaceEntries(host) {
739
+ const payload =
740
+ host === "claude"
741
+ ? runClaudeJson(["plugin", "marketplace", "list", "--json"])
742
+ : runCodexJson(["plugin", "marketplace", "list", "--json"]).marketplaces;
743
+ const entries = payload;
223
744
  if (!Array.isArray(entries)) {
224
- fail("Codex marketplace list returned an unexpected schema");
745
+ fail(`${host} marketplace list returned an unexpected schema`);
225
746
  }
747
+ return entries;
748
+ }
749
+
750
+ function marketplaceEntry(host) {
751
+ const entries = marketplaceEntries(host);
226
752
  const matches = entries.filter((entry) => entry?.name === MARKETPLACE_NAME);
227
753
  if (matches.length > 1) {
228
- fail(`Codex reported duplicate marketplaces named ${MARKETPLACE_NAME}`);
754
+ fail(`${host} reported duplicate marketplaces named ${MARKETPLACE_NAME}`);
229
755
  }
230
756
  return matches[0] ?? null;
231
757
  }
232
758
 
233
- function pluginState() {
759
+ function pluginState(host) {
760
+ if (host === "claude") {
761
+ const installed = runClaudeJson(["plugin", "list", "--json"]);
762
+ if (!Array.isArray(installed)) {
763
+ fail("Claude Code plugin list returned an unexpected schema");
764
+ }
765
+ const matches = installed.filter((entry) => entry?.id === PLUGIN_ID);
766
+ if (matches.length > 1) {
767
+ fail(`Claude Code reported duplicate entries for ${PLUGIN_ID}`);
768
+ }
769
+ return matches.length === 1
770
+ ? { kind: "installed", version: matches[0].version ?? null }
771
+ : { kind: "missing", version: null };
772
+ }
234
773
  const payload = runCodexJson([
235
- "plugin",
236
- "list",
237
- "--marketplace",
238
- MARKETPLACE_NAME,
239
- "--available",
240
- "--json",
774
+ "plugin", "list", "--marketplace", MARKETPLACE_NAME, "--available", "--json",
241
775
  ]);
242
776
  const installed = Array.isArray(payload.installed) ? payload.installed : null;
243
777
  const available = Array.isArray(payload.available) ? payload.available : null;
@@ -258,6 +792,56 @@ function pluginState() {
258
792
  return { kind: "missing", version: null };
259
793
  }
260
794
 
795
+ function detectLegacyClaudeInstallation() {
796
+ const marketplaces = marketplaceEntries("claude").filter(
797
+ (entry) =>
798
+ typeof entry?.name === "string" &&
799
+ entry.name !== MARKETPLACE_NAME &&
800
+ entry.name.toLowerCase() === MARKETPLACE_NAME,
801
+ );
802
+ const plugins = runClaudeJson(["plugin", "list", "--json"]);
803
+ const legacyPlugins = Array.isArray(plugins)
804
+ ? plugins.filter(
805
+ (entry) =>
806
+ typeof entry?.id === "string" &&
807
+ entry.id !== PLUGIN_ID &&
808
+ entry.id.toLowerCase() === PLUGIN_ID,
809
+ )
810
+ : [];
811
+ const names = marketplaces.map((entry) => entry.name).join(", ") || "OpenSocrates";
812
+ return { found: marketplaces.length > 0 || legacyPlugins.length > 0, names };
813
+ }
814
+
815
+ // Install and update write into the managed root, so a case-variant pre-1.0
816
+ // registration must be resolved by the user first. Status and remove are
817
+ // diagnostic and scoped to the root this installer owns, so they only warn:
818
+ // blocking them would strand a user who needs to inspect or back out.
819
+ function requireNoLegacyClaudeInstallation() {
820
+ const legacy = detectLegacyClaudeInstallation();
821
+ if (legacy.found) {
822
+ fail(
823
+ `a legacy Claude marketplace (${legacy.names}) is installed; OpenSocrates will not remove it automatically. ` +
824
+ "Uninstall its plugin and marketplace explicitly, then rerun this command. See the README migration section.",
825
+ );
826
+ }
827
+ }
828
+
829
+ function warnLegacyClaudeInstallation() {
830
+ let legacy;
831
+ try {
832
+ legacy = detectLegacyClaudeInstallation();
833
+ } catch {
834
+ return;
835
+ }
836
+ if (legacy.found) {
837
+ console.warn(
838
+ `warning: a legacy Claude marketplace (${legacy.names}) is also registered. ` +
839
+ "OpenSocrates never removes it automatically; this command only affects the root it owns. " +
840
+ "See the README migration section.",
841
+ );
842
+ }
843
+ }
844
+
261
845
  async function readJsonObject(target) {
262
846
  let payload;
263
847
  try {
@@ -271,13 +855,13 @@ async function readJsonObject(target) {
271
855
  return payload;
272
856
  }
273
857
 
274
- async function requireOwnedRoot(root) {
858
+ async function requireOwnedRoot(root, host) {
275
859
  const info = await lstat(root);
276
860
  if (!info.isDirectory() || info.isSymbolicLink()) {
277
861
  fail(`managed marketplace path is not an owned directory: ${root}`);
278
862
  }
279
863
  const marker = await readJsonObject(join(root, MARKER_NAME));
280
- if (!markerMatches(marker)) {
864
+ if (!markerMatches(marker, host)) {
281
865
  fail(`managed marketplace path has no valid ownership marker: ${root}`);
282
866
  }
283
867
  }
@@ -313,24 +897,34 @@ async function downloadFile(url, destination) {
313
897
  }
314
898
  }
315
899
 
316
- async function resolveAssetInputs(options, scratch) {
317
- if (options.asset !== null) {
318
- if (!(await exists(options.asset)) || !(await exists(options.checksum))) {
900
+ function localAssetInputs(options, host) {
901
+ if (options.host === ALL_HOST) {
902
+ return options.hostAssets[host];
903
+ }
904
+ return { asset: options.asset, checksum: options.checksum };
905
+ }
906
+
907
+ async function resolveAssetInputs(options, scratch, host) {
908
+ const local = localAssetInputs(options, host);
909
+ if (local.asset !== null) {
910
+ if (!(await exists(local.asset)) || !(await exists(local.checksum))) {
319
911
  fail("the supplied asset or checksum file does not exist");
320
912
  }
321
- return { asset: options.asset, checksum: options.checksum };
913
+ return local;
322
914
  }
323
915
  const base = `https://github.com/${REPOSITORY}/releases/download/v${PRODUCT_VERSION}`;
324
- const asset = join(scratch, ASSET_NAME);
325
- const checksum = join(scratch, CHECKSUM_NAME);
916
+ const assetName = assetNameFor(host);
917
+ const checksumName = `${assetName}.sha256`;
918
+ const asset = join(scratch, assetName);
919
+ const checksum = join(scratch, checksumName);
326
920
  console.log(`Downloading OpenSocrates ${PRODUCT_VERSION} from GitHub Releases...`);
327
- await downloadFile(`${base}/${ASSET_NAME}`, asset);
328
- await downloadFile(`${base}/${CHECKSUM_NAME}`, checksum);
921
+ await downloadFile(`${base}/${assetName}`, asset);
922
+ await downloadFile(`${base}/${checksumName}`, checksum);
329
923
  return { asset, checksum };
330
924
  }
331
925
 
332
- async function verifyOuterChecksum(asset, checksum) {
333
- const expected = parseChecksumText(await readFile(checksum, "utf8"));
926
+ async function verifyOuterChecksum(asset, checksum, host) {
927
+ const expected = parseChecksumText(await readFile(checksum, "utf8"), assetNameFor(host));
334
928
  const actual = await sha256File(asset);
335
929
  if (actual !== expected) {
336
930
  fail(`release checksum mismatch: expected ${expected}, got ${actual}`);
@@ -420,8 +1014,8 @@ async function verifyPackageChecksums(pluginRoot) {
420
1014
  return declared.size;
421
1015
  }
422
1016
 
423
- async function verifyExtractedPackage(pluginRoot) {
424
- const manifest = await readJsonObject(join(pluginRoot, ".codex-plugin", "plugin.json"));
1017
+ async function verifyExtractedPackage(pluginRoot, host) {
1018
+ const manifest = await readJsonObject(join(pluginRoot, HOST_LAYOUTS[host].manifestRelative));
425
1019
  if (manifest.name !== PLUGIN_NAME || manifest.version !== PRODUCT_VERSION) {
426
1020
  fail(
427
1021
  `plugin manifest mismatch: expected ${PLUGIN_NAME} ${PRODUCT_VERSION}, ` +
@@ -431,7 +1025,7 @@ async function verifyExtractedPackage(pluginRoot) {
431
1025
  const release = await readJsonObject(join(pluginRoot, "release-manifest.json"));
432
1026
  if (
433
1027
  release.product_version !== PRODUCT_VERSION ||
434
- release.host !== "codex" ||
1028
+ release.host !== host ||
435
1029
  release.schema !== "opensocrates.plugin-release-manifest/1.0.0"
436
1030
  ) {
437
1031
  fail("package release manifest does not match this installer");
@@ -450,22 +1044,61 @@ async function verifyExtractedPackage(pluginRoot) {
450
1044
  return verifyPackageChecksums(pluginRoot);
451
1045
  }
452
1046
 
453
- async function prepareVerifiedPackage(options) {
1047
+ async function prepareVerifiedPackage(options, host = options.host) {
1048
+ if (!SUPPORTED_HOSTS.includes(host)) {
1049
+ fail(`cannot prepare a package for unsupported host ${JSON.stringify(host)}`);
1050
+ }
454
1051
  const scratch = await mkdtemp(join(tmpdir(), "opensocrates-install-"));
455
1052
  try {
456
- const { asset, checksum } = await resolveAssetInputs(options, scratch);
457
- await verifyOuterChecksum(asset, checksum);
1053
+ const { asset, checksum } = await resolveAssetInputs(options, scratch, host);
1054
+ await verifyOuterChecksum(asset, checksum, host);
458
1055
  const pluginRoot = join(scratch, "plugin");
459
1056
  await extractArchive(asset, pluginRoot);
460
- const checkedFiles = await verifyExtractedPackage(pluginRoot);
461
- return { scratch, pluginRoot, checkedFiles };
1057
+ const checkedFiles = await verifyExtractedPackage(pluginRoot, host);
1058
+ return { scratch, pluginRoot, checkedFiles, host };
462
1059
  } catch (error) {
463
1060
  await rm(scratch, { recursive: true, force: true });
464
1061
  throw error;
465
1062
  }
466
1063
  }
467
1064
 
468
- function expectedMarketplace() {
1065
+ async function prepareVerifiedPackages(options, hosts) {
1066
+ const settled = await Promise.allSettled(
1067
+ hosts.map((host) => prepareVerifiedPackage(options, host)),
1068
+ );
1069
+ const prepared = settled
1070
+ .filter((result) => result.status === "fulfilled")
1071
+ .map((result) => result.value);
1072
+ const failure = settled.find((result) => result.status === "rejected");
1073
+ if (failure) {
1074
+ await Promise.all(
1075
+ prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
1076
+ );
1077
+ throw failure.reason;
1078
+ }
1079
+ return prepared;
1080
+ }
1081
+
1082
+ function expectedMarketplace(host) {
1083
+ if (host === "claude") {
1084
+ return {
1085
+ name: MARKETPLACE_NAME,
1086
+ owner: { name: "Parker Hwang" },
1087
+ metadata: {
1088
+ description: "OpenSocrates reasoning support for Claude Code and Cowork",
1089
+ version: PRODUCT_VERSION,
1090
+ },
1091
+ plugins: [
1092
+ {
1093
+ name: PLUGIN_NAME,
1094
+ source: `./plugins/${PLUGIN_NAME}`,
1095
+ description:
1096
+ "Local reasoning-system selection for Claude Code and Cowork, plus one /opensocrates entry.",
1097
+ category: "workflow",
1098
+ },
1099
+ ],
1100
+ };
1101
+ }
469
1102
  return {
470
1103
  name: MARKETPLACE_NAME,
471
1104
  interface: { displayName: "OpenSocrates" },
@@ -486,23 +1119,24 @@ function expectedMarketplace() {
486
1119
  };
487
1120
  }
488
1121
 
489
- async function buildStagingTree(parent, pluginSource) {
1122
+ async function buildStagingTree(parent, pluginSource, host) {
1123
+ const layout = HOST_LAYOUTS[host];
490
1124
  const staging = await mkdtemp(join(parent, ".opensocrates.staging-"));
491
1125
  try {
492
- const marketplace = join(staging, MARKETPLACE_RELATIVE);
493
- const plugin = join(staging, PLUGIN_RELATIVE);
1126
+ const marketplace = join(staging, layout.marketplaceRelative);
1127
+ const plugin = join(staging, layout.pluginRelative);
494
1128
  await mkdir(dirname(marketplace), { recursive: true, mode: 0o700 });
495
1129
  await mkdir(dirname(plugin), { recursive: true, mode: 0o700 });
496
1130
  await cp(pluginSource, plugin, { recursive: true, preserveTimestamps: true });
497
- await writeFile(marketplace, `${JSON.stringify(expectedMarketplace(), null, 2)}\n`, {
1131
+ await writeFile(marketplace, `${JSON.stringify(expectedMarketplace(host), null, 2)}\n`, {
498
1132
  encoding: "utf8",
499
1133
  mode: 0o600,
500
1134
  });
501
- await writeFile(join(staging, MARKER_NAME), `${JSON.stringify(MARKER, null, 2)}\n`, {
1135
+ await writeFile(join(staging, MARKER_NAME), `${JSON.stringify(markerFor(host), null, 2)}\n`, {
502
1136
  encoding: "utf8",
503
1137
  mode: 0o600,
504
1138
  });
505
- await verifyExtractedPackage(plugin);
1139
+ await verifyExtractedPackage(plugin, host);
506
1140
  return staging;
507
1141
  } catch (error) {
508
1142
  await rm(staging, { recursive: true, force: true });
@@ -510,17 +1144,38 @@ async function buildStagingTree(parent, pluginSource) {
510
1144
  }
511
1145
  }
512
1146
 
513
- function entryRoot(entry) {
1147
+ function entryRoot(entry, host) {
514
1148
  if (entry === null) {
515
1149
  return null;
516
1150
  }
517
- if (typeof entry.root !== "string" || entry.root.trim().length === 0) {
518
- fail(`Codex marketplace ${MARKETPLACE_NAME} has no usable root`);
1151
+ const value = host === "claude" ? entry.path ?? entry.installLocation : entry.root;
1152
+ if (typeof value !== "string" || value.trim().length === 0) {
1153
+ fail(`${host} marketplace ${MARKETPLACE_NAME} has no usable root`);
1154
+ }
1155
+ const resolved = resolve(value);
1156
+ try {
1157
+ // Host CLIs may report an equivalent canonical path even when the user
1158
+ // configured a symlinked home (for example /var versus /private/var on
1159
+ // macOS). Compare canonical existing paths without weakening ownership
1160
+ // checks for missing or unmanaged roots.
1161
+ return realpathSync(resolved);
1162
+ } catch {
1163
+ return resolved;
519
1164
  }
520
- return resolve(entry.root);
521
1165
  }
522
1166
 
523
- function removeRegistration(entry, state) {
1167
+ function removeRegistration(host, entry, state) {
1168
+ if (host === "claude") {
1169
+ if (state.kind === "installed") {
1170
+ run(claudeBinary(), ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]);
1171
+ }
1172
+ if (entry !== null) {
1173
+ run(claudeBinary(), [
1174
+ "plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user",
1175
+ ]);
1176
+ }
1177
+ return;
1178
+ }
524
1179
  if (state.kind === "installed") {
525
1180
  runCodexJson(["plugin", "remove", PLUGIN_ID, "--json"]);
526
1181
  }
@@ -529,7 +1184,14 @@ function removeRegistration(entry, state) {
529
1184
  }
530
1185
  }
531
1186
 
532
- function addRegistration(root, installPlugin) {
1187
+ function addRegistration(host, root, installPlugin) {
1188
+ if (host === "claude") {
1189
+ run(claudeBinary(), ["plugin", "marketplace", "add", root, "--scope", "user"]);
1190
+ if (installPlugin) {
1191
+ run(claudeBinary(), ["plugin", "install", PLUGIN_ID, "--scope", "user"]);
1192
+ }
1193
+ return null;
1194
+ }
533
1195
  runCodexJson(["plugin", "marketplace", "add", root, "--json"]);
534
1196
  if (installPlugin) {
535
1197
  return runCodexJson(["plugin", "add", PLUGIN_ID, "--json"]);
@@ -537,10 +1199,10 @@ function addRegistration(root, installPlugin) {
537
1199
  return null;
538
1200
  }
539
1201
 
540
- function removeRegistrationBestEffort() {
1202
+ function removeRegistrationBestEffort(host) {
541
1203
  const entry = (() => {
542
1204
  try {
543
- return marketplaceEntry();
1205
+ return marketplaceEntry(host);
544
1206
  } catch {
545
1207
  return null;
546
1208
  }
@@ -549,174 +1211,963 @@ function removeRegistrationBestEffort() {
549
1211
  return;
550
1212
  }
551
1213
  try {
552
- const state = pluginState();
1214
+ const state = pluginState(host);
553
1215
  if (state.kind === "installed") {
554
- run(codexBinary(), ["plugin", "remove", PLUGIN_ID, "--json"], { allowFailure: true });
1216
+ const binary = host === "claude" ? claudeBinary() : codexBinary();
1217
+ const args =
1218
+ host === "claude"
1219
+ ? ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]
1220
+ : ["plugin", "remove", PLUGIN_ID, "--json"];
1221
+ run(binary, args, { allowFailure: true });
555
1222
  }
556
1223
  } catch {
557
1224
  // Rollback continues with marketplace cleanup.
558
1225
  }
559
- run(codexBinary(), ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--json"], {
560
- allowFailure: true,
561
- });
1226
+ const binary = host === "claude" ? claudeBinary() : codexBinary();
1227
+ const args =
1228
+ host === "claude"
1229
+ ? ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user"]
1230
+ : ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--json"];
1231
+ run(binary, args, { allowFailure: true });
562
1232
  }
563
1233
 
564
- async function installVerifiedPackage(pluginSource, action) {
565
- requireCodex();
566
- const paths = managedPaths();
567
- await mkdir(paths.parent, { recursive: true, mode: 0o700 });
1234
+ async function recoveryStep(label, action) {
1235
+ try {
1236
+ await action();
1237
+ return true;
1238
+ } catch (recoveryError) {
1239
+ const detail =
1240
+ recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
1241
+ console.error(`warning: rollback step failed (${label}): ${detail}`);
1242
+ return false;
1243
+ }
1244
+ }
568
1245
 
569
- const previousEntry = marketplaceEntry();
570
- if (previousEntry !== null && entryRoot(previousEntry) !== paths.root) {
1246
+ async function preflightHost(host, action) {
1247
+ requireHostCli(host, { authenticated: ["install", "update"].includes(action) });
1248
+ if (host === "claude") {
1249
+ if (["install", "update"].includes(action)) {
1250
+ requireNoLegacyClaudeInstallation();
1251
+ } else {
1252
+ warnLegacyClaudeInstallation();
1253
+ }
1254
+ }
1255
+ const paths = managedPaths(host);
1256
+ const previousEntry = marketplaceEntry(host);
1257
+ if (previousEntry !== null && entryRoot(previousEntry, host) !== paths.root) {
571
1258
  fail(
572
- `marketplace ${MARKETPLACE_NAME} is already registered at ${entryRoot(previousEntry)}; ` +
1259
+ `marketplace ${MARKETPLACE_NAME} is already registered at ${entryRoot(previousEntry, host)}; ` +
573
1260
  "refusing to overwrite an unmanaged location",
574
1261
  );
575
1262
  }
576
- const previousState = previousEntry === null ? { kind: "missing", version: null } : pluginState();
1263
+ const rootExists = await exists(paths.root);
1264
+ if (rootExists) {
1265
+ await requireOwnedRoot(paths.root, host);
1266
+ }
1267
+ if (previousEntry !== null && !rootExists) {
1268
+ fail(`${host} has a managed registration whose root is missing: ${paths.root}`);
1269
+ }
1270
+ const previousState =
1271
+ previousEntry === null ? { kind: "missing", version: null } : pluginState(host);
1272
+ return { host, paths, previousEntry, previousState, rootExists };
1273
+ }
1274
+
1275
+ async function preflightSelectedHosts(options, action, desiredState) {
1276
+ if (options.host !== ALL_HOST) {
1277
+ return [await preflightHost(options.host, action)];
1278
+ }
1279
+ const desiredHosts = new Set(desiredState.installedHosts);
1280
+ const rootPresence = Object.fromEntries(
1281
+ await Promise.all(
1282
+ SUPPORTED_HOSTS.map(async (host) => [host, await exists(managedPaths(host).root)]),
1283
+ ),
1284
+ );
1285
+ const settled = await Promise.allSettled(
1286
+ SUPPORTED_HOSTS.map((host) => preflightHost(host, action)),
1287
+ );
1288
+ const successes = new Map();
1289
+ const failures = new Map();
1290
+ settled.forEach((result, index) => {
1291
+ const host = SUPPORTED_HOSTS[index];
1292
+ if (result.status === "fulfilled") successes.set(host, result.value);
1293
+ else failures.set(host, result.reason);
1294
+ });
1295
+
1296
+ const required = new Set();
1297
+ if (action === "install") {
1298
+ for (const host of SUPPORTED_HOSTS) {
1299
+ if (desiredHosts.has(host) || rootPresence[host]) required.add(host);
1300
+ }
1301
+ } else if (desiredHosts.size > 0) {
1302
+ for (const host of desiredHosts) required.add(host);
1303
+ } else if (action === "remove") {
1304
+ for (const host of SUPPORTED_HOSTS) {
1305
+ if (rootPresence[host]) required.add(host);
1306
+ }
1307
+ }
1308
+ for (const host of required) {
1309
+ if (failures.has(host)) {
1310
+ const reason = failures.get(host);
1311
+ fail(`preflight failed for ${host}: ${reason instanceof Error ? reason.message : reason}`);
1312
+ }
1313
+ }
1314
+
1315
+ let selected;
1316
+ if (action === "update" && desiredHosts.size > 0) {
1317
+ selected = [...desiredHosts].map((host) => successes.get(host)).filter(Boolean);
1318
+ } else if (action === "remove") {
1319
+ selected = [...successes.values()].filter(
1320
+ (item) => desiredHosts.has(item.host) || item.rootExists || item.previousEntry !== null,
1321
+ );
1322
+ } else {
1323
+ selected = [...successes.values()];
1324
+ }
1325
+ for (const [host, reason] of failures) {
1326
+ if (!required.has(host)) {
1327
+ console.log(
1328
+ `${host}: skipped (host is unavailable or not authenticated: ` +
1329
+ `${reason instanceof Error ? reason.message : reason})`,
1330
+ );
1331
+ }
1332
+ }
1333
+ if (selected.length === 0 && action !== "remove") {
1334
+ fail(`preflight found no ready hosts for ${action} --host all`);
1335
+ }
1336
+ return selected.sort((left, right) => left.host.localeCompare(right.host));
1337
+ }
1338
+
1339
+ async function stageInstallation(preflight, pluginSource) {
1340
+ const { host, paths } = preflight;
1341
+ await mkdir(paths.parent, { recursive: true, mode: 0o700 });
1342
+ const staging = await buildStagingTree(paths.parent, pluginSource, host);
1343
+ return {
1344
+ ...preflight,
1345
+ staging,
1346
+ backup: join(paths.parent, `.opensocrates.backup-${randomUUID()}`),
1347
+ registrationRemoved: false,
1348
+ backupCreated: false,
1349
+ newRootActive: false,
1350
+ activationStarted: false,
1351
+ };
1352
+ }
1353
+
1354
+ async function activateInstallation(transaction) {
1355
+ const { host, paths, previousEntry, previousState } = transaction;
1356
+ transaction.activationStarted = true;
1357
+ if (previousEntry !== null) {
1358
+ transaction.registrationRemoved = true;
1359
+ removeRegistration(host, previousEntry, previousState);
1360
+ }
577
1361
  if (await exists(paths.root)) {
578
- await requireOwnedRoot(paths.root);
1362
+ await rename(paths.root, transaction.backup);
1363
+ transaction.backupCreated = true;
1364
+ }
1365
+ await rename(transaction.staging, paths.root);
1366
+ transaction.newRootActive = true;
1367
+ const result = addRegistration(host, paths.root, true);
1368
+ const state = pluginState(host);
1369
+ if (
1370
+ (host === "codex" &&
1371
+ (result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
1372
+ state.kind !== "installed" ||
1373
+ state.version !== PRODUCT_VERSION
1374
+ ) {
1375
+ fail(`${host} did not confirm the expected installed plugin and version`);
1376
+ }
1377
+ }
1378
+
1379
+ async function rollbackInstallation(transaction) {
1380
+ if (!transaction.activationStarted) return;
1381
+ const { host, paths, previousEntry, previousState } = transaction;
1382
+ let restored = false;
1383
+ await recoveryStep(`unregister the failed ${host} installation`, async () => {
1384
+ removeRegistrationBestEffort(host);
1385
+ });
1386
+ if (transaction.newRootActive && (await exists(paths.root))) {
1387
+ await recoveryStep(`remove the failed ${host} installation root`, async () => {
1388
+ // This root was created by this transaction, so deleting it cannot
1389
+ // cross the ownership boundary even if a failed host command damaged
1390
+ // its marker.
1391
+ await rm(paths.root, { recursive: true, force: true });
1392
+ });
1393
+ }
1394
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1395
+ restored = await recoveryStep(`restore the previous ${host} installation`, async () => {
1396
+ await requireOwnedRoot(transaction.backup, host);
1397
+ await rename(transaction.backup, paths.root);
1398
+ });
1399
+ }
1400
+ if (
1401
+ transaction.registrationRemoved &&
1402
+ previousEntry !== null &&
1403
+ (restored || !transaction.backupCreated)
1404
+ ) {
1405
+ await recoveryStep(`re-register the previous ${host} installation`, async () => {
1406
+ addRegistration(host, paths.root, previousState.kind === "installed");
1407
+ });
1408
+ }
1409
+ if (transaction.backupCreated && !restored) {
1410
+ console.error(
1411
+ `error: the previous ${host} OpenSocrates installation could not be restored automatically.`,
1412
+ );
1413
+ console.error(`error: your previous files are preserved at: ${transaction.backup}`);
1414
+ console.error(
1415
+ `error: remove ${paths.root} if it still exists, move the preserved directory ` +
1416
+ `to ${paths.root}, then rerun \`opensocrates install --host ${host}\`.`,
1417
+ );
1418
+ }
1419
+ }
1420
+
1421
+ async function cleanupInstallationTransaction(transaction) {
1422
+ if (await exists(transaction.staging)) {
1423
+ await rm(transaction.staging, { recursive: true, force: true });
1424
+ }
1425
+ }
1426
+
1427
+ async function commitInstallation(transaction) {
1428
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1429
+ await requireOwnedRoot(transaction.backup, transaction.host);
1430
+ await rm(transaction.backup, { recursive: true });
1431
+ transaction.backupCreated = false;
579
1432
  }
1433
+ }
580
1434
 
581
- const staging = await buildStagingTree(paths.parent, pluginSource);
582
- const backup = join(paths.parent, `.opensocrates.backup-${randomUUID()}`);
583
- let registrationRemoved = false;
584
- let backupCreated = false;
585
- let newRootActive = false;
1435
+ function installedDesiredState(previous, hosts) {
1436
+ return {
1437
+ ...previous,
1438
+ installedHosts: [...new Set([...previous.installedHosts, ...hosts])].sort(),
1439
+ activeVersion: PRODUCT_VERSION,
1440
+ availableVersion: PRODUCT_VERSION,
1441
+ };
1442
+ }
1443
+
1444
+ async function runInstallOrUpdate(options, action) {
1445
+ const desired = await readDesiredState();
1446
+ const preflights = await preflightSelectedHosts(options, action, desired);
1447
+ const hosts = preflights.map((item) => item.host);
1448
+ const prepared = await prepareVerifiedPackages(options, hosts);
1449
+ const packages = new Map(prepared.map((item) => [item.host, item]));
1450
+ const transactions = [];
586
1451
  try {
587
- if (previousEntry !== null) {
588
- registrationRemoved = true;
589
- removeRegistration(previousEntry, previousState);
590
- }
591
- if (await exists(paths.root)) {
592
- await rename(paths.root, backup);
593
- backupCreated = true;
594
- }
595
- await rename(staging, paths.root);
596
- newRootActive = true;
597
- const result = addRegistration(paths.root, true);
598
- const state = pluginState();
599
- if (
600
- result?.pluginId !== PLUGIN_ID ||
601
- result?.version !== PRODUCT_VERSION ||
602
- state.kind !== "installed" ||
603
- state.version !== PRODUCT_VERSION
604
- ) {
605
- fail("Codex did not confirm the expected installed plugin and version");
606
- }
607
- if (backupCreated) {
608
- await requireOwnedRoot(backup);
609
- await rm(backup, { recursive: true });
610
- }
611
- const verb = action === "update" ? "updated" : "installed";
612
- console.log(`OpenSocrates ${PRODUCT_VERSION} ${verb} successfully.`);
613
- console.log(`Managed marketplace: ${paths.root}`);
614
- console.log("Start a new Codex task to load the updated skills and hooks.");
615
- } catch (error) {
616
- removeRegistrationBestEffort();
617
- if (newRootActive && (await exists(paths.root))) {
618
- await requireOwnedRoot(paths.root);
619
- await rm(paths.root, { recursive: true });
1452
+ for (const preflight of preflights) {
1453
+ const item = packages.get(preflight.host);
1454
+ console.log(
1455
+ `${preflight.host}: verified OpenSocrates ${PRODUCT_VERSION} and ` +
1456
+ `${item.checkedFiles} package files`,
1457
+ );
1458
+ transactions.push(await stageInstallation(preflight, item.pluginRoot));
620
1459
  }
621
- if (backupCreated && (await exists(backup))) {
622
- await requireOwnedRoot(backup);
623
- await rename(backup, paths.root);
1460
+ for (const transaction of transactions) {
1461
+ await activateInstallation(transaction);
1462
+ console.log(`${transaction.host}: activated OpenSocrates ${PRODUCT_VERSION}`);
624
1463
  }
625
- if (registrationRemoved && previousEntry !== null) {
626
- try {
627
- addRegistration(paths.root, previousState.kind === "installed");
628
- } catch (rollbackError) {
629
- fail(`${error.message}; rollback also failed: ${rollbackError.message}`);
630
- }
1464
+ await writeDesiredState(installedDesiredState(desired, hosts));
1465
+ } catch (error) {
1466
+ for (const transaction of [...transactions].reverse()) {
1467
+ await rollbackInstallation(transaction);
631
1468
  }
632
1469
  throw error;
633
1470
  } finally {
634
- if (await exists(staging)) {
635
- await rm(staging, { recursive: true, force: true });
1471
+ await Promise.all(transactions.map(cleanupInstallationTransaction));
1472
+ await Promise.all(
1473
+ prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
1474
+ );
1475
+ }
1476
+ for (const transaction of transactions) {
1477
+ const cleaned = await recoveryStep(`remove the committed ${transaction.host} backup`, async () => {
1478
+ await commitInstallation(transaction);
1479
+ });
1480
+ if (!cleaned) {
1481
+ console.warn(`warning: ${transaction.host} is active, but its previous backup needs cleanup`);
636
1482
  }
637
1483
  }
1484
+ const verb = action === "update" ? "updated" : "installed";
1485
+ console.log(
1486
+ `OpenSocrates ${PRODUCT_VERSION} ${verb} successfully for ${hosts.join(", ")}.`,
1487
+ );
1488
+ console.log("Start new host tasks to load the updated skills and hooks.");
1489
+ return hosts;
638
1490
  }
639
1491
 
640
- async function showStatus() {
641
- requireCodex();
642
- const paths = managedPaths();
643
- const entry = marketplaceEntry();
1492
+ async function inspectHostStatus(host) {
1493
+ requireHostCli(host);
1494
+ if (host === "claude") warnLegacyClaudeInstallation();
1495
+ const paths = managedPaths(host);
1496
+ const entry = marketplaceEntry(host);
644
1497
  if (entry === null) {
645
1498
  if (await exists(paths.root)) {
646
- await requireOwnedRoot(paths.root);
647
- console.log(`OpenSocrates files are present but not registered: ${paths.root}`);
648
- } else {
649
- console.log("OpenSocrates is not installed.");
1499
+ await requireOwnedRoot(paths.root, host);
1500
+ return { host, kind: "files-only", version: null, paths };
650
1501
  }
651
- return;
1502
+ return { host, kind: "missing", version: null, paths };
652
1503
  }
653
- if (entryRoot(entry) !== paths.root) {
654
- fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry)}`);
1504
+ if (entryRoot(entry, host) !== paths.root) {
1505
+ fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry, host)}`);
655
1506
  }
656
- await requireOwnedRoot(paths.root);
657
- const state = pluginState();
658
- if (state.kind === "installed") {
659
- console.log(`OpenSocrates ${state.version ?? "unknown"} is installed.`);
660
- } else if (state.kind === "available") {
661
- console.log(`OpenSocrates ${state.version ?? "unknown"} is available but not installed.`);
1507
+ await requireOwnedRoot(paths.root, host);
1508
+ const state = pluginState(host);
1509
+ return { host, kind: state.kind, version: state.version, paths };
1510
+ }
1511
+
1512
+ async function showStatus(host) {
1513
+ if (host === ALL_HOST) {
1514
+ const desired = await readDesiredState();
1515
+ console.log(`Desired version: ${desired.activeVersion ?? "none"}`);
1516
+ console.log(`Available version: ${desired.availableVersion ?? "unknown"}`);
1517
+ console.log(`Last check: ${desired.lastCheckAt ?? "never"}`);
1518
+ console.log(`Last successful update: ${desired.lastSuccessfulUpdateAt ?? "never"}`);
1519
+ console.log(`Auto-update: ${desired.autoUpdate.enabled ? "enabled" : "disabled"}`);
1520
+ let drift = false;
1521
+ for (const candidate of SUPPORTED_HOSTS) {
1522
+ try {
1523
+ const status = await inspectHostStatus(candidate);
1524
+ const expected = desired.installedHosts.includes(candidate);
1525
+ const hostDrift = expected
1526
+ ? status.kind !== "installed" ||
1527
+ desired.activeVersion === null ||
1528
+ status.version !== desired.activeVersion
1529
+ : status.kind !== "missing";
1530
+ drift ||= hostDrift;
1531
+ if (status.kind === "installed") {
1532
+ console.log(
1533
+ `${candidate}: installed ${status.version ?? "unknown"}` +
1534
+ (expected
1535
+ ? hostDrift
1536
+ ? ` (drift from ${desired.activeVersion ?? "desired state"})`
1537
+ : " (in sync)"
1538
+ : " (not in desired state)"),
1539
+ );
1540
+ } else if (status.kind === "available") {
1541
+ console.log(
1542
+ `${candidate}: available but not installed (${status.version ?? "unknown"})` +
1543
+ (expected ? " (drift: desired host is not active)" : ""),
1544
+ );
1545
+ } else if (status.kind === "files-only") {
1546
+ console.log(`${candidate}: managed files present but not registered`);
1547
+ } else {
1548
+ console.log(
1549
+ `${candidate}: not installed` + (expected ? " (drift: desired host is missing)" : ""),
1550
+ );
1551
+ }
1552
+ } catch (error) {
1553
+ if (desired.installedHosts.includes(candidate)) drift = true;
1554
+ console.log(
1555
+ `${candidate}: unavailable (${errorCategory(error)})` +
1556
+ (desired.installedHosts.includes(candidate) ? " (drift unknown)" : ""),
1557
+ );
1558
+ }
1559
+ }
1560
+ console.log(`Overall: ${drift ? "drift detected" : "no detected drift"}`);
1561
+ return;
1562
+ }
1563
+ const status = await inspectHostStatus(host);
1564
+ if (status.kind === "installed") {
1565
+ console.log(`OpenSocrates ${status.version ?? "unknown"} is installed.`);
1566
+ } else if (status.kind === "available") {
1567
+ console.log(`OpenSocrates ${status.version ?? "unknown"} is available but not installed.`);
1568
+ } else if (status.kind === "files-only") {
1569
+ console.log(`OpenSocrates files are present but not registered: ${status.paths.root}`);
662
1570
  } else {
663
- console.log("The OpenSocrates marketplace is registered, but the plugin is missing.");
1571
+ console.log("OpenSocrates is not installed.");
664
1572
  }
665
1573
  }
666
1574
 
667
- async function removeInstalled() {
668
- requireCodex();
669
- const paths = managedPaths();
670
- const entry = marketplaceEntry();
671
- if (entry !== null && entryRoot(entry) !== paths.root) {
672
- fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry)}`);
1575
+ function removalTransaction(preflight) {
1576
+ return {
1577
+ ...preflight,
1578
+ backup: join(preflight.paths.parent, `.opensocrates.removed-${randomUUID()}`),
1579
+ registrationRemoved: false,
1580
+ backupCreated: false,
1581
+ };
1582
+ }
1583
+
1584
+ async function activateRemoval(transaction) {
1585
+ const { host, previousEntry, previousState, paths } = transaction;
1586
+ if (previousEntry !== null) {
1587
+ transaction.registrationRemoved = true;
1588
+ removeRegistration(host, previousEntry, previousState);
673
1589
  }
674
- const state = entry === null ? { kind: "missing", version: null } : pluginState();
675
- removeRegistration(entry, state);
676
1590
  if (await exists(paths.root)) {
677
- await requireOwnedRoot(paths.root);
678
- await rm(paths.root, { recursive: true });
1591
+ await rename(paths.root, transaction.backup);
1592
+ transaction.backupCreated = true;
679
1593
  }
680
- console.log("OpenSocrates was removed from Codex.");
681
1594
  }
682
1595
 
683
- export async function main(argv = process.argv.slice(2)) {
684
- const options = parseCli(argv);
685
- if (options.action === "help") {
686
- showHelp();
687
- return 0;
1596
+ async function rollbackRemoval(transaction) {
1597
+ let restored = !transaction.backupCreated;
1598
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1599
+ restored = await recoveryStep(`restore the removed ${transaction.host} files`, async () => {
1600
+ await requireOwnedRoot(transaction.backup, transaction.host);
1601
+ await rename(transaction.backup, transaction.paths.root);
1602
+ });
688
1603
  }
689
- if (options.action === "status") {
690
- await showStatus();
691
- return 0;
1604
+ if (transaction.registrationRemoved && transaction.previousEntry !== null && restored) {
1605
+ await recoveryStep(`restore the removed ${transaction.host} registration`, async () => {
1606
+ addRegistration(
1607
+ transaction.host,
1608
+ transaction.paths.root,
1609
+ transaction.previousState.kind === "installed",
1610
+ );
1611
+ });
692
1612
  }
693
- if (options.action === "remove") {
694
- await removeInstalled();
695
- return 0;
1613
+ }
1614
+
1615
+ async function commitRemoval(transaction) {
1616
+ if (transaction.backupCreated && (await exists(transaction.backup))) {
1617
+ await requireOwnedRoot(transaction.backup, transaction.host);
1618
+ await rm(transaction.backup, { recursive: true });
1619
+ }
1620
+ }
1621
+
1622
+ function xmlEscape(value) {
1623
+ return String(value)
1624
+ .replaceAll("&", "&amp;")
1625
+ .replaceAll("<", "&lt;")
1626
+ .replaceAll(">", "&gt;")
1627
+ .replaceAll('"', "&quot;")
1628
+ .replaceAll("'", "&apos;");
1629
+ }
1630
+
1631
+ async function executablePath(name, environmentOverride) {
1632
+ const override = process.env[environmentOverride];
1633
+ if (override) {
1634
+ const candidate = resolve(override);
1635
+ await access(candidate, fsConstants.X_OK);
1636
+ return candidate;
1637
+ }
1638
+ const which = run("/usr/bin/which", [name], { allowFailure: true });
1639
+ const candidate = which.status === 0 ? which.stdout.trim() : "";
1640
+ if (!candidate || !candidate.startsWith("/")) {
1641
+ fail(`could not find an executable ${name} for the automatic updater`);
1642
+ }
1643
+ await access(candidate, fsConstants.X_OK);
1644
+ return candidate;
1645
+ }
1646
+
1647
+ function launchctlBinary() {
1648
+ return process.env.OPENSOCRATES_LAUNCHCTL_BIN || "/bin/launchctl";
1649
+ }
1650
+
1651
+ function launchctlDomain() {
1652
+ if (typeof process.getuid !== "function") {
1653
+ fail("automatic updates require a user launchd domain");
1654
+ }
1655
+ return `gui/${process.getuid()}`;
1656
+ }
1657
+
1658
+ function launchAgentDocument(npx, channel, environment) {
1659
+ const packageTag = channel === "next" ? "next" : "latest";
1660
+ const arguments_ = [
1661
+ npx,
1662
+ "--yes",
1663
+ `opensocrates@${packageTag}`,
1664
+ "auto-update",
1665
+ "run",
1666
+ ];
1667
+ const argumentsXml = arguments_
1668
+ .map((argument) => ` <string>${xmlEscape(argument)}</string>`)
1669
+ .join("\n");
1670
+ const environmentEntries = Object.entries(environment).sort(([left], [right]) =>
1671
+ left.localeCompare(right),
1672
+ );
1673
+ const environmentXml =
1674
+ environmentEntries.length === 0
1675
+ ? ""
1676
+ : `\n <key>EnvironmentVariables</key>\n <dict>\n` +
1677
+ environmentEntries
1678
+ .map(
1679
+ ([key, value]) =>
1680
+ ` <key>${xmlEscape(key)}</key>\n` +
1681
+ ` <string>${xmlEscape(value)}</string>`,
1682
+ )
1683
+ .join("\n") +
1684
+ "\n </dict>";
1685
+ return `<?xml version="1.0" encoding="UTF-8"?>
1686
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1687
+ <plist version="1.0">
1688
+ <dict>
1689
+ <key>Label</key>
1690
+ <string>${AUTO_UPDATE_LABEL}</string>
1691
+ <key>ProgramArguments</key>
1692
+ <array>
1693
+ ${argumentsXml}
1694
+ </array>
1695
+ <key>RunAtLoad</key>
1696
+ <true/>
1697
+ <key>StartInterval</key>
1698
+ <integer>${AUTO_UPDATE_POLL_SECONDS}</integer>
1699
+ <key>ProcessType</key>
1700
+ <string>Background</string>
1701
+ <key>StandardOutPath</key>
1702
+ <string>/dev/null</string>
1703
+ <key>StandardErrorPath</key>
1704
+ <string>/dev/null</string>${environmentXml}
1705
+ </dict>
1706
+ </plist>
1707
+ `;
1708
+ }
1709
+
1710
+ function launchAgentTarget() {
1711
+ return `${launchctlDomain()}/${AUTO_UPDATE_LABEL}`;
1712
+ }
1713
+
1714
+ function launchAgentLoaded() {
1715
+ return run(launchctlBinary(), ["print", launchAgentTarget()], { allowFailure: true }).status === 0;
1716
+ }
1717
+
1718
+ function stopLoadedLaunchAgent() {
1719
+ if (!launchAgentLoaded()) return;
1720
+ const stopped = run(launchctlBinary(), ["bootout", launchAgentTarget()], {
1721
+ allowFailure: true,
1722
+ });
1723
+ if (stopped.status !== 0 && launchAgentLoaded()) {
1724
+ const detail = stopped.stderr?.trim() || stopped.stdout?.trim() || `exit ${stopped.status}`;
1725
+ fail(`could not stop the OpenSocrates LaunchAgent: ${detail}`);
1726
+ }
1727
+ }
1728
+
1729
+ async function updaterEnvironment(hosts, npx) {
1730
+ const node = await executablePath("node", "OPENSOCRATES_NODE_BIN");
1731
+ const environment = {
1732
+ PATH: [...new Set([dirname(npx), dirname(node), "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(
1733
+ ":",
1734
+ ),
1735
+ };
1736
+ for (const host of hosts) {
1737
+ const key = host === "claude" ? "CLAUDE_BIN" : "CODEX_BIN";
1738
+ const executable = await executablePath(host, key);
1739
+ environment[key] = executable;
1740
+ environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
1741
+ }
1742
+ for (const key of ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "OPENSOCRATES_STATE_DIR"]) {
1743
+ if (process.env[key]) environment[key] = resolve(process.env[key]);
1744
+ }
1745
+ return environment;
1746
+ }
1747
+
1748
+ async function installLaunchAgent(channel, hosts) {
1749
+ if (process.platform !== "darwin" || process.arch !== "arm64") {
1750
+ fail(
1751
+ `automatic updates currently support darwin-arm64 only; detected ` +
1752
+ `${process.platform}-${process.arch}`,
1753
+ );
1754
+ }
1755
+ const paths = statePaths();
1756
+ const npx = await executablePath("npx", "OPENSOCRATES_NPX_BIN");
1757
+ const environment = await updaterEnvironment(hosts, npx);
1758
+ let previousDocument = null;
1759
+ if (await exists(paths.launchAgent)) {
1760
+ const info = await lstat(paths.launchAgent);
1761
+ if (!info.isFile() || info.isSymbolicLink()) {
1762
+ fail(`refusing to replace an unsafe LaunchAgent path: ${paths.launchAgent}`);
1763
+ }
1764
+ previousDocument = await readFile(paths.launchAgent, "utf8");
1765
+ }
1766
+ const document = launchAgentDocument(npx, channel, environment);
1767
+ if (process.env.OPENSOCRATES_SKIP_LAUNCHCTL === "1") {
1768
+ await atomicWritePrivateFile(paths.launchAgent, document);
1769
+ return;
1770
+ }
1771
+ const wasLoaded = launchAgentLoaded();
1772
+ if (wasLoaded) stopLoadedLaunchAgent();
1773
+ await atomicWritePrivateFile(paths.launchAgent, document);
1774
+ const launched = run(
1775
+ launchctlBinary(),
1776
+ ["bootstrap", launchctlDomain(), paths.launchAgent],
1777
+ { allowFailure: true },
1778
+ );
1779
+ if (launched.status !== 0) {
1780
+ if (previousDocument === null) {
1781
+ await rm(paths.launchAgent, { force: true });
1782
+ } else {
1783
+ await atomicWritePrivateFile(paths.launchAgent, previousDocument);
1784
+ if (wasLoaded) {
1785
+ const restored = run(
1786
+ launchctlBinary(),
1787
+ ["bootstrap", launchctlDomain(), paths.launchAgent],
1788
+ { allowFailure: true },
1789
+ );
1790
+ if (restored.status !== 0) {
1791
+ console.error("warning: the previous OpenSocrates LaunchAgent could not be reloaded");
1792
+ }
1793
+ }
1794
+ }
1795
+ const detail = launched.stderr?.trim() || launched.stdout?.trim() || `exit ${launched.status}`;
1796
+ fail(`could not enable the OpenSocrates LaunchAgent: ${detail}`);
1797
+ }
1798
+ }
1799
+
1800
+ async function disableLaunchAgent() {
1801
+ const paths = statePaths();
1802
+ const agentPresent = await exists(paths.launchAgent);
1803
+ if (agentPresent) {
1804
+ const info = await lstat(paths.launchAgent);
1805
+ if (!info.isFile() || info.isSymbolicLink()) {
1806
+ fail(`refusing to remove an unsafe LaunchAgent path: ${paths.launchAgent}`);
1807
+ }
1808
+ }
1809
+ if (process.env.OPENSOCRATES_SKIP_LAUNCHCTL !== "1") {
1810
+ stopLoadedLaunchAgent();
1811
+ }
1812
+ if (agentPresent) await rm(paths.launchAgent);
1813
+ }
1814
+
1815
+ async function disableAutoUpdateState(desired) {
1816
+ await disableLaunchAgent();
1817
+ return {
1818
+ ...desired,
1819
+ autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1820
+ };
1821
+ }
1822
+
1823
+ async function runRemove(options) {
1824
+ const desired = await readDesiredState();
1825
+ const preflights = await preflightSelectedHosts(options, "remove", desired);
1826
+ const removedHosts = preflights.map((item) => item.host);
1827
+ const remainingHosts = desired.installedHosts.filter((host) => !removedHosts.includes(host));
1828
+ const remainingAutoUpdateHosts = desired.autoUpdate.hosts.filter(
1829
+ (host) => !removedHosts.includes(host),
1830
+ );
1831
+ const keepAutoUpdate = desired.autoUpdate.enabled && remainingAutoUpdateHosts.length > 0;
1832
+ const nextDesired = {
1833
+ ...desired,
1834
+ installedHosts: remainingHosts,
1835
+ activeVersion: remainingHosts.length === 0 ? null : desired.activeVersion,
1836
+ autoUpdate: keepAutoUpdate
1837
+ ? { ...desired.autoUpdate, hosts: remainingAutoUpdateHosts }
1838
+ : { enabled: false, hosts: [], nextCheckAt: null },
1839
+ };
1840
+ const launchAgentPresent = await exists(statePaths().launchAgent);
1841
+ const schedulerNeedsChange =
1842
+ options.host === ALL_HOST ||
1843
+ remainingHosts.length === 0 ||
1844
+ launchAgentPresent !== keepAutoUpdate ||
1845
+ desired.autoUpdate.hosts.some((host) => removedHosts.includes(host));
1846
+ const transactions = preflights.map(removalTransaction);
1847
+ let schedulerTouched = false;
1848
+ try {
1849
+ for (const transaction of transactions) await activateRemoval(transaction);
1850
+ if (schedulerNeedsChange) {
1851
+ schedulerTouched = true;
1852
+ if (keepAutoUpdate) {
1853
+ await installLaunchAgent(desired.channel, remainingAutoUpdateHosts);
1854
+ } else {
1855
+ await disableLaunchAgent();
1856
+ }
1857
+ }
1858
+ await writeDesiredState(nextDesired);
1859
+ } catch (error) {
1860
+ for (const transaction of [...transactions].reverse()) await rollbackRemoval(transaction);
1861
+ if (schedulerTouched) {
1862
+ const schedulerRestored = await recoveryStep(
1863
+ "restore the automatic updater after removal failure",
1864
+ async () => {
1865
+ if (desired.autoUpdate.enabled) {
1866
+ await installLaunchAgent(desired.channel, desired.autoUpdate.hosts);
1867
+ } else {
1868
+ await disableLaunchAgent();
1869
+ }
1870
+ },
1871
+ );
1872
+ if (!schedulerRestored && desired.autoUpdate.enabled) {
1873
+ await recoveryStep("record the disabled updater after removal failure", async () => {
1874
+ await writeDesiredState({
1875
+ ...desired,
1876
+ autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1877
+ });
1878
+ });
1879
+ }
1880
+ } else if (!nextDesired.autoUpdate.enabled && desired.autoUpdate.enabled) {
1881
+ await recoveryStep("record the disabled updater after removal failure", async () => {
1882
+ await writeDesiredState({
1883
+ ...desired,
1884
+ autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1885
+ });
1886
+ });
1887
+ }
1888
+ throw error;
1889
+ }
1890
+ for (const transaction of transactions) await commitRemoval(transaction);
1891
+ for (const host of removedHosts) {
1892
+ console.log(`OpenSocrates was removed from ${host === "claude" ? "Claude" : "Codex"}.`);
1893
+ }
1894
+ if (removedHosts.length === 0) console.log("OpenSocrates is not installed on any managed host.");
1895
+ }
1896
+
1897
+ async function enableAutoUpdate(options) {
1898
+ const previous = await readDesiredState();
1899
+ const preflights = new Map();
1900
+ let installedHosts = [...previous.installedHosts];
1901
+ if (installedHosts.length === 0) {
1902
+ const discoveryOptions = { ...options, host: ALL_HOST };
1903
+ const discovered = await preflightSelectedHosts(discoveryOptions, "update", previous);
1904
+ for (const item of discovered) {
1905
+ if (item.previousState.kind === "installed") {
1906
+ preflights.set(item.host, item);
1907
+ installedHosts.push(item.host);
1908
+ }
1909
+ }
1910
+ }
1911
+ let updateHosts = options.host === ALL_HOST ? [...installedHosts] : [options.host];
1912
+ if (updateHosts.length === 0) {
1913
+ fail("install OpenSocrates on at least one host before enabling automatic updates");
1914
+ }
1915
+ for (const host of updateHosts) {
1916
+ const preflight = preflights.get(host) ?? (await preflightHost(host, "update"));
1917
+ if (preflight.previousState.kind !== "installed") {
1918
+ fail(`OpenSocrates is not installed on ${host}`);
1919
+ }
1920
+ if (typeof preflight.previousState.version !== "string") {
1921
+ fail(`cannot determine the installed OpenSocrates version on ${host}`);
1922
+ }
1923
+ preflights.set(host, preflight);
1924
+ installedHosts.push(host);
1925
+ }
1926
+ installedHosts = [...new Set(installedHosts)].sort();
1927
+ updateHosts = [...new Set(updateHosts)].sort();
1928
+ let activeVersion = previous.activeVersion;
1929
+ if (activeVersion === null) {
1930
+ const observedVersions = new Set();
1931
+ for (const host of installedHosts) {
1932
+ const preflight = preflights.get(host) ?? (await preflightHost(host, "update"));
1933
+ if (
1934
+ preflight.previousState.kind !== "installed" ||
1935
+ typeof preflight.previousState.version !== "string"
1936
+ ) {
1937
+ fail(`cannot determine the installed OpenSocrates version on ${host}`);
1938
+ }
1939
+ preflights.set(host, preflight);
1940
+ observedVersions.add(preflight.previousState.version);
1941
+ }
1942
+ if (observedVersions.size !== 1) {
1943
+ fail(
1944
+ "installed hosts do not share one known version; run update --host all before enabling automatic updates",
1945
+ );
1946
+ }
1947
+ [activeVersion] = observedVersions;
1948
+ }
1949
+ const desired = {
1950
+ ...previous,
1951
+ channel: options.channel,
1952
+ installedHosts,
1953
+ activeVersion,
1954
+ updatePolicy: {
1955
+ intervalHours: options.intervalHours,
1956
+ allowMajor: options.allowMajor,
1957
+ },
1958
+ autoUpdate: {
1959
+ enabled: true,
1960
+ hosts: updateHosts,
1961
+ nextCheckAt: nowIso(),
1962
+ },
1963
+ };
1964
+ await writeDesiredState(desired);
1965
+ try {
1966
+ await installLaunchAgent(options.channel, desired.autoUpdate.hosts);
1967
+ } catch (error) {
1968
+ await writeDesiredState(previous);
1969
+ throw error;
696
1970
  }
1971
+ console.log(
1972
+ `Automatic updates enabled for ${desired.autoUpdate.hosts.join(", ")} on the ` +
1973
+ `${desired.channel} channel every ${desired.updatePolicy.intervalHours} hours with jitter.`,
1974
+ );
1975
+ console.log(
1976
+ desired.updatePolicy.allowMajor
1977
+ ? "Automatic major-version upgrades are enabled."
1978
+ : "Automatic major-version upgrades remain disabled.",
1979
+ );
1980
+ }
1981
+
1982
+ async function showAutoUpdateStatus() {
1983
+ const desired = await readDesiredState();
1984
+ const agentPresent = await exists(statePaths().launchAgent);
1985
+ console.log(`Automatic updates: ${desired.autoUpdate.enabled ? "enabled" : "disabled"}`);
1986
+ console.log(`LaunchAgent: ${agentPresent ? "installed" : "not installed"}`);
1987
+ console.log(`Channel: ${desired.channel}`);
1988
+ console.log(`Installed hosts: ${desired.installedHosts.join(", ") || "none"}`);
1989
+ console.log(`Automatic-update hosts: ${desired.autoUpdate.hosts.join(", ") || "none"}`);
1990
+ console.log(`Interval: ${desired.updatePolicy.intervalHours} hours with jitter`);
1991
+ console.log(`Major upgrades: ${desired.updatePolicy.allowMajor ? "allowed" : "blocked"}`);
1992
+ console.log(`Next check: ${desired.autoUpdate.nextCheckAt ?? "not scheduled"}`);
1993
+ console.log(`Last check: ${desired.lastCheckAt ?? "never"}`);
1994
+ console.log(`Last successful update: ${desired.lastSuccessfulUpdateAt ?? "never"}`);
1995
+ }
1996
+
1997
+ async function disableAutoUpdate() {
1998
+ const desired = await readDesiredState();
1999
+ await writeDesiredState(await disableAutoUpdateState(desired));
2000
+ console.log("Automatic updates are disabled and the LaunchAgent was removed.");
2001
+ }
2002
+
2003
+ async function runScheduledUpdate(options) {
2004
+ let desired = await readDesiredState();
2005
+ if (!desired.autoUpdate.enabled) {
2006
+ console.log("Automatic updates are disabled.");
2007
+ return;
2008
+ }
2009
+ const scheduledAt = Date.parse(desired.autoUpdate.nextCheckAt ?? "");
2010
+ if (!options.force && Number.isFinite(scheduledAt) && scheduledAt > Date.now()) {
2011
+ console.log(`The next automatic update check is scheduled for ${desired.autoUpdate.nextCheckAt}.`);
2012
+ return;
2013
+ }
2014
+ const checkedAt = nowIso();
2015
+ const hosts = desired.autoUpdate.hosts;
2016
+ if (hosts.length === 0) {
2017
+ fail("the automatic updater has no selected hosts in desired state");
2018
+ }
2019
+ const next = nextCheckAt(desired.updatePolicy.intervalHours);
2020
+ const currentMajor = majorVersion(desired.activeVersion);
2021
+ const availableMajor = majorVersion(PRODUCT_VERSION);
697
2022
  if (
698
- ["install", "update"].includes(options.action) &&
699
- (process.platform !== "darwin" || process.arch !== "arm64")
2023
+ !desired.updatePolicy.allowMajor &&
2024
+ currentMajor !== null &&
2025
+ availableMajor !== null &&
2026
+ availableMajor > currentMajor
700
2027
  ) {
2028
+ desired = await writeDesiredState({
2029
+ ...desired,
2030
+ availableVersion: PRODUCT_VERSION,
2031
+ lastCheckAt: checkedAt,
2032
+ autoUpdate: { ...desired.autoUpdate, nextCheckAt: next },
2033
+ });
2034
+ await writeAutoUpdateReceipt({
2035
+ version: PRODUCT_VERSION,
2036
+ checkedAt,
2037
+ hosts: hosts.map((host) => ({ host, result: "blocked-major" })),
2038
+ result: "blocked",
2039
+ errorCategory: "major-policy",
2040
+ });
2041
+ console.log(`OpenSocrates ${PRODUCT_VERSION} is available but blocked by the major-version policy.`);
2042
+ return;
2043
+ }
2044
+ try {
2045
+ const ready = [];
2046
+ for (const host of hosts) ready.push(await preflightHost(host, "update"));
2047
+ const alreadyCurrent =
2048
+ desired.activeVersion === PRODUCT_VERSION &&
2049
+ ready.every(
2050
+ (item) =>
2051
+ item.previousState.kind === "installed" && item.previousState.version === PRODUCT_VERSION,
2052
+ );
2053
+ if (!alreadyCurrent) {
2054
+ if (hosts.length === SUPPORTED_HOSTS.length) {
2055
+ await runInstallOrUpdate({ ...options, host: ALL_HOST }, "update");
2056
+ } else {
2057
+ const [host] = hosts;
2058
+ const local = localAssetInputs(options, host);
2059
+ await runInstallOrUpdate(
2060
+ { ...options, host, asset: local.asset, checksum: local.checksum },
2061
+ "update",
2062
+ );
2063
+ }
2064
+ }
2065
+ const refreshed = await readDesiredState();
2066
+ desired = await writeDesiredState({
2067
+ ...refreshed,
2068
+ availableVersion: PRODUCT_VERSION,
2069
+ lastCheckAt: checkedAt,
2070
+ lastSuccessfulUpdateAt: alreadyCurrent
2071
+ ? refreshed.lastSuccessfulUpdateAt
2072
+ : checkedAt,
2073
+ autoUpdate: { ...refreshed.autoUpdate, nextCheckAt: next },
2074
+ });
2075
+ await writeAutoUpdateReceipt({
2076
+ version: PRODUCT_VERSION,
2077
+ checkedAt,
2078
+ hosts: hosts.map((host) => ({ host, result: alreadyCurrent ? "current" : "updated" })),
2079
+ result: alreadyCurrent ? "no-update" : "updated",
2080
+ errorCategory: null,
2081
+ });
2082
+ console.log(
2083
+ alreadyCurrent
2084
+ ? `OpenSocrates ${PRODUCT_VERSION} is already current on every managed host.`
2085
+ : `OpenSocrates ${PRODUCT_VERSION} was reconciled across ${hosts.join(", ")}.`,
2086
+ );
2087
+ } catch (error) {
2088
+ await recoveryStep("record the failed automatic update", async () => {
2089
+ desired = await writeDesiredState({
2090
+ ...(await readDesiredState()),
2091
+ availableVersion: PRODUCT_VERSION,
2092
+ lastCheckAt: checkedAt,
2093
+ autoUpdate: { ...desired.autoUpdate, nextCheckAt: next },
2094
+ });
2095
+ await writeAutoUpdateReceipt({
2096
+ version: PRODUCT_VERSION,
2097
+ checkedAt,
2098
+ hosts: hosts.map((host) => ({ host, result: "failed" })),
2099
+ result: "failed",
2100
+ errorCategory: errorCategory(error),
2101
+ });
2102
+ });
2103
+ throw error;
2104
+ }
2105
+ }
2106
+
2107
+ function requireSupportedPlatform() {
2108
+ if (process.platform !== "darwin" || process.arch !== "arm64") {
701
2109
  fail(
702
2110
  `OpenSocrates ${PRODUCT_VERSION} prebuilt installation supports darwin-arm64 only; ` +
703
2111
  `detected ${process.platform}-${process.arch}`,
704
2112
  );
705
2113
  }
2114
+ }
706
2115
 
707
- const prepared = await prepareVerifiedPackage(options);
2116
+ async function verifyPackages(options) {
2117
+ const hosts = options.host === ALL_HOST ? SUPPORTED_HOSTS : [options.host];
2118
+ const prepared = await prepareVerifiedPackages(options, hosts);
708
2119
  try {
709
- console.log(
710
- `Verified OpenSocrates ${PRODUCT_VERSION} release and ${prepared.checkedFiles} package files.`,
2120
+ for (const item of prepared) {
2121
+ console.log(
2122
+ `${item.host}: verified OpenSocrates ${PRODUCT_VERSION} release and ` +
2123
+ `${item.checkedFiles} package files.`,
2124
+ );
2125
+ }
2126
+ } finally {
2127
+ await Promise.all(
2128
+ prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
711
2129
  );
712
- if (options.action === "verify") {
2130
+ }
2131
+ }
2132
+
2133
+ export async function main(argv = process.argv.slice(2)) {
2134
+ const options = parseCli(argv);
2135
+ if (options.action === "help") {
2136
+ showHelp();
2137
+ return 0;
2138
+ }
2139
+ if (options.action === "status") {
2140
+ await showStatus(options.host);
2141
+ return 0;
2142
+ }
2143
+ if (options.action === "verify") {
2144
+ await verifyPackages(options);
2145
+ return 0;
2146
+ }
2147
+ if (options.action === "auto-update" && options.autoUpdateAction === "status") {
2148
+ await showAutoUpdateStatus();
2149
+ return 0;
2150
+ }
2151
+ return withOperationLock(async () => {
2152
+ if (options.action === "remove") {
2153
+ await runRemove(options);
2154
+ return 0;
2155
+ }
2156
+ if (options.action === "auto-update") {
2157
+ if (options.autoUpdateAction === "enable") {
2158
+ await enableAutoUpdate(options);
2159
+ } else if (options.autoUpdateAction === "disable") {
2160
+ await disableAutoUpdate();
2161
+ } else {
2162
+ requireSupportedPlatform();
2163
+ await runScheduledUpdate(options);
2164
+ }
713
2165
  return 0;
714
2166
  }
715
- await installVerifiedPackage(prepared.pluginRoot, options.action);
2167
+ requireSupportedPlatform();
2168
+ await runInstallOrUpdate(options, options.action);
716
2169
  return 0;
717
- } finally {
718
- await rm(prepared.scratch, { recursive: true, force: true });
719
- }
2170
+ });
720
2171
  }
721
2172
 
722
2173
  let invokedDirectly = false;