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.
- package/dist/bin/cli.js +307 -85
- package/dist/fixtures/update-verification/config/application.xml +4 -0
- package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
- package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
- package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
- package/dist/fixtures/update-verification/fixture.json +147 -0
- package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
- package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
- package/dist/fixtures/update-verification/package.json +5 -0
- package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
- package/dist/fixtures/update-verification/sql/functions.sql +16 -0
- package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
- package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
- package/dist/fixtures/update-verification/src/javascript.js +7 -0
- package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
- package/dist/fixtures/update-verification/src/typescript.ts +7 -0
- package/dist/fixtures/update-verification/terraform/main.tf +7 -0
- package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
- package/dist/src/agent-integration.js +26 -3
- package/dist/src/backup-retention.js +811 -0
- package/dist/src/cli-model.js +35 -8
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/doctor.js +142 -12
- package/dist/src/engine/embeddings.js +8 -1
- package/dist/src/engine/index.js +355 -292
- package/dist/src/init.js +222 -179
- package/dist/src/manager-update.js +455 -0
- package/dist/src/release-preflight.js +106 -95
- package/dist/src/repair-lease.js +77 -8
- package/dist/src/response-budget.js +5 -1
- package/dist/src/server.js +18 -5
- package/dist/src/shared-index/compatibility.js +4 -2
- package/dist/src/tools/knodin-tools.js +69 -10
- package/dist/src/update-ceremony.js +20 -24
- package/dist/src/update-coordination.js +158 -0
- package/dist/src/update-executor.js +336 -0
- package/dist/src/update-verifier.js +358 -0
- package/docs/BACKUP-RETENTION.md +54 -0
- package/docs/CLI.md +6 -0
- package/docs/DOCTOR-AND-UPDATES.md +44 -12
- package/docs/SIGNED-UPDATES.md +29 -22
- package/docs/releases/0.9.0.md +43 -0
- package/package.json +7 -1
- 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
|
+
}
|