litclaude-ai 0.3.39 → 0.3.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/README.md +20 -12
- package/README_ko-KR.md +20 -12
- package/RELEASE_CHECKLIST.md +7 -7
- package/bin/litclaude-ai.js +166 -4
- package/docs/hooks.md +16 -1
- package/docs/native-goal-surface.md +4 -2
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-hook.js +35 -3
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/hooks.json +2 -2
- package/plugins/litclaude/lib/automatic-update.mjs +684 -0
- package/plugins/litclaude/output-styles/asd-ste100.md +46 -0
- package/plugins/litclaude/output-styles/asd-ste100_ko-KR.md +47 -0
- package/plugins/litclaude/output-styles/eli5.md +17 -0
- package/plugins/litclaude/output-styles/eli5_ko-KR.md +19 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { spawnSync as nodeSpawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
appendFileSync,
|
|
5
|
+
cpSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readlinkSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
symlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const PACKAGE_NAME = "litclaude-ai";
|
|
20
|
+
export const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
21
|
+
export const AUTO_UPDATE_INSTALL_TIMEOUT_MS = 30_000;
|
|
22
|
+
export const AUTO_UPDATE_DOCTOR_TIMEOUT_MS = 15_000;
|
|
23
|
+
export const AUTO_UPDATE_TIMEOUT_MS = AUTO_UPDATE_INSTALL_TIMEOUT_MS + AUTO_UPDATE_DOCTOR_TIMEOUT_MS;
|
|
24
|
+
export const AUTO_UPDATE_LOCK_STALE_MS = 60_000;
|
|
25
|
+
export const AUTO_UPDATE_MAX_OUTPUT_BYTES = 128 * 1_024;
|
|
26
|
+
export const AUTO_UPDATE_SCHEMA = 1;
|
|
27
|
+
|
|
28
|
+
const stableSemverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
|
|
29
|
+
const generationPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
30
|
+
const sensitiveEnvironmentName = /(?:token|auth|password|secret|cookie|credential|private.?key|node_options|userconfig)/iu;
|
|
31
|
+
|
|
32
|
+
const parseStableSemver = (value) => {
|
|
33
|
+
const match = typeof value === "string" ? value.match(stableSemverPattern) : null;
|
|
34
|
+
if (!match) throw new Error("version is not strict stable semver");
|
|
35
|
+
return match.slice(1).map((part) => BigInt(part));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const compareStableSemver = (left, right) => {
|
|
39
|
+
const leftParts = parseStableSemver(left);
|
|
40
|
+
const rightParts = parseStableSemver(right);
|
|
41
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
42
|
+
if (leftParts[index] > rightParts[index]) return 1;
|
|
43
|
+
if (leftParts[index] < rightParts[index]) return -1;
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const canonicalTimestamp = (value, now) => {
|
|
49
|
+
if (typeof value !== "string") return false;
|
|
50
|
+
const parsed = Date.parse(value);
|
|
51
|
+
return Number.isFinite(parsed) && parsed <= now && new Date(parsed).toISOString() === value;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const validCacheRecord = (value, now) => {
|
|
55
|
+
if (!value || Array.isArray(value) || typeof value !== "object") return false;
|
|
56
|
+
if (value.schema !== 3 || value.packageName !== PACKAGE_NAME) return false;
|
|
57
|
+
if (Object.keys(value).sort().join(",") !== "attemptedAt,checkedAt,generation,latestVersion,packageName,schema") return false;
|
|
58
|
+
if (typeof value.generation !== "string" || !generationPattern.test(value.generation)) return false;
|
|
59
|
+
if (!canonicalTimestamp(value.attemptedAt, now)) return false;
|
|
60
|
+
if (value.latestVersion === null || value.checkedAt === null) {
|
|
61
|
+
return value.latestVersion === null && value.checkedAt === null;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
parseStableSemver(value.latestVersion);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return canonicalTimestamp(value.checkedAt, now);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const readCacheCandidate = (cachePath, currentVersion, now) => {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return { status: "no-candidate" };
|
|
77
|
+
}
|
|
78
|
+
if (!validCacheRecord(parsed, now)) return { status: "no-candidate" };
|
|
79
|
+
if (parsed.latestVersion === null || parsed.checkedAt === null) return { status: "no-candidate" };
|
|
80
|
+
const checkedAt = Date.parse(parsed.checkedAt);
|
|
81
|
+
if (now - checkedAt > CACHE_TTL_MS) return { status: "stale-cache", checkedAt: parsed.checkedAt };
|
|
82
|
+
try {
|
|
83
|
+
if (compareStableSemver(parsed.latestVersion, currentVersion) <= 0) return { status: "current" };
|
|
84
|
+
} catch {
|
|
85
|
+
return { status: "no-candidate" };
|
|
86
|
+
}
|
|
87
|
+
return { status: "candidate", version: parsed.latestVersion, checkedAt: parsed.checkedAt };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const automaticUpdateRoot = (litHome = resolveHome()) => join(litHome, "update-notifier", "automatic");
|
|
91
|
+
|
|
92
|
+
export const automaticUpdateCachePath = (litHome = resolveHome()) => join(litHome, "update-notifier", "latest.json");
|
|
93
|
+
|
|
94
|
+
const resolveHome = () => process.env.LITCLAUDE_HOME
|
|
95
|
+
? process.env.LITCLAUDE_HOME
|
|
96
|
+
: join(homedir(), ".litclaude");
|
|
97
|
+
|
|
98
|
+
const resolveClaudeHome = () => process.env.CLAUDE_CONFIG_DIR
|
|
99
|
+
?? process.env.CLAUDE_HOME
|
|
100
|
+
?? join(homedir(), ".claude");
|
|
101
|
+
|
|
102
|
+
const hasOptOut = (env, name) => Object.hasOwn(env ?? {}, name);
|
|
103
|
+
|
|
104
|
+
const streamIsTty = (stream) => stream?.isTTY === true;
|
|
105
|
+
|
|
106
|
+
export const shouldRunAutomaticUpdate = ({
|
|
107
|
+
surface = "management",
|
|
108
|
+
command,
|
|
109
|
+
rest = [],
|
|
110
|
+
dryRun = false,
|
|
111
|
+
env = process.env,
|
|
112
|
+
stdin = process.stdin,
|
|
113
|
+
stdout = process.stdout,
|
|
114
|
+
stderr = process.stderr,
|
|
115
|
+
input = {},
|
|
116
|
+
noAutoUpdate = false,
|
|
117
|
+
} = {}) => {
|
|
118
|
+
if (!["session-start", "management"].includes(surface)) return false;
|
|
119
|
+
if (dryRun || noAutoUpdate) return false;
|
|
120
|
+
if (hasOptOut(env, "CI")
|
|
121
|
+
|| hasOptOut(env, "NO_UPDATE_NOTIFIER")
|
|
122
|
+
|| hasOptOut(env, "LITCLAUDE_NO_UPDATE_CHECK")
|
|
123
|
+
|| hasOptOut(env, "LITCLAUDE_NO_AUTO_UPDATE")
|
|
124
|
+
|| hasOptOut(env, "LITCLAUDE_AUTO_UPDATE_ACTIVE")) return false;
|
|
125
|
+
if (rest.some((arg) => arg === "--json" || arg.startsWith("--json=") || arg.startsWith("--no-auto-update="))) return false;
|
|
126
|
+
|
|
127
|
+
if (surface === "session-start") {
|
|
128
|
+
// Claude Code invokes hooks through pipes, so TTY checks do not apply to this
|
|
129
|
+
// host-owned interactive lifecycle. Other hook/tool/import surfaces never call
|
|
130
|
+
// this function with `session-start` and therefore remain no-op by construction.
|
|
131
|
+
return (input?.hook_event_name === undefined || input.hook_event_name === "SessionStart")
|
|
132
|
+
&& typeof input?.session_id === "string"
|
|
133
|
+
&& input.session_id.length > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!new Set(["install", "update", "doctor"]).has(command)) return false;
|
|
137
|
+
return streamIsTty(stdin) && streamIsTty(stdout) && streamIsTty(stderr);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const safeUrl = (value, { allowPath = false } = {}) => {
|
|
141
|
+
if (typeof value !== "string" || value.length > 2_048) return null;
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
|
144
|
+
if (!new Set(["http:", "https:"]).has(url.protocol)) return null;
|
|
145
|
+
if (url.username || url.password) return null;
|
|
146
|
+
if (!allowPath && (url.pathname !== "/" || url.search || url.hash)) return null;
|
|
147
|
+
return url.toString();
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const safeProxy = (value) => safeUrl(value, { allowPath: true });
|
|
154
|
+
|
|
155
|
+
const SAFE_ENVIRONMENT_KEYS = new Set([
|
|
156
|
+
"PATH", "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "TMPDIR", "TMP", "TEMP",
|
|
157
|
+
"SystemRoot", "WINDIR", "ComSpec", "PATHEXT", "LANG", "LC_ALL", "LC_CTYPE", "TZ",
|
|
158
|
+
"NODE_EXTRA_CA_CERTS", "LITCLAUDE_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_HOME",
|
|
159
|
+
"LITCLAUDE_PERMISSION_MODE", "LITCLAUDE_HUD_ACCENT", "LITCLAUDE_HUD_ACCENT_PROMPT",
|
|
160
|
+
"LITCLAUDE_OUTPUT_STYLE", "LITCLAUDE_OUTPUT_STYLE_PROMPT", "NO_COLOR",
|
|
161
|
+
"npm_config_registry", "NPM_CONFIG_REGISTRY",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const SAFE_PROXY_KEYS = new Set([
|
|
165
|
+
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy",
|
|
166
|
+
"NO_PROXY", "no_proxy",
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
export const sanitizeNpmEnvironment = (source = process.env) => {
|
|
170
|
+
const result = {};
|
|
171
|
+
for (const [name, value] of Object.entries(source ?? {})) {
|
|
172
|
+
if (typeof value !== "string" || sensitiveEnvironmentName.test(name)) continue;
|
|
173
|
+
if (SAFE_PROXY_KEYS.has(name)) {
|
|
174
|
+
const sanitized = name.toLowerCase().includes("no_proxy")
|
|
175
|
+
? value.replace(/[^A-Za-z0-9.:[\],_ -]/gu, "")
|
|
176
|
+
: safeProxy(value);
|
|
177
|
+
if (sanitized) result[name] = sanitized;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!SAFE_ENVIRONMENT_KEYS.has(name)) continue;
|
|
181
|
+
if (name === "npm_config_registry" || name === "NPM_CONFIG_REGISTRY") {
|
|
182
|
+
const sanitized = safeUrl(value);
|
|
183
|
+
if (sanitized) result[name] = sanitized;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
result[name] = value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The child must not inherit the parent update lifecycle. These are product
|
|
190
|
+
// controls, not user credentials, and also fence npm's own update notifier.
|
|
191
|
+
result.NO_UPDATE_NOTIFIER = "1";
|
|
192
|
+
result.LITCLAUDE_NO_UPDATE_CHECK = "1";
|
|
193
|
+
result.LITCLAUDE_NO_AUTO_UPDATE = "1";
|
|
194
|
+
result.LITCLAUDE_AUTO_UPDATE_ACTIVE = "1";
|
|
195
|
+
result.NPM_CONFIG_UPDATE_NOTIFIER = "false";
|
|
196
|
+
result.npm_config_update_notifier = "false";
|
|
197
|
+
result.NPM_CONFIG_FUND = "false";
|
|
198
|
+
result.NPM_CONFIG_AUDIT = "false";
|
|
199
|
+
result.NPM_CONFIG_PROGRESS = "false";
|
|
200
|
+
result.npm_config_fund = "false";
|
|
201
|
+
result.npm_config_audit = "false";
|
|
202
|
+
result.npm_config_progress = "false";
|
|
203
|
+
return result;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const writeAtomic = (path, content, mode = 0o600) => {
|
|
207
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
208
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
209
|
+
try {
|
|
210
|
+
writeFileSync(temporaryPath, content, { encoding: "utf8", mode });
|
|
211
|
+
renameSync(temporaryPath, path);
|
|
212
|
+
} finally {
|
|
213
|
+
rmSync(temporaryPath, { force: true });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const canonicalNow = (now) => {
|
|
218
|
+
const value = typeof now === "function" ? now() : now;
|
|
219
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const safeDetail = (value) => String(value ?? "unknown")
|
|
223
|
+
.replace(/(?:token|auth|password|secret|cookie|credential)[^\n]{0,120}/giu, "[redacted]")
|
|
224
|
+
.replace(/[\r\n]+/gu, " ")
|
|
225
|
+
.slice(0, 400);
|
|
226
|
+
|
|
227
|
+
const appendJournal = (journalPath, event, fields = {}, now = Date.now()) => {
|
|
228
|
+
const row = {
|
|
229
|
+
schema: AUTO_UPDATE_SCHEMA,
|
|
230
|
+
event,
|
|
231
|
+
at: new Date(canonicalNow(now)).toISOString(),
|
|
232
|
+
...fields,
|
|
233
|
+
};
|
|
234
|
+
mkdirSync(dirname(journalPath), { recursive: true, mode: 0o700 });
|
|
235
|
+
appendFileSync(journalPath, `${JSON.stringify(row)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const processIsLive = (pid) => {
|
|
239
|
+
try {
|
|
240
|
+
process.kill(pid, 0);
|
|
241
|
+
return true;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return error?.code !== "ESRCH";
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const readLockOwner = (lockPath) => {
|
|
248
|
+
try {
|
|
249
|
+
const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8"));
|
|
250
|
+
if (!owner || Object.keys(owner).sort().join(",") !== "acquiredAt,generation,pid,schema") return null;
|
|
251
|
+
if (owner.schema !== AUTO_UPDATE_SCHEMA || !generationPattern.test(owner.generation)) return null;
|
|
252
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) return null;
|
|
253
|
+
if (!canonicalTimestamp(owner.acquiredAt, Date.now() + AUTO_UPDATE_LOCK_STALE_MS)) return null;
|
|
254
|
+
return owner;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const acquireInstallLock = (lockPath, now) => {
|
|
261
|
+
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
262
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
263
|
+
try {
|
|
264
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
265
|
+
const lease = { path: lockPath, generation: randomUUID(), pid: process.pid };
|
|
266
|
+
try {
|
|
267
|
+
writeAtomic(join(lockPath, "owner.json"), `${JSON.stringify({
|
|
268
|
+
schema: AUTO_UPDATE_SCHEMA,
|
|
269
|
+
generation: lease.generation,
|
|
270
|
+
pid: process.pid,
|
|
271
|
+
acquiredAt: new Date(now).toISOString(),
|
|
272
|
+
})}\n`);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
return lease;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error?.code !== "EEXIST") throw error;
|
|
280
|
+
let stale = false;
|
|
281
|
+
try {
|
|
282
|
+
const owner = readLockOwner(lockPath);
|
|
283
|
+
const age = now - lstatSync(lockPath).mtimeMs;
|
|
284
|
+
stale = age >= AUTO_UPDATE_LOCK_STALE_MS && (owner ? !processIsLive(owner.pid) : true);
|
|
285
|
+
} catch {
|
|
286
|
+
stale = false;
|
|
287
|
+
}
|
|
288
|
+
if (!stale) return null;
|
|
289
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const releaseInstallLock = (lease) => {
|
|
296
|
+
if (!lease) return false;
|
|
297
|
+
try {
|
|
298
|
+
const owner = readLockOwner(lease.path);
|
|
299
|
+
if (owner?.generation !== lease.generation || owner.pid !== process.pid) return false;
|
|
300
|
+
rmSync(lease.path, { recursive: true, force: true });
|
|
301
|
+
return true;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const nodeKind = (path) => {
|
|
308
|
+
try {
|
|
309
|
+
const stats = lstatSync(path);
|
|
310
|
+
if (stats.isSymbolicLink()) return "symlink";
|
|
311
|
+
if (stats.isDirectory()) return "directory";
|
|
312
|
+
if (stats.isFile()) return "file";
|
|
313
|
+
return "other";
|
|
314
|
+
} catch {
|
|
315
|
+
return "missing";
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const copyNode = (source, target) => {
|
|
320
|
+
const kind = nodeKind(source);
|
|
321
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
322
|
+
if (kind === "symlink") {
|
|
323
|
+
symlinkSync(readlinkSync(source), target);
|
|
324
|
+
} else if (kind === "directory") {
|
|
325
|
+
cpSync(source, target, { recursive: true, dereference: false, force: true });
|
|
326
|
+
} else if (kind === "file" || kind === "other") {
|
|
327
|
+
cpSync(source, target, { force: true, dereference: false });
|
|
328
|
+
}
|
|
329
|
+
return kind;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export const automaticUpdateTransactionPaths = ({ litHome = resolveHome(), claudeHome = resolveClaudeHome() } = {}) => [
|
|
333
|
+
{ name: "claude-plugin-cache", path: join(claudeHome, "plugins", "cache", PACKAGE_NAME) },
|
|
334
|
+
{ name: "installed-plugins", path: join(claudeHome, "plugins", "installed_plugins.json") },
|
|
335
|
+
{ name: "known-marketplaces", path: join(claudeHome, "plugins", "known_marketplaces.json") },
|
|
336
|
+
{ name: "claude-settings", path: join(claudeHome, "settings.json") },
|
|
337
|
+
{ name: "versioned-state", path: join(litHome, "litclaude-ai") },
|
|
338
|
+
{ name: "current-state", path: join(litHome, "current") },
|
|
339
|
+
{ name: "marketplace-state", path: join(litHome, "marketplaces", PACKAGE_NAME) },
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
const backupTransaction = (backupPath, paths) => {
|
|
343
|
+
mkdirSync(backupPath, { recursive: true, mode: 0o700 });
|
|
344
|
+
const entries = paths.map(({ name, path }, index) => {
|
|
345
|
+
const kind = nodeKind(path);
|
|
346
|
+
const artifact = join(backupPath, `${String(index).padStart(2, "0")}-${name}`);
|
|
347
|
+
if (kind !== "missing") copyNode(path, artifact);
|
|
348
|
+
return { name, path, kind, artifact: kind === "missing" ? null : artifact };
|
|
349
|
+
});
|
|
350
|
+
writeAtomic(join(backupPath, "manifest.json"), `${JSON.stringify({ schema: AUTO_UPDATE_SCHEMA, entries }, null, 2)}\n`);
|
|
351
|
+
return entries;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const restoreTransaction = (entries) => {
|
|
355
|
+
const failures = [];
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
try {
|
|
358
|
+
rmSync(entry.path, { recursive: true, force: true });
|
|
359
|
+
if (entry.kind !== "missing") copyNode(entry.artifact, entry.path);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
failures.push(`${entry.name}: ${safeDetail(error?.message)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (failures.length) throw new Error(`rollback failed (${failures.join("; ")})`);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const commandForNpm = (env, explicit) => explicit ?? env.LITCLAUDE_NPM_BIN ?? (process.platform === "win32" ? "npm.cmd" : "npm");
|
|
368
|
+
|
|
369
|
+
const buildNpmArgs = (targetVersion, command, extraArgs = []) => [
|
|
370
|
+
"exec", "--yes", "--package", `${PACKAGE_NAME}@${targetVersion}`, "--", "litclaude-ai", command,
|
|
371
|
+
...extraArgs.filter((arg) => arg !== "--no-auto-update"),
|
|
372
|
+
"--no-auto-update",
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
export const buildAutomaticInstallInvocation = (targetVersion, extraArgs = [], npmCommand) => ({
|
|
376
|
+
command: npmCommand ?? (process.platform === "win32" ? "npm.cmd" : "npm"),
|
|
377
|
+
args: buildNpmArgs(targetVersion, "install", extraArgs),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const childResult = (result) => {
|
|
381
|
+
const hasError = result?.error != null;
|
|
382
|
+
const hasSignal = result?.signal != null;
|
|
383
|
+
const errorCode = result?.error?.code ?? (hasError ? "SPAWN_ERROR" : null);
|
|
384
|
+
const timedOut = errorCode === "ETIMEDOUT" || result?.signal === "SIGTERM" || result?.signal === "SIGKILL";
|
|
385
|
+
const status = Number.isInteger(result?.status) ? result.status : null;
|
|
386
|
+
return {
|
|
387
|
+
// Node can expose an error or signal alongside a misleading zero status
|
|
388
|
+
// (and test doubles often do). Either condition means the child outcome is
|
|
389
|
+
// not trustworthy; only an un-signalled, error-free zero exit is success.
|
|
390
|
+
ok: !hasError && !hasSignal && !timedOut && status === 0,
|
|
391
|
+
status,
|
|
392
|
+
timedOut,
|
|
393
|
+
signal: result?.signal ?? null,
|
|
394
|
+
errorCode,
|
|
395
|
+
stdoutBytes: Buffer.byteLength(String(result?.stdout ?? ""), "utf8"),
|
|
396
|
+
stderrBytes: Buffer.byteLength(String(result?.stderr ?? ""), "utf8"),
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const verifyInstalledVersion = ({ claudeHome, targetVersion }) => {
|
|
401
|
+
try {
|
|
402
|
+
const pluginManifest = JSON.parse(readFileSync(
|
|
403
|
+
join(claudeHome, "plugins", "cache", PACKAGE_NAME, "litclaude", targetVersion, ".claude-plugin", "plugin.json"),
|
|
404
|
+
"utf8",
|
|
405
|
+
));
|
|
406
|
+
if (pluginManifest.version !== targetVersion) return false;
|
|
407
|
+
const registry = JSON.parse(readFileSync(join(claudeHome, "plugins", "installed_plugins.json"), "utf8"));
|
|
408
|
+
return registry?.plugins?.["litclaude@litclaude-ai"]?.[0]?.version === targetVersion;
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const runNpm = ({ command, args, env, cwd, timeout, spawn }) => {
|
|
415
|
+
try {
|
|
416
|
+
const result = spawn(command, args, {
|
|
417
|
+
cwd,
|
|
418
|
+
env,
|
|
419
|
+
encoding: "utf8",
|
|
420
|
+
stdio: "pipe",
|
|
421
|
+
windowsHide: true,
|
|
422
|
+
timeout,
|
|
423
|
+
maxBuffer: AUTO_UPDATE_MAX_OUTPUT_BYTES,
|
|
424
|
+
killSignal: "SIGTERM",
|
|
425
|
+
});
|
|
426
|
+
return childResult(result);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
status: null,
|
|
431
|
+
timedOut: false,
|
|
432
|
+
signal: null,
|
|
433
|
+
errorCode: error?.code ?? "SPAWN_ERROR",
|
|
434
|
+
detail: safeDetail(error?.message),
|
|
435
|
+
stdoutBytes: 0,
|
|
436
|
+
stderrBytes: 0,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const writeReceipt = (autoRoot, receipt) => {
|
|
442
|
+
const receiptPath = join(autoRoot, "receipt.json");
|
|
443
|
+
const attemptPath = join(autoRoot, "receipts", `${receipt.generation}.json`);
|
|
444
|
+
const encoded = `${JSON.stringify(receipt, null, 2)}\n`;
|
|
445
|
+
writeAtomic(attemptPath, encoded);
|
|
446
|
+
writeAtomic(receiptPath, encoded);
|
|
447
|
+
return receiptPath;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const resultWithoutSecrets = ({ receiptPath, journalPath, backupPath, ...result }) => ({
|
|
451
|
+
...result,
|
|
452
|
+
receiptPath,
|
|
453
|
+
journalPath,
|
|
454
|
+
backupPath: backupPath ?? null,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
export const runAutomaticUpdate = ({
|
|
458
|
+
surface = "management",
|
|
459
|
+
command = "install",
|
|
460
|
+
rest = [],
|
|
461
|
+
dryRun = false,
|
|
462
|
+
currentVersion,
|
|
463
|
+
cachePath,
|
|
464
|
+
litHome,
|
|
465
|
+
claudeHome,
|
|
466
|
+
env = process.env,
|
|
467
|
+
stdin = process.stdin,
|
|
468
|
+
stdout = process.stdout,
|
|
469
|
+
stderr = process.stderr,
|
|
470
|
+
input = {},
|
|
471
|
+
noAutoUpdate = false,
|
|
472
|
+
now = Date.now(),
|
|
473
|
+
cwd = process.cwd(),
|
|
474
|
+
npmCommand,
|
|
475
|
+
spawn = nodeSpawnSync,
|
|
476
|
+
verifyInstall = verifyInstalledVersion,
|
|
477
|
+
restore = restoreTransaction,
|
|
478
|
+
} = {}) => {
|
|
479
|
+
const resolvedLitHome = litHome ?? env.LITCLAUDE_HOME ?? resolveHome();
|
|
480
|
+
const resolvedClaudeHome = claudeHome ?? env.CLAUDE_CONFIG_DIR ?? env.CLAUDE_HOME ?? resolveClaudeHome();
|
|
481
|
+
const resolvedCachePath = cachePath ?? automaticUpdateCachePath(resolvedLitHome);
|
|
482
|
+
if (!shouldRunAutomaticUpdate({ surface, command, rest, dryRun, env, stdin, stdout, stderr, input, noAutoUpdate })) {
|
|
483
|
+
return { status: "gated" };
|
|
484
|
+
}
|
|
485
|
+
let parsedCurrent;
|
|
486
|
+
try {
|
|
487
|
+
parsedCurrent = parseStableSemver(currentVersion);
|
|
488
|
+
if (!parsedCurrent) return { status: "gated" };
|
|
489
|
+
} catch {
|
|
490
|
+
return { status: "gated" };
|
|
491
|
+
}
|
|
492
|
+
const timestamp = canonicalNow(now);
|
|
493
|
+
const candidate = readCacheCandidate(resolvedCachePath, currentVersion, timestamp);
|
|
494
|
+
if (candidate.status !== "candidate") return candidate;
|
|
495
|
+
|
|
496
|
+
const autoRoot = automaticUpdateRoot(resolvedLitHome);
|
|
497
|
+
const lockPath = join(autoRoot, "install.lock");
|
|
498
|
+
let lease;
|
|
499
|
+
try {
|
|
500
|
+
lease = acquireInstallLock(lockPath, timestamp);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
return { status: "failed", reason: safeDetail(error?.message) };
|
|
503
|
+
}
|
|
504
|
+
if (!lease) return { status: "locked", lockPath };
|
|
505
|
+
|
|
506
|
+
const journalPath = join(autoRoot, "journal.jsonl");
|
|
507
|
+
const backupPath = join(autoRoot, "backups", lease.generation);
|
|
508
|
+
const startedAt = new Date(timestamp).toISOString();
|
|
509
|
+
const transactionPaths = automaticUpdateTransactionPaths({ litHome: resolvedLitHome, claudeHome: resolvedClaudeHome });
|
|
510
|
+
let backupEntries;
|
|
511
|
+
let receiptPath;
|
|
512
|
+
let userConfigPath;
|
|
513
|
+
try {
|
|
514
|
+
appendJournal(journalPath, "started", {
|
|
515
|
+
generation: lease.generation,
|
|
516
|
+
fromVersion: currentVersion,
|
|
517
|
+
targetVersion: candidate.version,
|
|
518
|
+
surface,
|
|
519
|
+
}, timestamp);
|
|
520
|
+
backupEntries = backupTransaction(backupPath, transactionPaths);
|
|
521
|
+
appendJournal(journalPath, "backup-created", {
|
|
522
|
+
generation: lease.generation,
|
|
523
|
+
targetVersion: candidate.version,
|
|
524
|
+
pathCount: backupEntries.length,
|
|
525
|
+
}, timestamp);
|
|
526
|
+
|
|
527
|
+
const childEnv = sanitizeNpmEnvironment(env);
|
|
528
|
+
userConfigPath = join(autoRoot, `${lease.generation}.npmrc`);
|
|
529
|
+
writeAtomic(userConfigPath, "# LitClaude automatic update: no credentials\n");
|
|
530
|
+
childEnv.npm_config_userconfig = userConfigPath;
|
|
531
|
+
childEnv.NPM_CONFIG_USERCONFIG = userConfigPath;
|
|
532
|
+
const resolvedNpm = commandForNpm(env, npmCommand);
|
|
533
|
+
const installInvocation = buildAutomaticInstallInvocation(candidate.version, rest, resolvedNpm);
|
|
534
|
+
const install = runNpm({
|
|
535
|
+
command: installInvocation.command,
|
|
536
|
+
args: installInvocation.args,
|
|
537
|
+
env: childEnv,
|
|
538
|
+
cwd,
|
|
539
|
+
timeout: AUTO_UPDATE_INSTALL_TIMEOUT_MS,
|
|
540
|
+
spawn,
|
|
541
|
+
});
|
|
542
|
+
appendJournal(journalPath, "install-finished", {
|
|
543
|
+
generation: lease.generation,
|
|
544
|
+
targetVersion: candidate.version,
|
|
545
|
+
ok: install.ok,
|
|
546
|
+
status: install.status,
|
|
547
|
+
timedOut: install.timedOut,
|
|
548
|
+
errorCode: install.errorCode,
|
|
549
|
+
}, Date.now());
|
|
550
|
+
|
|
551
|
+
let doctor = null;
|
|
552
|
+
if (install.ok) {
|
|
553
|
+
doctor = runNpm({
|
|
554
|
+
command: resolvedNpm,
|
|
555
|
+
args: buildNpmArgs(candidate.version, "doctor"),
|
|
556
|
+
env: childEnv,
|
|
557
|
+
cwd,
|
|
558
|
+
timeout: AUTO_UPDATE_DOCTOR_TIMEOUT_MS,
|
|
559
|
+
spawn,
|
|
560
|
+
});
|
|
561
|
+
appendJournal(journalPath, "doctor-finished", {
|
|
562
|
+
generation: lease.generation,
|
|
563
|
+
targetVersion: candidate.version,
|
|
564
|
+
ok: doctor.ok,
|
|
565
|
+
status: doctor.status,
|
|
566
|
+
timedOut: doctor.timedOut,
|
|
567
|
+
errorCode: doctor.errorCode,
|
|
568
|
+
}, Date.now());
|
|
569
|
+
}
|
|
570
|
+
rmSync(userConfigPath, { force: true });
|
|
571
|
+
userConfigPath = undefined;
|
|
572
|
+
|
|
573
|
+
const verified = install.ok && doctor?.ok === true && verifyInstall({
|
|
574
|
+
claudeHome: resolvedClaudeHome,
|
|
575
|
+
litHome: resolvedLitHome,
|
|
576
|
+
targetVersion: candidate.version,
|
|
577
|
+
});
|
|
578
|
+
const success = install.ok && doctor?.ok === true && verified;
|
|
579
|
+
if (!success) {
|
|
580
|
+
let rollbackError = null;
|
|
581
|
+
try {
|
|
582
|
+
restore(backupEntries);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
rollbackError = safeDetail(error?.message);
|
|
585
|
+
}
|
|
586
|
+
const status = rollbackError ? "rollback-failed" : "rolled-back";
|
|
587
|
+
appendJournal(journalPath, "rollback", {
|
|
588
|
+
generation: lease.generation,
|
|
589
|
+
targetVersion: candidate.version,
|
|
590
|
+
status,
|
|
591
|
+
reason: rollbackError ?? (install.ok ? "post-install doctor or version verification failed" : install.errorCode ?? "npm install failed"),
|
|
592
|
+
}, Date.now());
|
|
593
|
+
const receipt = {
|
|
594
|
+
schema: AUTO_UPDATE_SCHEMA,
|
|
595
|
+
generation: lease.generation,
|
|
596
|
+
status,
|
|
597
|
+
packageName: PACKAGE_NAME,
|
|
598
|
+
fromVersion: currentVersion,
|
|
599
|
+
targetVersion: candidate.version,
|
|
600
|
+
startedAt,
|
|
601
|
+
finishedAt: new Date().toISOString(),
|
|
602
|
+
backupPath,
|
|
603
|
+
journalPath,
|
|
604
|
+
install,
|
|
605
|
+
doctor,
|
|
606
|
+
verified,
|
|
607
|
+
rollback: { attempted: true, ok: rollbackError === null, error: rollbackError },
|
|
608
|
+
};
|
|
609
|
+
receiptPath = writeReceipt(autoRoot, receipt);
|
|
610
|
+
return resultWithoutSecrets({ status, receiptPath, journalPath, backupPath, targetVersion: candidate.version, rollback: receipt.rollback });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
appendJournal(journalPath, "completed", {
|
|
614
|
+
generation: lease.generation,
|
|
615
|
+
fromVersion: currentVersion,
|
|
616
|
+
targetVersion: candidate.version,
|
|
617
|
+
verified: true,
|
|
618
|
+
}, Date.now());
|
|
619
|
+
const receipt = {
|
|
620
|
+
schema: AUTO_UPDATE_SCHEMA,
|
|
621
|
+
generation: lease.generation,
|
|
622
|
+
status: "installed",
|
|
623
|
+
packageName: PACKAGE_NAME,
|
|
624
|
+
fromVersion: currentVersion,
|
|
625
|
+
targetVersion: candidate.version,
|
|
626
|
+
startedAt,
|
|
627
|
+
finishedAt: new Date().toISOString(),
|
|
628
|
+
backupPath,
|
|
629
|
+
journalPath,
|
|
630
|
+
install,
|
|
631
|
+
doctor,
|
|
632
|
+
verified: true,
|
|
633
|
+
rollback: { attempted: false, ok: true, error: null },
|
|
634
|
+
};
|
|
635
|
+
receiptPath = writeReceipt(autoRoot, receipt);
|
|
636
|
+
return resultWithoutSecrets({ status: "installed", receiptPath, journalPath, backupPath, targetVersion: candidate.version, rollback: receipt.rollback });
|
|
637
|
+
} catch (error) {
|
|
638
|
+
const reason = safeDetail(error?.message);
|
|
639
|
+
let rollbackError = null;
|
|
640
|
+
if (backupEntries) {
|
|
641
|
+
try {
|
|
642
|
+
restore(backupEntries);
|
|
643
|
+
} catch (restoreError) {
|
|
644
|
+
rollbackError = safeDetail(restoreError?.message);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
appendJournal(journalPath, "failed", {
|
|
649
|
+
generation: lease.generation,
|
|
650
|
+
targetVersion: candidate.version,
|
|
651
|
+
reason,
|
|
652
|
+
rollback: rollbackError ? "failed" : backupEntries ? "ok" : "not-started",
|
|
653
|
+
}, Date.now());
|
|
654
|
+
const receipt = {
|
|
655
|
+
schema: AUTO_UPDATE_SCHEMA,
|
|
656
|
+
generation: lease.generation,
|
|
657
|
+
status: rollbackError ? "rollback-failed" : "failed",
|
|
658
|
+
packageName: PACKAGE_NAME,
|
|
659
|
+
fromVersion: currentVersion,
|
|
660
|
+
targetVersion: candidate.version,
|
|
661
|
+
startedAt,
|
|
662
|
+
finishedAt: new Date().toISOString(),
|
|
663
|
+
backupPath,
|
|
664
|
+
journalPath,
|
|
665
|
+
reason,
|
|
666
|
+
rollback: { attempted: Boolean(backupEntries), ok: rollbackError === null, error: rollbackError },
|
|
667
|
+
};
|
|
668
|
+
receiptPath = writeReceipt(autoRoot, receipt);
|
|
669
|
+
} catch {
|
|
670
|
+
// The primary failure remains the only claim if the evidence surface itself is unavailable.
|
|
671
|
+
}
|
|
672
|
+
return resultWithoutSecrets({
|
|
673
|
+
status: rollbackError ? "rollback-failed" : "failed",
|
|
674
|
+
reason,
|
|
675
|
+
receiptPath,
|
|
676
|
+
journalPath,
|
|
677
|
+
backupPath,
|
|
678
|
+
targetVersion: candidate.version,
|
|
679
|
+
});
|
|
680
|
+
} finally {
|
|
681
|
+
if (userConfigPath) rmSync(userConfigPath, { force: true });
|
|
682
|
+
releaseInstallLock(lease);
|
|
683
|
+
}
|
|
684
|
+
};
|