check-update.mjs
1717 lines · archify/scripts/check-update.mjs at a406594
1#!/usr/bin/env node
3import crypto from 'node:crypto';
4import { constants as fsConstants } from 'node:fs';
5import fs from 'node:fs/promises';
6import os from 'node:os';
7import path from 'node:path';
8import { performance } from 'node:perf_hooks';
9import { fileURLToPath } from 'node:url';
11import {
12 DEFAULT_MANIFEST_URL,
13 SKILL_ID,
14 UpdateContractError,
15 compareSemver,
16 isStableCoreVersion,
17 parseSemver,
18 validateLocalRelease,
19 validateReleaseNotesUrl,
20 validateReleaseSummary,
21 validateStableUpdateManifest,
22} from './update-contract.mjs';
23import { sameEntry } from '../renderers/shared/path-semantics.mjs';
25const OPERATION_STATE_FILE = 'state.json';
26const OPERATION_OWNER_FILE = 'owner.json';
27const ACTIVE_CLAIM_DIRECTORY = 'active-claim';
28const OPERATION_NAME = /^(reserved|pending|committed|fenced|cancelled)-(\d{1,20})$/;
29const MAX_OPERATION_GENERATION = (10n ** 20n) - 1n;
30const CHECK_TTL_MS = 24 * 60 * 60 * 1_000;
31const MAX_CACHE_HORIZON_MS = Math.ceil(CHECK_TTL_MS * 1.2);
32const FIRST_FAILURE_DELAY_MS = 6 * 60 * 60 * 1_000;
33const LATER_FAILURE_DELAY_MS = 24 * 60 * 60 * 1_000;
34const DEFAULT_TIMEOUT_MS = 1_000;
35const MAX_RESPONSE_BYTES = 32 * 1_024;
36const MAX_LOCAL_RELEASE_BYTES = 4 * 1_024;
37const MAX_CACHE_STATE_BYTES = 64 * 1_024;
38const MAX_CLAIM_OWNER_BYTES = 1_024;
39const LONGEST_ISO_TIMESTAMP = '+275760-09-13T00:00:00.000Z';
40const LOCK_STALE_MS = 30_000;
41const PREFERENCE_LOCK_WAIT_MS = 1_200;
42const LOCK_RETRY_DELAY_MS = 20;
43const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
44const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
45const defaultReleasePath = path.resolve(scriptDirectory, '..', 'skill-release.json');
47class FileIdentityChangedError extends Error {}
48class ManifestWithdrawnError extends Error {}
50function silent(reason) {
51 return { status: 'silent', reason };
52}
54function updatesDisabled() {
55 return process.env.ARCHIFY_UPDATE_CHECK_DISABLED === '1';
56}
58function isPlainObject(value) {
59 return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
60}
62function hasOnlyKeys(value, allowed) {
63 return Object.keys(value).every((key) => allowed.has(key));
64}
66function validateManifest(value) {
67 const manifest = validateStableUpdateManifest(value);
68 return {
69 version: manifest.version,
70 targetDigest: `sha256:${manifest.artifact.sha256}`,
71 severity: manifest.severity,
72 summary: manifest.summary,
73 releaseNotes: manifest.releaseNotes,
74 };
75}
77function validateCachedCandidate(value) {
78 if (!isPlainObject(value)
79 || !hasOnlyKeys(value, new Set([
80 'version', 'targetDigest', 'severity', 'releaseNotes', 'checkedAt', 'summary',
81 ]))) return null;
82 try {
83 if (!isStableCoreVersion(value.version)) return null;
84 if (!DIGEST_PATTERN.test(value.targetDigest)
85 || !['normal', 'security'].includes(value.severity)) return null;
86 validateReleaseNotesUrl(value.releaseNotes, value.version);
87 if (Object.hasOwn(value, 'summary')) validateReleaseSummary(value.summary);
88 if (typeof value.checkedAt !== 'string' || !Number.isFinite(Date.parse(value.checkedAt))) return null;
89 return { ...value };
90 } catch {
91 return null;
92 }
93}
95function emptyState(installedVersion = null) {
96 return {
97 schemaVersion: 2,
98 skillId: SKILL_ID,
99 installedVersion,
100 check: {
101 nextCheckAt: null,
102 consecutiveFailures: 0,
103 },
104 preferences: [],
105 };
106}
108function validPreference(value) {
109 return isPlainObject(value)
110 && hasOnlyKeys(value, new Set(['targetDigest', 'version', 'mode', 'until']))
111 && DIGEST_PATTERN.test(value.targetDigest)
112 && isStableCoreVersion(value.version)
113 && ['ignore', 'snooze'].includes(value.mode)
114 && (value.mode === 'ignore' ? value.until === null
115 : typeof value.until === 'string' && Number.isFinite(Date.parse(value.until)));
116}
118function encodeBoundedState(state) {
119 const encoded = Buffer.from(`${JSON.stringify(state)}\n`, 'utf8');
120 return encoded.length <= MAX_CACHE_STATE_BYTES ? encoded : null;
121}
123function stateWithFailureCheck(state, nextCheckAt, consecutiveFailures, withdrawCandidate = false) {
124 const failedState = {
125 ...state,
126 check: { nextCheckAt, consecutiveFailures },
127 };
128 if (withdrawCandidate) delete failedState.candidate;
129 return failedState;
130}
132// A committed state must leave room for its own failure backoff.
133function encodeRecoverableState(state) {
134 const encoded = encodeBoundedState(state);
135 if (!encoded || !encodeBoundedState(stateWithFailureCheck(state, LONGEST_ISO_TIMESTAMP, 2))) return null;
136 return encoded;
137}
139function versionCacheDirectory(cacheDirectory, installedVersion) {
140 const partition = crypto.createHash('sha256').update(installedVersion).digest('hex').slice(0, 24);
141 return path.join(cacheDirectory, `version-${partition}`);
142}
144function normalizeState(value, installedVersion) {
145 const effectiveInstalledVersion = installedVersion ?? value?.installedVersion ?? null;
146 if (!isPlainObject(value) || value.schemaVersion !== 2 || value.skillId !== SKILL_ID
147 || (installedVersion !== null && value.installedVersion !== installedVersion)
148 || (value.installedVersion !== null && typeof value.installedVersion !== 'string')
149 || !isPlainObject(value.check)
150 || !(value.check.nextCheckAt === null || typeof value.check.nextCheckAt === 'string')
151 || !Number.isSafeInteger(value.check.consecutiveFailures)
152 || value.check.consecutiveFailures < 0
153 || !Array.isArray(value.preferences) || value.preferences.length > 32
154 || value.preferences.some((preference) => !validPreference(preference))) return null;
155 if (effectiveInstalledVersion !== null) {
156 try {
157 parseSemver(effectiveInstalledVersion);
158 } catch {
159 return null;
160 }
161 }
163 const state = emptyState(effectiveInstalledVersion);
164 state.check.nextCheckAt = value.check.nextCheckAt;
165 state.check.consecutiveFailures = value.check.consecutiveFailures;
166 state.preferences = value.preferences.map((preference) => ({ ...preference }));
167 const hasCandidate = Object.hasOwn(value, 'candidate');
168 const candidate = validateCachedCandidate(value.candidate);
169 if (hasCandidate && !candidate) return null;
170 if (candidate) state.candidate = candidate;
171 return encodeRecoverableState(state) ? state : null;
172}
174function isSameFile(left, right) {
175 if (left.dev !== right.dev || left.ino !== right.ino) return false;
176 if (left.ino !== 0n) return true;
177 if (left.birthtimeNs === 0n || right.birthtimeNs === 0n) return false;
178 return left.birthtimeNs === right.birthtimeNs
179 && left.mode === right.mode;
180}
182function assertBoundedRegularFile(metadata, maxBytes) {
183 if (!metadata.isFile()
184 || metadata.isSymbolicLink()
185 || metadata.size > BigInt(maxBytes)) {
186 throw new Error('unsafe JSON file');
187 }
188}
190async function readJsonFile(target, maxBytes, expectedMetadata = null) {
191 const pathMetadata = await fs.lstat(target, { bigint: true });
192 if (expectedMetadata && !isSameFile(expectedMetadata, pathMetadata)) {
193 throw new FileIdentityChangedError('JSON file identity changed');
194 }
195 assertBoundedRegularFile(pathMetadata, maxBytes);
196 const flags = fsConstants.O_RDONLY
197 | (fsConstants.O_NOFOLLOW ?? 0)
198 | (fsConstants.O_NONBLOCK ?? 0);
199 const handle = await fs.open(target, flags);
200 try {
201 const handleMetadata = await handle.stat({ bigint: true });
202 const currentPathMetadata = await fs.lstat(target, { bigint: true });
203 assertBoundedRegularFile(handleMetadata, maxBytes);
204 assertBoundedRegularFile(currentPathMetadata, maxBytes);
205 if (!isSameFile(pathMetadata, handleMetadata)
206 || !isSameFile(currentPathMetadata, handleMetadata)) {
207 throw new FileIdentityChangedError('JSON file changed while opening');
208 }
210 const chunks = [];
211 let size = 0;
212 while (size <= maxBytes) {
213 const chunk = Buffer.allocUnsafe(Math.min(8 * 1_024, (maxBytes + 1) - size));
214 const { bytesRead } = await handle.read(chunk, 0, chunk.length, size);
215 if (bytesRead === 0) break;
216 chunks.push(chunk.subarray(0, bytesRead));
217 size += bytesRead;
218 }
219 if (size > maxBytes) throw new Error('JSON file is too large');
220 return JSON.parse(Buffer.concat(chunks, size).toString('utf8'));
221 } finally {
222 await handle.close();
223 }
224}
226function isWithinDirectory(directory, target) {
227 const relative = path.relative(directory, target);
228 return relative === ''
229 // path-contract-allow: lexical-capability -- No-follow snapshots bind this mutation boundary.
230 || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
231}
233async function physicalDirectorySuffix(directory, target) {
234 const absoluteTarget = path.resolve(target);
235 const parsed = path.parse(absoluteTarget);
236 const segments = absoluteTarget.slice(parsed.root.length).split(path.sep).filter(Boolean);
237 let prefix = parsed.root;
238 for (let index = 0; index < segments.length; index += 1) {
239 prefix = path.join(prefix, segments[index]);
240 const relation = sameEntry(directory, prefix);
241 if (relation.status === 'match') {
242 const metadata = await fs.lstat(prefix, { bigint: true });
243 if (metadata.isSymbolicLink()) {
244 throw new Error(`trusted cache prefix is a symbolic link or junction: ${prefix}`);
245 }
246 return segments.slice(index + 1);
247 }
248 if (relation.status === 'unknown' && relation.reason.code === 'entry-missing') break;
249 }
250 return null;
251}
253async function canonicalizeTrustedDirectoryPrefix(target) {
254 const absoluteTarget = path.resolve(target);
255 const trustedDirectories = [...new Set([os.homedir(), os.tmpdir()].map((directory) => (
256 path.resolve(directory)
257 )))].sort((left, right) => right.length - left.length);
258 for (const trustedDirectory of trustedDirectories) {
259 const suffix = await physicalDirectorySuffix(trustedDirectory, absoluteTarget);
260 if (suffix === null) continue;
261 try {
262 const canonicalDirectory = await fs.realpath(trustedDirectory);
263 return path.resolve(canonicalDirectory, ...suffix);
264 } catch {
265 // Fall back to validating the absolute path from its filesystem root.
266 }
267 }
268 return target;
269}
271function assertSafeDirectory(metadata) {
272 if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
273 throw new Error('unsafe cache directory');
274 }
275}
277class CachePathChangedError extends Error {}
278class CacheOperationRaceError extends Error {}
279class OperationFencedError extends Error {}
281const preparedCacheDirectories = new Map();
283function invalidateCacheToken(token, message, cause) {
284 token.trusted = false;
285 if (cause instanceof CachePathChangedError) return cause;
286 const error = new CachePathChangedError(message);
287 if (cause) error.cause = cause;
288 return error;
289}
291async function verifyDirectorySnapshots(token, snapshots, description) {
292 if (!token.trusted) throw new CachePathChangedError('cache directory is no longer trusted');
293 try {
294 for (const snapshot of snapshots) {
295 const metadata = await fs.lstat(snapshot.directory, { bigint: true });
296 assertSafeDirectory(metadata);
297 if (!isSameFile(snapshot.metadata, metadata)) {
298 throw new Error(`${description} changed`);
299 }
300 }
301 } catch (error) {
302 throw invalidateCacheToken(token, `${description} changed`, error);
303 }
304}
306async function verifyCacheToken(token) {
307 await verifyDirectorySnapshots(token, token.ancestorSnapshots, 'cache directory');
308}
310async function guardedCacheRead(token, read) {
311 await verifyCacheToken(token);
312 let result;
313 try {
314 result = await read();
315 } catch (error) {
316 await verifyCacheToken(token);
317 throw error;
318 }
319 await verifyCacheToken(token);
320 return result;
321}
323function cacheTokenFor(cacheDirectory) {
324 const token = preparedCacheDirectories.get(path.resolve(cacheDirectory));
325 if (!token || !token.trusted) {
326 throw new CachePathChangedError('cache directory has not been prepared safely');
327 }
328 return token;
329}
331function resolveCacheTarget(token, target) {
332 const resolved = path.resolve(target);
333 const relative = path.relative(token.directory, resolved);
334 // This is a lexical capability check, not a physical-containment query. The
335 // async lstat snapshots below bind the prepared directory and every mutation
336 // parent across awaits; following a link here would weaken that no-link rule.
337 if (relative === '' || !isWithinDirectory(token.directory, resolved)) {
338 throw invalidateCacheToken(token, 'cache mutation escaped its prepared directory');
339 }
340 return resolved;
341}
343async function captureMutationParentSnapshots(token, targets) {
344 await verifyCacheToken(token);
345 const snapshots = new Map();
346 try {
347 for (const target of targets) {
348 const parent = path.dirname(resolveCacheTarget(token, target));
349 const relative = path.relative(token.directory, parent);
350 let current = token.directory;
351 for (const component of relative.split(path.sep).filter(Boolean)) {
352 current = path.join(current, component);
353 if (snapshots.has(current)) continue;
354 const metadata = await fs.lstat(current, { bigint: true });
355 assertSafeDirectory(metadata);
356 snapshots.set(current, { directory: current, metadata });
357 }
358 }
359 } catch (error) {
360 throw invalidateCacheToken(token, 'cache mutation parent changed', error);
361 }
362 const captured = [...snapshots.values()];
363 await verifyMutationParentSnapshots(token, captured);
364 await verifyCacheToken(token);
365 return captured;
366}
368async function verifyMutationParentSnapshots(token, snapshots) {
369 try {
370 for (const snapshot of snapshots) {
371 const metadata = await fs.lstat(snapshot.directory, { bigint: true });
372 assertSafeDirectory(metadata);
373 if (!isSameFile(snapshot.metadata, metadata)) {
374 throw new Error('cache mutation parent changed');
375 }
376 }
377 } catch (error) {
378 const raced = new CacheOperationRaceError('cache mutation parent changed');
379 raced.cause = error;
380 throw raced;
381 }
382}
384async function verifyMutationContext(token, parentSnapshots) {
385 await verifyCacheToken(token);
386 await verifyMutationParentSnapshots(token, parentSnapshots);
387 await verifyCacheToken(token);
388}
390function assertSafeCacheEntry(metadata) {
391 if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) {
392 throw new Error('unsafe cache entry');
393 }
394}
396function assertSafeRegularFile(metadata) {
397 if (!metadata.isFile() || metadata.isSymbolicLink()) {
398 throw new Error('unsafe cache file');
399 }
400}
402function assertRenameableCacheEntry(metadata) {
403 if (!metadata.isDirectory() && !metadata.isFile() && !metadata.isSymbolicLink()) {
404 throw new Error('unsafe cache rename entry');
405 }
406}
408async function stableCacheEntry(
409 token,
410 target,
411 validate,
412 expectedMetadata = null,
413 missingIsOperationRace = false,
414) {
415 const resolved = resolveCacheTarget(token, target);
416 try {
417 const first = await fs.lstat(resolved, { bigint: true });
418 validate(first);
419 if (expectedMetadata && !isSameFile(expectedMetadata, first)) {
420 throw new Error('cache entry identity changed');
421 }
422 await verifyCacheToken(token);
423 const second = await fs.lstat(resolved, { bigint: true });
424 validate(second);
425 if (!isSameFile(first, second)
426 || (expectedMetadata && !isSameFile(expectedMetadata, second))) {
427 throw new Error('cache entry identity changed');
428 }
429 return second;
430 } catch (error) {
431 if (missingIsOperationRace && error?.code === 'ENOENT') {
432 await verifyCacheToken(token);
433 throw error;
434 }
435 throw invalidateCacheToken(token, 'cache mutation produced an untrusted object', error);
436 }
437}
439async function guardedCacheEntryRead(
440 token,
441 target,
442 validate,
443 read,
444 expectedMetadata = null,
445) {
446 const metadata = await stableCacheEntry(
447 token,
448 target,
449 validate,
450 expectedMetadata,
451 true,
452 );
453 let result;
454 try {
455 result = await read(metadata);
456 } catch (error) {
457 await stableCacheEntry(token, target, validate, metadata, true);
458 throw error;
459 }
460 await stableCacheEntry(token, target, validate, metadata, true);
461 return result;
462}
464async function stablePreparedDirectory(token, target) {
465 try {
466 const first = await fs.lstat(target, { bigint: true });
467 assertSafeDirectory(first);
468 await verifyCacheToken(token);
469 const second = await fs.lstat(target, { bigint: true });
470 assertSafeDirectory(second);
471 if (!isSameFile(first, second)) throw new Error('prepared directory identity changed');
472 return second;
473 } catch (error) {
474 throw invalidateCacheToken(token, 'prepared directory could not be verified', error);
475 }
476}
478async function assertCacheTargetAbsent(token, target) {
479 const resolved = resolveCacheTarget(token, target);
480 try {
481 await fs.lstat(resolved, { bigint: true });
482 } catch (error) {
483 if (error?.code === 'ENOENT') return;
484 throw invalidateCacheToken(token, 'cache mutation target could not be verified', error);
485 }
486 throw invalidateCacheToken(token, 'cache mutation left an unexpected object');
487}
489async function guardedCacheMutation(token, parentSnapshots, {
490 mutate,
491 verifyBefore = null,
492 verifyAfter,
493}) {
494 await verifyMutationContext(token, parentSnapshots);
495 if (verifyBefore) await verifyBefore();
496 let result;
497 try {
498 result = await mutate();
499 } catch (error) {
500 await verifyMutationContext(token, parentSnapshots);
501 throw error;
502 }
503 try {
504 await verifyMutationContext(token, parentSnapshots);
505 const verified = await verifyAfter(result);
506 await verifyMutationContext(token, parentSnapshots);
507 return verified ?? result;
508 } catch (error) {
509 if (error instanceof CacheOperationRaceError) throw error;
510 throw invalidateCacheToken(token, 'cache mutation could not be verified', error);
511 }
512}
514async function cacheMkdirWithToken(token, target, options, parentSnapshots = null) {
515 const resolved = path.resolve(target);
516 const parents = parentSnapshots ?? await captureMutationParentSnapshots(token, [resolved]);
517 return guardedCacheMutation(token, parents, {
518 mutate: () => fs.mkdir(resolved, options),
519 verifyAfter: () => (parentSnapshots === null
520 ? stableCacheEntry(token, resolved, assertSafeDirectory)
521 : stablePreparedDirectory(token, resolved)),
522 });
523}
525async function cacheMkdir(cacheDirectory, target, options) {
526 return cacheMkdirWithToken(cacheTokenFor(cacheDirectory), target, options);
527}
529async function cacheWriteFile(cacheDirectory, target, data, options) {
530 if (options?.flag !== 'wx') {
531 throw new Error('cache files must be created exclusively');
532 }
533 const token = cacheTokenFor(cacheDirectory);
534 const resolved = resolveCacheTarget(token, target);
535 const parents = await captureMutationParentSnapshots(token, [resolved]);
536 return guardedCacheMutation(token, parents, {
537 mutate: async () => {
538 const flags = fsConstants.O_WRONLY
539 | fsConstants.O_CREAT
540 | fsConstants.O_EXCL
541 | (fsConstants.O_NOFOLLOW ?? 0);
542 const handle = await fs.open(resolved, flags, options.mode);
543 try {
544 const openedMetadata = await handle.stat({ bigint: true });
545 if (!openedMetadata.isFile() || openedMetadata.isSymbolicLink()) {
546 throw new Error('cache write did not open a regular file');
547 }
548 await handle.writeFile(data, options.encoding ? { encoding: options.encoding } : undefined);
549 const writtenMetadata = await handle.stat({ bigint: true });
550 const expectedBytes = Buffer.isBuffer(data)
551 ? data.length
552 : Buffer.byteLength(data, options.encoding || 'utf8');
553 if (!isSameFile(openedMetadata, writtenMetadata)
554 || writtenMetadata.size !== BigInt(expectedBytes)) {
555 throw new Error('cache write did not preserve the opened file identity and size');
556 }
557 return writtenMetadata;
558 } finally {
559 await handle.close();
560 }
561 },
562 verifyAfter: (writtenMetadata) => stableCacheEntry(token, resolved, (metadata) => {
563 if (!metadata.isFile() || metadata.isSymbolicLink()) {
564 throw new Error('cache write did not create a regular file');
565 }
566 }, writtenMetadata),
567 });
568}
570async function cacheRename(
571 cacheDirectory,
572 source,
573 destination,
574 validate,
575 expectedSourceMetadata = null,
576) {
577 if (typeof validate !== 'function') {
578 throw new TypeError('cache rename requires an explicit entry validator');
579 }
580 const token = cacheTokenFor(cacheDirectory);
581 const resolvedSource = resolveCacheTarget(token, source);
582 const resolvedDestination = resolveCacheTarget(token, destination);
583 const parents = await captureMutationParentSnapshots(
584 token,
585 [resolvedSource, resolvedDestination],
586 );
587 const sourceMetadata = await stableCacheEntry(
588 token,
589 resolvedSource,
590 validate,
591 expectedSourceMetadata,
592 true,
593 );
594 return guardedCacheMutation(token, parents, {
595 verifyBefore: () => stableCacheEntry(
596 token,
597 resolvedSource,
598 validate,
599 sourceMetadata,
600 true,
601 ),
602 mutate: () => fs.rename(resolvedSource, resolvedDestination),
603 verifyAfter: async () => {
604 await assertCacheTargetAbsent(token, resolvedSource);
605 return stableCacheEntry(
606 token,
607 resolvedDestination,
608 validate,
609 sourceMetadata,
610 );
611 },
612 });
613}
615async function cacheRm(cacheDirectory, target, options) {
616 if (options?.recursive) {
617 throw new Error('recursive cache removal is not allowed');
618 }
619 const token = cacheTokenFor(cacheDirectory);
620 const resolved = resolveCacheTarget(token, target);
621 const parents = await captureMutationParentSnapshots(token, [resolved]);
622 let initialMetadata;
623 try {
624 initialMetadata = await fs.lstat(resolved, { bigint: true });
625 } catch (error) {
626 if (error?.code === 'ENOENT' && options?.force) {
627 await verifyMutationContext(token, parents);
628 return;
629 }
630 throw error;
631 }
632 try {
633 assertSafeCacheEntry(initialMetadata);
634 } catch (error) {
635 throw invalidateCacheToken(token, 'cache removal target is unsafe', error);
636 }
637 const targetMetadata = await stableCacheEntry(
638 token,
639 resolved,
640 assertSafeCacheEntry,
641 initialMetadata,
642 );
643 return guardedCacheMutation(token, parents, {
644 verifyBefore: () => stableCacheEntry(
645 token,
646 resolved,
647 assertSafeCacheEntry,
648 targetMetadata,
649 ),
650 mutate: () => fs.rm(resolved, options),
651 verifyAfter: () => assertCacheTargetAbsent(token, resolved),
652 });
653}
655async function prepareCacheDirectory(cacheDirectory) {
656 const absoluteTarget = path.resolve(cacheDirectory);
657 const target = await canonicalizeTrustedDirectoryPrefix(absoluteTarget);
658 const parsed = path.parse(target);
659 const components = target.slice(parsed.root.length).split(path.sep).filter(Boolean);
660 let current = parsed.root;
661 const token = {
662 directory: target,
663 ancestorSnapshots: [],
664 trusted: true,
665 };
667 for (const component of components) {
668 current = path.join(current, component);
669 let metadata;
670 try {
671 metadata = await fs.lstat(current, { bigint: true });
672 } catch (error) {
673 if (error?.code !== 'ENOENT') throw error;
674 try {
675 await cacheMkdirWithToken(token, current, { mode: 0o700 }, []);
676 } catch (mkdirError) {
677 if (mkdirError?.code !== 'EEXIST') throw mkdirError;
678 }
679 metadata = await fs.lstat(current, { bigint: true });
680 }
681 assertSafeDirectory(metadata);
682 token.ancestorSnapshots.push({ directory: current, metadata });
683 }
685 await verifyCacheToken(token);
686 preparedCacheDirectories.set(target, token);
687 return target;
688}
690function operationName(kind, generation) {
691 return `${kind}-${generation.toString().padStart(20, '0')}`;
692}
694function parseOperationName(name) {
695 const match = OPERATION_NAME.exec(name);
696 if (!match) return null;
697 return { kind: match[1], generation: BigInt(match[2]) };
698}
700async function listOperations(cacheDirectory, token = cacheTokenFor(cacheDirectory)) {
701 return guardedCacheRead(token, async () => {
702 try {
703 const entries = await fs.readdir(cacheDirectory, { withFileTypes: true });
704 return entries.flatMap((entry) => {
705 const parsed = parseOperationName(entry.name);
706 if (!parsed) return [];
707 if (!entry.isDirectory() || entry.isSymbolicLink()) {
708 throw invalidateCacheToken(
709 token,
710 `cache operation ${entry.name} is not a safe directory`,
711 );
712 }
713 return [{ ...parsed, name: entry.name, directory: path.join(cacheDirectory, entry.name) }];
714 });
715 } catch (error) {
716 if (error?.code === 'ENOENT') return [];
717 throw error;
718 }
719 });
720}
722async function readState(cacheDirectory, installedVersion = null) {
723 const token = cacheTokenFor(cacheDirectory);
724 return guardedCacheRead(token, async () => {
725 const operations = (await listOperations(cacheDirectory, token))
726 .filter((operation) => operation.kind === 'committed')
727 .sort((left, right) => (left.generation > right.generation ? -1 : 1));
728 for (const operation of operations) {
729 try {
730 const state = await guardedCacheEntryRead(
731 token,
732 operation.directory,
733 assertSafeDirectory,
734 async () => normalizeState(
735 await readJsonFile(
736 path.join(operation.directory, OPERATION_STATE_FILE),
737 MAX_CACHE_STATE_BYTES,
738 ),
739 installedVersion,
740 ),
741 );
742 if (state) return state;
743 } catch (error) {
744 if (error instanceof FileIdentityChangedError
745 || error instanceof CachePathChangedError
746 || error instanceof CacheOperationRaceError) throw error;
747 // An incomplete or corrupt generation is ignored in favor of the previous commit.
748 }
749 }
750 return emptyState(installedVersion);
751 });
752}
754async function operationIsActive(
755 cacheDirectory,
756 operation,
757 token = cacheTokenFor(cacheDirectory),
758) {
759 try {
760 return await guardedCacheEntryRead(
761 token,
762 operation.directory,
763 assertSafeDirectory,
764 async (directoryMetadata) => {
765 try {
766 const ownerMetadata = await fs.lstat(
767 path.join(operation.directory, OPERATION_OWNER_FILE),
768 { bigint: true },
769 );
770 if (!ownerMetadata.isFile() || ownerMetadata.isSymbolicLink()) return false;
771 try {
772 const owner = await readJsonFile(
773 path.join(operation.directory, OPERATION_OWNER_FILE),
774 MAX_CLAIM_OWNER_BYTES,
775 ownerMetadata,
776 );
777 if (Number.isSafeInteger(owner?.pid) && owner.pid > 0 && processIsGone(owner.pid)) {
778 return false;
779 }
780 } catch (error) {
781 if (error instanceof FileIdentityChangedError) throw error;
782 // A malformed owner still gets the hard lease before recovery.
783 }
784 return metadataIsWithinLease(ownerMetadata);
785 } catch (error) {
786 if (error?.code !== 'ENOENT') throw error;
787 return metadataIsWithinLease(directoryMetadata);
788 }
789 },
790 );
791 } catch (error) {
792 if (error?.code === 'ENOENT') return false;
793 throw error;
794 }
795}
797function processIsGone(pid) {
798 try {
799 process.kill(pid, 0);
800 return false;
801 } catch (error) {
802 return error?.code === 'ESRCH';
803 }
804}
806function validateClaimOwner(value) {
807 if (!isPlainObject(value)
808 || !hasOnlyKeys(value, new Set(['pid', 'token', 'generation']))
809 || !Number.isSafeInteger(value.pid)
810 || value.pid <= 0
811 || typeof value.token !== 'string'
812 || !/^[a-f0-9]{32}$/.test(value.token)
813 || typeof value.generation !== 'string'
814 || !/^[1-9]\d*$/.test(value.generation)) return null;
815 return { ...value, generation: BigInt(value.generation) };
816}
818function metadataIsWithinLease(metadata) {
819 if (typeof metadata.mtimeNs === 'bigint') {
820 const ageNs = (BigInt(Date.now()) * 1_000_000n) - metadata.mtimeNs;
821 const absoluteAgeNs = ageNs < 0n ? -ageNs : ageNs;
822 return absoluteAgeNs <= BigInt(LOCK_STALE_MS) * 1_000_000n;
823 }
824 return Math.abs(Date.now() - metadata.mtimeMs) <= LOCK_STALE_MS;
825}
827function corruptClaimStatus(directory, directoryMetadata, ownerMetadata = directoryMetadata) {
828 if (metadataIsWithinLease(ownerMetadata)) {
829 return { state: 'busy', directory, metadata: directoryMetadata, owner: null };
830 }
831 const fingerprint = crypto.createHash('sha256')
832 .update(JSON.stringify({
833 directory: {
834 dev: directoryMetadata.dev.toString(),
835 ino: directoryMetadata.ino.toString(),
836 mode: directoryMetadata.mode.toString(),
837 birthtimeNs: directoryMetadata.birthtimeNs.toString(),
838 },
839 }))
840 .digest('hex')
841 .slice(0, 24);
842 return {
843 state: 'releasable',
844 directory,
845 metadata: directoryMetadata,
846 key: `corrupt-${fingerprint}`,
847 owner: null,
848 };
849}
851async function inspectActiveClaim(cacheDirectory) {
852 const token = cacheTokenFor(cacheDirectory);
853 return guardedCacheRead(token, async () => {
854 const directory = path.join(cacheDirectory, ACTIVE_CLAIM_DIRECTORY);
855 let directoryMetadata;
856 try {
857 directoryMetadata = await fs.lstat(directory, { bigint: true });
858 } catch (error) {
859 if (error?.code === 'ENOENT') return { state: 'absent', directory };
860 throw error;
861 }
862 if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
863 return corruptClaimStatus(directory, directoryMetadata);
864 }
865 return guardedCacheEntryRead(
866 token,
867 directory,
868 assertSafeDirectory,
869 async (stableDirectoryMetadata) => {
870 const ownerPath = path.join(directory, OPERATION_OWNER_FILE);
871 let ownerMetadata = stableDirectoryMetadata;
872 let owner = null;
873 try {
874 ownerMetadata = await fs.lstat(ownerPath, { bigint: true });
875 if (!ownerMetadata.isFile()
876 || ownerMetadata.isSymbolicLink()
877 || ownerMetadata.size > BigInt(MAX_CLAIM_OWNER_BYTES)) {
878 return corruptClaimStatus(directory, stableDirectoryMetadata, ownerMetadata);
879 }
880 const ownerSource = await readJsonFile(
881 ownerPath,
882 MAX_CLAIM_OWNER_BYTES,
883 ownerMetadata,
884 );
885 owner = validateClaimOwner(ownerSource);
886 } catch (error) {
887 if (error instanceof FileIdentityChangedError) throw error;
888 // A malformed claim is recoverable only after the same hard lease as a crashed owner.
889 }
891 if (owner) {
892 const operations = await listOperations(cacheDirectory, token);
893 if (operations.some((operation) => operation.generation === owner.generation
894 && ['committed', 'fenced', 'cancelled'].includes(operation.kind))) {
895 return {
896 state: 'releasable',
897 directory,
898 metadata: stableDirectoryMetadata,
899 key: owner.generation.toString(),
900 owner,
901 };
902 }
903 const pending = operations.find((operation) => operation.kind === 'pending'
904 && operation.generation === owner.generation);
905 if (pending) {
906 if (await operationIsActive(cacheDirectory, pending, token)) {
907 return {
908 state: 'busy',
909 directory,
910 metadata: stableDirectoryMetadata,
911 owner,
912 };
913 }
914 return {
915 state: 'releasable',
916 directory,
917 metadata: stableDirectoryMetadata,
918 key: owner.generation.toString(),
919 owner,
920 };
921 }
922 }
924 return corruptClaimStatus(directory, stableDirectoryMetadata, ownerMetadata);
925 },
926 directoryMetadata,
927 );
928 });
929}
931async function retireActiveClaim(cacheDirectory, claim) {
932 const retirement = path.join(cacheDirectory, `retired-claim-${claim.key}`);
933 await cacheMkdir(cacheDirectory, retirement, { mode: 0o700 }).catch((error) => {
934 if (error?.code !== 'EEXIST') throw error;
935 });
936 try {
937 const retirementMetadata = await fs.lstat(retirement);
938 if (!retirementMetadata.isDirectory() || retirementMetadata.isSymbolicLink()) return false;
939 } catch (error) {
940 if (error instanceof CacheOperationRaceError) return false;
941 if (error?.code === 'ENOENT') return false;
942 throw error;
943 }
944 try {
945 const metadata = await fs.lstat(claim.directory);
946 if (metadata.isDirectory() && !metadata.isSymbolicLink()) {
947 await cacheWriteFile(
948 cacheDirectory,
949 path.join(claim.directory, '.retirement-guard'),
950 `${claim.key}\n`,
951 { encoding: 'utf8', flag: 'wx', mode: 0o600 },
952 ).catch((error) => {
953 if (error?.code !== 'EEXIST') throw error;
954 });
955 }
956 } catch (error) {
957 if (error instanceof CacheOperationRaceError) return false;
958 if (error?.code === 'ENOENT') return false;
959 throw error;
960 }
961 try {
962 await cacheRename(
963 cacheDirectory,
964 claim.directory,
965 path.join(retirement, 'active'),
966 assertRenameableCacheEntry,
967 claim.metadata,
968 );
969 return true;
970 } catch (error) {
971 if (error instanceof CacheOperationRaceError) return false;
972 if (['ENOENT', 'EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error?.code)) return false;
973 throw error;
974 }
975}
977async function prepareOperationClaim(cacheDirectory, operation) {
978 const directory = path.join(cacheDirectory, operationName('claim', operation.generation));
979 await cacheMkdir(cacheDirectory, directory, { mode: 0o700 });
980 await cacheWriteFile(
981 cacheDirectory,
982 path.join(directory, OPERATION_OWNER_FILE),
983 `${JSON.stringify({
984 pid: process.pid,
985 token: operation.token,
986 generation: operation.generation.toString(),
987 })}\n`,
988 { encoding: 'utf8', flag: 'wx', mode: 0o600 },
989 );
990 operation.preparedClaimDirectory = directory;
991}
993async function discardPreparedClaim(cacheDirectory, operation) {
994 if (!operation.preparedClaimDirectory) return;
995 const discarded = path.join(
996 cacheDirectory,
997 `discarded-claim-${operation.generation.toString().padStart(20, '0')}-${operation.token}`,
998 );
999 try {
1000 await cacheRename(
1001 cacheDirectory,
1002 operation.preparedClaimDirectory,
1003 discarded,
1004 assertSafeDirectory,
1005 );
1006 } catch (error) {
1007 if (error?.code !== 'ENOENT') throw error;
1008 }
1009 delete operation.preparedClaimDirectory;
1010}
1012async function promoteOperationClaim(cacheDirectory, operation) {
1013 await prepareOperationClaim(cacheDirectory, operation);
1014 const activeDirectory = path.join(cacheDirectory, ACTIVE_CLAIM_DIRECTORY);
1015 for (let attempt = 0; attempt < 8; attempt += 1) {
1016 if (await operationIsSuperseded(cacheDirectory, operation)) {
1017 await discardPreparedClaim(cacheDirectory, operation);
1018 return false;
1019 }
1020 const active = await inspectActiveClaim(cacheDirectory);
1021 if (active.state === 'busy') {
1022 await discardPreparedClaim(cacheDirectory, operation);
1023 return false;
1024 }
1025 if (active.state === 'releasable') {
1026 if (active.owner?.generation > operation.generation) {
1027 await discardPreparedClaim(cacheDirectory, operation);
1028 return false;
1029 }
1030 await retireActiveClaim(cacheDirectory, active);
1031 continue;
1032 }
1033 try {
1034 await cacheRename(
1035 cacheDirectory,
1036 operation.preparedClaimDirectory,
1037 activeDirectory,
1038 assertSafeDirectory,
1039 );
1040 delete operation.preparedClaimDirectory;
1041 if (!await operationIsActive(cacheDirectory, operation)) return false;
1042 return true;
1043 } catch (promotionError) {
1044 const raced = await inspectActiveClaim(cacheDirectory);
1045 if (raced.state === 'absent') {
1046 if (['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(promotionError?.code)) continue;
1047 await discardPreparedClaim(cacheDirectory, operation);
1048 throw promotionError;
1049 }
1050 continue;
1051 }
1052 }
1053 await discardPreparedClaim(cacheDirectory, operation);
1054 return false;
1055}
1057async function operationOwnsActiveClaim(cacheDirectory, operation) {
1058 const active = await inspectActiveClaim(cacheDirectory);
1059 return active.state === 'busy'
1060 && active.owner?.generation === operation.generation
1061 && active.owner.token === operation.token;
1062}
1064async function hasActivePendingOperation(cacheDirectory) {
1065 const token = cacheTokenFor(cacheDirectory);
1066 return guardedCacheRead(token, async () => {
1067 const pending = (await listOperations(cacheDirectory, token))
1068 .filter((operation) => operation.kind === 'pending');
1069 return (await Promise.all(pending.map(
1070 (operation) => operationIsActive(cacheDirectory, operation, token),
1071 ))).some(Boolean);
1072 });
1073}
1075async function reserveOperation(cacheDirectory) {
1076 cacheDirectory = await prepareCacheDirectory(cacheDirectory);
1077 const operations = await listOperations(cacheDirectory);
1078 const occupied = new Set(operations.map((operation) => operation.generation.toString()));
1079 let generation = 1n;
1080 while (true) {
1081 while (occupied.has(generation.toString())) generation += 1n;
1082 if (generation > MAX_OPERATION_GENERATION) throw new Error('operation generation space exhausted');
1083 const reservation = path.join(cacheDirectory, operationName('reserved', generation));
1084 try {
1085 await cacheMkdir(cacheDirectory, reservation, { mode: 0o700 });
1086 } catch (error) {
1087 if (error?.code !== 'EEXIST') throw error;
1088 occupied.add(generation.toString());
1089 generation += 1n;
1090 continue;
1091 }
1092 const directory = path.join(cacheDirectory, operationName('pending', generation));
1093 try {
1094 await cacheMkdir(cacheDirectory, directory, { mode: 0o700 });
1095 const operation = {
1096 generation,
1097 token: crypto.randomBytes(16).toString('hex'),
1098 directory,
1099 };
1100 try {
1101 await cacheWriteFile(
1102 cacheDirectory,
1103 path.join(directory, OPERATION_OWNER_FILE),
1104 `${JSON.stringify({ pid: process.pid, token: operation.token })}\n`,
1105 { encoding: 'utf8', flag: 'wx', mode: 0o600 },
1106 );
1107 } catch (error) {
1108 if (await operationWasFenced(cacheDirectory, operation)) {
1109 const fencedError = new OperationFencedError('operation was fenced while reserving');
1110 fencedError.cause = error;
1111 throw fencedError;
1112 }
1113 await cacheRename(
1114 cacheDirectory,
1115 directory,
1116 path.join(cacheDirectory, operationName('cancelled', generation)),
1117 assertSafeDirectory,
1118 ).catch(() => {});
1119 throw error;
1120 }
1121 return operation;
1122 } catch (error) {
1123 if (error?.code !== 'EEXIST') throw error;
1124 occupied.add(generation.toString());
1125 generation += 1n;
1126 }
1127 }
1128}
1130async function operationIsSuperseded(cacheDirectory, operation) {
1131 return (await listOperations(cacheDirectory))
1132 .some((candidate) => candidate.generation > operation.generation);
1133}
1135async function operationWasFenced(cacheDirectory, operation) {
1136 const token = cacheTokenFor(cacheDirectory);
1137 return guardedCacheRead(token, async () => {
1138 try {
1139 await fs.lstat(operation.directory, { bigint: true });
1140 return false;
1141 } catch (error) {
1142 if (error?.code !== 'ENOENT') throw error;
1143 }
1144 const fencedDirectory = path.join(
1145 cacheDirectory,
1146 operationName('fenced', operation.generation),
1147 );
1148 try {
1149 await stableCacheEntry(
1150 token,
1151 fencedDirectory,
1152 assertSafeDirectory,
1153 null,
1154 true,
1155 );
1156 return true;
1157 } catch (error) {
1158 if (error?.code === 'ENOENT') return false;
1159 throw error;
1160 }
1161 });
1162}
1164async function transitionOperation(cacheDirectory, operation, kind) {
1165 const destination = path.join(cacheDirectory, operationName(kind, operation.generation));
1166 await cacheRename(cacheDirectory, operation.directory, destination, assertSafeDirectory);
1167}
1169async function cancelOperation(cacheDirectory, operation) {
1170 try {
1171 await transitionOperation(cacheDirectory, operation, 'cancelled');
1172 } catch (error) {
1173 // A higher generation may already have fenced this unique pending directory.
1174 if (error?.code !== 'ENOENT') throw error;
1175 }
1176}
1178async function acquireOperation(cacheDirectory) {
1179 cacheDirectory = await prepareCacheDirectory(cacheDirectory);
1180 if ((await inspectActiveClaim(cacheDirectory)).state === 'busy') return null;
1181 if (await hasActivePendingOperation(cacheDirectory)) return null;
1182 let operation;
1183 try {
1184 operation = await reserveOperation(cacheDirectory);
1185 } catch (error) {
1186 if (error instanceof OperationFencedError) return null;
1187 throw error;
1188 }
1189 if (!await operationIsSuperseded(cacheDirectory, operation)
1190 && await promoteOperationClaim(cacheDirectory, operation)) return operation;
1191 await cancelOperation(cacheDirectory, operation);
1192 return null;
1193}
1195async function fenceLowerOperations(cacheDirectory, operation) {
1196 const lower = (await listOperations(cacheDirectory))
1197 .filter((candidate) => candidate.kind === 'pending'
1198 && candidate.generation < operation.generation)
1199 .sort((left, right) => (left.generation < right.generation ? -1 : 1));
1200 for (const candidate of lower) {
1201 try {
1202 await cacheRename(
1203 cacheDirectory,
1204 candidate.directory,
1205 path.join(cacheDirectory, operationName('fenced', candidate.generation)),
1206 assertSafeDirectory,
1207 );
1208 } catch (error) {
1209 if (error?.code !== 'ENOENT') throw error;
1210 }
1211 }
1212}
1214async function commitOperation(cacheDirectory, operation, installedVersion, mutate) {
1215 if (!await operationOwnsActiveClaim(cacheDirectory, operation)) {
1216 await cancelOperation(cacheDirectory, operation);
1217 return { fenced: true, result: silent('check-in-progress') };
1218 }
1219 await fenceLowerOperations(cacheDirectory, operation);
1220 if (!await operationOwnsActiveClaim(cacheDirectory, operation)) {
1221 await cancelOperation(cacheDirectory, operation);
1222 return { fenced: true, result: silent('check-in-progress') };
1223 }
1224 const state = await readState(cacheDirectory, installedVersion);
1225 const decision = mutate(state);
1226 if (!decision.state) {
1227 await cancelOperation(cacheDirectory, operation);
1228 return { fenced: false, result: decision.result };
1229 }
1230 let committedState = decision.state;
1231 let committedResult = decision.result;
1232 let encodedState = encodeRecoverableState(committedState);
1233 if (!encodedState && decision.capacityFallback) {
1234 committedState = decision.capacityFallback.state;
1235 committedResult = decision.capacityFallback.result;
1236 encodedState = encodeRecoverableState(committedState);
1237 }
1238 if (!encodedState) {
1239 await cancelOperation(cacheDirectory, operation);
1240 return { fenced: false, result: silent('cache-unavailable') };
1241 }
1242 const temporary = path.join(
1243 operation.directory,
1244 `.state.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`,
1245 );
1246 try {
1247 await cacheWriteFile(cacheDirectory, temporary, encodedState, {
1248 flag: 'wx',
1249 mode: 0o600,
1250 });
1251 await cacheRename(
1252 cacheDirectory,
1253 temporary,
1254 path.join(operation.directory, OPERATION_STATE_FILE),
1255 assertSafeRegularFile,
1256 );
1257 await transitionOperation(cacheDirectory, operation, 'committed');
1258 return { fenced: false, result: committedResult };
1259 } catch (error) {
1260 if (await operationWasFenced(cacheDirectory, operation)) {
1261 return { fenced: true, result: silent('check-in-progress') };
1262 }
1263 await cacheRm(cacheDirectory, temporary, { force: true }).catch(() => {});
1264 throw error;
1265 }
1266}
1268function nextSuccessfulCheck(nowMs, random) {
1269 const boundedRandom = Math.min(1, Math.max(0, Number(random()) || 0));
1270 const multiplier = 0.8 + (boundedRandom * 0.4);
1271 return new Date(nowMs + Math.round(CHECK_TTL_MS * multiplier)).toISOString();
1272}
1274function nextFailedCheck(nowMs, failures) {
1275 const delay = failures <= 1 ? FIRST_FAILURE_DELAY_MS : LATER_FAILURE_DELAY_MS;
1276 return new Date(nowMs + delay).toISOString();
1277}
1279function stateAfterFailedCheck(state, nowMs, withdrawCandidate = false) {
1280 const failures = Math.min(state.check.consecutiveFailures + 1, 2);
1281 return stateWithFailureCheck(
1282 state,
1283 nextFailedCheck(nowMs, failures),
1284 failures,
1285 withdrawCandidate,
1286 );
1287}
1289function eventKeyForDigest(targetDigest) {
1290 return `${SKILL_ID}@${targetDigest}`;
1291}
1293function digestForEventKey(eventKey) {
1294 const prefix = `${SKILL_ID}@`;
1295 if (typeof eventKey !== 'string' || !eventKey.startsWith(prefix)) return null;
1296 const digest = eventKey.slice(prefix.length);
1297 return DIGEST_PATTERN.test(digest) ? digest : null;
1298}
1300function notification(localRelease, candidate, source) {
1301 return {
1302 status: 'update_available',
1303 eventKey: eventKeyForDigest(candidate.targetDigest),
1304 installedVersion: localRelease.version,
1305 latestVersion: candidate.version,
1306 targetDigest: candidate.targetDigest,
1307 severity: candidate.severity,
1308 summary: candidate.summary ?? null,
1309 releaseNotes: candidate.releaseNotes,
1310 source,
1311 checkedAt: candidate.checkedAt,
1312 noticeRequired: true,
1313 };
1314}
1316function resultForCandidate(localRelease, state, { source = 'cache', nowMs = Date.now() } = {}) {
1317 const { candidate } = state;
1318 if (!candidate) return silent('cache-valid');
1319 if (compareSemver(candidate.version, localRelease.version) <= 0) {
1320 return {
1321 status: 'current', installedVersion: localRelease.version,
1322 availableVersion: candidate.version, checkedAt: candidate.checkedAt,
1323 source, noticeRequired: false,
1324 };
1325 }
1326 const result = notification(localRelease, candidate, source);
1327 const preference = state.preferences?.find((entry) => entry.targetDigest === candidate.targetDigest
1328 && entry.version === candidate.version);
1329 if (preference?.mode === 'ignore') return { ...result, noticeRequired: false, reason: 'ignored' };
1330 if (preference?.mode === 'snooze' && Date.parse(preference.until) > nowMs) {
1331 return { ...result, noticeRequired: false, reason: 'snoozed', suppressedUntil: preference.until };
1332 }
1333 return result;
1334}
1336async function readBoundedBody(response) {
1337 const contentLength = Number(response.headers.get('content-length'));
1338 if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
1339 await cancelResponseBody(response);
1340 throw new UpdateContractError('manifest response is too large');
1341 }
1342 if (!response.body || typeof response.body.getReader !== 'function') {
1343 throw new UpdateContractError('manifest response has no bounded stream');
1344 }
1345 const reader = response.body.getReader();
1346 const chunks = [];
1347 let size = 0;
1348 try {
1349 while (true) {
1350 const { done, value } = await reader.read();
1351 if (done) break;
1352 size += value.byteLength;
1353 if (size > MAX_RESPONSE_BYTES) {
1354 await reader.cancel().catch(() => {});
1355 throw new UpdateContractError('manifest response is too large');
1356 }
1357 chunks.push(value);
1358 }
1359 } finally {
1360 reader.releaseLock();
1361 }
1362 const joined = new Uint8Array(size);
1363 let offset = 0;
1364 for (const chunk of chunks) {
1365 joined.set(chunk, offset);
1366 offset += chunk.byteLength;
1367 }
1368 try {
1369 return new TextDecoder('utf-8', { fatal: true }).decode(joined);
1370 } catch {
1371 throw new UpdateContractError('manifest is not valid UTF-8');
1372 }
1373}
1375async function cancelResponseBody(response) {
1376 try {
1377 if (response.body && typeof response.body.cancel === 'function') {
1378 await response.body.cancel();
1379 }
1380 } catch {
1381 // Network cleanup is best-effort; the original response classification wins.
1382 }
1383}
1385async function fetchCandidate({ fetchImpl, manifestUrl, timeoutMs }) {
1386 if (manifestUrl !== DEFAULT_MANIFEST_URL) throw new UpdateContractError('unexpected manifest URL');
1387 const controller = new AbortController();
1388 let timer;
1389 const timeout = new Promise((_resolve, reject) => {
1390 timer = setTimeout(() => {
1391 controller.abort();
1392 reject(new Error('update check timed out'));
1393 }, timeoutMs);
1394 });
1395 const request = (async () => {
1396 const headers = { accept: 'application/json' };
1397 const response = await fetchImpl(manifestUrl, {
1398 method: 'GET',
1399 headers,
1400 redirect: 'error',
1401 signal: controller.signal,
1402 });
1403 if (response.status !== 200) {
1404 await cancelResponseBody(response);
1405 if (response.status === 404 || response.status === 410) {
1406 throw new ManifestWithdrawnError('manifest was withdrawn');
1407 }
1408 throw new Error(`manifest returned HTTP ${response.status}`);
1409 }
1410 const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
1411 if (mediaType !== 'application/json') {
1412 await cancelResponseBody(response);
1413 throw new UpdateContractError('manifest is not JSON');
1414 }
1415 const source = await readBoundedBody(response);
1416 let decoded;
1417 try {
1418 decoded = JSON.parse(source);
1419 } catch {
1420 throw new UpdateContractError('manifest is not valid bounded JSON');
1421 }
1422 return { candidate: validateManifest(decoded) };
1423 })();
1424 try {
1425 return await Promise.race([request, timeout]);
1426 } finally {
1427 clearTimeout(timer);
1428 }
1429}
1431function defaultCacheDirectory() {
1432 const homeDirectory = os.homedir();
1433 if (process.platform === 'win32') {
1434 const localData = process.env.LOCALAPPDATA;
1435 return path.join(localData && path.isAbsolute(localData) ? localData : path.join(homeDirectory, 'AppData', 'Local'), 'archify-skill');
1436 }
1437 if (process.platform === 'darwin') return path.join(homeDirectory, 'Library', 'Caches', 'archify-skill');
1438 const xdgCache = process.env.XDG_CACHE_HOME;
1439 return path.join(xdgCache && path.isAbsolute(xdgCache) ? xdgCache : path.join(homeDirectory, '.cache'), 'archify-skill');
1440}
1442function freshStateResult(localRelease, state, nowMs) {
1443 const nextCheckAt = Date.parse(state.check.nextCheckAt || '');
1444 if (Number.isFinite(nextCheckAt) && nextCheckAt > nowMs
1445 && nextCheckAt <= nowMs + MAX_CACHE_HORIZON_MS) {
1446 return resultForCandidate(localRelease, state, { nowMs });
1447 }
1448 return null;
1449}
1451async function resultAfterLosingOperation(cacheDirectory, operation, localRelease, nowMs) {
1452 await cancelOperation(cacheDirectory, operation);
1453 const cached = await readState(cacheDirectory, localRelease.version);
1454 return cached.candidate ? resultForCandidate(localRelease, cached, { nowMs }) : silent('check-in-progress');
1455}
1457async function waitUntilRetry(deadline, monotonicNow) {
1458 let remaining;
1459 try {
1460 remaining = deadline - Number(monotonicNow());
1461 } catch {
1462 return false;
1463 }
1464 if (!Number.isFinite(remaining) || remaining <= 0) return false;
1465 await new Promise((resolve) => setTimeout(
1466 resolve,
1467 Math.min(LOCK_RETRY_DELAY_MS, remaining),
1468 ));
1469 return true;
1470}
1472export async function checkForUpdate({
1473 releasePath = defaultReleasePath,
1474 cacheDirectory = defaultCacheDirectory(),
1475 fetchImpl = globalThis.fetch,
1476 now = Date.now,
1477 random = Math.random,
1478 timeoutMs = DEFAULT_TIMEOUT_MS,
1479 fetchDeadlineNs,
1480} = {}) {
1481 if (updatesDisabled()) return silent('disabled');
1482 if (typeof fetchImpl !== 'function') return silent('runtime-unavailable');
1483 let localRelease;
1484 try {
1485 localRelease = validateLocalRelease(await readJsonFile(releasePath, MAX_LOCAL_RELEASE_BYTES));
1486 } catch {
1487 return silent('invalid-local-release');
1488 }
1490 const nowMs = Number(now());
1491 if (!Number.isFinite(nowMs)) return silent('invalid-clock');
1492 let stateDirectory = versionCacheDirectory(cacheDirectory, localRelease.version);
1493 let state;
1494 try {
1495 stateDirectory = await prepareCacheDirectory(stateDirectory);
1496 state = await readState(stateDirectory, localRelease.version);
1497 } catch {
1498 return silent('cache-unavailable');
1499 }
1500 const cachedResult = freshStateResult(localRelease, state, nowMs);
1501 if (cachedResult) return cachedResult;
1503 let operation;
1504 try {
1505 operation = await acquireOperation(stateDirectory);
1506 } catch {
1507 return silent('cache-unavailable');
1508 }
1509 if (!operation) {
1510 try {
1511 const cached = await readState(stateDirectory, localRelease.version);
1512 return cached.candidate ? resultForCandidate(localRelease, cached, { nowMs }) : silent('check-in-progress');
1513 } catch {
1514 return silent('cache-unavailable');
1515 }
1516 }
1518 try {
1519 state = await readState(stateDirectory, localRelease.version);
1520 const racedResult = freshStateResult(localRelease, state, nowMs);
1521 if (racedResult) {
1522 await cancelOperation(stateDirectory, operation);
1523 return racedResult;
1524 }
1526 if (!await operationOwnsActiveClaim(stateDirectory, operation)) {
1527 return await resultAfterLosingOperation(stateDirectory, operation, localRelease, nowMs);
1528 }
1530 await verifyCacheToken(cacheTokenFor(stateDirectory));
1531 if (!await operationOwnsActiveClaim(stateDirectory, operation)) {
1532 return await resultAfterLosingOperation(stateDirectory, operation, localRelease, nowMs);
1533 }
1534 let fetched;
1535 try {
1536 const remainingFetchMs = fetchDeadlineNs === undefined ? timeoutMs
1537 : Math.min(timeoutMs, Number(fetchDeadlineNs - process.hrtime.bigint()) / 1_000_000);
1538 if (remainingFetchMs <= 0) throw new Error('update fetch deadline expired during cache preparation');
1539 fetched = await fetchCandidate({
1540 fetchImpl,
1541 manifestUrl: localRelease.updateManifestUrl,
1542 timeoutMs: remainingFetchMs,
1543 });
1544 } catch (error) {
1545 const withdrawn = error instanceof ManifestWithdrawnError;
1546 const reason = withdrawn ? 'withdrawn'
1547 : error instanceof UpdateContractError ? 'invalid-manifest' : 'check-failed';
1548 // Offline or invalid checks keep repeating the last validated candidate.
1549 const failedResult = (known) => (!withdrawn && known.candidate
1550 ? resultForCandidate(localRelease, known, { nowMs }) : silent(reason));
1551 try {
1552 const committed = await commitOperation(
1553 stateDirectory,
1554 operation,
1555 localRelease.version,
1556 (current) => ({
1557 state: stateAfterFailedCheck(current, nowMs, withdrawn),
1558 result: failedResult(current),
1559 }),
1560 );
1561 if (committed.fenced) {
1562 return await resultAfterLosingOperation(stateDirectory, operation, localRelease, nowMs);
1563 }
1564 return committed.result;
1565 } catch {
1566 await cancelOperation(stateDirectory, operation).catch(() => {});
1567 return failedResult(state);
1568 }
1569 }
1571 const candidate = { ...fetched.candidate, checkedAt: new Date(nowMs).toISOString() };
1572 try {
1573 const committed = await commitOperation(
1574 stateDirectory,
1575 operation,
1576 localRelease.version,
1577 (current) => {
1578 const next = {
1579 ...current,
1580 candidate,
1581 check: {
1582 nextCheckAt: nextSuccessfulCheck(nowMs, random),
1583 consecutiveFailures: 0,
1584 },
1585 };
1586 const result = resultForCandidate(localRelease, next, { source: 'network', nowMs });
1587 return {
1588 state: next,
1589 result,
1590 capacityFallback: { state: stateAfterFailedCheck(current, nowMs, true), result },
1591 };
1592 },
1593 );
1594 if (committed.fenced) {
1595 return await resultAfterLosingOperation(stateDirectory, operation, localRelease, nowMs);
1596 }
1597 return committed.result;
1598 } catch {
1599 await cancelOperation(stateDirectory, operation).catch(() => {});
1600 return resultForCandidate(localRelease, { ...state, candidate }, { source: 'network', nowMs });
1601 }
1602 } catch {
1603 await cancelOperation(stateDirectory, operation).catch(() => {});
1604 return silent('cache-unavailable');
1605 }
1606}
1608export async function setUpdatePreference({
1609 releasePath = defaultReleasePath,
1610 cacheDirectory = defaultCacheDirectory(),
1611 eventKey,
1612 mode,
1613 days = 7,
1614 now = Date.now,
1615 monotonicNow = () => performance.now(),
1616} = {}) {
1617 const targetDigest = digestForEventKey(eventKey);
1618 if (!targetDigest || !['snooze', 'ignore'].includes(mode)
1619 || (mode === 'snooze' && (!Number.isInteger(days) || days < 1 || days > 365))) {
1620 return silent('invalid-preference');
1621 }
1622 let localRelease;
1623 try {
1624 localRelease = validateLocalRelease(await readJsonFile(releasePath, MAX_LOCAL_RELEASE_BYTES));
1625 } catch {
1626 return silent('invalid-local-release');
1627 }
1628 let stateDirectory = versionCacheDirectory(cacheDirectory, localRelease.version);
1629 try {
1630 stateDirectory = await prepareCacheDirectory(stateDirectory);
1631 } catch {
1632 return silent('cache-unavailable');
1633 }
1634 const nowMs = Number(now());
1635 if (!Number.isFinite(nowMs)) return silent('invalid-clock');
1636 const deadline = Number(monotonicNow()) + PREFERENCE_LOCK_WAIT_MS;
1637 while (Number(monotonicNow()) < deadline) {
1638 let operation;
1639 try {
1640 operation = await acquireOperation(stateDirectory);
1641 } catch {
1642 return silent('cache-unavailable');
1643 }
1644 if (!operation) {
1645 if (!await waitUntilRetry(deadline, monotonicNow)) break;
1646 continue;
1647 }
1648 try {
1649 const committed = await commitOperation(
1650 stateDirectory, operation, localRelease.version, (state) => {
1651 if (!state.candidate || state.candidate.targetDigest !== targetDigest
1652 || compareSemver(state.candidate.version, localRelease.version) <= 0) {
1653 return { state: null, result: silent('invalid-preference') };
1654 }
1655 const preference = {
1656 targetDigest,
1657 version: state.candidate.version,
1658 mode,
1659 until: mode === 'snooze' ? new Date(nowMs + days * 24 * 60 * 60 * 1_000).toISOString() : null,
1660 };
1661 const preferences = (state.preferences || [])
1662 .filter((entry) => entry.targetDigest !== targetDigest)
1663 .slice(-30);
1664 preferences.push(preference);
1665 return {
1666 state: { ...state, preferences },
1667 result: { status: mode === 'snooze' ? 'snoozed' : 'ignored', eventKey,
1668 ...(preference.until ? { suppressedUntil: preference.until } : {}) },
1669 };
1670 },
1671 );
1672 if (!committed.fenced) return committed.result;
1673 } catch {
1674 await cancelOperation(stateDirectory, operation).catch(() => {});
1675 return silent('cache-unavailable');
1676 }
1677 }
1678 return silent('check-in-progress');
1679}
1681async function runCli() {
1682 if (updatesDisabled()) return silent('disabled');
1683 const argumentsList = process.argv.slice(2);
1684 if (argumentsList.length === 0) return checkForUpdate();
1685 if (argumentsList.length === 2 && argumentsList[0] === '--ack') {
1686 return silent('legacy-ack-no-op');
1687 }
1688 if (argumentsList.length === 2 && argumentsList[0] === '--snooze') {
1689 return setUpdatePreference({ eventKey: argumentsList[1], mode: 'snooze' });
1690 }
1691 if (argumentsList.length === 2 && argumentsList[0] === '--ignore') {
1692 return setUpdatePreference({ eventKey: argumentsList[1], mode: 'ignore' });
1693 }
1694 return silent('invalid-arguments');
1695}
1697export function isMainModule({
1698 argvPath = process.argv[1],
1699 modulePath = fileURLToPath(import.meta.url),
1700} = {}) {
1701 if (!argvPath) return false;
1702 try {
1703 return sameEntry(argvPath, modulePath).status === 'match';
1704 } catch {
1705 return false;
1706 }
1707}
1709if (isMainModule()) {
1710 let result;
1711 try {
1712 result = await runCli();
1713 } catch {
1714 result = silent('check-failed');
1715 }
1716 process.stdout.write(`${JSON.stringify(result)}\n`);
1717}