knodin 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/bin/cli.js +307 -85
  2. package/dist/fixtures/update-verification/config/application.xml +4 -0
  3. package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
  4. package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
  5. package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
  6. package/dist/fixtures/update-verification/fixture.json +147 -0
  7. package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
  8. package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
  9. package/dist/fixtures/update-verification/package.json +5 -0
  10. package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
  11. package/dist/fixtures/update-verification/sql/functions.sql +16 -0
  12. package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
  13. package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
  14. package/dist/fixtures/update-verification/src/javascript.js +7 -0
  15. package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
  16. package/dist/fixtures/update-verification/src/typescript.ts +7 -0
  17. package/dist/fixtures/update-verification/terraform/main.tf +7 -0
  18. package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
  19. package/dist/src/agent-integration.js +26 -3
  20. package/dist/src/backup-retention.js +811 -0
  21. package/dist/src/cli-model.js +35 -8
  22. package/dist/src/docs-sections.js +1 -0
  23. package/dist/src/doctor.js +142 -12
  24. package/dist/src/engine/embeddings.js +8 -1
  25. package/dist/src/engine/index.js +355 -292
  26. package/dist/src/init.js +222 -179
  27. package/dist/src/manager-update.js +455 -0
  28. package/dist/src/release-preflight.js +106 -95
  29. package/dist/src/repair-lease.js +77 -8
  30. package/dist/src/response-budget.js +5 -1
  31. package/dist/src/server.js +18 -5
  32. package/dist/src/shared-index/compatibility.js +4 -2
  33. package/dist/src/tools/knodin-tools.js +69 -10
  34. package/dist/src/update-ceremony.js +20 -24
  35. package/dist/src/update-coordination.js +158 -0
  36. package/dist/src/update-executor.js +336 -0
  37. package/dist/src/update-verifier.js +358 -0
  38. package/docs/BACKUP-RETENTION.md +54 -0
  39. package/docs/CLI.md +6 -0
  40. package/docs/DOCTOR-AND-UPDATES.md +44 -12
  41. package/docs/SIGNED-UPDATES.md +29 -22
  42. package/docs/releases/0.9.0.md +43 -0
  43. package/package.json +7 -1
  44. package/roadmap/competitive-roadmap.md +8 -0
@@ -0,0 +1,455 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ export const UPDATE_JOURNAL_STATES = [
7
+ "planned",
8
+ "manager-applying",
9
+ "candidate-installed-unverified",
10
+ "candidate-verifying",
11
+ "candidate-verified",
12
+ "rollback-required",
13
+ "rollback-applying",
14
+ "rollback-installed-unverified",
15
+ "rollback-verifying",
16
+ "rollback-verified",
17
+ "terminal-unhealthy",
18
+ ];
19
+ const MAX_STATE_BYTES = 1024 * 1024;
20
+ const MAX_CHANNEL_BYTES = 4 * 1024 * 1024;
21
+ const CHANNEL_TIMEOUT_MS = 10_000;
22
+ function stateRoot(stateHome) {
23
+ const root = stateHome ?? process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state");
24
+ return path.join(root, "knodin", "updates");
25
+ }
26
+ export function managerUpdateStatePath(stateHome) {
27
+ return path.join(stateRoot(stateHome), "state.json");
28
+ }
29
+ export function updateJournalPath(stateHome) {
30
+ return path.join(stateRoot(stateHome), "journal.json");
31
+ }
32
+ function boundedJson(file) {
33
+ try {
34
+ const stat = fs.lstatSync(file);
35
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_STATE_BYTES)
36
+ return null;
37
+ return JSON.parse(fs.readFileSync(file, "utf8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function fsyncDirectory(directory) {
44
+ try {
45
+ const descriptor = fs.openSync(directory, "r");
46
+ try {
47
+ fs.fsyncSync(descriptor);
48
+ }
49
+ finally {
50
+ fs.closeSync(descriptor);
51
+ }
52
+ }
53
+ catch {
54
+ // Windows cannot fsync directories. The file itself is still fsynced.
55
+ }
56
+ }
57
+ function atomicWrite(file, value) {
58
+ const directory = path.dirname(file);
59
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
60
+ const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomUUID()}`);
61
+ const descriptor = fs.openSync(temporary, "wx", 0o600);
62
+ try {
63
+ fs.writeFileSync(descriptor, `${JSON.stringify(value)}\n`, "utf8");
64
+ fs.fsyncSync(descriptor);
65
+ }
66
+ finally {
67
+ fs.closeSync(descriptor);
68
+ }
69
+ fs.renameSync(temporary, file);
70
+ fsyncDirectory(directory);
71
+ }
72
+ export function readManagerUpdateState(stateHome) {
73
+ const value = boundedJson(managerUpdateStatePath(stateHome));
74
+ if (value?.schemaVersion === 1 && typeof value.autoEnabled === "boolean")
75
+ return value;
76
+ return { schemaVersion: 1, autoEnabled: false, platformAutoEligible: false };
77
+ }
78
+ export function writeManagerUpdateState(state, stateHome) {
79
+ atomicWrite(managerUpdateStatePath(stateHome), state);
80
+ }
81
+ export function readUpdateJournal(stateHome) {
82
+ const value = boundedJson(updateJournalPath(stateHome));
83
+ if (!value || value.schemaVersion !== 1 || !UPDATE_JOURNAL_STATES.includes(value.state))
84
+ return null;
85
+ return value;
86
+ }
87
+ export function writeUpdateJournal(journal, stateHome) {
88
+ atomicWrite(updateJournalPath(stateHome), journal);
89
+ }
90
+ export function transitionUpdateJournal(journal, state, changes = {}, stateHome) {
91
+ const next = { ...journal, ...changes, state, updatedAt: new Date().toISOString() };
92
+ writeUpdateJournal(next, stateHome);
93
+ return next;
94
+ }
95
+ function processStartToken(pid = process.pid) {
96
+ try {
97
+ if (process.platform === "win32") {
98
+ return execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`], { encoding: "utf8", timeout: 2_000 }).trim();
99
+ }
100
+ return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
101
+ encoding: "utf8",
102
+ timeout: 2_000,
103
+ }).trim();
104
+ }
105
+ catch {
106
+ return "unavailable";
107
+ }
108
+ }
109
+ export function createUpdateJournal(input) {
110
+ const now = new Date().toISOString();
111
+ return {
112
+ schemaVersion: 1,
113
+ id: randomUUID(),
114
+ state: "planned",
115
+ previousVersion: input.previousVersion,
116
+ targetVersion: input.targetVersion,
117
+ manager: input.manager,
118
+ previousExecutable: input.manager.executable,
119
+ fixtureVersion: input.fixtureVersion,
120
+ pid: process.pid,
121
+ processStartToken: processStartToken(),
122
+ createdAt: now,
123
+ updatedAt: now,
124
+ };
125
+ }
126
+ function realIdentity(candidate) {
127
+ try {
128
+ const stat = fs.lstatSync(candidate);
129
+ if (!stat.isFile() && !stat.isSymbolicLink())
130
+ return null;
131
+ return { path: path.resolve(candidate), realpath: fs.realpathSync(candidate) };
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ function pathExecutables(env) {
138
+ const found = [];
139
+ const stale = [];
140
+ const names = process.platform === "win32" ? ["knodin.cmd", "knodin.exe", "knodin"] : ["knodin"];
141
+ for (const directory of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
142
+ for (const name of names) {
143
+ const candidate = path.join(directory, name);
144
+ try {
145
+ const stat = fs.lstatSync(candidate);
146
+ if (stat.isSymbolicLink()) {
147
+ try {
148
+ fs.realpathSync(candidate);
149
+ }
150
+ catch {
151
+ stale.push(path.resolve(candidate));
152
+ continue;
153
+ }
154
+ }
155
+ const identity = realIdentity(candidate);
156
+ if (identity && !found.some((entry) => entry.realpath === identity.realpath))
157
+ found.push(identity);
158
+ }
159
+ catch {
160
+ // PATH entries are untrusted hints; absence is ordinary.
161
+ }
162
+ }
163
+ }
164
+ return { found, stale };
165
+ }
166
+ function methodFromEvidence(chain, env) {
167
+ const text = chain.join("\n").toLowerCase();
168
+ if (text.includes("/cellar/") || text.includes("/homebrew/") || env.HOMEBREW_PREFIX)
169
+ return "homebrew";
170
+ if (text.includes("/.local/share/mise/") || env.MISE_DATA_DIR || env.MISE_ENV)
171
+ return "mise";
172
+ if (text.includes("/.volta/") || env.VOLTA_HOME)
173
+ return "volta";
174
+ if (text.includes("/.nvm/") || env.NVM_BIN || env.NVM_DIR)
175
+ return "nvm";
176
+ if (text.includes("/fnm") || env.FNM_DIR || env.FNM_MULTISHELL_PATH)
177
+ return "fnm";
178
+ if (text.includes("/.asdf/") || env.ASDF_DIR || env.ASDF_DATA_DIR)
179
+ return "asdf";
180
+ if (text.includes("node_modules/knodin") || text.includes("/npm/"))
181
+ return "npm";
182
+ return "unknown";
183
+ }
184
+ export function resolveManagerOwnership(options = {}) {
185
+ const env = options.env ?? process.env;
186
+ const executablePath = options.executablePath ?? options.runtimeCommand?.[0] ?? process.argv[1] ?? process.execPath;
187
+ const executable = realIdentity(executablePath) ?? {
188
+ path: path.resolve(executablePath),
189
+ realpath: path.resolve(executablePath),
190
+ };
191
+ const chain = [executable.path, executable.realpath, ...(options.runtimeCommand ?? [])];
192
+ const packageMarker = `${path.sep}node_modules${path.sep}knodin${path.sep}`;
193
+ const markerIndex = executable.realpath.lastIndexOf(packageMarker);
194
+ const packageRoot = markerIndex >= 0
195
+ ? executable.realpath.slice(0, markerIndex + packageMarker.length - 1)
196
+ : undefined;
197
+ const receipts = [];
198
+ if (packageRoot && fs.existsSync(path.join(packageRoot, "package.json")))
199
+ receipts.push(path.join(packageRoot, "package.json"));
200
+ const paths = pathExecutables(env);
201
+ const method = methodFromEvidence([...chain, ...receipts], env);
202
+ const duplicates = paths.found.filter((entry) => entry.realpath !== executable.realpath);
203
+ const pathShadowing = paths.found.length > 0 && paths.found[0]?.realpath !== executable.realpath;
204
+ const ambiguous = duplicates.length > 0 || paths.stale.length > 0 || pathShadowing;
205
+ const evidence = [
206
+ `active executable: ${executable.path}`,
207
+ `resolved executable: ${executable.realpath}`,
208
+ ...(packageRoot ? [`package root: ${packageRoot}`] : []),
209
+ ...receipts.map((receipt) => `manager receipt: ${receipt}`),
210
+ ...duplicates.map((entry) => `competing executable: ${entry.path} -> ${entry.realpath}`),
211
+ ...paths.stale.map((entry) => `stale PATH link: ${entry}`),
212
+ ];
213
+ return {
214
+ method,
215
+ status: ambiguous ? "ambiguous" : method === "unknown" ? "unknown" : "owned",
216
+ executable,
217
+ packageRoot,
218
+ receipts,
219
+ chain,
220
+ duplicates,
221
+ staleLinks: paths.stale,
222
+ pathShadowing,
223
+ evidence,
224
+ };
225
+ }
226
+ export function setManualPin(version, stateHome) {
227
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
228
+ throw new Error("pin requires a semantic version");
229
+ const state = readManagerUpdateState(stateHome);
230
+ const next = {
231
+ ...state,
232
+ pin: { kind: "manual", version, createdAt: new Date().toISOString() },
233
+ };
234
+ writeManagerUpdateState(next, stateHome);
235
+ return next;
236
+ }
237
+ export function setAutomaticRollbackPin(restoredVersion, failedVersion, stateHome) {
238
+ const state = readManagerUpdateState(stateHome);
239
+ const next = {
240
+ ...state,
241
+ pin: {
242
+ kind: "automatic-rollback",
243
+ version: restoredVersion,
244
+ failedVersion,
245
+ createdAt: new Date().toISOString(),
246
+ },
247
+ };
248
+ writeManagerUpdateState(next, stateHome);
249
+ return next;
250
+ }
251
+ export function unpinUpdate(stateHome) {
252
+ const { pin: _pin, ...state } = readManagerUpdateState(stateHome);
253
+ const next = { ...state, schemaVersion: 1 };
254
+ writeManagerUpdateState(next, stateHome);
255
+ return next;
256
+ }
257
+ export function acknowledgeUpdateFailure(stateHome) {
258
+ const state = readManagerUpdateState(stateHome);
259
+ if (state.pin?.kind !== "automatic-rollback" || !state.pin.failedVersion)
260
+ return state;
261
+ const next = {
262
+ ...state,
263
+ pin: { ...state.pin, acknowledgedFailedVersion: state.pin.failedVersion },
264
+ };
265
+ writeManagerUpdateState(next, stateHome);
266
+ return next;
267
+ }
268
+ export function updateAttention(state) {
269
+ const pin = state.pin;
270
+ if (pin?.kind !== "automatic-rollback" || !pin.failedVersion)
271
+ return null;
272
+ if (pin.newerAvailableVersion && pin.newerAvailableVersion !== pin.failedVersion) {
273
+ return `Knodin remains pinned to ${pin.version} after ${pin.failedVersion} failed verification.\n${pin.newerAvailableVersion} is now available; review the release and run \`knodin update unpin\` when ready.`;
274
+ }
275
+ if (pin.acknowledgedFailedVersion === pin.failedVersion)
276
+ return null;
277
+ return `Knodin remains pinned to ${pin.version} after ${pin.failedVersion} failed verification. Run \`knodin update acknowledge\` to suppress this notice or \`knodin update unpin\` when ready.`;
278
+ }
279
+ function parseVersion(version) {
280
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.exec(version);
281
+ if (!match)
282
+ return null;
283
+ return {
284
+ major: Number(match[1]),
285
+ minor: Number(match[2]),
286
+ patch: Number(match[3]),
287
+ prerelease: version.includes("-"),
288
+ };
289
+ }
290
+ function compareVersion(left, right) {
291
+ const a = parseVersion(left);
292
+ const b = parseVersion(right);
293
+ if (!a || !b)
294
+ return left < right ? -1 : left === right ? 0 : 1;
295
+ return (a.major - b.major ||
296
+ a.minor - b.minor ||
297
+ a.patch - b.patch ||
298
+ Number(b.prerelease) - Number(a.prerelease));
299
+ }
300
+ async function boundedChannelJson(url, options) {
301
+ const parsed = new URL(url);
302
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password)
303
+ throw new Error("owner channel must use credential-free HTTPS");
304
+ const controller = new AbortController();
305
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? CHANNEL_TIMEOUT_MS);
306
+ try {
307
+ let bytes;
308
+ if (options.fetchBytes)
309
+ bytes = await options.fetchBytes(url, controller.signal, MAX_CHANNEL_BYTES);
310
+ else {
311
+ const response = await fetch(url, {
312
+ signal: controller.signal,
313
+ redirect: "error",
314
+ headers: { accept: "application/json" },
315
+ });
316
+ if (!response.ok)
317
+ throw new Error(`owner channel returned HTTP ${response.status}`);
318
+ const declared = Number(response.headers.get("content-length"));
319
+ if (Number.isFinite(declared) && declared > MAX_CHANNEL_BYTES)
320
+ throw new Error("owner channel metadata exceeds its byte limit");
321
+ bytes = new Uint8Array(await response.arrayBuffer());
322
+ }
323
+ if (bytes.byteLength > MAX_CHANNEL_BYTES)
324
+ throw new Error("owner channel metadata exceeds its byte limit");
325
+ return JSON.parse(Buffer.from(bytes).toString("utf8"));
326
+ }
327
+ finally {
328
+ clearTimeout(timeout);
329
+ }
330
+ }
331
+ function npmReleases(value) {
332
+ const versions = value.versions;
333
+ const times = value.time;
334
+ if (!versions || !times)
335
+ throw new Error("npm metadata is incomplete");
336
+ return Object.entries(versions)
337
+ .filter(([version]) => parseVersion(version))
338
+ .map(([version, manifest]) => {
339
+ const policy = manifest.knodin;
340
+ return {
341
+ version,
342
+ publishedAt: times[version] ?? "",
343
+ yanked: manifest.deprecated === true || typeof manifest.deprecated === "string",
344
+ breaking: policy?.compatibility === "non-breaking"
345
+ ? false
346
+ : policy?.compatibility === "breaking"
347
+ ? true
348
+ : null,
349
+ channel: "npm",
350
+ };
351
+ })
352
+ .sort((left, right) => compareVersion(right.version, left.version));
353
+ }
354
+ function homebrewReleases(value) {
355
+ const versions = value.versions;
356
+ if (!Array.isArray(versions))
357
+ throw new Error("Homebrew version metadata is incomplete");
358
+ return versions
359
+ .filter((entry) => Boolean(entry && typeof entry === "object"))
360
+ .filter((entry) => typeof entry.version === "string" && parseVersion(entry.version))
361
+ .map((entry) => ({
362
+ version: String(entry.version),
363
+ publishedAt: typeof entry.publishedAt === "string" ? entry.publishedAt : "",
364
+ yanked: entry.yanked === true,
365
+ breaking: entry.compatibility === "non-breaking"
366
+ ? false
367
+ : entry.compatibility === "breaking"
368
+ ? true
369
+ : null,
370
+ channel: "homebrew",
371
+ }))
372
+ .sort((left, right) => compareVersion(right.version, left.version));
373
+ }
374
+ export async function queryOwnerAvailability(options) {
375
+ const owner = options.owner ?? resolveManagerOwnership();
376
+ if (owner.status === "ambiguous")
377
+ return {
378
+ status: "owner-ambiguous",
379
+ owner,
380
+ installedVersion: options.installedVersion,
381
+ releases: [],
382
+ autoEligible: false,
383
+ reason: "multiple, stale, or shadowing installations require manual cleanup",
384
+ networkUsed: false,
385
+ };
386
+ if (owner.status === "unknown")
387
+ return {
388
+ status: "owner-unknown",
389
+ owner,
390
+ installedVersion: options.installedVersion,
391
+ releases: [],
392
+ autoEligible: false,
393
+ reason: "the active executable owner could not be proven",
394
+ networkUsed: false,
395
+ };
396
+ try {
397
+ const homebrew = owner.method === "homebrew";
398
+ const url = homebrew
399
+ ? "https://raw.githubusercontent.com/knodin/knodin/main/versions.json"
400
+ : "https://registry.npmjs.org/knodin";
401
+ const metadata = await boundedChannelJson(url, options);
402
+ const releases = homebrew ? homebrewReleases(metadata) : npmReleases(metadata);
403
+ const latest = releases.find((release) => !release.yanked);
404
+ if (!latest)
405
+ throw new Error("owner channel has no retained release");
406
+ const current = parseVersion(options.installedVersion);
407
+ const candidate = parseVersion(latest.version);
408
+ const ageHours = ((options.now ?? new Date()).getTime() - Date.parse(latest.publishedAt)) / 3_600_000;
409
+ const autoEligible = Boolean(current &&
410
+ candidate &&
411
+ !candidate.prerelease &&
412
+ candidate.major === current.major &&
413
+ candidate.minor === current.minor &&
414
+ candidate.patch > current.patch &&
415
+ latest.breaking === false &&
416
+ Number.isFinite(ageHours) &&
417
+ ageHours >= 24);
418
+ return {
419
+ status: compareVersion(latest.version, options.installedVersion) > 0 ? "available" : "up-to-date",
420
+ owner,
421
+ installedVersion: options.installedVersion,
422
+ latestVersion: latest.version,
423
+ releases,
424
+ autoEligible,
425
+ reason: autoEligible
426
+ ? undefined
427
+ : "only explicitly non-breaking patch releases at least 24 hours old are automatic",
428
+ networkUsed: true,
429
+ };
430
+ }
431
+ catch (error) {
432
+ return {
433
+ status: "unavailable",
434
+ owner,
435
+ installedVersion: options.installedVersion,
436
+ releases: [],
437
+ autoEligible: false,
438
+ reason: error instanceof Error ? error.message : String(error),
439
+ networkUsed: true,
440
+ };
441
+ }
442
+ }
443
+ export function managerInstallArgv(method, version) {
444
+ if (!parseVersion(version))
445
+ throw new Error("manager installation requires a semantic version");
446
+ if (method === "homebrew")
447
+ return ["brew", "install", `knodin/homebrew-tap/knodin@${version}`];
448
+ if (method === "volta")
449
+ return ["volta", "install", `knodin@${version}`];
450
+ if (method === "mise")
451
+ return ["mise", "use", "--global", `npm:knodin@${version}`];
452
+ if (["npm", "nvm", "fnm", "asdf"].includes(method))
453
+ return ["npm", "install", "--global", `knodin@${version}`];
454
+ return null;
455
+ }