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