yam-flow 2.3.2 → 2.5.0

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.
@@ -0,0 +1,1150 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fsp from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ export const EXTERNAL_UPDATE_COMPONENTS = ['yam', 'scrapling', 'insane-search'] as const;
8
+ export type ExternalUpdateComponent = typeof EXTERNAL_UPDATE_COMPONENTS[number];
9
+
10
+ type JsonRecord = Record<string, any>;
11
+
12
+ export interface ExternalCommandResult {
13
+ ok: boolean;
14
+ status: number | null;
15
+ stdout: string;
16
+ stderr: string;
17
+ }
18
+
19
+ export interface ExternalCommandOptions {
20
+ cwd?: string;
21
+ env?: NodeJS.ProcessEnv;
22
+ timeout?: number;
23
+ }
24
+
25
+ export interface ExternalUpdatePaths {
26
+ stateDir: string;
27
+ receiptDir: string;
28
+ lockFile: string;
29
+ scraplingRoot: string;
30
+ scraplingBin: string;
31
+ }
32
+
33
+ export interface ExternalUpdateDependencies {
34
+ run?: (command: string, args: string[], options?: ExternalCommandOptions) => Promise<ExternalCommandResult> | ExternalCommandResult;
35
+ fetchJson?: (url: string) => Promise<any>;
36
+ now?: () => Date;
37
+ homeDir?: string;
38
+ env?: NodeJS.ProcessEnv;
39
+ paths?: Partial<ExternalUpdatePaths>;
40
+ removeCandidate?: (candidate: string, root: string) => Promise<void>;
41
+ }
42
+
43
+ export interface ExternalComponentCheck {
44
+ component: ExternalUpdateComponent;
45
+ installed_version: string;
46
+ latest_version: string;
47
+ update_available: boolean;
48
+ status: 'up_to_date' | 'update_available' | 'not_installed' | 'check_failed';
49
+ source: string;
50
+ source_revision?: {
51
+ local: string;
52
+ remote: string;
53
+ drift: boolean;
54
+ note: string;
55
+ };
56
+ executable?: string;
57
+ error?: string;
58
+ }
59
+
60
+ export interface ExternalComponentReceipt {
61
+ schema: 'yam.external-update-receipt.v1';
62
+ generated_at: string;
63
+ component: ExternalUpdateComponent;
64
+ outcome: 'up_to_date' | 'updated' | 'failed' | 'manual_plugin_update_required';
65
+ previous_version: string;
66
+ requested_version: string;
67
+ installed_version: string;
68
+ source: string;
69
+ source_revision: JsonRecord | null;
70
+ checks: Array<{
71
+ id: string;
72
+ status: 'passed' | 'failed' | 'skipped';
73
+ note: string;
74
+ }>;
75
+ rollback_hint: {
76
+ automatic: boolean;
77
+ previous_target: string;
78
+ guidance: string;
79
+ };
80
+ side_effects: string[];
81
+ error: string;
82
+ truth_status: 'verified' | 'partial' | 'blocked';
83
+ persistence?: 'written' | 'failed';
84
+ receipt_path?: string;
85
+ receipt_error?: string;
86
+ }
87
+
88
+ interface ResolvedDependencies {
89
+ run: NonNullable<ExternalUpdateDependencies['run']>;
90
+ fetchJson: NonNullable<ExternalUpdateDependencies['fetchJson']>;
91
+ now: NonNullable<ExternalUpdateDependencies['now']>;
92
+ homeDir: string;
93
+ env: NodeJS.ProcessEnv;
94
+ paths: ExternalUpdatePaths;
95
+ removeCandidate: NonNullable<ExternalUpdateDependencies['removeCandidate']>;
96
+ }
97
+
98
+ interface ReceiptWriteResult {
99
+ path: string;
100
+ error: string;
101
+ }
102
+
103
+ const YAM_REGISTRY_URL = 'https://registry.npmjs.org/yam-flow/latest';
104
+ const SCRAPLING_PYPI_URL = 'https://pypi.org/pypi/scrapling/json';
105
+ const INSANE_MARKETPLACE = 'gptaku-codex';
106
+ const INSANE_PLUGIN_ID = 'insane-search-codex@gptaku-codex';
107
+ const INSANE_MARKETPLACE_SOURCE = 'https://github.com/fivetaku/gptaku-plugins-codex.git';
108
+ const INSANE_MANIFEST_ROOT = 'https://raw.githubusercontent.com/fivetaku/gptaku-plugins-codex';
109
+ const INSANE_MANIFEST_PATH = 'plugins/insane-search-codex/.codex-plugin/plugin.json';
110
+ const UPDATE_ORDER: ExternalUpdateComponent[] = ['scrapling', 'insane-search', 'yam'];
111
+
112
+ export async function checkExternalUpdates(
113
+ currentYamVersion: string,
114
+ dependencies: ExternalUpdateDependencies = {}
115
+ ) {
116
+ const deps = await resolveDependencies(dependencies);
117
+ const checks = await Promise.all([
118
+ checkYam(currentYamVersion, deps),
119
+ checkScrapling(deps),
120
+ checkInsaneSearch(deps)
121
+ ]);
122
+ const failed = checks.filter((item) => item.status === 'check_failed');
123
+ return {
124
+ schema: 'yam.external-update-check.v1',
125
+ generated_at: deps.now().toISOString(),
126
+ mutation_authorized: false,
127
+ components: checks,
128
+ update_available: checks.some((item) => item.update_available),
129
+ success: failed.length === 0,
130
+ truth_status: failed.length ? 'partial' : 'verified',
131
+ next_action: failed.length
132
+ ? 'resolve the failed read-only checks before applying updates'
133
+ : checks.some((item) => item.update_available)
134
+ ? 'review the component versions, then explicitly run `yam update apply --component <name>` or `yam update apply --all`'
135
+ : 'no update action is needed'
136
+ };
137
+ }
138
+
139
+ export async function applyExternalUpdates(
140
+ currentYamVersion: string,
141
+ selection: { component?: ExternalUpdateComponent; all?: boolean },
142
+ dependencies: ExternalUpdateDependencies = {}
143
+ ) {
144
+ const deps = await resolveDependencies(dependencies);
145
+ const components = selection.all
146
+ ? UPDATE_ORDER
147
+ : selection.component && EXTERNAL_UPDATE_COMPONENTS.includes(selection.component)
148
+ ? [selection.component]
149
+ : [];
150
+ if (components.length === 0) {
151
+ throw new Error('choose exactly one of --component yam|scrapling|insane-search or --all');
152
+ }
153
+
154
+ let lockHandle: fsp.FileHandle | null = null;
155
+ let lockReleased = false;
156
+ const receipts: ExternalComponentReceipt[] = [];
157
+ try {
158
+ lockHandle = await acquireApplyLock(deps);
159
+ for (const component of components) {
160
+ const receipt = component === 'yam'
161
+ ? await applyYam(currentYamVersion, deps)
162
+ : component === 'scrapling'
163
+ ? await applyScrapling(deps)
164
+ : await applyInsaneSearch(deps);
165
+ receipts.push(receipt);
166
+ if (
167
+ receipt.outcome === 'failed'
168
+ || receipt.outcome === 'manual_plugin_update_required'
169
+ || receipt.persistence === 'failed'
170
+ ) break;
171
+ }
172
+ } finally {
173
+ if (lockHandle) {
174
+ try {
175
+ await lockHandle.close();
176
+ } catch {
177
+ // Lock-file removal below is the observable cleanup boundary.
178
+ }
179
+ try {
180
+ await fsp.unlink(deps.paths.lockFile);
181
+ lockReleased = true;
182
+ } catch {
183
+ lockReleased = false;
184
+ }
185
+ }
186
+ }
187
+
188
+ const failed = receipts.some((receipt) => (
189
+ receipt.outcome === 'failed'
190
+ || receipt.outcome === 'manual_plugin_update_required'
191
+ || receipt.persistence === 'failed'
192
+ ));
193
+ const incomplete = receipts.length !== components.length;
194
+ return {
195
+ schema: 'yam.external-update-apply.v1',
196
+ generated_at: deps.now().toISOString(),
197
+ mutation_authorized: true,
198
+ requested_components: components,
199
+ applied_components: receipts.map((receipt) => receipt.component),
200
+ receipts,
201
+ stopped_early: incomplete,
202
+ lock: {
203
+ path: deps.paths.lockFile,
204
+ released: lockReleased
205
+ },
206
+ success: !failed && !incomplete && lockReleased,
207
+ truth_status: failed || incomplete || !lockReleased ? 'blocked' : 'verified',
208
+ next_action: failed
209
+ ? firstFailureNextAction(receipts)
210
+ : !lockReleased
211
+ ? `confirm no updater is running, inspect the stale lock, then remove it manually: ${deps.paths.lockFile}`
212
+ : 'review the component receipts and restart Codex only if yam skills or a Codex plugin changed'
213
+ };
214
+ }
215
+
216
+ async function resolveDependencies(input: ExternalUpdateDependencies): Promise<ResolvedDependencies> {
217
+ const homeDir = path.resolve(input.homeDir || os.homedir());
218
+ const env = { ...process.env, ...(input.env || {}) };
219
+ const run = input.run || defaultRun;
220
+ const stateDir = path.resolve(input.paths?.stateDir || env.YAM_UPDATE_STATE_DIR || path.join(homeDir, '.local', 'state', 'yam'));
221
+ const scraplingRoot = path.resolve(input.paths?.scraplingRoot || env.YAM_SCRAPLING_ROOT || path.join(homeDir, '.local', 'share', 'scrapling'));
222
+ const explicitBin = input.paths?.scraplingBin || env.YAM_SCRAPLING_BIN || '';
223
+ const scraplingBin = explicitBin
224
+ ? path.resolve(expandHome(explicitBin, homeDir))
225
+ : await discoverScraplingBin(homeDir, run, env);
226
+ const receiptDir = path.resolve(input.paths?.receiptDir || env.YAM_UPDATE_RECEIPT_DIR || path.join(stateDir, 'update-receipts'));
227
+ const lockFile = path.resolve(input.paths?.lockFile || env.YAM_UPDATE_LOCK_FILE || path.join(stateDir, 'external-update.lock'));
228
+ return {
229
+ run,
230
+ fetchJson: input.fetchJson || defaultFetchJson,
231
+ now: input.now || (() => new Date()),
232
+ homeDir,
233
+ env,
234
+ removeCandidate: input.removeCandidate || removeCreatedCandidate,
235
+ paths: {
236
+ stateDir,
237
+ receiptDir,
238
+ lockFile,
239
+ scraplingRoot,
240
+ scraplingBin
241
+ }
242
+ };
243
+ }
244
+
245
+ async function discoverScraplingBin(
246
+ homeDir: string,
247
+ run: ResolvedDependencies['run'],
248
+ env: NodeJS.ProcessEnv
249
+ ) {
250
+ const which = await run('which', ['scrapling'], { env, timeout: 10000 });
251
+ const discovered = which.ok ? String(which.stdout || '').trim().split(/\r?\n/)[0] : '';
252
+ if (discovered && path.isAbsolute(discovered)) return path.resolve(discovered);
253
+ const candidates = [
254
+ path.join(homeDir, '.homebrew', 'bin', 'scrapling'),
255
+ path.join(homeDir, '.local', 'bin', 'scrapling')
256
+ ];
257
+ for (const candidate of candidates) {
258
+ if (await pathExists(candidate)) return candidate;
259
+ }
260
+ return candidates[1];
261
+ }
262
+
263
+ async function acquireApplyLock(deps: ResolvedDependencies) {
264
+ await fsp.mkdir(path.dirname(deps.paths.lockFile), { recursive: true });
265
+ let handle: fsp.FileHandle;
266
+ try {
267
+ handle = await fsp.open(deps.paths.lockFile, 'wx', 0o600);
268
+ } catch (error) {
269
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
270
+ throw new Error(`another external update may be running; inspect the lock before retrying: ${deps.paths.lockFile}`);
271
+ }
272
+ throw error;
273
+ }
274
+ try {
275
+ await handle.writeFile(`${JSON.stringify({
276
+ schema: 'yam.external-update-lock.v1',
277
+ pid: process.pid,
278
+ created_at: deps.now().toISOString()
279
+ })}\n`, 'utf8');
280
+ } catch (error) {
281
+ await handle.close().catch(() => undefined);
282
+ await unlinkIfExists(deps.paths.lockFile).catch(() => undefined);
283
+ throw error;
284
+ }
285
+ return handle;
286
+ }
287
+
288
+ async function checkYam(currentYamVersion: string, deps: ResolvedDependencies): Promise<ExternalComponentCheck> {
289
+ try {
290
+ const data = await deps.fetchJson(YAM_REGISTRY_URL);
291
+ const latest = requireStableVersion(data?.version, 'yam registry version');
292
+ const installed = requireStableVersion(currentYamVersion, 'installed yam version');
293
+ return componentCheck('yam', installed, latest, 'npm:yam-flow');
294
+ } catch (error) {
295
+ return failedCheck('yam', currentYamVersion, 'npm:yam-flow', error);
296
+ }
297
+ }
298
+
299
+ async function checkScrapling(deps: ResolvedDependencies): Promise<ExternalComponentCheck> {
300
+ try {
301
+ const data = await deps.fetchJson(SCRAPLING_PYPI_URL);
302
+ const latest = requireStableVersion(data?.info?.version, 'Scrapling PyPI version');
303
+ const executable = deps.paths.scraplingBin;
304
+ const versionResult = await deps.run(executable, ['--version'], { env: deps.env, timeout: 30000 });
305
+ if (!versionResult.ok) {
306
+ return {
307
+ component: 'scrapling',
308
+ installed_version: '',
309
+ latest_version: latest,
310
+ update_available: true,
311
+ status: 'not_installed',
312
+ source: 'pypi:scrapling',
313
+ executable
314
+ };
315
+ }
316
+ const installed = requireStableVersion(extractVersion(versionResult.stdout), 'installed Scrapling version');
317
+ return {
318
+ ...componentCheck('scrapling', installed, latest, 'pypi:scrapling'),
319
+ executable
320
+ };
321
+ } catch (error) {
322
+ return failedCheck('scrapling', '', 'pypi:scrapling', error);
323
+ }
324
+ }
325
+
326
+ async function checkInsaneSearch(deps: ResolvedDependencies): Promise<ExternalComponentCheck> {
327
+ try {
328
+ const remoteRevisionResult = await deps.run('git', ['ls-remote', INSANE_MARKETPLACE_SOURCE, 'HEAD'], {
329
+ env: deps.env,
330
+ timeout: 30000
331
+ });
332
+ if (!remoteRevisionResult.ok) {
333
+ throw new Error(`could not resolve official Insane Search source revision: ${commandNote(remoteRevisionResult)}`);
334
+ }
335
+ const remoteRevision = remoteRevisionResult.stdout.trim().split(/\s+/)[0] || '';
336
+ if (!/^[0-9a-f]{40}$/i.test(remoteRevision)) {
337
+ throw new Error('official Insane Search source did not return a pinned 40-character Git revision');
338
+ }
339
+ const manifestUrl = `${INSANE_MANIFEST_ROOT}/${remoteRevision}/${INSANE_MANIFEST_PATH}`;
340
+ const [manifest, pluginList, marketplaceList] = await Promise.all([
341
+ deps.fetchJson(manifestUrl),
342
+ deps.run('codex', ['plugin', 'list', '--json'], { env: deps.env, timeout: 30000 }),
343
+ deps.run('codex', ['plugin', 'marketplace', 'list', '--json'], { env: deps.env, timeout: 30000 })
344
+ ]);
345
+ if (!pluginList.ok) throw new Error(`codex plugin list failed: ${commandNote(pluginList)}`);
346
+ if (!marketplaceList.ok) throw new Error(`codex plugin marketplace list failed: ${commandNote(marketplaceList)}`);
347
+ const latest = requireStableVersion(manifest?.version, 'Insane Search manifest version');
348
+ const parsedPlugins = parseJsonOutput(pluginList.stdout, 'codex plugin list');
349
+ const installedEntry = arrayValue(parsedPlugins?.installed).find((item) => item?.pluginId === INSANE_PLUGIN_ID);
350
+ const installed = installedEntry?.version
351
+ ? requireStableVersion(installedEntry.version, 'installed Insane Search version')
352
+ : '';
353
+
354
+ let localRevision = '';
355
+ const parsedMarketplaces = parseJsonOutput(marketplaceList.stdout, 'codex plugin marketplace list');
356
+ const marketplaceEntry = arrayValue(parsedMarketplaces?.marketplaces).find((item) => item?.name === INSANE_MARKETPLACE);
357
+ if (!marketplaceEntry) {
358
+ throw new Error(`required Codex marketplace is not configured: ${INSANE_MARKETPLACE}`);
359
+ }
360
+ const configuredSource = String(marketplaceEntry?.marketplaceSource?.source || '');
361
+ if (
362
+ marketplaceEntry?.marketplaceSource?.sourceType !== 'git'
363
+ || configuredSource !== INSANE_MARKETPLACE_SOURCE
364
+ ) {
365
+ throw new Error(`refusing unexpected ${INSANE_MARKETPLACE} marketplace source`);
366
+ }
367
+ const marketplaceRoot = String(marketplaceEntry.root || '');
368
+ if (!marketplaceRoot || !isSafeMarketplaceRoot(marketplaceRoot, deps.homeDir)) {
369
+ throw new Error(`refusing unsafe ${INSANE_MARKETPLACE} marketplace root`);
370
+ }
371
+ const localRevisionResult = await deps.run('git', ['-C', marketplaceRoot, 'rev-parse', 'HEAD'], {
372
+ env: deps.env,
373
+ timeout: 30000
374
+ });
375
+ if (!localRevisionResult.ok) {
376
+ throw new Error(`could not read local ${INSANE_MARKETPLACE} revision: ${commandNote(localRevisionResult)}`);
377
+ }
378
+ localRevision = localRevisionResult.stdout.trim();
379
+ if (!/^[0-9a-f]{40}$/i.test(localRevision)) {
380
+ throw new Error(`local ${INSANE_MARKETPLACE} revision is not a 40-character Git revision`);
381
+ }
382
+ const base = installed
383
+ ? componentCheck('insane-search', installed, latest, `git:${INSANE_MARKETPLACE_SOURCE}`)
384
+ : {
385
+ component: 'insane-search' as const,
386
+ installed_version: '',
387
+ latest_version: latest,
388
+ update_available: true,
389
+ status: 'not_installed' as const,
390
+ source: `git:${INSANE_MARKETPLACE_SOURCE}`
391
+ };
392
+ return {
393
+ ...base,
394
+ source_revision: {
395
+ local: localRevision,
396
+ remote: remoteRevision,
397
+ drift: Boolean(localRevision && remoteRevision && localRevision !== remoteRevision),
398
+ note: installed && compareVersions(installed, latest) === 0
399
+ ? 'marketplace revision drift alone is not treated as an Insane Search update'
400
+ : 'plugin update availability is based on the official plugin manifest version'
401
+ }
402
+ };
403
+ } catch (error) {
404
+ return failedCheck('insane-search', '', `git:${INSANE_MARKETPLACE_SOURCE}`, error);
405
+ }
406
+ }
407
+
408
+ function componentCheck(
409
+ component: ExternalUpdateComponent,
410
+ installed: string,
411
+ latest: string,
412
+ source: string
413
+ ): ExternalComponentCheck {
414
+ const updateAvailable = compareVersions(installed, latest) < 0;
415
+ return {
416
+ component,
417
+ installed_version: installed,
418
+ latest_version: latest,
419
+ update_available: updateAvailable,
420
+ status: updateAvailable ? 'update_available' : 'up_to_date',
421
+ source
422
+ };
423
+ }
424
+
425
+ function failedCheck(
426
+ component: ExternalUpdateComponent,
427
+ installed: string,
428
+ source: string,
429
+ error: unknown
430
+ ): ExternalComponentCheck {
431
+ return {
432
+ component,
433
+ installed_version: installed,
434
+ latest_version: '',
435
+ update_available: false,
436
+ status: 'check_failed',
437
+ source,
438
+ error: errorMessage(error)
439
+ };
440
+ }
441
+
442
+ async function applyYam(currentYamVersion: string, deps: ResolvedDependencies) {
443
+ const check = await checkYam(currentYamVersion, deps);
444
+ if (check.status === 'check_failed') return persistFailureFromCheck(check, deps);
445
+ if (!check.update_available) {
446
+ return persistReceipt(baseReceipt(check, 'up_to_date', [], {
447
+ automatic: true,
448
+ previous_target: check.installed_version,
449
+ guidance: 'No rollback is needed because no mutation ran.'
450
+ }), deps);
451
+ }
452
+
453
+ const checks: ExternalComponentReceipt['checks'] = [];
454
+ const sideEffects: string[] = [];
455
+ const exactPackage = `yam-flow@${check.latest_version}`;
456
+ const install = await deps.run('npm', ['install', '-g', exactPackage], {
457
+ env: deps.env,
458
+ timeout: 600000
459
+ });
460
+ checks.push(commandCheck('npm_global_install', install));
461
+ if (!install.ok) {
462
+ return persistReceipt(baseReceipt(check, 'failed', checks, yamRollback(check.installed_version), {
463
+ error: `global yam install failed: ${commandNote(install)}`
464
+ }), deps);
465
+ }
466
+ sideEffects.push(`globally installed ${exactPackage}`);
467
+
468
+ const postCommands: Array<[string, string[]]> = [
469
+ ['yam_install_skills', ['install']],
470
+ ['yam_version', ['version']],
471
+ ['yam_status', ['status']]
472
+ ];
473
+ let postFailure = '';
474
+ for (const [id, args] of postCommands) {
475
+ const result = await deps.run('yam', args, { env: deps.env, timeout: 180000 });
476
+ checks.push(commandCheck(id, result));
477
+ if (!result.ok || (id === 'yam_version' && extractVersion(result.stdout) !== check.latest_version)) {
478
+ postFailure = `${id} failed: ${commandNote(result)}`;
479
+ break;
480
+ }
481
+ }
482
+ if (postFailure) {
483
+ const rollback = await rollbackYam(check.installed_version, deps, checks);
484
+ return persistReceipt(baseReceipt(check, 'failed', checks, {
485
+ ...yamRollback(check.installed_version),
486
+ automatic: rollback
487
+ }, {
488
+ side_effects: sideEffects,
489
+ error: postFailure
490
+ }), deps);
491
+ }
492
+
493
+ const receipt = baseReceipt(check, 'updated', checks, yamRollback(check.installed_version), {
494
+ installed_version: check.latest_version,
495
+ side_effects: sideEffects,
496
+ truth_status: 'verified'
497
+ });
498
+ const persisted = await persistReceipt(receipt, deps);
499
+ if (persisted.persistence === 'failed') {
500
+ const rollback = await rollbackYam(check.installed_version, deps, persisted.checks);
501
+ persisted.rollback_hint.automatic = rollback;
502
+ persisted.outcome = 'failed';
503
+ persisted.truth_status = 'blocked';
504
+ persisted.error = `update completed but receipt persistence failed; automatic rollback ${rollback ? 'passed' : 'failed'}`;
505
+ }
506
+ return persisted;
507
+ }
508
+
509
+ async function rollbackYam(
510
+ previousVersion: string,
511
+ deps: ResolvedDependencies,
512
+ checks: ExternalComponentReceipt['checks']
513
+ ) {
514
+ if (!previousVersion) return false;
515
+ const result = await deps.run('npm', ['install', '-g', `yam-flow@${previousVersion}`], {
516
+ env: deps.env,
517
+ timeout: 600000
518
+ });
519
+ checks.push(commandCheck('automatic_yam_rollback', result));
520
+ if (!result.ok) return false;
521
+ const install = await deps.run('yam', ['install'], { env: deps.env, timeout: 180000 });
522
+ const version = await deps.run('yam', ['version'], { env: deps.env, timeout: 30000 });
523
+ const status = await deps.run('yam', ['status'], { env: deps.env, timeout: 120000 });
524
+ checks.push(commandCheck('automatic_yam_rollback_skills', install));
525
+ checks.push(commandCheck('automatic_yam_rollback_version', version));
526
+ checks.push(commandCheck('automatic_yam_rollback_status', status));
527
+ return install.ok && version.ok && extractVersion(version.stdout) === previousVersion && status.ok;
528
+ }
529
+
530
+ async function applyScrapling(deps: ResolvedDependencies) {
531
+ const check = await checkScrapling(deps);
532
+ if (check.status === 'check_failed') return persistFailureFromCheck(check, deps);
533
+ if (!check.update_available) {
534
+ return persistReceipt(baseReceipt(check, 'up_to_date', [], {
535
+ automatic: true,
536
+ previous_target: await readSymlinkTarget(deps.paths.scraplingBin),
537
+ guidance: 'No rollback is needed because no mutation ran.'
538
+ }), deps);
539
+ }
540
+
541
+ const checks: ExternalComponentReceipt['checks'] = [];
542
+ const sideEffects: string[] = [];
543
+ let previousTarget = '';
544
+ let candidate = '';
545
+ let linkSwitched = false;
546
+ let candidateCreated = false;
547
+ try {
548
+ previousTarget = await validateManagedScraplingLink(deps);
549
+ await fsp.mkdir(deps.paths.scraplingRoot, { recursive: true });
550
+ const suffix = `${safeTimestamp(deps.now())}-${randomUUID().slice(0, 8)}`;
551
+ candidate = path.join(deps.paths.scraplingRoot, `${check.latest_version}-yam-${suffix}`);
552
+ assertChildPath(deps.paths.scraplingRoot, candidate, 'Scrapling candidate');
553
+ const python = String(deps.env.YAM_SCRAPLING_PYTHON || 'python3');
554
+ const venv = await deps.run(python, ['-m', 'venv', candidate], { env: deps.env, timeout: 180000 });
555
+ checks.push(commandCheck('create_versioned_venv', venv));
556
+ candidateCreated = await pathExists(candidate);
557
+ if (!venv.ok || !candidateCreated) throw new Error(`could not create Scrapling venv: ${commandNote(venv)}`);
558
+ sideEffects.push(`created versioned environment ${candidate}`);
559
+
560
+ const candidatePython = path.join(candidate, 'bin', 'python');
561
+ const candidateScrapling = path.join(candidate, 'bin', 'scrapling');
562
+ const packageSpec = `scrapling[fetchers]==${check.latest_version}`;
563
+ const install = await deps.run(candidatePython, ['-m', 'pip', 'install', packageSpec], {
564
+ env: deps.env,
565
+ timeout: 600000
566
+ });
567
+ checks.push(commandCheck('install_exact_scrapling', install));
568
+ if (!install.ok) throw new Error(`Scrapling install failed: ${commandNote(install)}`);
569
+
570
+ const browsers = await deps.run(candidateScrapling, ['install', '--force'], {
571
+ env: deps.env,
572
+ timeout: 600000
573
+ });
574
+ checks.push(commandCheck('install_browser_dependencies', browsers));
575
+ if (!browsers.ok) throw new Error(`Scrapling browser install failed: ${commandNote(browsers)}`);
576
+ sideEffects.push('Scrapling browser dependencies may update the user Playwright cache.');
577
+
578
+ const pipCheck = await deps.run(candidatePython, ['-m', 'pip', 'check'], {
579
+ env: deps.env,
580
+ timeout: 120000
581
+ });
582
+ checks.push(commandCheck('pip_check', pipCheck));
583
+ if (!pipCheck.ok) throw new Error(`Scrapling pip check failed: ${commandNote(pipCheck)}`);
584
+
585
+ const httpOutput = path.join(candidate, '.yam-http-smoke.txt');
586
+ const browserOutput = path.join(candidate, '.yam-browser-smoke.txt');
587
+ const httpSmoke = await deps.run(candidateScrapling, [
588
+ 'extract', 'get', 'https://example.com', httpOutput, '--css-selector', 'h1', '--timeout', '30'
589
+ ], { env: deps.env, timeout: 120000 });
590
+ checks.push(commandCheck('http_smoke', httpSmoke));
591
+ if (!httpSmoke.ok || !await fileContains(httpOutput, 'Example Domain')) {
592
+ throw new Error(`Scrapling HTTP smoke failed: ${commandNote(httpSmoke)}`);
593
+ }
594
+ const browserSmoke = await deps.run(candidateScrapling, [
595
+ 'extract', 'stealthy-fetch', 'https://example.com', browserOutput,
596
+ '--css-selector', 'h1', '--headless', '--timeout', '30000'
597
+ ], { env: deps.env, timeout: 180000 });
598
+ checks.push(commandCheck('browser_smoke', browserSmoke));
599
+ if (!browserSmoke.ok || !await fileContains(browserOutput, 'Example Domain')) {
600
+ throw new Error(`Scrapling browser smoke failed: ${commandNote(browserSmoke)}`);
601
+ }
602
+ await unlinkIfExists(httpOutput);
603
+ await unlinkIfExists(browserOutput);
604
+
605
+ const directVersion = await deps.run(candidateScrapling, ['--version'], { env: deps.env, timeout: 30000 });
606
+ checks.push(commandCheck('candidate_version', directVersion));
607
+ if (!directVersion.ok || extractVersion(directVersion.stdout) !== check.latest_version) {
608
+ throw new Error(`candidate Scrapling version mismatch: ${commandNote(directVersion)}`);
609
+ }
610
+
611
+ await fsp.writeFile(path.join(candidate, '.yam-scrapling-install.json'), `${JSON.stringify({
612
+ schema: 'yam.scrapling-install.v1',
613
+ version: check.latest_version,
614
+ created_at: deps.now().toISOString(),
615
+ executable: candidateScrapling
616
+ }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
617
+ const currentTarget = await validateManagedScraplingLink(deps);
618
+ if (currentTarget !== previousTarget) {
619
+ throw new Error('Scrapling executable changed during the update; refusing the symlink switch');
620
+ }
621
+ await atomicSwitchSymlink(deps.paths.scraplingBin, candidateScrapling);
622
+ linkSwitched = true;
623
+ sideEffects.push(`atomically switched ${deps.paths.scraplingBin}`);
624
+
625
+ const linkedVersion = await deps.run(deps.paths.scraplingBin, ['--version'], {
626
+ env: deps.env,
627
+ timeout: 30000
628
+ });
629
+ checks.push(commandCheck('linked_version', linkedVersion));
630
+ if (!linkedVersion.ok || extractVersion(linkedVersion.stdout) !== check.latest_version) {
631
+ throw new Error(`linked Scrapling version mismatch: ${commandNote(linkedVersion)}`);
632
+ }
633
+
634
+ const receipt = baseReceipt(check, 'updated', checks, scraplingRollback(previousTarget, deps.paths.scraplingBin), {
635
+ installed_version: check.latest_version,
636
+ side_effects: sideEffects,
637
+ truth_status: 'verified'
638
+ });
639
+ const persisted = await persistReceipt(receipt, deps);
640
+ if (persisted.persistence === 'failed') {
641
+ const rolledBack = await restoreScraplingLink(deps.paths.scraplingBin, previousTarget);
642
+ persisted.rollback_hint.automatic = rolledBack;
643
+ persisted.outcome = 'failed';
644
+ persisted.truth_status = 'blocked';
645
+ persisted.error = `receipt persistence failed; symlink rollback ${rolledBack ? 'passed' : 'failed'}`;
646
+ persisted.side_effects.push(`receipt failure triggered symlink rollback: ${rolledBack ? 'passed' : 'failed'}`);
647
+ }
648
+ return persisted;
649
+ } catch (error) {
650
+ let rolledBack = false;
651
+ let cleanupError = '';
652
+ if (linkSwitched) rolledBack = await restoreScraplingLink(deps.paths.scraplingBin, previousTarget);
653
+ if (candidateCreated && !linkSwitched) {
654
+ try {
655
+ await deps.removeCandidate(candidate, deps.paths.scraplingRoot);
656
+ sideEffects.push(`removed failed transaction-created environment ${candidate}`);
657
+ } catch (candidateError) {
658
+ cleanupError = errorMessage(candidateError);
659
+ sideEffects.push(`failed transaction-created environment retained for inspection: ${candidate}`);
660
+ }
661
+ }
662
+ const rollbackHint = scraplingRollback(previousTarget, deps.paths.scraplingBin);
663
+ if (cleanupError) {
664
+ rollbackHint.guidance = `${rollbackHint.guidance} Inspect and remove only the failed yam candidate after confirming no process uses it: ${candidate}`;
665
+ }
666
+ const receipt = baseReceipt(check, 'failed', checks, {
667
+ ...rollbackHint,
668
+ automatic: linkSwitched ? rolledBack : !cleanupError
669
+ }, {
670
+ side_effects: sideEffects,
671
+ error: cleanupError
672
+ ? `${errorMessage(error)}; candidate cleanup also failed: ${cleanupError}`
673
+ : errorMessage(error)
674
+ });
675
+ return persistReceipt(receipt, deps);
676
+ }
677
+ }
678
+
679
+ async function applyInsaneSearch(deps: ResolvedDependencies) {
680
+ const check = await checkInsaneSearch(deps);
681
+ if (check.status === 'check_failed') return persistFailureFromCheck(check, deps);
682
+ if (!check.update_available) {
683
+ return persistReceipt(baseReceipt(check, 'up_to_date', [], {
684
+ automatic: false,
685
+ previous_target: check.installed_version,
686
+ guidance: 'No rollback is needed because no plugin mutation ran.'
687
+ }), deps);
688
+ }
689
+
690
+ const checks: ExternalComponentReceipt['checks'] = [];
691
+ const upgrade = await deps.run('codex', ['plugin', 'marketplace', 'upgrade', INSANE_MARKETPLACE, '--json'], {
692
+ env: deps.env,
693
+ timeout: 180000
694
+ });
695
+ checks.push(commandCheck('marketplace_upgrade', upgrade));
696
+ if (!upgrade.ok) {
697
+ return persistReceipt(baseReceipt(check, 'failed', checks, insaneRollback(check.installed_version), {
698
+ error: `marketplace upgrade failed: ${commandNote(upgrade)}`
699
+ }), deps);
700
+ }
701
+
702
+ let observation = await installedInsaneVersion(deps, checks, 'plugin_list_after_marketplace_upgrade');
703
+ if (!observation.ok) {
704
+ return persistReceipt(baseReceipt(check, 'failed', checks, insaneRollback(check.installed_version), {
705
+ installed_version: check.installed_version,
706
+ side_effects: ['The official Codex marketplace snapshot was upgraded; plugin state could not be observed, so no add command ran.'],
707
+ error: observation.error
708
+ }), deps);
709
+ }
710
+ let installedAfter = observation.version;
711
+ if (installedAfter !== check.latest_version) {
712
+ const add = await deps.run('codex', ['plugin', 'add', INSANE_PLUGIN_ID, '--json'], {
713
+ env: deps.env,
714
+ timeout: 180000
715
+ });
716
+ checks.push(commandCheck('official_plugin_add_in_place', add));
717
+ if (!add.ok) {
718
+ return persistReceipt(baseReceipt(check, 'manual_plugin_update_required', checks, insaneRollback(check.installed_version), {
719
+ installed_version: installedAfter || check.installed_version,
720
+ side_effects: ['The official Codex marketplace snapshot was upgraded; no plugin cache was edited directly.'],
721
+ error: `Codex did not support a safe in-place plugin update: ${commandNote(add)}`
722
+ }), deps);
723
+ }
724
+ observation = await installedInsaneVersion(deps, checks, 'plugin_list_after_add');
725
+ if (!observation.ok) {
726
+ return persistReceipt(baseReceipt(check, 'manual_plugin_update_required', checks, insaneRollback(check.installed_version), {
727
+ installed_version: check.installed_version,
728
+ side_effects: ['The official Codex marketplace snapshot and plugin add command ran, but final plugin state could not be observed.'],
729
+ error: observation.error
730
+ }), deps);
731
+ }
732
+ installedAfter = observation.version;
733
+ }
734
+
735
+ if (installedAfter !== check.latest_version) {
736
+ return persistReceipt(baseReceipt(check, 'manual_plugin_update_required', checks, insaneRollback(check.installed_version), {
737
+ installed_version: installedAfter || check.installed_version,
738
+ side_effects: ['The official Codex marketplace snapshot was upgraded; no plugin cache was edited directly.'],
739
+ error: `official CLI finished without installing expected version ${check.latest_version}`
740
+ }), deps);
741
+ }
742
+
743
+ return persistReceipt(baseReceipt(check, 'updated', checks, insaneRollback(check.installed_version), {
744
+ installed_version: installedAfter,
745
+ side_effects: [
746
+ `upgraded official marketplace ${INSANE_MARKETPLACE}`,
747
+ `updated ${INSANE_PLUGIN_ID} only through the official Codex CLI`
748
+ ],
749
+ truth_status: 'verified'
750
+ }), deps);
751
+ }
752
+
753
+ async function installedInsaneVersion(
754
+ deps: ResolvedDependencies,
755
+ checks: ExternalComponentReceipt['checks'],
756
+ id: string
757
+ ) {
758
+ const result = await deps.run('codex', ['plugin', 'list', '--json'], { env: deps.env, timeout: 30000 });
759
+ checks.push(commandCheck(id, result));
760
+ if (!result.ok) {
761
+ return {
762
+ ok: false,
763
+ version: '',
764
+ error: `${id} failed; refusing a plugin mutation without observed state: ${commandNote(result)}`
765
+ };
766
+ }
767
+ try {
768
+ const parsed = parseJsonOutput(result.stdout, 'codex plugin list');
769
+ const entry = arrayValue(parsed?.installed).find((item) => item?.pluginId === INSANE_PLUGIN_ID);
770
+ return {
771
+ ok: true,
772
+ version: entry?.version ? requireStableVersion(entry.version, 'installed Insane Search version') : '',
773
+ error: ''
774
+ };
775
+ } catch (error) {
776
+ return {
777
+ ok: false,
778
+ version: '',
779
+ error: `${id} returned unreadable plugin state; refusing a plugin mutation: ${errorMessage(error)}`
780
+ };
781
+ }
782
+ }
783
+
784
+ function baseReceipt(
785
+ check: ExternalComponentCheck,
786
+ outcome: ExternalComponentReceipt['outcome'],
787
+ checks: ExternalComponentReceipt['checks'],
788
+ rollbackHint: ExternalComponentReceipt['rollback_hint'],
789
+ overrides: Partial<ExternalComponentReceipt> = {}
790
+ ): ExternalComponentReceipt {
791
+ return {
792
+ schema: 'yam.external-update-receipt.v1',
793
+ generated_at: new Date().toISOString(),
794
+ component: check.component,
795
+ outcome,
796
+ previous_version: check.installed_version,
797
+ requested_version: check.latest_version,
798
+ installed_version: check.installed_version,
799
+ source: check.source,
800
+ source_revision: receiptSourceRevision(check),
801
+ checks,
802
+ rollback_hint: rollbackHint,
803
+ side_effects: [],
804
+ error: '',
805
+ truth_status: outcome === 'failed' || outcome === 'manual_plugin_update_required' ? 'blocked' : 'verified',
806
+ ...overrides
807
+ };
808
+ }
809
+
810
+ function receiptSourceRevision(check: ExternalComponentCheck) {
811
+ if (check.source_revision) return check.source_revision;
812
+ if (check.component === 'yam') {
813
+ return {
814
+ kind: 'npm_registry_release',
815
+ package: 'yam-flow',
816
+ version: check.latest_version
817
+ };
818
+ }
819
+ if (check.component === 'scrapling') {
820
+ return {
821
+ kind: 'pypi_release',
822
+ package: 'scrapling',
823
+ version: check.latest_version
824
+ };
825
+ }
826
+ return null;
827
+ }
828
+
829
+ async function persistFailureFromCheck(check: ExternalComponentCheck, deps: ResolvedDependencies) {
830
+ return persistReceipt(baseReceipt(check, 'failed', [{
831
+ id: 'update_check',
832
+ status: 'failed',
833
+ note: check.error || 'component update check failed'
834
+ }], genericRollback(check), {
835
+ error: check.error || 'component update check failed'
836
+ }), deps);
837
+ }
838
+
839
+ async function persistReceipt(receipt: ExternalComponentReceipt, deps: ResolvedDependencies) {
840
+ receipt.generated_at = deps.now().toISOString();
841
+ const write = await safeWriteReceipt(receipt, deps);
842
+ if (write.error) {
843
+ receipt.persistence = 'failed';
844
+ receipt.receipt_error = write.error;
845
+ receipt.truth_status = 'blocked';
846
+ } else {
847
+ receipt.persistence = 'written';
848
+ receipt.receipt_path = write.path;
849
+ }
850
+ return receipt;
851
+ }
852
+
853
+ async function safeWriteReceipt(
854
+ receipt: ExternalComponentReceipt,
855
+ deps: ResolvedDependencies
856
+ ): Promise<ReceiptWriteResult> {
857
+ let temporary = '';
858
+ try {
859
+ await fsp.mkdir(deps.paths.receiptDir, { recursive: true });
860
+ const name = `${safeTimestamp(deps.now())}-${receipt.component}-${randomUUID().slice(0, 8)}.json`;
861
+ const target = path.join(deps.paths.receiptDir, name);
862
+ assertChildPath(deps.paths.receiptDir, target, 'update receipt');
863
+ temporary = `${target}.tmp`;
864
+ await fsp.writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, {
865
+ encoding: 'utf8',
866
+ mode: 0o600,
867
+ flag: 'wx'
868
+ });
869
+ await fsp.rename(temporary, target);
870
+ return { path: target, error: '' };
871
+ } catch (error) {
872
+ if (temporary) await unlinkIfExists(temporary).catch(() => undefined);
873
+ return { path: '', error: errorMessage(error) };
874
+ }
875
+ }
876
+
877
+ async function validateManagedScraplingLink(deps: ResolvedDependencies) {
878
+ const bin = deps.paths.scraplingBin;
879
+ let stat;
880
+ try {
881
+ stat = await fsp.lstat(bin);
882
+ } catch (error) {
883
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '';
884
+ throw error;
885
+ }
886
+ if (!stat.isSymbolicLink()) {
887
+ throw new Error(`refusing to replace non-symlink Scrapling executable: ${bin}`);
888
+ }
889
+ const rawTarget = await fsp.readlink(bin);
890
+ const resolvedTarget = path.resolve(path.dirname(bin), rawTarget);
891
+ assertChildPath(deps.paths.scraplingRoot, resolvedTarget, 'existing Scrapling executable');
892
+ if (path.basename(resolvedTarget) !== 'scrapling' || path.basename(path.dirname(resolvedTarget)) !== 'bin') {
893
+ throw new Error(`existing Scrapling link is outside the managed versioned layout: ${bin} -> ${resolvedTarget}`);
894
+ }
895
+ const environmentName = path.basename(path.dirname(path.dirname(resolvedTarget)));
896
+ if (!/^\d+(?:\.\d+){1,3}(?:-yam-[0-9a-z-]+)?$/.test(environmentName)) {
897
+ throw new Error(`existing Scrapling environment is not versioned: ${environmentName}`);
898
+ }
899
+ const realRoot = await fsp.realpath(deps.paths.scraplingRoot);
900
+ const realTarget = await fsp.realpath(resolvedTarget);
901
+ assertChildPath(realRoot, realTarget, 'existing Scrapling real executable');
902
+ const environmentDir = path.dirname(path.dirname(resolvedTarget));
903
+ const markerFile = path.join(environmentDir, '.yam-scrapling-install.json');
904
+ let marker: JsonRecord;
905
+ try {
906
+ marker = JSON.parse(await fsp.readFile(markerFile, 'utf8'));
907
+ } catch {
908
+ throw new Error(`existing Scrapling environment lacks a readable yam ownership marker: ${markerFile}`);
909
+ }
910
+ const markerVersion = requireStableVersion(marker?.version, 'Scrapling ownership marker version');
911
+ if (
912
+ marker?.schema !== 'yam.scrapling-install.v1'
913
+ || path.resolve(String(marker?.executable || '')) !== resolvedTarget
914
+ || extractVersion(environmentName) !== markerVersion
915
+ ) {
916
+ throw new Error(`existing Scrapling ownership marker does not match the executable: ${markerFile}`);
917
+ }
918
+ return resolvedTarget;
919
+ }
920
+
921
+ async function atomicSwitchSymlink(link: string, target: string) {
922
+ await fsp.mkdir(path.dirname(link), { recursive: true });
923
+ const temporary = `${link}.yam-update-${randomUUID().slice(0, 8)}`;
924
+ await fsp.symlink(target, temporary);
925
+ try {
926
+ await fsp.rename(temporary, link);
927
+ } catch (error) {
928
+ await unlinkIfExists(temporary);
929
+ throw error;
930
+ }
931
+ }
932
+
933
+ async function restoreScraplingLink(link: string, previousTarget: string) {
934
+ try {
935
+ const current = await fsp.lstat(link);
936
+ if (!current.isSymbolicLink()) return false;
937
+ if (previousTarget) {
938
+ await atomicSwitchSymlink(link, previousTarget);
939
+ } else {
940
+ await unlinkIfExists(link);
941
+ }
942
+ return true;
943
+ } catch {
944
+ return false;
945
+ }
946
+ }
947
+
948
+ async function removeCreatedCandidate(candidate: string, root: string) {
949
+ if (!candidate) return;
950
+ assertChildPath(root, candidate, 'failed Scrapling candidate');
951
+ await fsp.rm(candidate, { recursive: true, force: true });
952
+ }
953
+
954
+ async function readSymlinkTarget(file: string) {
955
+ try {
956
+ const stat = await fsp.lstat(file);
957
+ if (!stat.isSymbolicLink()) return '';
958
+ const target = await fsp.readlink(file);
959
+ return path.resolve(path.dirname(file), target);
960
+ } catch {
961
+ return '';
962
+ }
963
+ }
964
+
965
+ function yamRollback(previousVersion: string) {
966
+ return {
967
+ automatic: false,
968
+ previous_target: previousVersion,
969
+ guidance: previousVersion
970
+ ? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, and \`yam status\`.`
971
+ : 'Inspect the global npm installation before attempting a manual rollback.'
972
+ };
973
+ }
974
+
975
+ function scraplingRollback(previousTarget: string, link: string) {
976
+ return {
977
+ automatic: false,
978
+ previous_target: previousTarget,
979
+ guidance: previousTarget
980
+ ? `The previous environment is retained. Restore ${link} atomically to ${previousTarget}, then verify \`${link} --version\`.`
981
+ : `No previous managed target was found. Remove only the yam-created ${link} symlink if a rollback is required.`
982
+ };
983
+ }
984
+
985
+ function insaneRollback(previousVersion: string) {
986
+ return {
987
+ automatic: false,
988
+ previous_target: previousVersion,
989
+ guidance: 'Codex CLI has no version-pinned plugin rollback command. Do not edit `.codex/plugins/cache`; use an official Codex plugin workflow or restore a reviewed marketplace revision manually.'
990
+ };
991
+ }
992
+
993
+ function genericRollback(check: ExternalComponentCheck) {
994
+ if (check.component === 'yam') return yamRollback(check.installed_version);
995
+ if (check.component === 'insane-search') return insaneRollback(check.installed_version);
996
+ return {
997
+ automatic: false,
998
+ previous_target: '',
999
+ guidance: 'No mutation was attempted because the read-only update check failed.'
1000
+ };
1001
+ }
1002
+
1003
+ function commandCheck(id: string, result: ExternalCommandResult): ExternalComponentReceipt['checks'][number] {
1004
+ return {
1005
+ id,
1006
+ status: result.ok ? 'passed' : 'failed',
1007
+ note: commandNote(result)
1008
+ };
1009
+ }
1010
+
1011
+ function commandNote(result: ExternalCommandResult) {
1012
+ const text = redactSensitiveText([result.stdout, result.stderr].filter(Boolean).join('\n'));
1013
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1014
+ return (lines.slice(-4).join(' | ') || `exit ${result.status ?? 'unknown'}`).slice(0, 800);
1015
+ }
1016
+
1017
+ async function defaultRun(command: string, args: string[], options: ExternalCommandOptions = {}) {
1018
+ const result = spawnSync(command, args, {
1019
+ cwd: options.cwd || process.cwd(),
1020
+ env: options.env || process.env,
1021
+ encoding: 'utf8',
1022
+ timeout: options.timeout || 30000,
1023
+ shell: false,
1024
+ maxBuffer: 8 * 1024 * 1024
1025
+ });
1026
+ return {
1027
+ ok: result.status === 0,
1028
+ status: typeof result.status === 'number' ? result.status : null,
1029
+ stdout: redactSensitiveText(String(result.stdout || '')),
1030
+ stderr: redactSensitiveText(String(result.stderr || result.error?.message || ''))
1031
+ };
1032
+ }
1033
+
1034
+ async function defaultFetchJson(url: string) {
1035
+ const response = await fetch(url, {
1036
+ headers: {
1037
+ accept: 'application/json',
1038
+ 'user-agent': 'yam-flow external-update-check'
1039
+ },
1040
+ signal: AbortSignal.timeout(30000)
1041
+ });
1042
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
1043
+ return response.json();
1044
+ }
1045
+
1046
+ function requireStableVersion(value: unknown, label: string) {
1047
+ const version = String(value || '').trim();
1048
+ if (!/^\d+(?:\.\d+){1,3}$/.test(version)) {
1049
+ throw new Error(`${label} is not an allowed stable numeric version`);
1050
+ }
1051
+ return version;
1052
+ }
1053
+
1054
+ function extractVersion(text: string) {
1055
+ return String(text || '').match(/\b\d+(?:\.\d+){1,3}\b/)?.[0] || '';
1056
+ }
1057
+
1058
+ function compareVersions(left: string, right: string) {
1059
+ const a = left.split('.').map(Number);
1060
+ const b = right.split('.').map(Number);
1061
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
1062
+ const delta = (a[index] || 0) - (b[index] || 0);
1063
+ if (delta !== 0) return delta;
1064
+ }
1065
+ return 0;
1066
+ }
1067
+
1068
+ function parseJsonOutput(text: string, label: string) {
1069
+ try {
1070
+ return JSON.parse(String(text || ''));
1071
+ } catch {
1072
+ throw new Error(`${label} did not return valid JSON`);
1073
+ }
1074
+ }
1075
+
1076
+ function arrayValue(value: any) {
1077
+ return Array.isArray(value) ? value : [];
1078
+ }
1079
+
1080
+ function errorMessage(error: unknown) {
1081
+ return error instanceof Error ? error.message : String(error);
1082
+ }
1083
+
1084
+ function expandHome(value: string, homeDir: string) {
1085
+ return String(value || '').replace(/^~(?=$|\/)/, homeDir);
1086
+ }
1087
+
1088
+ function safeTimestamp(date: Date) {
1089
+ return date.toISOString().replace(/[^0-9A-Za-z]/g, '').toLowerCase();
1090
+ }
1091
+
1092
+ function isSafeMarketplaceRoot(root: string, homeDir: string) {
1093
+ const resolved = path.resolve(root);
1094
+ const marketplaceRoot = path.join(homeDir, '.codex', '.tmp', 'marketplaces');
1095
+ return resolved.startsWith(`${marketplaceRoot}${path.sep}`);
1096
+ }
1097
+
1098
+ function assertChildPath(root: string, candidate: string, label: string) {
1099
+ const resolvedRoot = path.resolve(root);
1100
+ const resolvedCandidate = path.resolve(candidate);
1101
+ if (resolvedCandidate === resolvedRoot || !resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`)) {
1102
+ throw new Error(`${label} must stay under ${resolvedRoot}`);
1103
+ }
1104
+ }
1105
+
1106
+ async function pathExists(file: string) {
1107
+ try {
1108
+ await fsp.lstat(file);
1109
+ return true;
1110
+ } catch {
1111
+ return false;
1112
+ }
1113
+ }
1114
+
1115
+ async function fileContains(file: string, expected: string) {
1116
+ try {
1117
+ return (await fsp.readFile(file, 'utf8')).includes(expected);
1118
+ } catch {
1119
+ return false;
1120
+ }
1121
+ }
1122
+
1123
+ async function unlinkIfExists(file: string) {
1124
+ try {
1125
+ await fsp.unlink(file);
1126
+ } catch (error) {
1127
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1128
+ }
1129
+ }
1130
+
1131
+ function redactSensitiveText(text: string) {
1132
+ return String(text || '')
1133
+ .replace(/(_authToken\s*=\s*)[^\s]+/gi, '$1[redacted]')
1134
+ .replace(/\b(password|passwd|api[_-]?key|secret|token)\s*[:=]\s*\S+/gi, '$1=[redacted]')
1135
+ .replace(/\/\/([^:\s]+):([^@\s]+)@/g, '//[redacted]@')
1136
+ .replace(/\b(npm_[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g, '[redacted-token]');
1137
+ }
1138
+
1139
+ function firstFailureNextAction(receipts: ExternalComponentReceipt[]) {
1140
+ const receipt = receipts.find((item) => (
1141
+ item.outcome === 'failed'
1142
+ || item.outcome === 'manual_plugin_update_required'
1143
+ || item.persistence === 'failed'
1144
+ ));
1145
+ if (!receipt) return 'inspect the incomplete component inventory before retrying';
1146
+ if (receipt.outcome === 'manual_plugin_update_required') {
1147
+ return 'update Insane Search through a reviewed official Codex plugin workflow; do not remove-first or edit the plugin cache directly';
1148
+ }
1149
+ return receipt.rollback_hint.guidance || `resolve the ${receipt.component} failure before retrying`;
1150
+ }