@timurproko/a1 0.1.8-dev.260 → 0.1.8-dev.269

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,272 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Security: this file intentionally depends only on Node built-ins: the updater copies it into a
4
+ // transaction capsule before npm is allowed to rename or remove the installed package.
5
+ import { spawn } from "node:child_process";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
8
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const SCHEMA = "a1-update-recovery-v1";
12
+ const [, , mode, manifestPath, separator, ...forwarded] = process.argv;
13
+
14
+ if ((mode !== "--worker" && mode !== "--launch") || !manifestPath || (mode === "--launch" && separator !== "--")) {
15
+ throw new Error("invalid A1 update recovery invocation");
16
+ }
17
+
18
+ const capsule = await readCapsule(manifestPath);
19
+ if (mode === "--worker") {
20
+ await runWorker(capsule);
21
+ } else {
22
+ process.exitCode = await runRecoveredCommand(capsule, forwarded);
23
+ }
24
+
25
+ async function runWorker(value) {
26
+ await rm(value.resultPath, { force: true });
27
+ await writeJson(value.ownerPath, {
28
+ schema: SCHEMA,
29
+ transactionId: value.transactionId,
30
+ pid: process.pid,
31
+ startIdentity: `${process.pid}:${Math.floor(Date.now() - process.uptime() * 1000)}`,
32
+ startedAt: new Date().toISOString(),
33
+ });
34
+ const heartbeat = setInterval(() => {
35
+ void writeJson(value.ownerPath, {
36
+ schema: SCHEMA,
37
+ transactionId: value.transactionId,
38
+ pid: process.pid,
39
+ startIdentity: `${process.pid}:${Math.floor(Date.now() - process.uptime() * 1000)}`,
40
+ heartbeatAt: new Date().toISOString(),
41
+ });
42
+ }, 500);
43
+ heartbeat.unref?.();
44
+ const child = spawn(process.execPath, [value.npmCli, ...value.npmArguments], {
45
+ detached: false,
46
+ windowsHide: true,
47
+ stdio: ["ignore", "pipe", "pipe"],
48
+ });
49
+ const stdout = [];
50
+ const stderr = [];
51
+ child.stdout?.on("data", chunk => stdout.push(Buffer.from(chunk)));
52
+ child.stderr?.on("data", chunk => stderr.push(Buffer.from(chunk)));
53
+ let cancelled = false;
54
+ let forced = false;
55
+ let forceTimer = null;
56
+ const cancellationPoll = setInterval(async () => {
57
+ if (cancelled || !await exists(value.cancellationPath)) return;
58
+ cancelled = true;
59
+ child.kill("SIGTERM");
60
+ forceTimer = setTimeout(() => { forced = true; child.kill("SIGKILL"); }, 2_000);
61
+ forceTimer.unref?.();
62
+ }, 50);
63
+ cancellationPoll.unref?.();
64
+
65
+ let npmExitCode = null;
66
+ let spawnError = null;
67
+ try {
68
+ npmExitCode = await new Promise((resolvePromise, rejectPromise) => {
69
+ child.once("error", rejectPromise);
70
+ child.once("close", code => resolvePromise(code));
71
+ });
72
+ } catch (error) {
73
+ spawnError = error;
74
+ } finally {
75
+ clearInterval(cancellationPoll);
76
+ clearInterval(heartbeat);
77
+ if (forceTimer) clearTimeout(forceTimer);
78
+ }
79
+
80
+ const targetReady = await installedTargetIsCallable(value);
81
+ let launcherDisposition = "unavailable";
82
+ let outcome = "failed";
83
+ if (targetReady) {
84
+ launcherDisposition = "target";
85
+ outcome = "installed";
86
+ } else {
87
+ await writeRecoveryLaunchers(value);
88
+ if (await recoveryLaunchersAreCallable(value)) {
89
+ launcherDisposition = "recovery";
90
+ outcome = "recovery-launcher";
91
+ }
92
+ }
93
+ await writeJson(value.resultPath, {
94
+ schema: SCHEMA,
95
+ transactionId: value.transactionId,
96
+ outcome,
97
+ npmExitCode,
98
+ cancelled,
99
+ launcherDisposition,
100
+ stdout: bounded(Buffer.concat(stdout).toString("utf8")),
101
+ stderr: bounded(`${Buffer.concat(stderr).toString("utf8")}${spawnError ? `\n${String(spawnError)}` : ""}${forced ? "\nforced npm termination after cancellation" : ""}`.trim()),
102
+ completedAt: new Date().toISOString(),
103
+ });
104
+ if (launcherDisposition === "unavailable") process.exitCode = 1;
105
+ }
106
+
107
+ async function runRecoveredCommand(value, arguments_) {
108
+ let entry = await installedTargetEntry(value);
109
+ if (entry === null && arguments_[0] === "update") {
110
+ await rm(value.cancellationPath, { force: true });
111
+ await rm(value.resultPath, { force: true });
112
+ await rm(value.ownerPath, { force: true });
113
+ const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), "--worker", manifestPath], {
114
+ detached: true,
115
+ windowsHide: true,
116
+ stdio: "ignore",
117
+ });
118
+ worker.unref();
119
+ const deadline = Date.now() + 15 * 60 * 1000;
120
+ const requestCancellation = signal => { void writeJson(value.cancellationPath, { schema: SCHEMA, transactionId: value.transactionId, signal, requestedAt: new Date().toISOString() }); };
121
+ const onSigint = () => requestCancellation("SIGINT");
122
+ const onSigterm = () => requestCancellation("SIGTERM");
123
+ process.on("SIGINT", onSigint);
124
+ process.on("SIGTERM", onSigterm);
125
+ try {
126
+ while (Date.now() < deadline && !await exists(value.resultPath)) await sleep(50);
127
+ } finally {
128
+ process.off("SIGINT", onSigint);
129
+ process.off("SIGTERM", onSigterm);
130
+ }
131
+ entry = await installedTargetEntry(value);
132
+ }
133
+ entry ??= resolve(value.priorReleaseRoot, "bin", "cli.js");
134
+ const child = spawn(process.execPath, [entry, ...arguments_], { stdio: "inherit", windowsHide: false });
135
+ return await new Promise(resolvePromise => {
136
+ child.once("error", error => { console.error(error instanceof Error ? error.message : String(error)); resolvePromise(1); });
137
+ child.once("close", code => resolvePromise(code ?? 1));
138
+ });
139
+ }
140
+
141
+ async function readCapsule(path) {
142
+ const value = JSON.parse(await readFile(path, "utf8"));
143
+ if (value.schema !== SCHEMA || typeof value.transactionId !== "string" || typeof value.packageName !== "string"
144
+ || typeof value.packageName !== "string" || !value.packageName.includes("/") || typeof value.targetVersion !== "string" || typeof value.packageRoot !== "string" || typeof value.globalRoot !== "string"
145
+ || typeof value.launcherRoot !== "string" || !Array.isArray(value.launchers) || typeof value.priorReleaseId !== "string" || typeof value.priorReleaseRoot !== "string"
146
+ || !/^[a-f0-9]{64}$/.test(value.priorContentDigest) || typeof value.releaseManifestName !== "string"
147
+ || typeof value.recoveryEntry !== "string" || typeof value.recoveryEntryDigest !== "string" || value.nodeExecutable !== process.execPath || typeof value.npmCli !== "string"
148
+ || !Array.isArray(value.npmArguments) || typeof value.resultPath !== "string" || typeof value.cancellationPath !== "string") {
149
+ throw new Error("invalid A1 update recovery capsule");
150
+ }
151
+ const lexicalCapsuleRoot = dirname(resolve(path));
152
+ if (lexicalCapsuleRoot.split(sep).at(-1) !== value.transactionId) throw new Error("A1 update recovery transaction path is invalid");
153
+ const capsuleRoot = await realpath(lexicalCapsuleRoot);
154
+ const entry = await realpath(value.recoveryEntry);
155
+ assertDirectChild(capsuleRoot, entry);
156
+ if (!samePath(entry, resolve(capsuleRoot, "recovery.js"))) throw new Error("A1 update recovery entry path is invalid");
157
+ const digest = createHash("sha256").update(await readFile(entry)).digest("hex");
158
+ if (digest !== value.recoveryEntryDigest) throw new Error("A1 update recovery payload digest mismatch");
159
+ const globalRoot = await realpath(value.globalRoot);
160
+ const packageRoot = await realpath(value.packageRoot).catch(() => resolve(value.packageRoot));
161
+ const expectedPackage = resolve(globalRoot, ...value.packageName.split("/"));
162
+ if (!samePath(packageRoot, expectedPackage) || !containedBy(globalRoot, packageRoot)) throw new Error("A1 update recovery package root escapes npm global root");
163
+ const expectedNpmArguments = ["install", "--global", "--loglevel=error", "--no-fund", "--no-audit", `${value.packageName}@${value.targetVersion}`];
164
+ if (JSON.stringify(value.npmArguments) !== JSON.stringify(expectedNpmArguments)) throw new Error("A1 update recovery npm arguments are invalid");
165
+ const launcherRoot = resolve(value.launcherRoot);
166
+ const expectedLaunchers = process.platform === "win32"
167
+ ? [resolve(launcherRoot, "a1"), resolve(launcherRoot, "a1.cmd"), resolve(launcherRoot, "a1.ps1")]
168
+ : [resolve(launcherRoot, "a1")];
169
+ if (JSON.stringify(value.launchers.map(path => resolve(path))) !== JSON.stringify(expectedLaunchers)) throw new Error("A1 update recovery launcher set is invalid");
170
+ for (const launcher of value.launchers) assertDirectChild(launcherRoot, resolve(launcher));
171
+ const expectedSidecars = [resolve(lexicalCapsuleRoot, "cancel.json"), resolve(lexicalCapsuleRoot, "result.json"), resolve(lexicalCapsuleRoot, "owner.json")];
172
+ if (![value.cancellationPath, value.resultPath, value.ownerPath].every((candidate, index) => samePath(candidate, expectedSidecars[index]))) {
173
+ throw new Error("A1 update recovery sidecar paths are invalid");
174
+ }
175
+ const dataDir = dirname(dirname(capsuleRoot));
176
+ const releasesRoot = await realpath(resolve(dataDir, "releases"));
177
+ const priorReleaseRoot = await realpath(value.priorReleaseRoot);
178
+ assertDirectChild(releasesRoot, priorReleaseRoot);
179
+ if (priorReleaseRoot.split(sep).at(-1) !== value.priorReleaseId) throw new Error("A1 update recovery prior release identity is invalid");
180
+ const priorManifest = JSON.parse(await readFile(resolve(priorReleaseRoot, value.releaseManifestName), "utf8"));
181
+ if (priorManifest.releaseId !== value.priorReleaseId || priorManifest.contentDigest !== value.priorContentDigest) {
182
+ throw new Error("A1 update recovery prior release manifest is invalid");
183
+ }
184
+ const npmMetadata = await lstat(value.npmCli);
185
+ if (!npmMetadata.isFile() || npmMetadata.isSymbolicLink()) throw new Error("A1 update recovery npm entry is invalid");
186
+ return value;
187
+ }
188
+
189
+ async function installedTargetEntry(value) {
190
+ try {
191
+ const manifest = JSON.parse(await readFile(resolve(value.packageRoot, "package.json"), "utf8"));
192
+ const entry = resolve(value.packageRoot, "bin", "cli.js");
193
+ if (manifest.name !== value.packageName || manifest.version !== value.targetVersion || !(await lstat(entry)).isFile()) return null;
194
+ return entry;
195
+ } catch { return null; }
196
+ }
197
+
198
+ async function installedTargetIsCallable(value) {
199
+ if (await installedTargetEntry(value) === null) return false;
200
+ const token = `node_modules/${value.packageName}/bin/cli.js`;
201
+ for (const path of value.launchers) {
202
+ try {
203
+ const metadata = await lstat(path);
204
+ if (!metadata.isFile() || metadata.isSymbolicLink()) return false;
205
+ if (process.platform !== "win32" && (metadata.mode & 0o111) === 0) return false;
206
+ const normalized = (await readFile(path, "utf8")).replaceAll("\\", "/");
207
+ if (!normalized.includes(token)) return false;
208
+ } catch { return false; }
209
+ }
210
+ return true;
211
+ }
212
+
213
+ async function writeRecoveryLaunchers(value) {
214
+ const node = value.nodeExecutable ?? process.execPath;
215
+ const entry = value.recoveryEntry;
216
+ const manifest = manifestPath;
217
+ const shell = `#!/bin/sh\nexec ${shellQuote(node)} ${shellQuote(entry)} --launch ${shellQuote(manifest)} -- "$@"\n`;
218
+ const command = `@ECHO off\r\n"${node}" "${entry}" --launch "${manifest}" -- %*\r\n`;
219
+ const powershell = `& '${psQuote(node)}' '${psQuote(entry)}' --launch '${psQuote(manifest)}' -- $args\nexit $LASTEXITCODE\n`;
220
+ const content = process.platform === "win32" ? [shell, command, powershell] : [shell];
221
+ await mkdir(value.launcherRoot, { recursive: true });
222
+ for (let index = 0; index < value.launchers.length; index += 1) await atomicWrite(value.launchers[index], content[index], 0o755);
223
+ }
224
+
225
+ async function recoveryLaunchersAreCallable(value) {
226
+ for (const path of value.launchers) {
227
+ try {
228
+ const metadata = await lstat(path);
229
+ if (!metadata.isFile() || metadata.isSymbolicLink()) return false;
230
+ if (process.platform !== "win32" && (metadata.mode & 0o111) === 0) return false;
231
+ if (!(await readFile(path, "utf8")).includes(value.recoveryEntry)) return false;
232
+ } catch { return false; }
233
+ }
234
+ return true;
235
+ }
236
+
237
+ async function atomicWrite(path, content, mode) {
238
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
239
+ await writeFile(temporary, content, { mode });
240
+ await chmod(temporary, mode);
241
+ await rm(path, { force: true });
242
+ await rename(temporary, path);
243
+ }
244
+
245
+ async function writeJson(path, value) {
246
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
247
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
248
+ const file = await open(temporary, "wx", 0o600);
249
+ try { await file.writeFile(JSON.stringify(value, null, 2)); await file.sync(); }
250
+ finally { await file.close(); }
251
+ await rm(path, { force: true });
252
+ await rename(temporary, path);
253
+ }
254
+
255
+ function samePath(left, right) {
256
+ return process.platform === "win32" ? resolve(left).toLowerCase() === resolve(right).toLowerCase() : resolve(left) === resolve(right);
257
+ }
258
+ function containedBy(parent, child) {
259
+ const fromParent = relative(parent, child);
260
+ return fromParent.length > 0 && fromParent !== ".." && !fromParent.startsWith(`..${sep}`) && !isAbsolute(fromParent);
261
+ }
262
+ function assertDirectChild(parent, child) {
263
+ const expectedParent = resolve(parent);
264
+ const actualParent = dirname(resolve(child));
265
+ const matches = process.platform === "win32" ? actualParent.toLowerCase() === expectedParent.toLowerCase() : actualParent === expectedParent;
266
+ if (!matches) throw new Error(`A1 update recovery path escapes its managed root: ${child}`);
267
+ }
268
+ function shellQuote(value) { return `'${String(value).replaceAll("'", `'"'"'`)}'`; }
269
+ function psQuote(value) { return String(value).replaceAll("'", "''"); }
270
+ function bounded(value) { return value.length <= 64 * 1024 ? value : value.slice(-64 * 1024); }
271
+ async function exists(path) { return await lstat(path).then(() => true).catch(() => false); }
272
+ async function sleep(ms) { await new Promise(resolvePromise => setTimeout(resolvePromise, ms)); }
@@ -10,5 +10,6 @@ export * from "./release-store.js";
10
10
  export * from "./restart-certification.js";
11
11
  export * from "./stable-release.js";
12
12
  export * from "./update.js";
13
+ export * from "./update-recovery.js";
13
14
  export * from "./update-transaction.js";
14
15
  export * from "./warmup.js";
@@ -10,5 +10,6 @@ export * from "./release-store.js";
10
10
  export * from "./restart-certification.js";
11
11
  export * from "./stable-release.js";
12
12
  export * from "./update.js";
13
+ export * from "./update-recovery.js";
13
14
  export * from "./update-transaction.js";
14
15
  export * from "./warmup.js";
@@ -9,6 +9,7 @@ import { resolveProductPaths } from "../lifecycle/index.js";
9
9
  import { RELEASE_MANIFEST_FILENAME } from "./release-store.js";
10
10
  import { DEPENDENCY_LAYER_MANIFEST, dependencyLayerCertificationPath } from "./dependency-layer.js";
11
11
  import { UpdateTransactionStore } from "./update-transaction.js";
12
+ import { cleanupUpdateRecoveryCapsules } from "./update-recovery.js";
12
13
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
13
14
  import { collectCompileCaches, startupCompileCachePath } from "../startup/index.js";
14
15
  const DEFAULT_CANDIDATE_AGE_MS = 60 * 60 * 1_000;
@@ -115,6 +116,7 @@ export async function runBoundedReleaseCleanup(dataDir, paths, options = {}) {
115
116
  }
116
117
  if (!activeTransaction && hasTime()) {
117
118
  await collectCompileCaches(dataDir, await protectedCompileCachePaths(dataDir, await store.read())).catch(() => { });
119
+ await cleanupUpdateRecoveryCapsules(dataDir).catch(() => { });
118
120
  }
119
121
  const remaining = Object.keys((await store.read()).cleanup.pending).length;
120
122
  return { planned: pending.length + artifacts.length, attempted, completed, remaining, durationMs: Math.max(0, now() - startedAt) };
@@ -0,0 +1,74 @@
1
+ import type { UpdateTransaction, UpdateRecoveryState } from "./update-transaction.js";
2
+ export declare const UPDATE_RECOVERY_SCHEMA: "a1-update-recovery-v1";
3
+ export interface UpdateRecoveryCapsule {
4
+ readonly schema: typeof UPDATE_RECOVERY_SCHEMA;
5
+ readonly transactionId: string;
6
+ readonly packageName: string;
7
+ readonly targetVersion: string;
8
+ readonly packageRoot: string;
9
+ readonly globalRoot: string;
10
+ readonly launcherRoot: string;
11
+ readonly launchers: readonly string[];
12
+ readonly priorReleaseId: string;
13
+ readonly priorReleaseRoot: string;
14
+ readonly priorContentDigest: string;
15
+ readonly releaseManifestName: string;
16
+ readonly recoveryEntry: string;
17
+ readonly recoveryEntryDigest: string;
18
+ readonly nodeExecutable: string;
19
+ readonly npmCli: string;
20
+ readonly npmArguments: readonly string[];
21
+ readonly cancellationPath: string;
22
+ readonly resultPath: string;
23
+ readonly ownerPath: string;
24
+ readonly createdAt: string;
25
+ }
26
+ export interface UpdateRecoveryResult {
27
+ readonly schema: typeof UPDATE_RECOVERY_SCHEMA;
28
+ readonly transactionId: string;
29
+ readonly outcome: "installed" | "recovery-launcher" | "failed";
30
+ readonly npmExitCode: number | null;
31
+ readonly cancelled: boolean;
32
+ readonly launcherDisposition: "target" | "recovery" | "unavailable";
33
+ readonly stdout: string;
34
+ readonly stderr: string;
35
+ readonly completedAt: string;
36
+ }
37
+ export interface ProtectedPackageReplacementOptions {
38
+ readonly dataDir: string;
39
+ readonly globalRoot: string;
40
+ readonly packageRoot: string;
41
+ readonly transaction: UpdateTransaction;
42
+ readonly priorRelease: {
43
+ readonly releaseId: string;
44
+ readonly releaseRoot: string;
45
+ readonly contentDigest: string;
46
+ };
47
+ readonly output: {
48
+ stderr(message: string): void;
49
+ };
50
+ readonly environment?: NodeJS.ProcessEnv;
51
+ readonly platform?: NodeJS.Platform;
52
+ readonly timeoutMs?: number;
53
+ readonly workerSpawner?: (entry: string, manifestPath: string, environment: NodeJS.ProcessEnv) => Promise<void>;
54
+ readonly onRecoveryState?: (state: UpdateRecoveryState) => Promise<void>;
55
+ }
56
+ export interface ProtectedPackageReplacementResult extends UpdateRecoveryResult {
57
+ readonly recovery: UpdateRecoveryState;
58
+ }
59
+ /** Resolve the complete public launcher set npm owns for the active platform. */
60
+ export declare function updateLauncherPaths(globalRoot: string, platform?: NodeJS.Platform): readonly string[];
61
+ /** Validate transaction-scoped recovery authority without trusting npm temporary names. */
62
+ export declare function readUpdateRecoveryCapsule(manifestPath: string, platform?: NodeJS.Platform): Promise<UpdateRecoveryCapsule>;
63
+ /** Prepare durable recovery authority before npm can mutate the live launcher set. */
64
+ export declare function prepareUpdateRecoveryCapsule(options: ProtectedPackageReplacementOptions): Promise<{
65
+ capsule: UpdateRecoveryCapsule;
66
+ manifestPath: string;
67
+ }>;
68
+ /** Execute global replacement behind a detached owner and coordinate terminal cancellation. */
69
+ export declare function runProtectedPackageReplacement(options: ProtectedPackageReplacementOptions): Promise<ProtectedPackageReplacementResult>;
70
+ export declare function removeUpdateRecoveryCapsule(dataDir: string, transactionId: string): Promise<void>;
71
+ /** Classify a complete launcher set without following linked or mixed launcher files. */
72
+ export declare function inspectUpdateLauncherSet(capsule: UpdateRecoveryCapsule, platform?: NodeJS.Platform): Promise<"target" | "recovery" | "unavailable">;
73
+ /** Remove terminal recovery capsules only after no canonical launcher references them. */
74
+ export declare function cleanupUpdateRecoveryCapsules(dataDir: string): Promise<void>;