@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.
Files changed (2) hide show
  1. package/bin/devboard.mjs +113 -26
  2. 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 ["NPM_TOKEN", "NODE_AUTH_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
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 releaseSourceFromEnv() {
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
- return { kind: "gh", repo: RELEASE_REPO };
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 githubRelease(source.repo);
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 githubRelease(repo) {
344
- const parsed = JSON.parse(mustRun("gh", ["api", `repos/${repo}/releases/latest`], "gh release"));
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) => ({ name: asset.name, id: asset.id, size: asset.size }))
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
- mustRun(
369
- "gh",
370
- ["release", "download", release.tag, "--repo", source.repo, "--pattern", name,
371
- "--dir", destination, "--clobber"],
372
- `gh release download ${name}`,
373
- { timeoutMs: 10 * 60_000 },
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: "gh auth",
1006
- run: () => {
1007
- mustRun("gh", ["auth", "status"], "gh auth status", { timeoutMs: 30_000 });
1008
- mustRun("gh", ["api", `repos/${RELEASE_REPO}`], "gh repository access", {
1009
- timeoutMs: 30_000,
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsdenes/devboard",
3
- "version": "0.1.541",
3
+ "version": "0.1.559",
4
4
  "description": "Launcher for the private Devboard harness: install, update, and doctor.",
5
5
  "type": "module",
6
6
  "bin": {