opensocrates 1.1.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.
- package/CHANGELOG.md +78 -0
- package/README.ko.md +121 -38
- package/README.md +130 -39
- package/SECURITY.md +33 -0
- package/VERSION +1 -1
- package/installer/opensocrates.mjs +1347 -160
- package/package.json +2 -2
|
@@ -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.
|
|
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
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
|
|
|
@@ -80,6 +92,273 @@ async function exists(target) {
|
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
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
|
+
|
|
83
362
|
function jsonEqual(left, right) {
|
|
84
363
|
const leftKeys = Object.keys(left).sort();
|
|
85
364
|
const rightKeys = Object.keys(right).sort();
|
|
@@ -139,7 +418,15 @@ export function parseChecksumText(text, expectedName = ASSET_NAME) {
|
|
|
139
418
|
}
|
|
140
419
|
|
|
141
420
|
export function parseCli(argv) {
|
|
142
|
-
const actions = new Set([
|
|
421
|
+
const actions = new Set([
|
|
422
|
+
"install",
|
|
423
|
+
"status",
|
|
424
|
+
"update",
|
|
425
|
+
"remove",
|
|
426
|
+
"verify",
|
|
427
|
+
"auto-update",
|
|
428
|
+
"help",
|
|
429
|
+
]);
|
|
143
430
|
const args = [...argv];
|
|
144
431
|
let action = "install";
|
|
145
432
|
if (args[0] && !args[0].startsWith("-")) {
|
|
@@ -148,7 +435,34 @@ export function parseCli(argv) {
|
|
|
148
435
|
if (!actions.has(action)) {
|
|
149
436
|
fail(`unknown action ${JSON.stringify(action)}`);
|
|
150
437
|
}
|
|
151
|
-
|
|
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();
|
|
152
466
|
while (args.length > 0) {
|
|
153
467
|
const flag = args.shift();
|
|
154
468
|
if (flag === "--help" || flag === "-h") {
|
|
@@ -156,13 +470,60 @@ export function parseCli(argv) {
|
|
|
156
470
|
continue;
|
|
157
471
|
}
|
|
158
472
|
if (flag === "--host") {
|
|
473
|
+
seenOptions.add("host");
|
|
159
474
|
const host = args.shift();
|
|
160
|
-
if (!
|
|
161
|
-
fail(`--host must be one of: ${
|
|
475
|
+
if (!HOST_CHOICES.includes(host)) {
|
|
476
|
+
fail(`--host must be one of: ${HOST_CHOICES.join(", ")}`);
|
|
162
477
|
}
|
|
163
478
|
options.host = host;
|
|
164
479
|
continue;
|
|
165
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
|
+
}
|
|
166
527
|
if (flag !== "--asset" && flag !== "--checksum") {
|
|
167
528
|
fail(`unknown option ${JSON.stringify(flag)}`);
|
|
168
529
|
}
|
|
@@ -171,13 +532,71 @@ export function parseCli(argv) {
|
|
|
171
532
|
fail(`${flag} requires a file path`);
|
|
172
533
|
}
|
|
173
534
|
options[flag === "--asset" ? "asset" : "checksum"] = resolve(value);
|
|
535
|
+
seenOptions.add(flag.slice(2));
|
|
174
536
|
}
|
|
175
537
|
if ((options.asset === null) !== (options.checksum === null)) {
|
|
176
538
|
fail("--asset and --checksum must be supplied together");
|
|
177
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
|
+
}
|
|
178
562
|
if (["status", "remove", "help"].includes(options.action) && options.asset !== null) {
|
|
179
563
|
fail(`${options.action} does not accept --asset or --checksum`);
|
|
180
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
|
+
}
|
|
181
600
|
return options;
|
|
182
601
|
}
|
|
183
602
|
|
|
@@ -185,14 +604,21 @@ function showHelp() {
|
|
|
185
604
|
console.log(`OpenSocrates ${PRODUCT_VERSION}
|
|
186
605
|
|
|
187
606
|
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]
|
|
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
|
|
193
617
|
|
|
194
618
|
Without --asset, install, update, and verify download the v${PRODUCT_VERSION}
|
|
195
|
-
package and checksum from GitHub Releases. The default host is codex.
|
|
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.
|
|
196
622
|
`);
|
|
197
623
|
}
|
|
198
624
|
|
|
@@ -290,7 +716,7 @@ function versionAtLeast(value, minimum) {
|
|
|
290
716
|
return true;
|
|
291
717
|
}
|
|
292
718
|
|
|
293
|
-
function
|
|
719
|
+
function requireHostCli(host, { authenticated = false } = {}) {
|
|
294
720
|
if (host === "claude") {
|
|
295
721
|
const result = run(claudeBinary(), ["--version"]);
|
|
296
722
|
if (!versionAtLeast(result.stdout, [2, 1, 205])) {
|
|
@@ -298,9 +724,15 @@ function requireHost(host) {
|
|
|
298
724
|
`Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`,
|
|
299
725
|
);
|
|
300
726
|
}
|
|
727
|
+
if (authenticated) {
|
|
728
|
+
run(claudeBinary(), ["auth", "status"]);
|
|
729
|
+
}
|
|
301
730
|
return;
|
|
302
731
|
}
|
|
303
732
|
run(codexBinary(), ["--version"]);
|
|
733
|
+
if (authenticated) {
|
|
734
|
+
run(codexBinary(), ["login", "status"]);
|
|
735
|
+
}
|
|
304
736
|
}
|
|
305
737
|
|
|
306
738
|
function marketplaceEntries(host) {
|
|
@@ -465,15 +897,23 @@ async function downloadFile(url, destination) {
|
|
|
465
897
|
}
|
|
466
898
|
}
|
|
467
899
|
|
|
468
|
-
|
|
469
|
-
if (options.
|
|
470
|
-
|
|
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))) {
|
|
471
911
|
fail("the supplied asset or checksum file does not exist");
|
|
472
912
|
}
|
|
473
|
-
return
|
|
913
|
+
return local;
|
|
474
914
|
}
|
|
475
915
|
const base = `https://github.com/${REPOSITORY}/releases/download/v${PRODUCT_VERSION}`;
|
|
476
|
-
const assetName = assetNameFor(
|
|
916
|
+
const assetName = assetNameFor(host);
|
|
477
917
|
const checksumName = `${assetName}.sha256`;
|
|
478
918
|
const asset = join(scratch, assetName);
|
|
479
919
|
const checksum = join(scratch, checksumName);
|
|
@@ -604,21 +1044,41 @@ async function verifyExtractedPackage(pluginRoot, host) {
|
|
|
604
1044
|
return verifyPackageChecksums(pluginRoot);
|
|
605
1045
|
}
|
|
606
1046
|
|
|
607
|
-
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
|
+
}
|
|
608
1051
|
const scratch = await mkdtemp(join(tmpdir(), "opensocrates-install-"));
|
|
609
1052
|
try {
|
|
610
|
-
const { asset, checksum } = await resolveAssetInputs(options, scratch);
|
|
611
|
-
await verifyOuterChecksum(asset, checksum,
|
|
1053
|
+
const { asset, checksum } = await resolveAssetInputs(options, scratch, host);
|
|
1054
|
+
await verifyOuterChecksum(asset, checksum, host);
|
|
612
1055
|
const pluginRoot = join(scratch, "plugin");
|
|
613
1056
|
await extractArchive(asset, pluginRoot);
|
|
614
|
-
const checkedFiles = await verifyExtractedPackage(pluginRoot,
|
|
615
|
-
return { scratch, pluginRoot, checkedFiles };
|
|
1057
|
+
const checkedFiles = await verifyExtractedPackage(pluginRoot, host);
|
|
1058
|
+
return { scratch, pluginRoot, checkedFiles, host };
|
|
616
1059
|
} catch (error) {
|
|
617
1060
|
await rm(scratch, { recursive: true, force: true });
|
|
618
1061
|
throw error;
|
|
619
1062
|
}
|
|
620
1063
|
}
|
|
621
1064
|
|
|
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
|
+
|
|
622
1082
|
function expectedMarketplace(host) {
|
|
623
1083
|
if (host === "claude") {
|
|
624
1084
|
return {
|
|
@@ -633,7 +1093,7 @@ function expectedMarketplace(host) {
|
|
|
633
1093
|
name: PLUGIN_NAME,
|
|
634
1094
|
source: `./plugins/${PLUGIN_NAME}`,
|
|
635
1095
|
description:
|
|
636
|
-
"Local reasoning-system selection for Claude Code and Cowork, plus
|
|
1096
|
+
"Local reasoning-system selection for Claude Code and Cowork, plus one /opensocrates entry.",
|
|
637
1097
|
category: "workflow",
|
|
638
1098
|
},
|
|
639
1099
|
],
|
|
@@ -783,14 +1243,16 @@ async function recoveryStep(label, action) {
|
|
|
783
1243
|
}
|
|
784
1244
|
}
|
|
785
1245
|
|
|
786
|
-
async function
|
|
787
|
-
|
|
1246
|
+
async function preflightHost(host, action) {
|
|
1247
|
+
requireHostCli(host, { authenticated: ["install", "update"].includes(action) });
|
|
788
1248
|
if (host === "claude") {
|
|
789
|
-
|
|
1249
|
+
if (["install", "update"].includes(action)) {
|
|
1250
|
+
requireNoLegacyClaudeInstallation();
|
|
1251
|
+
} else {
|
|
1252
|
+
warnLegacyClaudeInstallation();
|
|
1253
|
+
}
|
|
790
1254
|
}
|
|
791
1255
|
const paths = managedPaths(host);
|
|
792
|
-
await mkdir(paths.parent, { recursive: true, mode: 0o700 });
|
|
793
|
-
|
|
794
1256
|
const previousEntry = marketplaceEntry(host);
|
|
795
1257
|
if (previousEntry !== null && entryRoot(previousEntry, host) !== paths.root) {
|
|
796
1258
|
fail(
|
|
@@ -798,189 +1260,914 @@ async function installVerifiedPackage(pluginSource, action, host) {
|
|
|
798
1260
|
"refusing to overwrite an unmanaged location",
|
|
799
1261
|
);
|
|
800
1262
|
}
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
if (await exists(paths.root)) {
|
|
1263
|
+
const rootExists = await exists(paths.root);
|
|
1264
|
+
if (rootExists) {
|
|
804
1265
|
await requireOwnedRoot(paths.root, host);
|
|
805
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
|
+
}
|
|
806
1274
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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);
|
|
816
1300
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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);
|
|
820
1306
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
await rm(backup, { recursive: true });
|
|
836
|
-
}
|
|
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.",
|
|
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,
|
|
844
1321
|
);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
});
|
|
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
|
+
);
|
|
865
1331
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
+
}
|
|
1361
|
+
if (await exists(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;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
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 = [];
|
|
1451
|
+
try {
|
|
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));
|
|
871
1459
|
}
|
|
872
|
-
|
|
873
|
-
await
|
|
874
|
-
|
|
875
|
-
});
|
|
1460
|
+
for (const transaction of transactions) {
|
|
1461
|
+
await activateInstallation(transaction);
|
|
1462
|
+
console.log(`${transaction.host}: activated OpenSocrates ${PRODUCT_VERSION}`);
|
|
876
1463
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
);
|
|
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}\`.`,
|
|
886
|
-
);
|
|
1464
|
+
await writeDesiredState(installedDesiredState(desired, hosts));
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
for (const transaction of [...transactions].reverse()) {
|
|
1467
|
+
await rollbackInstallation(transaction);
|
|
887
1468
|
}
|
|
888
1469
|
throw error;
|
|
889
1470
|
} finally {
|
|
890
|
-
|
|
891
|
-
|
|
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`);
|
|
892
1482
|
}
|
|
893
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;
|
|
894
1490
|
}
|
|
895
1491
|
|
|
896
|
-
async function
|
|
897
|
-
|
|
898
|
-
if (host === "claude")
|
|
899
|
-
warnLegacyClaudeInstallation();
|
|
900
|
-
}
|
|
1492
|
+
async function inspectHostStatus(host) {
|
|
1493
|
+
requireHostCli(host);
|
|
1494
|
+
if (host === "claude") warnLegacyClaudeInstallation();
|
|
901
1495
|
const paths = managedPaths(host);
|
|
902
1496
|
const entry = marketplaceEntry(host);
|
|
903
1497
|
if (entry === null) {
|
|
904
1498
|
if (await exists(paths.root)) {
|
|
905
1499
|
await requireOwnedRoot(paths.root, host);
|
|
906
|
-
|
|
907
|
-
} else {
|
|
908
|
-
console.log("OpenSocrates is not installed.");
|
|
1500
|
+
return { host, kind: "files-only", version: null, paths };
|
|
909
1501
|
}
|
|
910
|
-
return;
|
|
1502
|
+
return { host, kind: "missing", version: null, paths };
|
|
911
1503
|
}
|
|
912
1504
|
if (entryRoot(entry, host) !== paths.root) {
|
|
913
1505
|
fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry, host)}`);
|
|
914
1506
|
}
|
|
915
1507
|
await requireOwnedRoot(paths.root, host);
|
|
916
1508
|
const state = pluginState(host);
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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}`);
|
|
921
1570
|
} else {
|
|
922
|
-
console.log("
|
|
1571
|
+
console.log("OpenSocrates is not installed.");
|
|
923
1572
|
}
|
|
924
1573
|
}
|
|
925
1574
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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);
|
|
937
1589
|
}
|
|
938
|
-
const state = entry === null ? { kind: "missing", version: null } : pluginState(host);
|
|
939
|
-
removeRegistration(host, entry, state);
|
|
940
1590
|
if (await exists(paths.root)) {
|
|
941
|
-
await
|
|
942
|
-
|
|
1591
|
+
await rename(paths.root, transaction.backup);
|
|
1592
|
+
transaction.backupCreated = true;
|
|
943
1593
|
}
|
|
944
|
-
console.log(`OpenSocrates was removed from ${host === "claude" ? "Claude" : "Codex"}.`);
|
|
945
1594
|
}
|
|
946
1595
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
if (
|
|
950
|
-
|
|
951
|
-
|
|
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
|
+
});
|
|
952
1603
|
}
|
|
953
|
-
if (
|
|
954
|
-
await
|
|
955
|
-
|
|
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
|
+
});
|
|
956
1612
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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("&", "&")
|
|
1625
|
+
.replaceAll("<", "<")
|
|
1626
|
+
.replaceAll(">", ">")
|
|
1627
|
+
.replaceAll('"', """)
|
|
1628
|
+
.replaceAll("'", "'");
|
|
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(":");
|
|
960
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;
|
|
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);
|
|
961
2022
|
if (
|
|
962
|
-
|
|
963
|
-
|
|
2023
|
+
!desired.updatePolicy.allowMajor &&
|
|
2024
|
+
currentMajor !== null &&
|
|
2025
|
+
availableMajor !== null &&
|
|
2026
|
+
availableMajor > currentMajor
|
|
964
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") {
|
|
965
2109
|
fail(
|
|
966
2110
|
`OpenSocrates ${PRODUCT_VERSION} prebuilt installation supports darwin-arm64 only; ` +
|
|
967
2111
|
`detected ${process.platform}-${process.arch}`,
|
|
968
2112
|
);
|
|
969
2113
|
}
|
|
2114
|
+
}
|
|
970
2115
|
|
|
971
|
-
|
|
2116
|
+
async function verifyPackages(options) {
|
|
2117
|
+
const hosts = options.host === ALL_HOST ? SUPPORTED_HOSTS : [options.host];
|
|
2118
|
+
const prepared = await prepareVerifiedPackages(options, hosts);
|
|
972
2119
|
try {
|
|
973
|
-
|
|
974
|
-
|
|
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 })),
|
|
975
2129
|
);
|
|
976
|
-
|
|
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);
|
|
977
2154
|
return 0;
|
|
978
2155
|
}
|
|
979
|
-
|
|
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
|
+
}
|
|
2165
|
+
return 0;
|
|
2166
|
+
}
|
|
2167
|
+
requireSupportedPlatform();
|
|
2168
|
+
await runInstallOrUpdate(options, options.action);
|
|
980
2169
|
return 0;
|
|
981
|
-
}
|
|
982
|
-
await rm(prepared.scratch, { recursive: true, force: true });
|
|
983
|
-
}
|
|
2170
|
+
});
|
|
984
2171
|
}
|
|
985
2172
|
|
|
986
2173
|
let invokedDirectly = false;
|