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.
- package/CHANGELOG.md +132 -0
- package/README.ko.md +224 -78
- package/README.md +237 -84
- package/SECURITY.md +71 -5
- package/VERSION +1 -1
- package/installer/opensocrates.mjs +1641 -190
- package/package.json +6 -3
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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([
|
|
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
|
-
|
|
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 =
|
|
165
|
-
|
|
166
|
-
const
|
|
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
|
-
|
|
642
|
+
hostHome,
|
|
169
643
|
root,
|
|
170
644
|
parent: dirname(root),
|
|
171
645
|
marker: join(root, MARKER_NAME),
|
|
172
|
-
marketplace: join(root,
|
|
173
|
-
plugin: join(root,
|
|
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
|
|
201
|
-
const result = run(
|
|
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"
|
|
205
|
-
fail(
|
|
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(
|
|
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
|
|
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
|
|
221
|
-
const payload =
|
|
222
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
317
|
-
if (options.
|
|
318
|
-
|
|
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
|
|
913
|
+
return local;
|
|
322
914
|
}
|
|
323
915
|
const base = `https://github.com/${REPOSITORY}/releases/download/v${PRODUCT_VERSION}`;
|
|
324
|
-
const
|
|
325
|
-
const
|
|
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}/${
|
|
328
|
-
await downloadFile(`${base}/${
|
|
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,
|
|
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 !==
|
|
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
|
|
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,
|
|
493
|
-
const plugin = join(staging,
|
|
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(
|
|
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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
622
|
-
await
|
|
623
|
-
|
|
1460
|
+
for (const transaction of transactions) {
|
|
1461
|
+
await activateInstallation(transaction);
|
|
1462
|
+
console.log(`${transaction.host}: activated OpenSocrates ${PRODUCT_VERSION}`);
|
|
624
1463
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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("
|
|
1571
|
+
console.log("OpenSocrates is not installed.");
|
|
664
1572
|
}
|
|
665
1573
|
}
|
|
666
1574
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
|
678
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
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 (
|
|
690
|
-
await
|
|
691
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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(":");
|
|
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
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2167
|
+
requireSupportedPlatform();
|
|
2168
|
+
await runInstallOrUpdate(options, options.action);
|
|
716
2169
|
return 0;
|
|
717
|
-
}
|
|
718
|
-
await rm(prepared.scratch, { recursive: true, force: true });
|
|
719
|
-
}
|
|
2170
|
+
});
|
|
720
2171
|
}
|
|
721
2172
|
|
|
722
2173
|
let invokedDirectly = false;
|