@blurname/debrief 0.1.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/LICENSE +21 -0
- package/README.md +188 -0
- package/dist/debrief.js +1207 -0
- package/package.json +39 -0
package/dist/debrief.js
ADDED
|
@@ -0,0 +1,1207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/backends/mock.ts
|
|
5
|
+
import { join as join2 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/version.ts
|
|
8
|
+
function parseVersion(raw) {
|
|
9
|
+
const v = raw.trim();
|
|
10
|
+
let epoch = 0;
|
|
11
|
+
let rest = v;
|
|
12
|
+
const colon = v.indexOf(":");
|
|
13
|
+
if (colon !== -1) {
|
|
14
|
+
const e = v.slice(0, colon);
|
|
15
|
+
if (/^\d+$/.test(e)) {
|
|
16
|
+
epoch = parseInt(e, 10);
|
|
17
|
+
rest = v.slice(colon + 1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
let upstream = rest;
|
|
21
|
+
let revision = "";
|
|
22
|
+
const dash = rest.lastIndexOf("-");
|
|
23
|
+
if (dash !== -1) {
|
|
24
|
+
upstream = rest.slice(0, dash);
|
|
25
|
+
revision = rest.slice(dash + 1);
|
|
26
|
+
}
|
|
27
|
+
return { epoch, upstream, revision };
|
|
28
|
+
}
|
|
29
|
+
function order(c) {
|
|
30
|
+
if (c === undefined || c === "")
|
|
31
|
+
return 0;
|
|
32
|
+
if (c === "~")
|
|
33
|
+
return -1;
|
|
34
|
+
if (/[a-zA-Z]/.test(c))
|
|
35
|
+
return c.charCodeAt(0);
|
|
36
|
+
return c.charCodeAt(0) + 256;
|
|
37
|
+
}
|
|
38
|
+
var isDigit = (c) => c !== undefined && c >= "0" && c <= "9";
|
|
39
|
+
function verrevcmp(a, b) {
|
|
40
|
+
let i = 0;
|
|
41
|
+
let j = 0;
|
|
42
|
+
while (i < a.length || j < b.length) {
|
|
43
|
+
while (i < a.length && !isDigit(a[i]) || j < b.length && !isDigit(b[j])) {
|
|
44
|
+
const ac = order(a[i]);
|
|
45
|
+
const bc = order(b[j]);
|
|
46
|
+
if (ac !== bc)
|
|
47
|
+
return ac - bc;
|
|
48
|
+
i++;
|
|
49
|
+
j++;
|
|
50
|
+
}
|
|
51
|
+
while (a[i] === "0")
|
|
52
|
+
i++;
|
|
53
|
+
while (b[j] === "0")
|
|
54
|
+
j++;
|
|
55
|
+
let firstDiff = 0;
|
|
56
|
+
while (isDigit(a[i]) && isDigit(b[j])) {
|
|
57
|
+
if (firstDiff === 0)
|
|
58
|
+
firstDiff = a.charCodeAt(i) - b.charCodeAt(j);
|
|
59
|
+
i++;
|
|
60
|
+
j++;
|
|
61
|
+
}
|
|
62
|
+
if (isDigit(a[i]))
|
|
63
|
+
return 1;
|
|
64
|
+
if (isDigit(b[j]))
|
|
65
|
+
return -1;
|
|
66
|
+
if (firstDiff !== 0)
|
|
67
|
+
return firstDiff;
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
function compareVersions(a, b) {
|
|
72
|
+
const va = parseVersion(a);
|
|
73
|
+
const vb = parseVersion(b);
|
|
74
|
+
if (va.epoch !== vb.epoch)
|
|
75
|
+
return va.epoch - vb.epoch;
|
|
76
|
+
const up = verrevcmp(va.upstream, vb.upstream);
|
|
77
|
+
if (up !== 0)
|
|
78
|
+
return up;
|
|
79
|
+
return verrevcmp(va.revision, vb.revision);
|
|
80
|
+
}
|
|
81
|
+
function maxVersion(versions) {
|
|
82
|
+
if (versions.length === 0)
|
|
83
|
+
return;
|
|
84
|
+
return versions.reduce((best, v) => compareVersions(v, best) > 0 ? v : best);
|
|
85
|
+
}
|
|
86
|
+
function numericComponents(upstream) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const part of upstream.split(".")) {
|
|
89
|
+
const m = /^(\d+)/.exec(part);
|
|
90
|
+
if (!m)
|
|
91
|
+
break;
|
|
92
|
+
out.push(parseInt(m[1], 10));
|
|
93
|
+
if (m[1].length !== part.length)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
var gte = (v, bound) => compareVersions(v, bound) >= 0;
|
|
99
|
+
var gt = (v, bound) => compareVersions(v, bound) > 0;
|
|
100
|
+
var lt = (v, bound) => compareVersions(v, bound) < 0;
|
|
101
|
+
var lte = (v, bound) => compareVersions(v, bound) <= 0;
|
|
102
|
+
var eq = (v, bound) => compareVersions(v, bound) === 0;
|
|
103
|
+
function caretUpper(version) {
|
|
104
|
+
const { epoch, upstream } = parseVersion(version);
|
|
105
|
+
const comps = numericComponents(upstream);
|
|
106
|
+
const major = comps[0] ?? 0;
|
|
107
|
+
const prefix = epoch > 0 ? `${epoch}:` : "";
|
|
108
|
+
return `${prefix}${major + 1}`;
|
|
109
|
+
}
|
|
110
|
+
function tildeUpper(version) {
|
|
111
|
+
const { epoch, upstream } = parseVersion(version);
|
|
112
|
+
const comps = numericComponents(upstream);
|
|
113
|
+
const prefix = epoch > 0 ? `${epoch}:` : "";
|
|
114
|
+
if (comps.length >= 2)
|
|
115
|
+
return `${prefix}${comps[0]}.${comps[1] + 1}`;
|
|
116
|
+
return `${prefix}${(comps[0] ?? 0) + 1}`;
|
|
117
|
+
}
|
|
118
|
+
function parseComparator(token) {
|
|
119
|
+
const t = token.trim();
|
|
120
|
+
if (t === "" || t === "*" || t === "x" || t === "latest" || t === "any") {
|
|
121
|
+
return () => true;
|
|
122
|
+
}
|
|
123
|
+
let m;
|
|
124
|
+
if (m = /^\^\s*(.+)$/.exec(t)) {
|
|
125
|
+
const base = m[1].trim();
|
|
126
|
+
const upper = caretUpper(base);
|
|
127
|
+
return (v) => gte(v, base) && lt(v, upper);
|
|
128
|
+
}
|
|
129
|
+
if (m = /^~\s*(.+)$/.exec(t)) {
|
|
130
|
+
const base = m[1].trim();
|
|
131
|
+
const upper = tildeUpper(base);
|
|
132
|
+
return (v) => gte(v, base) && lt(v, upper);
|
|
133
|
+
}
|
|
134
|
+
if (m = /^>=\s*(.+)$/.exec(t))
|
|
135
|
+
return (v) => gte(v, m[1].trim());
|
|
136
|
+
if (m = /^<=\s*(.+)$/.exec(t))
|
|
137
|
+
return (v) => lte(v, m[1].trim());
|
|
138
|
+
if (m = /^>\s*(.+)$/.exec(t))
|
|
139
|
+
return (v) => gt(v, m[1].trim());
|
|
140
|
+
if (m = /^<\s*(.+)$/.exec(t))
|
|
141
|
+
return (v) => lt(v, m[1].trim());
|
|
142
|
+
if (m = /^!=\s*(.+)$/.exec(t))
|
|
143
|
+
return (v) => !eq(v, m[1].trim());
|
|
144
|
+
if (m = /^=\s*(.+)$/.exec(t))
|
|
145
|
+
return (v) => eq(v, m[1].trim());
|
|
146
|
+
return (v) => eq(v, t);
|
|
147
|
+
}
|
|
148
|
+
function parseConstraint(raw) {
|
|
149
|
+
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
150
|
+
const preds = tokens.length === 0 ? [() => true] : tokens.map(parseComparator);
|
|
151
|
+
return {
|
|
152
|
+
raw: raw.trim() || "*",
|
|
153
|
+
test: (version) => preds.every((p) => p(version))
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function bestMatch(constraint, candidates) {
|
|
157
|
+
const c = parseConstraint(constraint);
|
|
158
|
+
return maxVersion(candidates.filter((v) => c.test(v)));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/sys.ts
|
|
162
|
+
import { accessSync, constants } from "node:fs";
|
|
163
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
164
|
+
import { execFile } from "node:child_process";
|
|
165
|
+
import { join } from "node:path";
|
|
166
|
+
async function exists(path) {
|
|
167
|
+
try {
|
|
168
|
+
await access(path);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function readText(path) {
|
|
175
|
+
return readFile(path, "utf8");
|
|
176
|
+
}
|
|
177
|
+
async function readJson(path) {
|
|
178
|
+
return JSON.parse(await readText(path));
|
|
179
|
+
}
|
|
180
|
+
async function readBytes(path) {
|
|
181
|
+
return readFile(path);
|
|
182
|
+
}
|
|
183
|
+
async function writeText(path, data) {
|
|
184
|
+
await writeFile(path, data);
|
|
185
|
+
}
|
|
186
|
+
function run(cmd, opts = {}) {
|
|
187
|
+
const [bin, ...args] = cmd;
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
execFile(bin, args, {
|
|
190
|
+
env: opts.env,
|
|
191
|
+
timeout: opts.timeoutMs,
|
|
192
|
+
encoding: "utf8",
|
|
193
|
+
maxBuffer: 64 * 1024 * 1024
|
|
194
|
+
}, (err, stdout, stderr) => {
|
|
195
|
+
let code = 0;
|
|
196
|
+
if (err)
|
|
197
|
+
code = typeof err.code === "number" ? err.code : 1;
|
|
198
|
+
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function whichSync(cmd) {
|
|
203
|
+
const paths = (process.env.PATH ?? "").split(":");
|
|
204
|
+
for (const dir of paths) {
|
|
205
|
+
if (!dir)
|
|
206
|
+
continue;
|
|
207
|
+
const full = join(dir, cmd);
|
|
208
|
+
try {
|
|
209
|
+
accessSync(full, constants.X_OK);
|
|
210
|
+
return full;
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/backends/mock.ts
|
|
217
|
+
var DEFAULT_SYSTEM = {
|
|
218
|
+
nginx: { version: "1.24.0-2", auto: false },
|
|
219
|
+
git: { version: "1:2.39.2-1.1", auto: false },
|
|
220
|
+
htop: { version: "3.2.1-1", auto: false },
|
|
221
|
+
curl: { version: "7.88.1-10", auto: false },
|
|
222
|
+
vim: { version: "2:9.0.1378-2", auto: false },
|
|
223
|
+
jq: { version: "1.6-2.1", auto: false },
|
|
224
|
+
"build-essential": { version: "12.9", auto: false },
|
|
225
|
+
libc6: { version: "2.36-9+deb12u4", auto: true },
|
|
226
|
+
libssl3: { version: "3.0.11-1~deb12u2", auto: true },
|
|
227
|
+
zlib1g: { version: "1:1.2.13.dfsg-1", auto: true },
|
|
228
|
+
"libpcre2-8-0": { version: "10.42-1", auto: true },
|
|
229
|
+
"perl-base": { version: "5.36.0-7+deb12u1", auto: true },
|
|
230
|
+
"libstdc++6": { version: "12.2.0-14", auto: true },
|
|
231
|
+
"libgcc-s1": { version: "12.2.0-14", auto: true },
|
|
232
|
+
"ca-certificates": { version: "20230311", auto: true },
|
|
233
|
+
libcurl4: { version: "7.88.1-10", auto: true }
|
|
234
|
+
};
|
|
235
|
+
var DEFAULT_REPO = {
|
|
236
|
+
nginx: ["1.18.0-6.1", "1.22.1-9", "1.24.0-1", "1.24.0-2", "1.26.0-1"],
|
|
237
|
+
git: ["1:2.30.2-1", "1:2.39.2-1.1", "1:2.43.0-1"],
|
|
238
|
+
htop: ["3.0.5-7", "3.2.1-1", "3.3.0-1"],
|
|
239
|
+
curl: ["7.74.0-1.3", "7.88.1-10", "8.5.0-2"],
|
|
240
|
+
vim: ["2:8.2.3995-1", "2:9.0.1378-2", "2:9.1.0016-1"],
|
|
241
|
+
ripgrep: ["13.0.0-4", "14.1.0-1"],
|
|
242
|
+
jq: ["1.6-2.1", "1.7.1-3"],
|
|
243
|
+
postgresql: ["13+225", "15+248", "16+257"],
|
|
244
|
+
redis: ["5:6.0.16-1", "5:7.0.15-1"],
|
|
245
|
+
"build-essential": ["12.9", "12.10"]
|
|
246
|
+
};
|
|
247
|
+
var STATE_FILE = ".debrief-mock-state.json";
|
|
248
|
+
function normalizeInstalled(raw) {
|
|
249
|
+
const out = {};
|
|
250
|
+
const src = raw ?? {};
|
|
251
|
+
for (const [name, val] of Object.entries(src)) {
|
|
252
|
+
if (typeof val === "string")
|
|
253
|
+
out[name] = { version: val, auto: false };
|
|
254
|
+
else if (val && typeof val === "object" && "version" in val) {
|
|
255
|
+
const v = val;
|
|
256
|
+
out[name] = { version: v.version, auto: v.auto ?? false };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
var cloneInstalled = (src) => Object.fromEntries(Object.entries(src).map(([k, v]) => [k, { ...v }]));
|
|
262
|
+
|
|
263
|
+
class MockBackend {
|
|
264
|
+
name = "mock";
|
|
265
|
+
repo;
|
|
266
|
+
statePath;
|
|
267
|
+
initialInstalled;
|
|
268
|
+
ownedPaths;
|
|
269
|
+
state = { installed: {} };
|
|
270
|
+
loadPromise = null;
|
|
271
|
+
constructor(opts = {}) {
|
|
272
|
+
this.repo = opts.repo ?? DEFAULT_REPO;
|
|
273
|
+
this.statePath = opts.statePath ?? (opts.cwd ? join2(opts.cwd, STATE_FILE) : null);
|
|
274
|
+
this.initialInstalled = opts.initialInstalled ?? {};
|
|
275
|
+
this.ownedPaths = new Set(opts.ownedPaths ?? []);
|
|
276
|
+
}
|
|
277
|
+
async owns(path) {
|
|
278
|
+
return this.ownedPaths.has(path);
|
|
279
|
+
}
|
|
280
|
+
load() {
|
|
281
|
+
if (!this.loadPromise)
|
|
282
|
+
this.loadPromise = this.doLoad();
|
|
283
|
+
return this.loadPromise;
|
|
284
|
+
}
|
|
285
|
+
async doLoad() {
|
|
286
|
+
if (this.statePath && await exists(this.statePath)) {
|
|
287
|
+
try {
|
|
288
|
+
const raw = await readJson(this.statePath);
|
|
289
|
+
this.state = { installed: normalizeInstalled(raw.installed) };
|
|
290
|
+
return;
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
this.state = { installed: cloneInstalled(this.initialInstalled) };
|
|
294
|
+
}
|
|
295
|
+
async persist() {
|
|
296
|
+
if (!this.statePath)
|
|
297
|
+
return;
|
|
298
|
+
await writeText(this.statePath, JSON.stringify(this.state, null, 2) + `
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
async installed(pkg) {
|
|
302
|
+
await this.load();
|
|
303
|
+
return this.state.installed[pkg]?.version ?? null;
|
|
304
|
+
}
|
|
305
|
+
async listInstalled() {
|
|
306
|
+
await this.load();
|
|
307
|
+
return Object.entries(this.state.installed).map(([name, { version, auto }]) => ({ name, version, arch: "amd64", auto })).sort((a, b) => a.name.localeCompare(b.name));
|
|
308
|
+
}
|
|
309
|
+
async candidates(pkg) {
|
|
310
|
+
return this.repo[pkg] ? [...this.repo[pkg]] : [];
|
|
311
|
+
}
|
|
312
|
+
async install(pkg, version) {
|
|
313
|
+
await this.load();
|
|
314
|
+
const avail = this.repo[pkg];
|
|
315
|
+
if (!avail || avail.length === 0) {
|
|
316
|
+
throw new Error(`package "${pkg}" has no installation candidate (unknown to mock repo)`);
|
|
317
|
+
}
|
|
318
|
+
let target = version ?? maxVersion(avail);
|
|
319
|
+
if (version && !avail.includes(version)) {
|
|
320
|
+
throw new Error(`version "${version}" of "${pkg}" is not available (have: ${avail.join(", ")})`);
|
|
321
|
+
}
|
|
322
|
+
this.state.installed[pkg] = { version: target, auto: false };
|
|
323
|
+
await this.persist();
|
|
324
|
+
return target;
|
|
325
|
+
}
|
|
326
|
+
async remove(pkg) {
|
|
327
|
+
await this.load();
|
|
328
|
+
delete this.state.installed[pkg];
|
|
329
|
+
await this.persist();
|
|
330
|
+
}
|
|
331
|
+
async refresh() {}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/backends/apt.ts
|
|
335
|
+
function usrmergeVariants(path) {
|
|
336
|
+
const merged = /^\/(bin|sbin|lib|lib32|lib64|libx32)\//;
|
|
337
|
+
if (path.startsWith("/usr/")) {
|
|
338
|
+
const stripped = path.slice(4);
|
|
339
|
+
if (merged.test(stripped))
|
|
340
|
+
return [path, stripped];
|
|
341
|
+
} else if (merged.test(path)) {
|
|
342
|
+
return [path, "/usr" + path];
|
|
343
|
+
}
|
|
344
|
+
return [path];
|
|
345
|
+
}
|
|
346
|
+
function privileged(cmd) {
|
|
347
|
+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
348
|
+
if (isRoot)
|
|
349
|
+
return cmd;
|
|
350
|
+
if (Bun.which("sudo"))
|
|
351
|
+
return ["sudo", ...cmd];
|
|
352
|
+
return cmd;
|
|
353
|
+
}
|
|
354
|
+
var APT_ENV = { ...process.env, DEBIAN_FRONTEND: "noninteractive" };
|
|
355
|
+
var runWithEnv = (cmd) => run(cmd, { env: APT_ENV });
|
|
356
|
+
|
|
357
|
+
class AptBackend {
|
|
358
|
+
name = "apt";
|
|
359
|
+
async installed(pkg) {
|
|
360
|
+
const res = await run([
|
|
361
|
+
"dpkg-query",
|
|
362
|
+
"-W",
|
|
363
|
+
"-f=${db:Status-Status} ${Version}",
|
|
364
|
+
pkg
|
|
365
|
+
]);
|
|
366
|
+
if (res.code !== 0)
|
|
367
|
+
return null;
|
|
368
|
+
const [status, version] = res.stdout.trim().split(" ");
|
|
369
|
+
if (status !== "installed" || !version)
|
|
370
|
+
return null;
|
|
371
|
+
return version;
|
|
372
|
+
}
|
|
373
|
+
async listInstalled() {
|
|
374
|
+
const dq = await run([
|
|
375
|
+
"dpkg-query",
|
|
376
|
+
"-W",
|
|
377
|
+
"-f=${db:Status-Status}\t${Package}\t${Version}\t${Architecture}\n"
|
|
378
|
+
]);
|
|
379
|
+
if (dq.code !== 0) {
|
|
380
|
+
throw new Error(`dpkg-query failed:
|
|
381
|
+
${dq.stderr.trim() || dq.stdout.trim()}`);
|
|
382
|
+
}
|
|
383
|
+
const manual = new Set;
|
|
384
|
+
const mk = await run(["apt-mark", "showmanual"]);
|
|
385
|
+
if (mk.code === 0) {
|
|
386
|
+
for (const line of mk.stdout.split(`
|
|
387
|
+
`)) {
|
|
388
|
+
const name = line.trim();
|
|
389
|
+
if (name)
|
|
390
|
+
manual.add(name);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const out = [];
|
|
394
|
+
for (const line of dq.stdout.split(`
|
|
395
|
+
`)) {
|
|
396
|
+
if (!line)
|
|
397
|
+
continue;
|
|
398
|
+
const [status, name, version, arch] = line.split("\t");
|
|
399
|
+
if (status !== "installed" || !name || !version)
|
|
400
|
+
continue;
|
|
401
|
+
const isManual = manual.has(name) || (arch ? manual.has(`${name}:${arch}`) : false);
|
|
402
|
+
out.push({ name, version, arch: arch || undefined, auto: !isManual });
|
|
403
|
+
}
|
|
404
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
async owns(path) {
|
|
408
|
+
for (const p of usrmergeVariants(path)) {
|
|
409
|
+
if ((await run(["dpkg", "-S", p])).code === 0)
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
async candidates(pkg) {
|
|
415
|
+
const res = await run(["apt-cache", "madison", pkg]);
|
|
416
|
+
if (res.code !== 0)
|
|
417
|
+
return [];
|
|
418
|
+
const versions = [];
|
|
419
|
+
for (const line of res.stdout.split(`
|
|
420
|
+
`)) {
|
|
421
|
+
const cols = line.split("|").map((c) => c.trim());
|
|
422
|
+
if (cols.length >= 2 && cols[1])
|
|
423
|
+
versions.push(cols[1]);
|
|
424
|
+
}
|
|
425
|
+
return [...new Set(versions)];
|
|
426
|
+
}
|
|
427
|
+
async install(pkg, version) {
|
|
428
|
+
const spec = version ? `${pkg}=${version}` : pkg;
|
|
429
|
+
const cmd = privileged(["apt-get", "install", "-y", "--allow-downgrades", spec]);
|
|
430
|
+
const res = await runWithEnv(cmd);
|
|
431
|
+
if (res.code !== 0) {
|
|
432
|
+
throw new Error(`apt-get install ${spec} failed:
|
|
433
|
+
${res.stderr.trim() || res.stdout.trim()}`);
|
|
434
|
+
}
|
|
435
|
+
const now = await this.installed(pkg);
|
|
436
|
+
if (!now)
|
|
437
|
+
throw new Error(`installed ${spec} but dpkg does not report it as installed`);
|
|
438
|
+
return now;
|
|
439
|
+
}
|
|
440
|
+
async remove(pkg) {
|
|
441
|
+
const cmd = privileged(["apt-get", "remove", "-y", pkg]);
|
|
442
|
+
const res = await runWithEnv(cmd);
|
|
443
|
+
if (res.code !== 0) {
|
|
444
|
+
throw new Error(`apt-get remove ${pkg} failed:
|
|
445
|
+
${res.stderr.trim() || res.stdout.trim()}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async refresh() {
|
|
449
|
+
const res = await runWithEnv(privileged(["apt-get", "update"]));
|
|
450
|
+
if (res.code !== 0) {
|
|
451
|
+
throw new Error(`apt-get update failed:
|
|
452
|
+
${res.stderr.trim() || res.stdout.trim()}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/backend.ts
|
|
458
|
+
function selectBackend(opts) {
|
|
459
|
+
const chosen = opts.explicit ?? process.env.DEBRIEF_BACKEND ?? opts.fromLock ?? (whichSync("apt-get") ? "apt" : "mock");
|
|
460
|
+
switch (chosen) {
|
|
461
|
+
case "apt":
|
|
462
|
+
return new AptBackend;
|
|
463
|
+
case "mock":
|
|
464
|
+
return new MockBackend({ cwd: opts.cwd, initialInstalled: DEFAULT_SYSTEM });
|
|
465
|
+
default:
|
|
466
|
+
throw new Error(`unknown backend "${chosen}" (expected "apt" or "mock")`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/store.ts
|
|
471
|
+
import { join as join3 } from "node:path";
|
|
472
|
+
var MANIFEST_FILE = "debrief.json";
|
|
473
|
+
var LOCK_FILE = "debrief.lock";
|
|
474
|
+
var LOCKFILE_VERSION = 1;
|
|
475
|
+
function manifestPath(dir) {
|
|
476
|
+
return join3(dir, MANIFEST_FILE);
|
|
477
|
+
}
|
|
478
|
+
function lockPath(dir) {
|
|
479
|
+
return join3(dir, LOCK_FILE);
|
|
480
|
+
}
|
|
481
|
+
function emptyManifest() {
|
|
482
|
+
return { packages: {} };
|
|
483
|
+
}
|
|
484
|
+
async function readManifest(dir) {
|
|
485
|
+
if (!await exists(manifestPath(dir)))
|
|
486
|
+
return null;
|
|
487
|
+
const data = await readJson(manifestPath(dir));
|
|
488
|
+
return {
|
|
489
|
+
name: data.name,
|
|
490
|
+
description: data.description,
|
|
491
|
+
packages: data.packages ?? {}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
async function requireManifest(dir) {
|
|
495
|
+
const m = await readManifest(dir);
|
|
496
|
+
if (!m) {
|
|
497
|
+
throw new Error(`no ${MANIFEST_FILE} found in ${dir} — run \`debrief init\` first`);
|
|
498
|
+
}
|
|
499
|
+
return m;
|
|
500
|
+
}
|
|
501
|
+
async function writeManifest(dir, manifest) {
|
|
502
|
+
const sorted = {};
|
|
503
|
+
for (const key of Object.keys(manifest.packages).sort()) {
|
|
504
|
+
sorted[key] = manifest.packages[key];
|
|
505
|
+
}
|
|
506
|
+
const out = {
|
|
507
|
+
...manifest.name !== undefined ? { name: manifest.name } : {},
|
|
508
|
+
...manifest.description !== undefined ? { description: manifest.description } : {},
|
|
509
|
+
packages: sorted
|
|
510
|
+
};
|
|
511
|
+
await writeText(manifestPath(dir), JSON.stringify(out, null, 2) + `
|
|
512
|
+
`);
|
|
513
|
+
}
|
|
514
|
+
async function readLock(dir) {
|
|
515
|
+
if (!await exists(lockPath(dir)))
|
|
516
|
+
return null;
|
|
517
|
+
return readJson(lockPath(dir));
|
|
518
|
+
}
|
|
519
|
+
async function writeLock(dir, lock) {
|
|
520
|
+
const sorted = {};
|
|
521
|
+
for (const key of Object.keys(lock.packages).sort()) {
|
|
522
|
+
sorted[key] = lock.packages[key];
|
|
523
|
+
}
|
|
524
|
+
const out = { ...lock, packages: sorted };
|
|
525
|
+
if (lock.binaries !== undefined) {
|
|
526
|
+
const sortedBins = {};
|
|
527
|
+
for (const key of Object.keys(lock.binaries).sort()) {
|
|
528
|
+
sortedBins[key] = lock.binaries[key];
|
|
529
|
+
}
|
|
530
|
+
out.binaries = sortedBins;
|
|
531
|
+
} else {
|
|
532
|
+
delete out.binaries;
|
|
533
|
+
}
|
|
534
|
+
await writeText(lockPath(dir), JSON.stringify(out, null, 2) + `
|
|
535
|
+
`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/commands.ts
|
|
539
|
+
import { basename } from "node:path";
|
|
540
|
+
|
|
541
|
+
// src/resolve.ts
|
|
542
|
+
async function inspect(pkg, constraint, backend, lock) {
|
|
543
|
+
const [installed, candidates] = await Promise.all([
|
|
544
|
+
backend.installed(pkg),
|
|
545
|
+
backend.candidates(pkg)
|
|
546
|
+
]);
|
|
547
|
+
const parsed = parseConstraint(constraint);
|
|
548
|
+
const target = bestMatch(constraint, candidates) ?? null;
|
|
549
|
+
const latest = maxVersion(candidates) ?? null;
|
|
550
|
+
const satisfied = installed !== null && parsed.test(installed);
|
|
551
|
+
const locked = lock?.packages[pkg]?.version ?? null;
|
|
552
|
+
const lockedUsable = locked !== null && candidates.includes(locked) && parsed.test(locked);
|
|
553
|
+
const resolvedTarget = lockedUsable ? locked : target;
|
|
554
|
+
return { pkg, constraint, installed, locked, target, latest, satisfied, resolvedTarget };
|
|
555
|
+
}
|
|
556
|
+
async function inspectAll(manifest, backend, lock) {
|
|
557
|
+
const entries = Object.entries(manifest.packages);
|
|
558
|
+
const rows = await Promise.all(entries.map(([pkg, c]) => inspect(pkg, c, backend, lock)));
|
|
559
|
+
return rows.sort((a, b) => a.pkg.localeCompare(b.pkg));
|
|
560
|
+
}
|
|
561
|
+
function classify(row) {
|
|
562
|
+
if (row.installed === null)
|
|
563
|
+
return row.target ? "missing" : "unavailable";
|
|
564
|
+
if (row.satisfied)
|
|
565
|
+
return "satisfied";
|
|
566
|
+
return "drifted";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// src/collector.ts
|
|
570
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
571
|
+
import { join as join4 } from "node:path";
|
|
572
|
+
import { createHash } from "node:crypto";
|
|
573
|
+
function defaultBinDirs() {
|
|
574
|
+
const home = process.env.HOME ?? "";
|
|
575
|
+
return [
|
|
576
|
+
"/usr/local/bin",
|
|
577
|
+
"/usr/local/sbin",
|
|
578
|
+
"/opt/bin",
|
|
579
|
+
home && `${home}/.local/bin`,
|
|
580
|
+
home && `${home}/bin`
|
|
581
|
+
].filter((d) => Boolean(d));
|
|
582
|
+
}
|
|
583
|
+
var HASH_MAX_BYTES = 256 * 1024 * 1024;
|
|
584
|
+
async function sha256File(path, size) {
|
|
585
|
+
if (size > HASH_MAX_BYTES)
|
|
586
|
+
return;
|
|
587
|
+
try {
|
|
588
|
+
const bytes = await readBytes(path);
|
|
589
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
590
|
+
} catch {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async function probeVersion(path) {
|
|
595
|
+
const res = await run([path, "--version"], { timeoutMs: 3000 });
|
|
596
|
+
const line = (res.stdout + res.stderr).split(`
|
|
597
|
+
`).find((l) => l.trim())?.trim() ?? "";
|
|
598
|
+
if (!line)
|
|
599
|
+
return;
|
|
600
|
+
const m = /\d+\.\d+(?:\.\d+)?(?:[-+.\w~]*)?/.exec(line);
|
|
601
|
+
return m?.[0] ?? line.slice(0, 60);
|
|
602
|
+
}
|
|
603
|
+
async function collectBinaries(opts) {
|
|
604
|
+
const wantHash = opts.hash !== false;
|
|
605
|
+
const out = {};
|
|
606
|
+
for (const dir of opts.dirs) {
|
|
607
|
+
let names;
|
|
608
|
+
try {
|
|
609
|
+
names = await readdir(dir);
|
|
610
|
+
} catch {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
for (const name of names) {
|
|
614
|
+
if (name in out)
|
|
615
|
+
continue;
|
|
616
|
+
const full = join4(dir, name);
|
|
617
|
+
let st;
|
|
618
|
+
try {
|
|
619
|
+
st = await stat(full);
|
|
620
|
+
} catch {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (!st.isFile())
|
|
624
|
+
continue;
|
|
625
|
+
if ((st.mode & 73) === 0)
|
|
626
|
+
continue;
|
|
627
|
+
let real = full;
|
|
628
|
+
try {
|
|
629
|
+
real = await realpath(full);
|
|
630
|
+
} catch {}
|
|
631
|
+
if (await opts.backend.owns(real))
|
|
632
|
+
continue;
|
|
633
|
+
const entry = { path: full, size: st.size };
|
|
634
|
+
if (wantHash)
|
|
635
|
+
entry.sha256 = await sha256File(real, st.size);
|
|
636
|
+
if (opts.probeVersions)
|
|
637
|
+
entry.version = await probeVersion(full);
|
|
638
|
+
out[name] = entry;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/table.ts
|
|
645
|
+
var stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
646
|
+
function table(headers, rows) {
|
|
647
|
+
const all = [headers, ...rows];
|
|
648
|
+
const widths = headers.map((_, col) => Math.max(...all.map((r) => stripAnsi(r[col] ?? "").length)));
|
|
649
|
+
const fmtRow = (r) => r.map((cell, col) => {
|
|
650
|
+
const pad = widths[col] - stripAnsi(cell ?? "").length;
|
|
651
|
+
return (cell ?? "") + " ".repeat(Math.max(0, pad));
|
|
652
|
+
}).join(" ").trimEnd();
|
|
653
|
+
return [fmtRow(headers), ...rows.map(fmtRow)].join(`
|
|
654
|
+
`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/log.ts
|
|
658
|
+
var enabled = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
659
|
+
var wrap = (code) => (s) => enabled ? `\x1B[${code}m${s}\x1B[0m` : String(s);
|
|
660
|
+
var c = {
|
|
661
|
+
bold: wrap(1),
|
|
662
|
+
dim: wrap(2),
|
|
663
|
+
red: wrap(31),
|
|
664
|
+
green: wrap(32),
|
|
665
|
+
yellow: wrap(33),
|
|
666
|
+
blue: wrap(34),
|
|
667
|
+
cyan: wrap(36),
|
|
668
|
+
gray: wrap(90)
|
|
669
|
+
};
|
|
670
|
+
var sym = {
|
|
671
|
+
add: c.green("+"),
|
|
672
|
+
ok: c.dim("="),
|
|
673
|
+
warn: c.yellow("!"),
|
|
674
|
+
err: c.red("✗"),
|
|
675
|
+
arrow: c.gray("→")
|
|
676
|
+
};
|
|
677
|
+
function info(msg) {
|
|
678
|
+
console.log(msg);
|
|
679
|
+
}
|
|
680
|
+
function warn(msg) {
|
|
681
|
+
console.warn(`${c.yellow("warning")} ${msg}`);
|
|
682
|
+
}
|
|
683
|
+
function error(msg) {
|
|
684
|
+
console.error(`${c.red("error")} ${msg}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/commands.ts
|
|
688
|
+
function parseSpec(spec) {
|
|
689
|
+
const eq2 = spec.indexOf("=");
|
|
690
|
+
if (eq2 !== -1) {
|
|
691
|
+
const pkg = spec.slice(0, eq2);
|
|
692
|
+
const version = spec.slice(eq2 + 1);
|
|
693
|
+
return { pkg, constraint: version, exactVersion: version };
|
|
694
|
+
}
|
|
695
|
+
const at = spec.indexOf("@");
|
|
696
|
+
if (at !== -1) {
|
|
697
|
+
return { pkg: spec.slice(0, at), constraint: spec.slice(at + 1) };
|
|
698
|
+
}
|
|
699
|
+
return { pkg: spec, constraint: null };
|
|
700
|
+
}
|
|
701
|
+
function defaultConstraint(version) {
|
|
702
|
+
const { epoch, upstream } = parseVersion(version);
|
|
703
|
+
const prefix = epoch > 0 ? `${epoch}:` : "";
|
|
704
|
+
return `^${prefix}${upstream}`;
|
|
705
|
+
}
|
|
706
|
+
async function loadLockOrNew(ctx) {
|
|
707
|
+
const existing = await readLock(ctx.cwd);
|
|
708
|
+
if (existing)
|
|
709
|
+
return existing;
|
|
710
|
+
return { lockfileVersion: LOCKFILE_VERSION, backend: ctx.backend.name, packages: {} };
|
|
711
|
+
}
|
|
712
|
+
async function cmdInit(ctx, opts) {
|
|
713
|
+
const existing = await readManifest(ctx.cwd);
|
|
714
|
+
if (existing) {
|
|
715
|
+
warn(`${MANIFEST_FILE} already exists — leaving it untouched`);
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
const manifest = emptyManifest();
|
|
719
|
+
manifest.name = opts.name ?? basename(ctx.cwd);
|
|
720
|
+
if (ctx.dryRun) {
|
|
721
|
+
info(`${sym.arrow} would create ${MANIFEST_FILE} (name: ${manifest.name})`);
|
|
722
|
+
return 0;
|
|
723
|
+
}
|
|
724
|
+
await writeManifest(ctx.cwd, manifest);
|
|
725
|
+
info(`${sym.add} created ${c.bold(MANIFEST_FILE)} (name: ${manifest.name})`);
|
|
726
|
+
info(`${c.dim("add packages with:")} debrief add <package>`);
|
|
727
|
+
return 0;
|
|
728
|
+
}
|
|
729
|
+
async function cmdAdd(ctx, specs) {
|
|
730
|
+
if (specs.length === 0) {
|
|
731
|
+
error("nothing to add — usage: debrief add <package>[@constraint|=version] ...");
|
|
732
|
+
return 1;
|
|
733
|
+
}
|
|
734
|
+
const manifest = await readManifest(ctx.cwd) ?? emptyManifest();
|
|
735
|
+
const lock = await loadLockOrNew(ctx);
|
|
736
|
+
lock.backend = ctx.backend.name;
|
|
737
|
+
let failures = 0;
|
|
738
|
+
for (const spec of specs.map(parseSpec)) {
|
|
739
|
+
const constraint = spec.constraint ?? "*";
|
|
740
|
+
const row = await inspect(spec.pkg, constraint, ctx.backend, lock);
|
|
741
|
+
if (row.latest === null && row.installed === null) {
|
|
742
|
+
error(`${spec.pkg}: no installation candidate (unknown package)`);
|
|
743
|
+
failures++;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const wanted = spec.exactVersion ?? row.target ?? row.latest;
|
|
747
|
+
if (spec.constraint && row.target === null && !spec.exactVersion) {
|
|
748
|
+
error(`${spec.pkg}: no available version satisfies "${constraint}" (latest is ${row.latest})`);
|
|
749
|
+
failures++;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
const alreadyGood = row.installed !== null && row.satisfied && !spec.exactVersion;
|
|
753
|
+
let installedVersion = row.installed;
|
|
754
|
+
if (alreadyGood) {
|
|
755
|
+
info(`${sym.ok} ${spec.pkg} ${row.installed} ${c.dim(`(already satisfies ${constraint})`)}`);
|
|
756
|
+
} else if (ctx.dryRun) {
|
|
757
|
+
info(`${sym.arrow} would install ${spec.pkg} ${c.bold(String(wanted))}`);
|
|
758
|
+
installedVersion = wanted ?? row.installed;
|
|
759
|
+
} else {
|
|
760
|
+
try {
|
|
761
|
+
installedVersion = await ctx.backend.install(spec.pkg, spec.exactVersion ?? wanted ?? undefined);
|
|
762
|
+
info(`${sym.add} ${spec.pkg} ${c.bold(installedVersion)}`);
|
|
763
|
+
} catch (e) {
|
|
764
|
+
error(`${spec.pkg}: ${e.message}`);
|
|
765
|
+
failures++;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const finalConstraint = spec.constraint ?? (installedVersion ? defaultConstraint(installedVersion) : "*");
|
|
770
|
+
manifest.packages[spec.pkg] = finalConstraint;
|
|
771
|
+
if (installedVersion) {
|
|
772
|
+
lock.packages[spec.pkg] = { version: installedVersion, constraint: finalConstraint };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (!ctx.dryRun) {
|
|
776
|
+
await writeManifest(ctx.cwd, manifest);
|
|
777
|
+
await writeLock(ctx.cwd, lock);
|
|
778
|
+
}
|
|
779
|
+
return failures > 0 ? 1 : 0;
|
|
780
|
+
}
|
|
781
|
+
async function cmdRemove(ctx, pkgs, opts) {
|
|
782
|
+
if (pkgs.length === 0) {
|
|
783
|
+
error("nothing to remove — usage: debrief remove <package> ...");
|
|
784
|
+
return 1;
|
|
785
|
+
}
|
|
786
|
+
const manifest = await requireManifest(ctx.cwd);
|
|
787
|
+
const lock = await loadLockOrNew(ctx);
|
|
788
|
+
let failures = 0;
|
|
789
|
+
for (const pkg of pkgs) {
|
|
790
|
+
const inManifest = pkg in manifest.packages;
|
|
791
|
+
const installed = opts.saveOnly ? null : await ctx.backend.installed(pkg);
|
|
792
|
+
if (!inManifest && !installed) {
|
|
793
|
+
warn(`${pkg}: not in ${MANIFEST_FILE} and not installed — skipping`);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (!opts.saveOnly && installed) {
|
|
797
|
+
if (ctx.dryRun) {
|
|
798
|
+
info(`${sym.arrow} would uninstall ${pkg} ${c.dim(installed)}`);
|
|
799
|
+
} else {
|
|
800
|
+
try {
|
|
801
|
+
await ctx.backend.remove(pkg);
|
|
802
|
+
info(`${c.red("-")} ${pkg} ${c.dim(`(was ${installed})`)}`);
|
|
803
|
+
} catch (e) {
|
|
804
|
+
error(`${pkg}: ${e.message}`);
|
|
805
|
+
failures++;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
delete manifest.packages[pkg];
|
|
811
|
+
delete lock.packages[pkg];
|
|
812
|
+
if (opts.saveOnly)
|
|
813
|
+
info(`${c.red("-")} ${pkg} ${c.dim("(removed from manifest only)")}`);
|
|
814
|
+
}
|
|
815
|
+
if (!ctx.dryRun) {
|
|
816
|
+
await writeManifest(ctx.cwd, manifest);
|
|
817
|
+
await writeLock(ctx.cwd, lock);
|
|
818
|
+
}
|
|
819
|
+
return failures > 0 ? 1 : 0;
|
|
820
|
+
}
|
|
821
|
+
async function cmdInstall(ctx) {
|
|
822
|
+
const manifest = await requireManifest(ctx.cwd);
|
|
823
|
+
const lock = await loadLockOrNew(ctx);
|
|
824
|
+
lock.backend = ctx.backend.name;
|
|
825
|
+
const rows = await inspectAll(manifest, ctx.backend, lock);
|
|
826
|
+
if (rows.length === 0) {
|
|
827
|
+
info(`${c.dim("no packages declared in")} ${MANIFEST_FILE}`);
|
|
828
|
+
return 0;
|
|
829
|
+
}
|
|
830
|
+
let installed = 0;
|
|
831
|
+
let failures = 0;
|
|
832
|
+
let drifted = 0;
|
|
833
|
+
for (const row of rows) {
|
|
834
|
+
const state = classify(row);
|
|
835
|
+
switch (state) {
|
|
836
|
+
case "missing": {
|
|
837
|
+
const version = row.resolvedTarget;
|
|
838
|
+
const pinned = row.locked !== null && version === row.locked;
|
|
839
|
+
const mark = pinned ? c.dim(" ↺") : "";
|
|
840
|
+
if (ctx.dryRun) {
|
|
841
|
+
info(`${sym.arrow} would install ${row.pkg} ${c.bold(String(version))}${mark}`);
|
|
842
|
+
if (version)
|
|
843
|
+
lock.packages[row.pkg] = { version, constraint: row.constraint };
|
|
844
|
+
installed++;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
const v = await ctx.backend.install(row.pkg, version ?? undefined);
|
|
849
|
+
info(`${sym.add} ${row.pkg} ${c.bold(v)}${mark}`);
|
|
850
|
+
lock.packages[row.pkg] = { version: v, constraint: row.constraint };
|
|
851
|
+
installed++;
|
|
852
|
+
} catch (e) {
|
|
853
|
+
error(`${row.pkg}: ${e.message}`);
|
|
854
|
+
failures++;
|
|
855
|
+
}
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
case "satisfied": {
|
|
859
|
+
info(`${sym.ok} ${row.pkg} ${c.dim(row.installed)}`);
|
|
860
|
+
lock.packages[row.pkg] = { version: row.installed, constraint: row.constraint };
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
case "drifted": {
|
|
864
|
+
warn(`${row.pkg} ${row.installed} does not satisfy ${c.bold(row.constraint)} ` + `${c.dim(`(run \`debrief add ${row.pkg}@${row.constraint}\` to change it)`)}`);
|
|
865
|
+
drifted++;
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
case "unavailable": {
|
|
869
|
+
error(`${row.pkg}: no candidate satisfies ${c.bold(row.constraint)}`);
|
|
870
|
+
failures++;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (!ctx.dryRun)
|
|
876
|
+
await writeLock(ctx.cwd, lock);
|
|
877
|
+
const parts = [];
|
|
878
|
+
parts.push(`${installed} installed`);
|
|
879
|
+
if (drifted)
|
|
880
|
+
parts.push(c.yellow(`${drifted} drifted`));
|
|
881
|
+
if (failures)
|
|
882
|
+
parts.push(c.red(`${failures} failed`));
|
|
883
|
+
info(c.dim(`— ${parts.join(", ")}`));
|
|
884
|
+
return failures > 0 ? 1 : 0;
|
|
885
|
+
}
|
|
886
|
+
function statusLabel(row) {
|
|
887
|
+
switch (classify(row)) {
|
|
888
|
+
case "missing":
|
|
889
|
+
return c.blue("missing");
|
|
890
|
+
case "satisfied":
|
|
891
|
+
return c.green("ok");
|
|
892
|
+
case "drifted":
|
|
893
|
+
return c.yellow("drift");
|
|
894
|
+
case "unavailable":
|
|
895
|
+
return c.red("no-candidate");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
async function cmdList(ctx) {
|
|
899
|
+
const manifest = await requireManifest(ctx.cwd);
|
|
900
|
+
const lock = await readLock(ctx.cwd);
|
|
901
|
+
const rows = await inspectAll(manifest, ctx.backend, lock);
|
|
902
|
+
if (rows.length === 0) {
|
|
903
|
+
info(`${c.dim("no packages declared. add one with:")} debrief add <package>`);
|
|
904
|
+
return 0;
|
|
905
|
+
}
|
|
906
|
+
const body = rows.map((r) => {
|
|
907
|
+
const newer = r.latest && r.installed && r.latest !== r.installed ? c.dim(`(latest ${r.latest})`) : "";
|
|
908
|
+
return [
|
|
909
|
+
c.bold(r.pkg),
|
|
910
|
+
r.constraint,
|
|
911
|
+
r.installed ?? c.dim("—"),
|
|
912
|
+
statusLabel(r),
|
|
913
|
+
newer
|
|
914
|
+
];
|
|
915
|
+
});
|
|
916
|
+
info(table(["PACKAGE", "CONSTRAINT", "INSTALLED", "STATUS", ""], body));
|
|
917
|
+
info(c.dim(`
|
|
918
|
+
backend: ${ctx.backend.name} • ${rows.length} package(s) declared`));
|
|
919
|
+
return 0;
|
|
920
|
+
}
|
|
921
|
+
var byName = (a, b) => a.name.localeCompare(b.name);
|
|
922
|
+
async function cmdCollect(ctx, opts) {
|
|
923
|
+
const inventory = await ctx.backend.listInstalled();
|
|
924
|
+
if (inventory.length === 0) {
|
|
925
|
+
warn("the backend reports no installed packages — nothing to collect");
|
|
926
|
+
return 0;
|
|
927
|
+
}
|
|
928
|
+
const manual = inventory.filter((p) => !p.auto);
|
|
929
|
+
const manifest = opts.replace ? emptyManifest() : await readManifest(ctx.cwd) ?? emptyManifest();
|
|
930
|
+
manifest.name ??= basename(ctx.cwd);
|
|
931
|
+
let added = 0;
|
|
932
|
+
for (const p of manual) {
|
|
933
|
+
if (p.name in manifest.packages && !opts.replace)
|
|
934
|
+
continue;
|
|
935
|
+
manifest.packages[p.name] = opts.pin ? p.version : "*";
|
|
936
|
+
added++;
|
|
937
|
+
}
|
|
938
|
+
const lock = { lockfileVersion: LOCKFILE_VERSION, backend: ctx.backend.name, packages: {} };
|
|
939
|
+
for (const p of inventory) {
|
|
940
|
+
lock.packages[p.name] = {
|
|
941
|
+
version: p.version,
|
|
942
|
+
constraint: manifest.packages[p.name] ?? "*",
|
|
943
|
+
...p.arch ? { arch: p.arch } : {},
|
|
944
|
+
...p.auto ? { auto: true } : {}
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
let bins = {};
|
|
948
|
+
if (opts.binaries !== false) {
|
|
949
|
+
bins = await collectBinaries({
|
|
950
|
+
dirs: opts.dirs ?? defaultBinDirs(),
|
|
951
|
+
backend: ctx.backend,
|
|
952
|
+
probeVersions: opts.probeVersions
|
|
953
|
+
});
|
|
954
|
+
lock.binaries = bins;
|
|
955
|
+
}
|
|
956
|
+
const binCount = Object.keys(bins).length;
|
|
957
|
+
if (ctx.dryRun) {
|
|
958
|
+
info(`${sym.arrow} would record ${c.bold(String(manual.length))} manual package(s) in ${MANIFEST_FILE} ` + `(${added} new), ${c.bold(String(inventory.length))} total + ${c.bold(String(binCount))} loose binaries in ${LOCK_FILE}`);
|
|
959
|
+
return 0;
|
|
960
|
+
}
|
|
961
|
+
await writeManifest(ctx.cwd, manifest);
|
|
962
|
+
await writeLock(ctx.cwd, lock);
|
|
963
|
+
info(`${sym.add} collected ${c.bold(String(inventory.length))} installed package(s)`);
|
|
964
|
+
info(` ${c.dim("→ manifest:")} ${manual.length} manual ${c.dim(`(+${added} new)`)} ` + `${c.dim("→ lock:")} ${inventory.length} total ${c.dim(`(incl. ${inventory.length - manual.length} deps)`)}`);
|
|
965
|
+
if (opts.binaries !== false) {
|
|
966
|
+
info(` ${c.dim("→ loose binaries (non-dpkg):")} ${binCount}`);
|
|
967
|
+
}
|
|
968
|
+
return 0;
|
|
969
|
+
}
|
|
970
|
+
async function cmdStatus(ctx, opts = {}) {
|
|
971
|
+
const lock = await readLock(ctx.cwd);
|
|
972
|
+
if (!lock) {
|
|
973
|
+
error(`no snapshot found — run \`debrief collect\` first`);
|
|
974
|
+
return 1;
|
|
975
|
+
}
|
|
976
|
+
const live = await ctx.backend.listInstalled();
|
|
977
|
+
const liveNames = new Set(live.map((p) => p.name));
|
|
978
|
+
const snap = lock.packages;
|
|
979
|
+
const added = [];
|
|
980
|
+
const changed = [];
|
|
981
|
+
for (const p of live) {
|
|
982
|
+
const s = snap[p.name];
|
|
983
|
+
if (!s)
|
|
984
|
+
added.push(p);
|
|
985
|
+
else if (s.version !== p.version) {
|
|
986
|
+
changed.push({ name: p.name, from: s.version, to: p.version, auto: p.auto });
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const removed = Object.entries(snap).filter(([name]) => !liveNames.has(name)).map(([name, e]) => ({ name, version: e.version, auto: e.auto ?? false }));
|
|
990
|
+
const binAdded = [];
|
|
991
|
+
const binChanged = [];
|
|
992
|
+
const binRemoved = [];
|
|
993
|
+
if (lock.binaries) {
|
|
994
|
+
const liveBins = await collectBinaries({ dirs: opts.dirs ?? defaultBinDirs(), backend: ctx.backend });
|
|
995
|
+
const snapBins = lock.binaries;
|
|
996
|
+
for (const [name, e] of Object.entries(liveBins)) {
|
|
997
|
+
const s = snapBins[name];
|
|
998
|
+
if (!s)
|
|
999
|
+
binAdded.push(`${name} ${e.path}`);
|
|
1000
|
+
else if (s.sha256 && e.sha256 && s.sha256 !== e.sha256)
|
|
1001
|
+
binChanged.push(`${name} ${e.path}`);
|
|
1002
|
+
}
|
|
1003
|
+
for (const name of Object.keys(snapBins)) {
|
|
1004
|
+
if (!(name in liveBins))
|
|
1005
|
+
binRemoved.push(`${name} ${snapBins[name].path}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const nothing = added.length + changed.length + removed.length + binAdded.length + binChanged.length + binRemoved.length === 0;
|
|
1009
|
+
if (nothing) {
|
|
1010
|
+
info(c.green("in sync — the live system matches the snapshot"));
|
|
1011
|
+
return 0;
|
|
1012
|
+
}
|
|
1013
|
+
const tag = (auto) => auto ? c.dim(" (dep)") : "";
|
|
1014
|
+
for (const p of added.sort(byName))
|
|
1015
|
+
info(`${c.green("+")} ${p.name} ${p.version}${tag(p.auto)}`);
|
|
1016
|
+
for (const ch of changed.sort(byName)) {
|
|
1017
|
+
info(`${c.yellow("~")} ${ch.name} ${c.dim(ch.from)} ${sym.arrow} ${ch.to}${tag(ch.auto)}`);
|
|
1018
|
+
}
|
|
1019
|
+
for (const p of removed.sort(byName))
|
|
1020
|
+
info(`${c.red("-")} ${p.name} ${c.dim(p.version)}${tag(p.auto)}`);
|
|
1021
|
+
if (binAdded.length + binChanged.length + binRemoved.length > 0) {
|
|
1022
|
+
info(c.dim(`
|
|
1023
|
+
non-dpkg binaries (curl|sh / manual / brew / ...):`));
|
|
1024
|
+
for (const b of binAdded.sort())
|
|
1025
|
+
info(`${c.green("+")} ${b}`);
|
|
1026
|
+
for (const b of binChanged.sort())
|
|
1027
|
+
info(`${c.yellow("~")} ${b} ${c.dim("(contents changed)")}`);
|
|
1028
|
+
for (const b of binRemoved.sort())
|
|
1029
|
+
info(`${c.red("-")} ${b}`);
|
|
1030
|
+
}
|
|
1031
|
+
const pkgLine = `${added.length} added ~${changed.length} changed -${removed.length} removed`;
|
|
1032
|
+
const binLine = lock.binaries !== undefined ? ` binaries: +${binAdded.length} ~${binChanged.length} -${binRemoved.length}` : "";
|
|
1033
|
+
info(c.dim(`
|
|
1034
|
+
packages: +${pkgLine}${binLine} (run \`debrief collect\` to update the snapshot)`));
|
|
1035
|
+
return 0;
|
|
1036
|
+
}
|
|
1037
|
+
async function cmdOutdated(ctx) {
|
|
1038
|
+
const manifest = await requireManifest(ctx.cwd);
|
|
1039
|
+
const lock = await readLock(ctx.cwd);
|
|
1040
|
+
const rows = await inspectAll(manifest, ctx.backend, lock);
|
|
1041
|
+
const outdated = rows.filter((r) => r.installed !== null && r.latest !== null && r.installed !== r.latest);
|
|
1042
|
+
if (outdated.length === 0) {
|
|
1043
|
+
info(c.green("everything is up to date"));
|
|
1044
|
+
return 0;
|
|
1045
|
+
}
|
|
1046
|
+
const body = outdated.map((r) => [
|
|
1047
|
+
c.bold(r.pkg),
|
|
1048
|
+
r.installed ?? c.dim("—"),
|
|
1049
|
+
r.target && r.target !== r.installed ? c.green(r.target) : c.dim(r.target ?? "—"),
|
|
1050
|
+
c.cyan(r.latest)
|
|
1051
|
+
]);
|
|
1052
|
+
info(table(["PACKAGE", "INSTALLED", "WANTED", "LATEST"], body));
|
|
1053
|
+
info(c.dim(`
|
|
1054
|
+
WANTED = newest within your constraint • LATEST = newest in the repo
|
|
1055
|
+
` + `bump a constraint with: debrief add <pkg>@<constraint>`));
|
|
1056
|
+
return 0;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// src/cli.ts
|
|
1060
|
+
var VERSION = "0.1.0";
|
|
1061
|
+
var HELP = `${c.bold("debrief")} — a package.json for your Debian packages
|
|
1062
|
+
|
|
1063
|
+
${c.bold("USAGE")}
|
|
1064
|
+
debrief <command> [args] [options]
|
|
1065
|
+
|
|
1066
|
+
${c.bold("COMMANDS")}
|
|
1067
|
+
${c.bold("collect")} snapshot dpkg packages + loose PATH binaries into the lock
|
|
1068
|
+
${c.bold("status")} diff the live system against the snapshot
|
|
1069
|
+
list show declared packages and their status (alias: ls)
|
|
1070
|
+
outdated show packages with newer versions available
|
|
1071
|
+
init create an empty debrief.json
|
|
1072
|
+
add <pkg[@ver|=ver]> … install package(s) and record them in the manifest
|
|
1073
|
+
remove <pkg> … uninstall package(s) and drop them from the manifest
|
|
1074
|
+
install install everything in the manifest that is missing
|
|
1075
|
+
help show this help
|
|
1076
|
+
|
|
1077
|
+
${c.bold("COLLECT / STATUS OPTIONS")}
|
|
1078
|
+
--pin (collect) record exact installed versions, not "*"
|
|
1079
|
+
--replace (collect) overwrite the manifest instead of merging
|
|
1080
|
+
--no-binaries skip scanning PATH for loose (non-dpkg) binaries
|
|
1081
|
+
--probe-versions run \`<bin> --version\` on loose binaries (executes them)
|
|
1082
|
+
--scan-dirs a:b:c override which dirs to scan for loose binaries
|
|
1083
|
+
|
|
1084
|
+
${c.bold("VERSION SPECS")} (npm-flavored, resolved against real Debian versions)
|
|
1085
|
+
nginx latest candidate; manifest gets ^<installed>
|
|
1086
|
+
nginx@^1.24 >= 1.24 and < 2 (caret: allow non-major)
|
|
1087
|
+
nginx@~1.24 >= 1.24 and < 1.25 (tilde: pin the minor)
|
|
1088
|
+
nginx@'>=1.22 <1.26' an AND-ed range
|
|
1089
|
+
nginx=1.24.0-1 an exact Debian version
|
|
1090
|
+
|
|
1091
|
+
${c.bold("OPTIONS")}
|
|
1092
|
+
--backend <mock|apt> force a backend (default: from lockfile, else auto)
|
|
1093
|
+
-n, --dry-run show what would happen without changing anything
|
|
1094
|
+
-y, --yes assume yes (non-interactive)
|
|
1095
|
+
--save-only (remove) drop from manifest without uninstalling
|
|
1096
|
+
--name <name> (init) set the project name
|
|
1097
|
+
--cwd <dir> run as if in <dir>
|
|
1098
|
+
-V, --version print version
|
|
1099
|
+
-h, --help show this help
|
|
1100
|
+
|
|
1101
|
+
${c.dim("On a machine without apt (e.g. NixOS) the mock backend is used automatically.")}`;
|
|
1102
|
+
var VALUE_FLAGS = new Set(["backend", "cwd", "name", "scan-dirs"]);
|
|
1103
|
+
var ALIASES = {
|
|
1104
|
+
n: "dry-run",
|
|
1105
|
+
y: "yes",
|
|
1106
|
+
h: "help",
|
|
1107
|
+
V: "version"
|
|
1108
|
+
};
|
|
1109
|
+
function parse(argv) {
|
|
1110
|
+
const positionals = [];
|
|
1111
|
+
const flags = {};
|
|
1112
|
+
for (let i = 0;i < argv.length; i++) {
|
|
1113
|
+
const tok = argv[i];
|
|
1114
|
+
if (tok.startsWith("--")) {
|
|
1115
|
+
const body = tok.slice(2);
|
|
1116
|
+
const eq2 = body.indexOf("=");
|
|
1117
|
+
if (eq2 !== -1) {
|
|
1118
|
+
flags[body.slice(0, eq2)] = body.slice(eq2 + 1);
|
|
1119
|
+
} else if (VALUE_FLAGS.has(body)) {
|
|
1120
|
+
flags[body] = argv[++i] ?? "";
|
|
1121
|
+
} else {
|
|
1122
|
+
flags[body] = true;
|
|
1123
|
+
}
|
|
1124
|
+
} else if (tok.startsWith("-") && tok.length > 1) {
|
|
1125
|
+
const name = ALIASES[tok.slice(1)] ?? tok.slice(1);
|
|
1126
|
+
if (VALUE_FLAGS.has(name))
|
|
1127
|
+
flags[name] = argv[++i] ?? "";
|
|
1128
|
+
else
|
|
1129
|
+
flags[name] = true;
|
|
1130
|
+
} else {
|
|
1131
|
+
positionals.push(tok);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
const [command = null, ...rest] = positionals;
|
|
1135
|
+
return { command, positionals: rest, flags };
|
|
1136
|
+
}
|
|
1137
|
+
function scanDirs(flags) {
|
|
1138
|
+
const v = flags["scan-dirs"];
|
|
1139
|
+
return typeof v === "string" ? v.split(":").filter(Boolean) : undefined;
|
|
1140
|
+
}
|
|
1141
|
+
async function main(argv) {
|
|
1142
|
+
const { command, positionals, flags } = parse(argv);
|
|
1143
|
+
if (flags.version) {
|
|
1144
|
+
console.log(VERSION);
|
|
1145
|
+
return 0;
|
|
1146
|
+
}
|
|
1147
|
+
if (flags.help || command === null || command === "help") {
|
|
1148
|
+
console.log(HELP);
|
|
1149
|
+
return 0;
|
|
1150
|
+
}
|
|
1151
|
+
const cwd = typeof flags.cwd === "string" ? flags.cwd : process.cwd();
|
|
1152
|
+
const dryRun = !!flags["dry-run"];
|
|
1153
|
+
const yes = !!flags.yes;
|
|
1154
|
+
const lock = await readLock(cwd).catch(() => null);
|
|
1155
|
+
const backend = selectBackend({
|
|
1156
|
+
explicit: typeof flags.backend === "string" ? flags.backend : undefined,
|
|
1157
|
+
fromLock: lock?.backend,
|
|
1158
|
+
cwd
|
|
1159
|
+
});
|
|
1160
|
+
const ctx = { cwd, backend, dryRun, yes };
|
|
1161
|
+
try {
|
|
1162
|
+
switch (command) {
|
|
1163
|
+
case "collect":
|
|
1164
|
+
case "import":
|
|
1165
|
+
case "freeze":
|
|
1166
|
+
return await cmdCollect(ctx, {
|
|
1167
|
+
pin: !!flags.pin,
|
|
1168
|
+
replace: !!flags.replace,
|
|
1169
|
+
binaries: !flags["no-binaries"],
|
|
1170
|
+
probeVersions: !!flags["probe-versions"],
|
|
1171
|
+
dirs: scanDirs(flags)
|
|
1172
|
+
});
|
|
1173
|
+
case "status":
|
|
1174
|
+
case "diff":
|
|
1175
|
+
return await cmdStatus(ctx, { dirs: scanDirs(flags) });
|
|
1176
|
+
case "init":
|
|
1177
|
+
return await cmdInit(ctx, { name: typeof flags.name === "string" ? flags.name : undefined });
|
|
1178
|
+
case "add":
|
|
1179
|
+
return await cmdAdd(ctx, positionals);
|
|
1180
|
+
case "remove":
|
|
1181
|
+
case "rm":
|
|
1182
|
+
case "uninstall":
|
|
1183
|
+
return await cmdRemove(ctx, positionals, { saveOnly: !!flags["save-only"] });
|
|
1184
|
+
case "install":
|
|
1185
|
+
case "i":
|
|
1186
|
+
if (positionals.length > 0) {
|
|
1187
|
+
error(`\`install\` takes no package arguments — use \`debrief add ${positionals.join(" ")}\``);
|
|
1188
|
+
return 1;
|
|
1189
|
+
}
|
|
1190
|
+
return await cmdInstall(ctx);
|
|
1191
|
+
case "list":
|
|
1192
|
+
case "ls":
|
|
1193
|
+
return await cmdList(ctx);
|
|
1194
|
+
case "outdated":
|
|
1195
|
+
return await cmdOutdated(ctx);
|
|
1196
|
+
default:
|
|
1197
|
+
error(`unknown command "${command}" — run \`debrief help\``);
|
|
1198
|
+
return 1;
|
|
1199
|
+
}
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
error(e.message);
|
|
1202
|
+
return 1;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// bin/debrief.ts
|
|
1207
|
+
process.exit(await main(process.argv.slice(2)));
|