@dsdenes/devboard 0.1.541 → 0.1.559
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/bin/devboard.mjs +113 -26
- package/package.json +1 -1
package/bin/devboard.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
} from "node:fs/promises";
|
|
34
34
|
import { createServer } from "node:net";
|
|
35
35
|
import { homedir } from "node:os";
|
|
36
|
+
import { Readable } from "node:stream";
|
|
37
|
+
import { pipeline } from "node:stream/promises";
|
|
36
38
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
37
39
|
import { fileURLToPath } from "node:url";
|
|
38
40
|
|
|
@@ -73,7 +75,14 @@ function ownVersion() {
|
|
|
73
75
|
|
|
74
76
|
function redact(text) {
|
|
75
77
|
let value = String(text ?? "");
|
|
76
|
-
for (const name of [
|
|
78
|
+
for (const name of [
|
|
79
|
+
"NPM_TOKEN",
|
|
80
|
+
"NODE_AUTH_TOKEN",
|
|
81
|
+
"GH_TOKEN",
|
|
82
|
+
"GITHUB_TOKEN",
|
|
83
|
+
"GITEA_TOKEN",
|
|
84
|
+
"DEVBOARD_GITEA_TOKEN",
|
|
85
|
+
]) {
|
|
77
86
|
const secret = process.env[name];
|
|
78
87
|
if (secret !== undefined && secret.length >= 8) value = value.split(secret).join("<redacted>");
|
|
79
88
|
}
|
|
@@ -277,11 +286,25 @@ async function waitForHttp(port, timeoutMs = START_TIMEOUT_MS) {
|
|
|
277
286
|
return false;
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
function
|
|
289
|
+
function configuredReleaseUrl() {
|
|
290
|
+
for (const name of ["DEVBOARD_RELEASE_URL", "DEVBOARD_GITEA_URL", "GITEA_URL"]) {
|
|
291
|
+
const value = process.env[name];
|
|
292
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
293
|
+
return value.trim().replace(/\/+$/, "");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function releaseSourceFromEnv(prefix) {
|
|
281
300
|
if (process.env.DEVBOARD_RELEASE_DIR !== undefined && process.env.DEVBOARD_RELEASE_DIR.length > 0) {
|
|
282
301
|
return { kind: "dir", directory: resolve(process.env.DEVBOARD_RELEASE_DIR) };
|
|
283
302
|
}
|
|
284
|
-
|
|
303
|
+
const persisted = prefix === undefined ? {} : readInstallState(prefix).release ?? {};
|
|
304
|
+
const baseUrl = configuredReleaseUrl() ?? persisted.url ?? "";
|
|
305
|
+
const repo = process.env.DEVBOARD_RELEASE_REPO ?? persisted.repo ?? RELEASE_REPO;
|
|
306
|
+
const token = process.env.GITEA_TOKEN ?? process.env.DEVBOARD_GITEA_TOKEN;
|
|
307
|
+
return { kind: "gitea", baseUrl, repo, ...(token === undefined ? {} : { token }) };
|
|
285
308
|
}
|
|
286
309
|
|
|
287
310
|
function tarballName(version) {
|
|
@@ -311,7 +334,7 @@ function versionFromTarball(name) {
|
|
|
311
334
|
|
|
312
335
|
async function latestRelease(source) {
|
|
313
336
|
if (source.kind === "dir") return directoryRelease(source.directory);
|
|
314
|
-
return
|
|
337
|
+
return giteaRelease(source);
|
|
315
338
|
}
|
|
316
339
|
|
|
317
340
|
async function directoryRelease(directory) {
|
|
@@ -340,13 +363,40 @@ async function directoryRelease(directory) {
|
|
|
340
363
|
};
|
|
341
364
|
}
|
|
342
365
|
|
|
343
|
-
function
|
|
344
|
-
|
|
366
|
+
function apiPath(project, suffix) {
|
|
367
|
+
return project.split("/").map(encodeURIComponent).join("/") + suffix;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function apiHeaders(source, accept) {
|
|
371
|
+
return {
|
|
372
|
+
Accept: accept,
|
|
373
|
+
...(typeof source.token === "string" && source.token.length > 0
|
|
374
|
+
? { Authorization: `token ${source.token}` }
|
|
375
|
+
: {}),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function giteaRelease(source) {
|
|
380
|
+
if (typeof source.baseUrl !== "string" || source.baseUrl.length === 0) {
|
|
381
|
+
throw new LauncherError("the Gitea release address is not configured; set DEVBOARD_GITEA_URL");
|
|
382
|
+
}
|
|
383
|
+
const url = `${source.baseUrl}/api/v1/repos/${apiPath(source.repo, "/releases/latest")}`;
|
|
384
|
+
const response = await fetch(url, { headers: apiHeaders(source, "application/json") });
|
|
385
|
+
if (!response.ok) {
|
|
386
|
+
const detail = firstLine(await response.text().catch(() => ""));
|
|
387
|
+
throw new LauncherError(`the release feed answered HTTP ${response.status}: ${detail}`);
|
|
388
|
+
}
|
|
389
|
+
const parsed = await response.json();
|
|
345
390
|
if (typeof parsed.tag_name !== "string") throw new LauncherError("the latest release has no tag");
|
|
346
391
|
const assets = Array.isArray(parsed.assets)
|
|
347
392
|
? parsed.assets
|
|
348
393
|
.filter((asset) => asset !== null && typeof asset === "object" && typeof asset.name === "string")
|
|
349
|
-
.map((asset) => ({
|
|
394
|
+
.map((asset) => ({
|
|
395
|
+
name: asset.name,
|
|
396
|
+
id: asset.id,
|
|
397
|
+
size: asset.size,
|
|
398
|
+
browser_download_url: asset.browser_download_url,
|
|
399
|
+
}))
|
|
350
400
|
: [];
|
|
351
401
|
return { version: parsed.tag_name.replace(/^v/, ""), tag: parsed.tag_name, assets };
|
|
352
402
|
}
|
|
@@ -365,13 +415,40 @@ async function downloadRelease(source, release, destination) {
|
|
|
365
415
|
return { tarball, checksum };
|
|
366
416
|
}
|
|
367
417
|
for (const name of [basename(tarball), basename(checksum)]) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
418
|
+
const asset = release.assets.find((entry) => entry.name === name);
|
|
419
|
+
if (asset === undefined) throw new LauncherError(`release ${release.tag} lacks ${name}`);
|
|
420
|
+
const url =
|
|
421
|
+
typeof asset.browser_download_url === "string"
|
|
422
|
+
? asset.browser_download_url
|
|
423
|
+
: asset.id === undefined
|
|
424
|
+
? undefined
|
|
425
|
+
: `${source.baseUrl}/api/v1/repos/${apiPath(
|
|
426
|
+
source.repo,
|
|
427
|
+
`/releases/assets/${asset.id}`,
|
|
428
|
+
)}`;
|
|
429
|
+
if (url === undefined) throw new LauncherError(`release asset ${name} has no address`);
|
|
430
|
+
const response = await fetch(url, {
|
|
431
|
+
headers: apiHeaders(
|
|
432
|
+
source,
|
|
433
|
+
typeof asset.browser_download_url === "string" ? "*/*" : "application/octet-stream",
|
|
434
|
+
),
|
|
435
|
+
signal: AbortSignal.timeout(10 * 60_000),
|
|
436
|
+
});
|
|
437
|
+
if (!response.ok) {
|
|
438
|
+
const detail = firstLine(await response.text().catch(() => ""));
|
|
439
|
+
throw new LauncherError(`downloading ${name} failed (HTTP ${response.status}): ${detail}`);
|
|
440
|
+
}
|
|
441
|
+
if (response.body === null) throw new LauncherError(`downloading ${name} returned no body`);
|
|
442
|
+
await mkdir(destination, { recursive: true });
|
|
443
|
+
try {
|
|
444
|
+
await pipeline(
|
|
445
|
+
Readable.fromWeb(response.body),
|
|
446
|
+
createWriteStream(join(destination, name)),
|
|
447
|
+
);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
await rm(join(destination, name), { force: true }).catch(() => undefined);
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
375
452
|
}
|
|
376
453
|
return { tarball, checksum };
|
|
377
454
|
}
|
|
@@ -706,6 +783,16 @@ async function writeInstallState(prefix, state) {
|
|
|
706
783
|
await writeFile(installStatePath(prefix), `${JSON.stringify(state, null, 2)}\n`);
|
|
707
784
|
}
|
|
708
785
|
|
|
786
|
+
/** Persist the release coordinates so the dashboard feed needs no environment. */
|
|
787
|
+
async function persistRelease(prefix, source) {
|
|
788
|
+
if (source.kind !== "gitea") return;
|
|
789
|
+
const state = readInstallState(prefix);
|
|
790
|
+
await writeInstallState(prefix, {
|
|
791
|
+
...state,
|
|
792
|
+
release: { url: source.baseUrl, repo: source.repo },
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
709
796
|
function isDashboardFrom(pid, harness) {
|
|
710
797
|
const entry = processInfo(pid);
|
|
711
798
|
if (entry === undefined) return false;
|
|
@@ -813,9 +900,9 @@ async function installCommand(options) {
|
|
|
813
900
|
`${host} is the canonical harness source checkout; install runs in a host project instead`,
|
|
814
901
|
);
|
|
815
902
|
}
|
|
816
|
-
const source = releaseSourceFromEnv();
|
|
817
|
-
const release = await latestRelease(source);
|
|
818
903
|
const prefix = join(host, ".devboard");
|
|
904
|
+
const source = releaseSourceFromEnv(prefix);
|
|
905
|
+
const release = await latestRelease(source);
|
|
819
906
|
const skip = trackedPaths(host, [".omp/commands", ".omp/extensions", "AGENTS.md"]);
|
|
820
907
|
const trackedHarness = [...trackedPaths(host, [".devboard"])].filter(
|
|
821
908
|
(path) => path === prefix || path.startsWith(`${prefix}${sep}`),
|
|
@@ -842,6 +929,7 @@ async function installCommand(options) {
|
|
|
842
929
|
release.version,
|
|
843
930
|
Number.isInteger(portOption) ? portOption : undefined,
|
|
844
931
|
);
|
|
932
|
+
await persistRelease(prefix, source);
|
|
845
933
|
console.log(`devboard: installed ${HARNESS_PACKAGE}@${release.version} in ${prefix}`);
|
|
846
934
|
if (agents === "drift") {
|
|
847
935
|
console.log("devboard: AGENTS.md differs from the harness template; left unchanged (drift)");
|
|
@@ -867,7 +955,7 @@ async function updateCommand(options) {
|
|
|
867
955
|
if (before === null) {
|
|
868
956
|
throw new LauncherError(`no harness is installed in ${prefix}; run install`);
|
|
869
957
|
}
|
|
870
|
-
const source = releaseSourceFromEnv();
|
|
958
|
+
const source = releaseSourceFromEnv(prefix);
|
|
871
959
|
const release = await latestRelease(source);
|
|
872
960
|
if (!newerVersion(release.version, before) && release.version !== before) {
|
|
873
961
|
// An older latest release is still installed as asked, never downgraded.
|
|
@@ -877,6 +965,7 @@ async function updateCommand(options) {
|
|
|
877
965
|
const harness = await installRelease(source, release, prefix, join(prefix, ".tmp"));
|
|
878
966
|
const skip = trackedPaths(host, [".omp/commands", ".omp/extensions"]);
|
|
879
967
|
await syncWrappers(host, harness, skip);
|
|
968
|
+
await persistRelease(prefix, source);
|
|
880
969
|
console.log(`devboard: harness ${before} -> ${release.version}`);
|
|
881
970
|
console.log("devboard: the dashboard keeps running; reload it to restart on the new version");
|
|
882
971
|
}
|
|
@@ -1002,19 +1091,17 @@ const CHECKS = [
|
|
|
1002
1091
|
},
|
|
1003
1092
|
},
|
|
1004
1093
|
{
|
|
1005
|
-
name: "
|
|
1006
|
-
run: () => {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
});
|
|
1011
|
-
return `can read ${RELEASE_REPO}`;
|
|
1094
|
+
name: "release access",
|
|
1095
|
+
run: async (_host, prefix) => {
|
|
1096
|
+
const source = releaseSourceFromEnv(prefix);
|
|
1097
|
+
const release = await latestRelease(source);
|
|
1098
|
+
return `the public feed serves ${release.tag}`;
|
|
1012
1099
|
},
|
|
1013
1100
|
},
|
|
1014
1101
|
{
|
|
1015
1102
|
name: "release",
|
|
1016
1103
|
run: async (_host, prefix) => {
|
|
1017
|
-
const source = releaseSourceFromEnv();
|
|
1104
|
+
const source = releaseSourceFromEnv(prefix);
|
|
1018
1105
|
const release = await latestRelease(source);
|
|
1019
1106
|
await readReleaseDigest(source, release, prefix);
|
|
1020
1107
|
return `${release.tag} with a matching digest`;
|
|
@@ -1035,7 +1122,7 @@ const CHECKS = [
|
|
|
1035
1122
|
run: async (_host, prefix) => {
|
|
1036
1123
|
const version = installedVersion(prefix);
|
|
1037
1124
|
if (version === null) throw new LauncherError("no installed version");
|
|
1038
|
-
const release = await latestRelease(releaseSourceFromEnv());
|
|
1125
|
+
const release = await latestRelease(releaseSourceFromEnv(prefix));
|
|
1039
1126
|
if (version !== release.version) {
|
|
1040
1127
|
throw new LauncherError(`stale install: installed ${version}, latest ${release.version}`);
|
|
1041
1128
|
}
|