@dsdenes/devboard 0.0.0-stage → 0.1.147

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 (3) hide show
  1. package/README.md +33 -2
  2. package/bin/devboard.mjs +1164 -0
  3. package/package.json +16 -4
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # @dsdenes/devboard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Public launcher for the private `@dsdenes/devboard-harness` package. The
4
+ launcher holds bootstrap code only; the harness tarball carries the dashboard
5
+ server, client bundle, prompts, and omp wrappers and is distributed from GitHub
6
+ Releases, never from a registry.
7
+
8
+ ```bash
9
+ npx @dsdenes/devboard install # install or refresh the harness for this host
10
+ npx @dsdenes/devboard update # install the newest release without restarting
11
+ npx @dsdenes/devboard doctor # print one PASS or FAIL line per check
12
+ ```
13
+
14
+ `install` writes a real npm prefix at `<host>/.devboard`, replaces harness
15
+ symlinks, copies the omp wrappers and `AGENTS.md` template, adds the ignored
16
+ paths to `<host>/.gitignore`, and leaves exactly one dashboard running from the
17
+ installed package. A reinstall keeps `.devboard/.tmp` and reuses a dashboard
18
+ that is already current.
19
+
20
+ `update` and every running dashboard read the newest private release through
21
+ the authenticated `gh` CLI, verify its `.sha256`, install it into the existing
22
+ prefix, and never expose a token. A dashboard installs without restarting and
23
+ shows its reload control until someone clicks it; `update` never touches the
24
+ dashboard.
25
+
26
+ `doctor` needs Node 20 or newer, Bun, npm, git, an authenticated `gh` account
27
+ that can read the release repository, `omp`, and a working install. It reports
28
+ wrapper drift, a stale or missing install, a symlinked `.devboard`, a missing
29
+ dashboard, and a recorded update failure, and exits nonzero when any check
30
+ fails.
31
+
32
+ `--host <path>` targets a host other than the git root of the current
33
+ directory. `DEVBOARD_RELEASE_DIR` points the launcher at a local directory of
34
+ release tarballs for offline verification; production runs leave it unset.
@@ -0,0 +1,1164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @dsdenes/devboard launcher.
4
+ *
5
+ * Bootstrap only: this package resolves and installs the private harness
6
+ * package, keeps a host's npm prefix and wrapper copies in sync, and reports
7
+ * diagnostics. The harness server, client, prompts, and omp wrappers live in
8
+ * the separately released @dsdenes/devboard-harness tarball and never in this
9
+ * package.
10
+ */
11
+
12
+ import { spawn, spawnSync } from "node:child_process";
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import {
15
+ closeSync,
16
+ createWriteStream,
17
+ existsSync,
18
+ lstatSync,
19
+ openSync,
20
+ readFileSync,
21
+ readdirSync,
22
+ } from "node:fs";
23
+ import {
24
+ cp,
25
+ mkdir,
26
+ readFile,
27
+ readdir,
28
+ realpath,
29
+ rename,
30
+ rm,
31
+ unlink,
32
+ writeFile,
33
+ } from "node:fs/promises";
34
+ import { createServer } from "node:net";
35
+ import { basename, dirname, join, resolve, sep } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ const HARNESS_PACKAGE = "@dsdenes/devboard-harness";
39
+ const HARNESS_SCOPE = "@dsdenes";
40
+ const RELEASE_REPO = process.env.DEVBOARD_RELEASE_REPO ?? "dsdenes/devboard";
41
+ const RELEASE_PREFIX = "devboard-harness-";
42
+ const UPDATE_INTERVAL_MS = 60_000;
43
+ const DEFAULT_PORT = 8080;
44
+ const START_TIMEOUT_MS = 30_000;
45
+
46
+ const usage = `Usage: devboard <command> [options]
47
+
48
+ Commands:
49
+ install Install or refresh the harness for a host and start its dashboard.
50
+ update Install the newest harness release without touching the dashboard.
51
+ doctor Check one host installation and print one PASS or FAIL line per check.
52
+
53
+ Options:
54
+ --host <path> Host repository root; defaults to the git root of the cwd.
55
+ --port <n> Dashboard port for install; defaults to the existing or first free port.
56
+ -h, --help Show this text.
57
+
58
+ Examples:
59
+ npx @dsdenes/devboard install
60
+ npx @dsdenes/devboard update
61
+ npx @dsdenes/devboard doctor`;
62
+
63
+ function ownVersion() {
64
+ try {
65
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
66
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
67
+ return typeof parsed.version === "string" ? parsed.version : "0.0.0";
68
+ } catch {
69
+ return "0.0.0";
70
+ }
71
+ }
72
+
73
+ function redact(text) {
74
+ let value = String(text ?? "");
75
+ for (const name of ["NPM_TOKEN", "NODE_AUTH_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
76
+ const secret = process.env[name];
77
+ if (secret !== undefined && secret.length >= 8) value = value.split(secret).join("<redacted>");
78
+ }
79
+ return value
80
+ .replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, "<redacted>")
81
+ .replace(/npm_[A-Za-z0-9]{20,}/g, "<redacted>");
82
+ }
83
+
84
+ function firstLine(text) {
85
+ const line = redact(text).trim().split("\n")[0] ?? "";
86
+ return line.length > 0 ? line : "no output";
87
+ }
88
+
89
+ class LauncherError extends Error {}
90
+
91
+ function run(command, args, options = {}) {
92
+ const result = spawnSync(command, args, {
93
+ cwd: options.cwd,
94
+ env: { ...process.env, ...options.env, GH_PROMPT_DISABLED: "1" },
95
+ encoding: "utf8",
96
+ timeout: options.timeoutMs ?? 120_000,
97
+ maxBuffer: 32 * 1024 * 1024,
98
+ stdio: ["ignore", "pipe", "pipe"],
99
+ });
100
+ return {
101
+ code: result.status ?? 1,
102
+ stdout: result.stdout ?? "",
103
+ stderr: result.stderr ?? "",
104
+ error: result.error,
105
+ };
106
+ }
107
+
108
+ function mustRun(command, args, description, options = {}) {
109
+ const result = run(command, args, options);
110
+ if (result.code !== 0) {
111
+ throw new LauncherError(`${description}: ${firstLine(result.stderr || result.stdout)}`);
112
+ }
113
+ return result.stdout;
114
+ }
115
+
116
+ function parseArgs(argv) {
117
+ const options = { command: undefined, host: undefined, port: undefined, help: false };
118
+ for (let index = 0; index < argv.length; index += 1) {
119
+ const argument = argv[index];
120
+ if (argument === "-h" || argument === "--help") options.help = true;
121
+ else if (argument === "--host") options.host = argv[++index];
122
+ else if (argument === "--port") options.port = argv[++index];
123
+ else if (argument.startsWith("-")) throw new LauncherError(`unknown option: ${argument}`);
124
+ else if (options.command === undefined) options.command = argument;
125
+ else throw new LauncherError(`unexpected argument: ${argument}`);
126
+ }
127
+ return options;
128
+ }
129
+
130
+ function resolveHost(option) {
131
+ if (typeof option === "string" && option.length > 0) return resolve(option);
132
+ const top = run("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd() });
133
+ if (top.code === 0 && top.stdout.trim().length > 0) return top.stdout.trim();
134
+ return process.cwd();
135
+ }
136
+
137
+ async function realOrUndefined(path) {
138
+ try {
139
+ return await realpath(path);
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ function lstatOrUndefined(path) {
146
+ try {
147
+ return lstatSync(path);
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ function readDashboardProcesses(host) {
154
+ const wanted = resolve(host);
155
+ const found = [];
156
+ let entries;
157
+ try {
158
+ entries = readdirSync("/proc");
159
+ } catch {
160
+ return found;
161
+ }
162
+ for (const entry of entries) {
163
+ if (!/^[0-9]+$/.test(entry)) continue;
164
+ const pid = Number(entry);
165
+ if (pid === process.pid) continue;
166
+ let cmdline;
167
+ try {
168
+ cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim();
169
+ } catch {
170
+ continue;
171
+ }
172
+ if (!cmdline.includes("server/index.ts")) continue;
173
+ const env = readEnviron(pid);
174
+ if (env.REPO_DIR === undefined || resolve(env.REPO_DIR) !== wanted) continue;
175
+ const port = Number(env.PORT ?? "");
176
+ found.push({ pid, port: Number.isInteger(port) && port > 0 ? port : undefined, cmdline, env });
177
+ }
178
+ return found;
179
+ }
180
+
181
+ function readEnviron(pid) {
182
+ const values = {};
183
+ try {
184
+ for (const entry of readFileSync(`/proc/${pid}/environ`, "utf8").split("\0")) {
185
+ const split = entry.indexOf("=");
186
+ if (split > 0) values[entry.slice(0, split)] = entry.slice(split + 1);
187
+ }
188
+ } catch {
189
+ // The process exited while it was being inspected.
190
+ }
191
+ return values;
192
+ }
193
+
194
+ function processRunning(pid) {
195
+ if (!Number.isInteger(pid) || pid <= 0) return false;
196
+ try {
197
+ process.kill(pid, 0);
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ async function sleep(ms) {
205
+ await new Promise((settle) => setTimeout(settle, ms));
206
+ }
207
+
208
+ async function stopDashboardProcesses(host) {
209
+ const stopped = [];
210
+ for (const dashboard of readDashboardProcesses(host)) {
211
+ try {
212
+ process.kill(dashboard.pid, "SIGTERM");
213
+ } catch {
214
+ continue;
215
+ }
216
+ stopped.push(dashboard.pid);
217
+ }
218
+ const deadline = Date.now() + 10_000;
219
+ while (Date.now() < deadline && stopped.some(processRunning)) await sleep(100);
220
+ for (const pid of stopped) {
221
+ if (!processRunning(pid)) continue;
222
+ try {
223
+ process.kill(pid, "SIGKILL");
224
+ } catch {
225
+ // It exited between the check and the signal.
226
+ }
227
+ }
228
+ return stopped;
229
+ }
230
+
231
+ function portFree(port) {
232
+ return new Promise((settle) => {
233
+ const probe = createServer();
234
+ probe.once("error", () => settle(false));
235
+ probe.once("listening", () => probe.close(() => settle(true)));
236
+ probe.listen(port, "127.0.0.1");
237
+ });
238
+ }
239
+
240
+ async function choosePort(preferred) {
241
+ if (Number.isInteger(preferred) && preferred > 0 && (await portFree(preferred))) return preferred;
242
+ for (let port = DEFAULT_PORT; port < DEFAULT_PORT + 200; port += 1) {
243
+ if (await portFree(port)) return port;
244
+ }
245
+ throw new LauncherError("no free port between 8080 and 8279");
246
+ }
247
+
248
+ async function httpOk(port) {
249
+ try {
250
+ const response = await fetch(`http://127.0.0.1:${port}/`, {
251
+ method: "HEAD",
252
+ signal: AbortSignal.timeout(2000),
253
+ });
254
+ return response.ok;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ async function waitForHttp(port, timeoutMs = START_TIMEOUT_MS) {
261
+ const deadline = Date.now() + timeoutMs;
262
+ while (Date.now() < deadline) {
263
+ if (await httpOk(port)) return true;
264
+ await sleep(200);
265
+ }
266
+ return false;
267
+ }
268
+
269
+ function releaseSourceFromEnv() {
270
+ if (process.env.DEVBOARD_RELEASE_DIR !== undefined && process.env.DEVBOARD_RELEASE_DIR.length > 0) {
271
+ return { kind: "dir", directory: resolve(process.env.DEVBOARD_RELEASE_DIR) };
272
+ }
273
+ return { kind: "gh", repo: RELEASE_REPO };
274
+ }
275
+
276
+ function tarballName(version) {
277
+ return `${RELEASE_PREFIX}${version}.tgz`;
278
+ }
279
+
280
+ function checksumName(version) {
281
+ return `${tarballName(version)}.sha256`;
282
+ }
283
+
284
+ function newerVersion(candidate, installed) {
285
+ const left = candidate.split("-")[0].split(".").map(Number);
286
+ const right = installed.split("-")[0].split(".").map(Number);
287
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
288
+ const a = left[index] ?? 0;
289
+ const b = right[index] ?? 0;
290
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
291
+ if (a !== b) return a > b;
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function versionFromTarball(name) {
297
+ const match = new RegExp(`^(?:dsdenes-)?${RELEASE_PREFIX}(.+)\\.tgz$`).exec(name);
298
+ return match?.[1];
299
+ }
300
+
301
+ async function latestRelease(source) {
302
+ if (source.kind === "dir") return directoryRelease(source.directory);
303
+ return githubRelease(source.repo);
304
+ }
305
+
306
+ async function directoryRelease(directory) {
307
+ let names;
308
+ try {
309
+ names = await readdir(directory);
310
+ } catch (error) {
311
+ throw new LauncherError(
312
+ `release directory is unreadable: ${redact(error.message ?? String(error))}`,
313
+ );
314
+ }
315
+ const tarballs = names
316
+ .filter((name) => name.endsWith(".tgz") && versionFromTarball(name) !== undefined)
317
+ .map((name) => ({ name, version: versionFromTarball(name) }))
318
+ .sort((left, right) => (newerVersion(left.version, right.version) ? -1 : 1));
319
+ if (tarballs.length === 0) throw new LauncherError("no release tarball available");
320
+ const newest = tarballs[0];
321
+ const checksum = `${newest.name}.sha256`;
322
+ if (!existsSync(join(directory, checksum))) {
323
+ throw new LauncherError(`release checksum is missing: ${checksum}`);
324
+ }
325
+ return {
326
+ version: newest.version,
327
+ tag: `v${newest.version}`,
328
+ assets: [{ name: newest.name }, { name: checksum }],
329
+ };
330
+ }
331
+
332
+ function githubRelease(repo) {
333
+ const parsed = JSON.parse(mustRun("gh", ["api", `repos/${repo}/releases/latest`], "gh release"));
334
+ if (typeof parsed.tag_name !== "string") throw new LauncherError("the latest release has no tag");
335
+ const assets = Array.isArray(parsed.assets)
336
+ ? parsed.assets
337
+ .filter((asset) => asset !== null && typeof asset === "object" && typeof asset.name === "string")
338
+ .map((asset) => ({ name: asset.name, id: asset.id, size: asset.size }))
339
+ : [];
340
+ return { version: parsed.tag_name.replace(/^v/, ""), tag: parsed.tag_name, assets };
341
+ }
342
+
343
+ async function downloadRelease(source, release, destination) {
344
+ const tarball = join(destination, tarballName(release.version));
345
+ const checksum = join(destination, checksumName(release.version));
346
+ const names = new Set(release.assets.map((asset) => asset.name));
347
+ if (!names.has(basename(tarball)) || !names.has(basename(checksum))) {
348
+ throw new LauncherError(`release ${release.tag} lacks a tarball or checksum asset`);
349
+ }
350
+ await mkdir(destination, { recursive: true });
351
+ if (source.kind === "dir") {
352
+ await cp(join(source.directory, basename(tarball)), tarball);
353
+ await cp(join(source.directory, basename(checksum)), checksum);
354
+ return { tarball, checksum };
355
+ }
356
+ for (const name of [basename(tarball), basename(checksum)]) {
357
+ mustRun(
358
+ "gh",
359
+ ["release", "download", release.tag, "--repo", source.repo, "--pattern", name,
360
+ "--dir", destination, "--clobber"],
361
+ `gh release download ${name}`,
362
+ { timeoutMs: 10 * 60_000 },
363
+ );
364
+ }
365
+ return { tarball, checksum };
366
+ }
367
+
368
+ function parseChecksum(text) {
369
+ return /^([0-9a-fA-F]{64})\b/.exec(text.trim())?.[1]?.toLowerCase();
370
+ }
371
+
372
+ function fileDigest(path) {
373
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
374
+ }
375
+
376
+ function verifyChecksum(tarball, checksumFile) {
377
+ const expected = parseChecksum(readFileSync(checksumFile, "utf8"));
378
+ if (expected === undefined) throw new LauncherError(`unreadable checksum: ${basename(checksumFile)}`);
379
+ const actual = fileDigest(tarball);
380
+ if (actual !== expected) {
381
+ throw new LauncherError(
382
+ `checksum mismatch for ${basename(tarball)}: expected ${expected}, got ${actual}`,
383
+ );
384
+ }
385
+ }
386
+
387
+ function harnessDir(prefix) {
388
+ return join(prefix, "node_modules", HARNESS_SCOPE, "devboard-harness");
389
+ }
390
+
391
+ function installedVersion(prefix) {
392
+ try {
393
+ const parsed = JSON.parse(readFileSync(join(harnessDir(prefix), "package.json"), "utf8"));
394
+ return typeof parsed.version === "string" ? parsed.version : null;
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ async function writePrefixManifest(prefix, version) {
401
+ const path = join(prefix, "package.json");
402
+ let existing = {};
403
+ try {
404
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
405
+ if (parsed !== null && typeof parsed === "object") existing = parsed;
406
+ } catch {
407
+ existing = {};
408
+ }
409
+ const dependencies =
410
+ existing.dependencies !== null && typeof existing.dependencies === "object"
411
+ ? { ...existing.dependencies }
412
+ : {};
413
+ dependencies[HARNESS_PACKAGE] = version;
414
+ await writeFile(
415
+ path,
416
+ `${JSON.stringify(
417
+ {
418
+ ...existing,
419
+ name: typeof existing.name === "string" ? existing.name : "devboard-host",
420
+ private: true,
421
+ dependencies,
422
+ },
423
+ null,
424
+ 2,
425
+ )}\n`,
426
+ );
427
+ }
428
+
429
+ async function installTarball(prefix, tarball, version) {
430
+ const updates = join(prefix, ".tmp", "updates");
431
+ await mkdir(updates, { recursive: true });
432
+ const staging = join(updates, `stage-${version}-${randomUUID()}`);
433
+ await mkdir(staging, { recursive: true });
434
+ try {
435
+ mustRun(
436
+ "npm",
437
+ ["install", "--prefix", staging, "--ignore-scripts", "--no-audit", "--no-fund",
438
+ "--no-save", tarball],
439
+ "npm install",
440
+ { timeoutMs: 10 * 60_000 },
441
+ );
442
+ const staged = harnessDir(staging);
443
+ const stagedVersion = installedVersion(staging);
444
+ if (stagedVersion !== version) {
445
+ throw new LauncherError(
446
+ `npm installed ${stagedVersion ?? "no version"}, expected ${version}`,
447
+ );
448
+ }
449
+ const parent = join(prefix, "node_modules", HARNESS_SCOPE);
450
+ await mkdir(parent, { recursive: true });
451
+ const target = join(parent, "devboard-harness");
452
+ const backup = join(updates, `previous-${Date.now()}-${randomUUID()}`);
453
+ let moved = false;
454
+ if (existsSync(target)) {
455
+ await rename(target, backup);
456
+ moved = true;
457
+ }
458
+ try {
459
+ await rename(staged, target);
460
+ } catch (error) {
461
+ if (moved) await rename(backup, target).catch(() => undefined);
462
+ throw error;
463
+ }
464
+ await rm(backup, { recursive: true, force: true });
465
+ return target;
466
+ } finally {
467
+ await rm(staging, { recursive: true, force: true });
468
+ }
469
+ }
470
+
471
+ async function installRelease(source, release, prefix, stateDir) {
472
+ const downloadDir = join(stateDir, "updates", `download-${randomUUID()}`);
473
+ try {
474
+ const files = await downloadRelease(source, release, downloadDir);
475
+ verifyChecksum(files.tarball, files.checksum);
476
+ const target = await installTarball(prefix, files.tarball, release.version);
477
+ await writePrefixManifest(prefix, release.version);
478
+ return target;
479
+ } finally {
480
+ await rm(downloadDir, { recursive: true, force: true });
481
+ }
482
+ }
483
+
484
+ function journalKey(repoDir) {
485
+ return createHash("sha256").update(repoDir).digest("hex").slice(0, 24);
486
+ }
487
+
488
+ async function captureTemporaryState(prefix, host) {
489
+ const captured = [];
490
+ const stateDir = join(prefix, ".tmp");
491
+ const state = join(stateDir, "state");
492
+ if (existsSync(state)) {
493
+ for (const name of await readdir(state)) {
494
+ const path = join(state, name);
495
+ if (existsSync(path)) captured.push({ path: join("state", name), data: await readFile(path) });
496
+ }
497
+ }
498
+ const journal = join(stateDir, "journal");
499
+ const key = journalKey(resolve(host));
500
+ if (existsSync(journal)) {
501
+ for (const name of await readdir(journal)) {
502
+ if (!name.startsWith(key)) continue;
503
+ const path = join(journal, name);
504
+ if (existsSync(path)) captured.push({ path: join("journal", name), data: await readFile(path) });
505
+ }
506
+ }
507
+ const install = join(stateDir, "install.json");
508
+ if (existsSync(install)) captured.push({ path: "install.json", data: await readFile(install) });
509
+ return captured;
510
+ }
511
+
512
+ async function restoreTemporaryState(prefix, captured) {
513
+ for (const entry of captured) {
514
+ const path = join(prefix, ".tmp", entry.path);
515
+ await mkdir(resolve(path, ".."), { recursive: true });
516
+ await writeFile(path, entry.data);
517
+ }
518
+ }
519
+
520
+ async function replacePathWithDirectory(path) {
521
+ const info = lstatOrUndefined(path);
522
+ if (info === undefined) {
523
+ await mkdir(path, { recursive: true });
524
+ return;
525
+ }
526
+ if (info.isDirectory()) return;
527
+ if (info.isSymbolicLink()) {
528
+ await unlink(path);
529
+ await mkdir(path, { recursive: true });
530
+ return;
531
+ }
532
+ throw new LauncherError(`${path} exists and is not a directory`);
533
+ }
534
+
535
+ async function ensureFixedDirectory(path) {
536
+ const info = lstatOrUndefined(path);
537
+ if (info === undefined) {
538
+ await mkdir(path, { recursive: true });
539
+ return;
540
+ }
541
+ if (info.isDirectory()) return;
542
+ if (info.isSymbolicLink()) {
543
+ await unlink(path);
544
+ await mkdir(path, { recursive: true });
545
+ return;
546
+ }
547
+ throw new LauncherError(`${path} exists and is not a directory`);
548
+ }
549
+
550
+ function trackedPaths(host, paths) {
551
+ const result = run("git", ["-C", host, "ls-files", "-z", "--", ...paths], { cwd: host });
552
+ if (result.code !== 0) return new Set();
553
+ return new Set(
554
+ result.stdout
555
+ .split("\0")
556
+ .filter((path) => path.length > 0)
557
+ .map((path) => resolve(host, path)),
558
+ );
559
+ }
560
+
561
+ async function syncTree(source, destination, skip) {
562
+ await ensureFixedDirectory(destination);
563
+ const present = new Set();
564
+ for (const entry of await readdir(source, { withFileTypes: true })) {
565
+ present.add(entry.name);
566
+ const from = join(source, entry.name);
567
+ const to = join(destination, entry.name);
568
+ if (skip?.has(to) ?? false) continue;
569
+ if (entry.isDirectory()) {
570
+ await syncTree(from, to, skip);
571
+ continue;
572
+ }
573
+ const target = lstatOrUndefined(to);
574
+ if (target?.isDirectory()) await rm(to, { recursive: true, force: true });
575
+ else if (target?.isSymbolicLink()) await unlink(to);
576
+ await cp(from, to);
577
+ }
578
+ for (const name of await readdir(destination)) {
579
+ const to = join(destination, name);
580
+ if (present.has(name)) continue;
581
+ if (skip?.has(to) ?? false) continue;
582
+ await rm(to, { recursive: true, force: true });
583
+ }
584
+ }
585
+
586
+ async function syncWrappers(host, harness, skip) {
587
+ for (const name of ["commands", "extensions"]) {
588
+ await syncTree(join(harness, "omp", name), join(host, ".omp", name), skip);
589
+ }
590
+ }
591
+
592
+ async function syncAgents(host, harness, skip) {
593
+ const source = join(harness, "templates", "host-AGENTS.md");
594
+ const target = join(host, "AGENTS.md");
595
+ const info = lstatOrUndefined(target);
596
+ if (info === undefined) {
597
+ if (skip?.has(target) ?? false) return "tracked-missing";
598
+ await cp(source, target);
599
+ return "installed";
600
+ }
601
+ if (info.isSymbolicLink()) {
602
+ await unlink(target);
603
+ await cp(source, target);
604
+ return "installed";
605
+ }
606
+ if (!info.isFile()) return "unchanged";
607
+ const current = readFileSync(target);
608
+ const template = readFileSync(source);
609
+ return current.equals(template) ? "current" : "drift";
610
+ }
611
+
612
+ async function ensureGitignore(host) {
613
+ const path = join(host, ".gitignore");
614
+ let source = "";
615
+ try {
616
+ source = readFileSync(path, "utf8");
617
+ } catch {
618
+ source = "";
619
+ }
620
+ const lines = source.split("\n");
621
+ const wanted = ["/.devboard", "/.omp/commands", "/.omp/extensions"];
622
+ const missing = wanted.filter((entry) => !lines.includes(entry));
623
+ if (missing.length === 0) return [];
624
+ const prefix = source.length > 0 && !source.endsWith("\n") ? `${source}\n` : source;
625
+ await writeFile(path, `${prefix}${missing.map((entry) => `${entry}\n`).join("")}`);
626
+ return missing;
627
+ }
628
+
629
+ function findBinary(name) {
630
+ const path = process.env.PATH ?? "";
631
+ for (const directory of path.split(":")) {
632
+ if (directory.length === 0) continue;
633
+ const candidate = join(directory, name);
634
+ if (existsSync(candidate)) return candidate;
635
+ }
636
+ return undefined;
637
+ }
638
+
639
+ async function startDashboard(host, harness, port, stateDir) {
640
+ const bun = findBinary("bun");
641
+ if (bun === undefined) throw new LauncherError("bun is required to start the dashboard");
642
+ const log = join(stateDir, "dashboard.log");
643
+ await mkdir(stateDir, { recursive: true });
644
+ const fd = openSync(log, "a");
645
+ const child = spawn(bun, [join(harness, "server", "index.ts")], {
646
+ cwd: harness,
647
+ detached: true,
648
+ stdio: ["ignore", fd, fd],
649
+ env: {
650
+ ...process.env,
651
+ HOST: process.env.HOST ?? "127.0.0.1",
652
+ PORT: String(port),
653
+ ADRS_DIR: join(host, "adrs"),
654
+ REPO_DIR: host,
655
+ GOAL_FILE: join(host, "GOAL.md"),
656
+ STATIC_DIR: join(harness, "dist"),
657
+ STATE_DIR: stateDir,
658
+ AGENT_COMMAND: process.env.AGENT_COMMAND ?? "omp acp",
659
+ },
660
+ });
661
+ closeSync(fd);
662
+ child.unref();
663
+ if (!(await waitForHttp(port))) {
664
+ let tail = "";
665
+ try {
666
+ tail = readFileSync(log, "utf8").split("\n").slice(-8).join("\n");
667
+ } catch {
668
+ tail = "";
669
+ }
670
+ throw new LauncherError(
671
+ `the dashboard did not answer on port ${port}${tail.length > 0 ? `: ${firstLine(tail)}` : ""}`,
672
+ );
673
+ }
674
+ return child.pid;
675
+ }
676
+
677
+ function installStatePath(prefix) {
678
+ return join(prefix, ".tmp", "install.json");
679
+ }
680
+
681
+ function readInstallState(prefix) {
682
+ try {
683
+ const parsed = JSON.parse(readFileSync(installStatePath(prefix), "utf8"));
684
+ return parsed !== null && typeof parsed === "object" ? parsed : {};
685
+ } catch {
686
+ return {};
687
+ }
688
+ }
689
+
690
+ async function writeInstallState(prefix, state) {
691
+ await mkdir(join(prefix, ".tmp"), { recursive: true });
692
+ await writeFile(installStatePath(prefix), `${JSON.stringify(state, null, 2)}\n`);
693
+ }
694
+
695
+ function isDashboardFrom(pid, harness) {
696
+ const entry = processInfo(pid);
697
+ if (entry === undefined) return false;
698
+ const script = join(harness, "server", "index.ts");
699
+ return entry.cmdline.includes(script);
700
+ }
701
+
702
+ function processInfo(pid) {
703
+ try {
704
+ return {
705
+ cmdline: readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim(),
706
+ env: readEnviron(pid),
707
+ };
708
+ } catch {
709
+ return undefined;
710
+ }
711
+ }
712
+
713
+ function canonicalSource(host) {
714
+ const prefix = join(host, ".devboard");
715
+ const info = lstatOrUndefined(prefix);
716
+ if (info === undefined || info.isSymbolicLink()) return false;
717
+ try {
718
+ const parsed = JSON.parse(readFileSync(join(prefix, "package.json"), "utf8"));
719
+ if (parsed.name !== HARNESS_PACKAGE) return false;
720
+ } catch {
721
+ return false;
722
+ }
723
+ const tracked = run("git", ["-C", host, "ls-files", "--error-unmatch", ".devboard/package.json"], {
724
+ cwd: host,
725
+ });
726
+ return tracked.code === 0;
727
+ }
728
+
729
+ async function ensureOneDashboard(host, harness, version, requestedPort) {
730
+ const prefix = join(host, ".devboard");
731
+ const stateDir = join(prefix, ".tmp");
732
+ const state = readInstallState(prefix);
733
+ const dashboards = readDashboardProcesses(host);
734
+ if (dashboards.length > 0) {
735
+ const kept = dashboards.filter(
736
+ (dashboard) =>
737
+ dashboard.pid === state.pid &&
738
+ processRunning(dashboard.pid) &&
739
+ dashboard.port !== undefined &&
740
+ isDashboardFrom(dashboard.pid, harness),
741
+ );
742
+ for (const dashboard of dashboards) {
743
+ if (kept.includes(dashboard)) continue;
744
+ try {
745
+ process.kill(dashboard.pid, "SIGTERM");
746
+ } catch {
747
+ // Already gone.
748
+ }
749
+ }
750
+ if (
751
+ kept.length === 1 &&
752
+ state.version === version &&
753
+ kept[0].port !== undefined &&
754
+ (await httpOk(kept[0].port))
755
+ ) {
756
+ await writeInstallState(prefix, {
757
+ ...state,
758
+ host,
759
+ version,
760
+ harness,
761
+ port: kept[0].port,
762
+ pid: kept[0].pid,
763
+ url: `http://127.0.0.1:${kept[0].port}/`,
764
+ stateDir,
765
+ });
766
+ return { port: kept[0].port, pid: kept[0].pid, reused: true };
767
+ }
768
+ await stopDashboardProcesses(host);
769
+ }
770
+ const preferred = Number.isInteger(requestedPort)
771
+ ? requestedPort
772
+ : Number.isInteger(state.port)
773
+ ? state.port
774
+ : undefined;
775
+ const port = await choosePort(preferred);
776
+ const pid = await startDashboard(host, harness, port, stateDir);
777
+ await writeInstallState(prefix, {
778
+ version,
779
+ host,
780
+ harness,
781
+ port,
782
+ pid,
783
+ url: `http://127.0.0.1:${port}/`,
784
+ stateDir,
785
+ installedAt: new Date().toISOString(),
786
+ });
787
+ const remaining = readDashboardProcesses(host);
788
+ if (remaining.length !== 1 || remaining[0].pid !== pid) {
789
+ await stopDashboardProcesses(host);
790
+ throw new LauncherError(`expected one dashboard process, found ${remaining.length}`);
791
+ }
792
+ return { port, pid, reused: false };
793
+ }
794
+
795
+ async function installCommand(options) {
796
+ const host = resolveHost(options.host);
797
+ if (canonicalSource(host)) {
798
+ throw new LauncherError(
799
+ `${host} is the canonical harness source checkout; install runs in a host project instead`,
800
+ );
801
+ }
802
+ const source = releaseSourceFromEnv();
803
+ const release = await latestRelease(source);
804
+ const prefix = join(host, ".devboard");
805
+ const skip = trackedPaths(host, [".omp/commands", ".omp/extensions", "AGENTS.md"]);
806
+ const trackedHarness = [...trackedPaths(host, [".devboard"])].filter(
807
+ (path) => path === prefix || path.startsWith(`${prefix}${sep}`),
808
+ );
809
+ if (trackedHarness.length > 0) {
810
+ throw new LauncherError(
811
+ `${host} tracks files under .devboard; refusing to replace a tracked harness tree`,
812
+ );
813
+ }
814
+ const captured = (lstatOrUndefined(prefix)?.isSymbolicLink() ?? false)
815
+ ? await captureTemporaryState(prefix, host)
816
+ : [];
817
+ await replacePathWithDirectory(prefix);
818
+ if (captured.length > 0) await restoreTemporaryState(prefix, captured);
819
+ await ensureFixedDirectory(join(prefix, ".tmp"));
820
+ const harness = await installRelease(source, release, prefix, join(prefix, ".tmp"));
821
+ await syncWrappers(host, harness, skip);
822
+ const agents = await syncAgents(host, harness, skip);
823
+ const ignored = await ensureGitignore(host);
824
+ const portOption = Number(options.port);
825
+ const dashboard = await ensureOneDashboard(
826
+ host,
827
+ harness,
828
+ release.version,
829
+ Number.isInteger(portOption) ? portOption : undefined,
830
+ );
831
+ console.log(`devboard: installed ${HARNESS_PACKAGE}@${release.version} in ${prefix}`);
832
+ if (agents === "drift") {
833
+ console.log("devboard: AGENTS.md differs from the harness template; left unchanged (drift)");
834
+ } else if (agents === "installed") {
835
+ console.log("devboard: installed AGENTS.md from the harness template");
836
+ } else if (agents === "tracked-missing") {
837
+ console.log("devboard: AGENTS.md is tracked and deleted; left absent (drift)");
838
+ }
839
+ if (ignored.length > 0) console.log(`devboard: added ${ignored.join(", ")} to .gitignore`);
840
+ console.log(
841
+ `devboard: dashboard ${dashboard.reused ? "already running" : "running"} at ` +
842
+ `http://127.0.0.1:${dashboard.port}/ (pid ${dashboard.pid})`,
843
+ );
844
+ }
845
+
846
+ async function updateCommand(options) {
847
+ const host = resolveHost(options.host);
848
+ const prefix = join(host, ".devboard");
849
+ if (lstatOrUndefined(prefix)?.isSymbolicLink() ?? false) {
850
+ throw new LauncherError(`${prefix} is a symlink; run install first`);
851
+ }
852
+ const before = installedVersion(prefix);
853
+ if (before === null) {
854
+ throw new LauncherError(`no harness is installed in ${prefix}; run install`);
855
+ }
856
+ const source = releaseSourceFromEnv();
857
+ const release = await latestRelease(source);
858
+ if (!newerVersion(release.version, before) && release.version !== before) {
859
+ // An older latest release is still installed as asked, never downgraded.
860
+ console.log(`devboard: installed ${before}; latest release is ${release.version}`);
861
+ return;
862
+ }
863
+ const harness = await installRelease(source, release, prefix, join(prefix, ".tmp"));
864
+ const skip = trackedPaths(host, [".omp/commands", ".omp/extensions"]);
865
+ await syncWrappers(host, harness, skip);
866
+ console.log(`devboard: harness ${before} -> ${release.version}`);
867
+ console.log("devboard: the dashboard keeps running; reload it to restart on the new version");
868
+ }
869
+
870
+ function recursiveFiles(directory, prefix = "") {
871
+ const files = [];
872
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
873
+ const name = join(prefix, entry.name);
874
+ if (entry.isDirectory()) files.push(...recursiveFiles(join(directory, entry.name), name));
875
+ else files.push(name);
876
+ }
877
+ return files.sort();
878
+ }
879
+
880
+ function wrapperMismatches(host, harness) {
881
+ const failures = [];
882
+ const drift = [];
883
+ const tracked = trackedPaths(host, [".omp/commands", ".omp/extensions"]);
884
+ for (const name of ["commands", "extensions"]) {
885
+ const expectedRoot = join(harness, "omp", name);
886
+ const actualRoot = join(host, ".omp", name);
887
+ let expected;
888
+ let actual;
889
+ try {
890
+ expected = recursiveFiles(expectedRoot);
891
+ actual = recursiveFiles(actualRoot);
892
+ } catch {
893
+ failures.push(`.omp/${name} is missing`);
894
+ continue;
895
+ }
896
+ const expectedSet = new Set(expected);
897
+ const actualSet = new Set(actual);
898
+ for (const path of expected) {
899
+ const target = join(actualRoot, path);
900
+ if (actualSet.has(path) && readFileSync(join(expectedRoot, path)).equals(readFileSync(target))) {
901
+ continue;
902
+ }
903
+ const label = `.omp/${name}/${path}`;
904
+ if (tracked.has(target)) drift.push(label);
905
+ else failures.push(label);
906
+ }
907
+ for (const path of actual) {
908
+ if (expectedSet.has(path)) continue;
909
+ const target = join(actualRoot, path);
910
+ const label = `.omp/${name}/${path}`;
911
+ if (tracked.has(target)) drift.push(label);
912
+ else failures.push(label);
913
+ }
914
+ }
915
+ return { failures, drift };
916
+ }
917
+
918
+ function readJournal(prefix, host) {
919
+ const path = join(prefix, ".tmp", "journal", `${journalKey(resolve(host))}.jsonl`);
920
+ try {
921
+ return readFileSync(path, "utf8")
922
+ .split("\n")
923
+ .filter((line) => line.trim().length > 0)
924
+ .flatMap((line) => {
925
+ try {
926
+ const record = JSON.parse(line);
927
+ return record !== null && typeof record === "object" ? [record] : [];
928
+ } catch {
929
+ return [];
930
+ }
931
+ });
932
+ } catch {
933
+ return [];
934
+ }
935
+ }
936
+
937
+ function lastUpdateFailure(prefix, host) {
938
+ const records = readJournal(prefix, host).filter(
939
+ (record) => record.kind === "update" || record.kind === "update-error",
940
+ );
941
+ const last = records.at(-1);
942
+ if (last === undefined || last.kind !== "update-error") return undefined;
943
+ return typeof last.text === "string" ? last.text : "update failed";
944
+ }
945
+
946
+ async function readReleaseDigest(source, release, prefix) {
947
+ await mkdir(join(prefix, ".tmp"), { recursive: true });
948
+ const scratch = join(prefix, ".tmp", `release-check-${randomUUID()}`);
949
+ try {
950
+ const files = await downloadRelease(source, release, scratch);
951
+ verifyChecksum(files.tarball, files.checksum);
952
+ return true;
953
+ } finally {
954
+ await rm(scratch, { recursive: true, force: true });
955
+ }
956
+ }
957
+
958
+ const CHECKS = [
959
+ {
960
+ name: "node 20+",
961
+ run: () => {
962
+ const major = Number(process.versions.node.split(".")[0]);
963
+ if (major < 20) throw new LauncherError(`Node ${process.versions.node} is older than 20`);
964
+ return `Node ${process.versions.node}`;
965
+ },
966
+ },
967
+ {
968
+ name: "bun",
969
+ run: () => firstLine(mustRun("bun", ["--version"], "bun --version", { timeoutMs: 15_000 })),
970
+ },
971
+ {
972
+ name: "npm",
973
+ run: () => firstLine(mustRun("npm", ["--version"], "npm --version", { timeoutMs: 15_000 })),
974
+ },
975
+ {
976
+ name: "git root",
977
+ run: async (host) => {
978
+ const top = await realOrUndefined(resolveHost(host));
979
+ const root = run("git", ["-C", host, "rev-parse", "--show-toplevel"], { cwd: host });
980
+ if (root.code !== 0 || root.stdout.trim().length === 0) {
981
+ throw new LauncherError("the host is not a git work tree");
982
+ }
983
+ const found = await realOrUndefined(root.stdout.trim());
984
+ if (top !== undefined && found !== undefined && top !== found) {
985
+ throw new LauncherError(`git root is ${found}, host is ${top}`);
986
+ }
987
+ return found ?? root.stdout.trim();
988
+ },
989
+ },
990
+ {
991
+ name: "gh auth",
992
+ run: () => {
993
+ mustRun("gh", ["auth", "status"], "gh auth status", { timeoutMs: 30_000 });
994
+ mustRun("gh", ["api", `repos/${RELEASE_REPO}`], "gh repository access", {
995
+ timeoutMs: 30_000,
996
+ });
997
+ return `can read ${RELEASE_REPO}`;
998
+ },
999
+ },
1000
+ {
1001
+ name: "release",
1002
+ run: async (_host, prefix) => {
1003
+ const source = releaseSourceFromEnv();
1004
+ const release = await latestRelease(source);
1005
+ await readReleaseDigest(source, release, prefix);
1006
+ return `${release.tag} with a matching digest`;
1007
+ },
1008
+ },
1009
+ {
1010
+ name: "harness install",
1011
+ run: async (_host, prefix) => {
1012
+ const version = installedVersion(prefix);
1013
+ if (version === null) {
1014
+ throw new LauncherError(`${harnessDir(prefix)} is missing; run install`);
1015
+ }
1016
+ return `${HARNESS_PACKAGE}@${version}`;
1017
+ },
1018
+ },
1019
+ {
1020
+ name: "version",
1021
+ run: async (_host, prefix) => {
1022
+ const version = installedVersion(prefix);
1023
+ if (version === null) throw new LauncherError("no installed version");
1024
+ const release = await latestRelease(releaseSourceFromEnv());
1025
+ if (version !== release.version) {
1026
+ throw new LauncherError(`stale install: installed ${version}, latest ${release.version}`);
1027
+ }
1028
+ return `${version} matches the latest release`;
1029
+ },
1030
+ },
1031
+ {
1032
+ name: "omp",
1033
+ run: () => firstLine(mustRun("omp", ["--version"], "omp --version", { timeoutMs: 15_000 })),
1034
+ },
1035
+ {
1036
+ name: "omp auth",
1037
+ run: () => {
1038
+ mustRun("omp", ["usage"], "omp usage", { timeoutMs: 30_000 });
1039
+ return "authenticated probe succeeded";
1040
+ },
1041
+ },
1042
+ {
1043
+ name: "wrappers",
1044
+ run: async (host, prefix) => {
1045
+ const { failures, drift } = wrapperMismatches(host, harnessDir(prefix));
1046
+ if (failures.length > 0) {
1047
+ throw new LauncherError(`does not match the installed package: ${failures.join(", ")}`);
1048
+ }
1049
+ if (drift.length > 0) {
1050
+ return `drift: preserved tracked paths (${drift.join(", ")})`;
1051
+ }
1052
+ return "commands and extensions match the package";
1053
+ },
1054
+ },
1055
+ {
1056
+ name: "AGENTS.md",
1057
+ run: async (host, prefix) => {
1058
+ const target = join(host, "AGENTS.md");
1059
+ const info = lstatOrUndefined(target);
1060
+ if (info === undefined) throw new LauncherError("AGENTS.md is missing; run install");
1061
+ if (info.isSymbolicLink()) throw new LauncherError("AGENTS.md is a symlink; run install");
1062
+ const template = join(harnessDir(prefix), "templates", "host-AGENTS.md");
1063
+ if (!readFileSync(target).equals(readFileSync(template))) {
1064
+ return "drift: AGENTS.md differs from the template (left unchanged)";
1065
+ }
1066
+ return "current";
1067
+ },
1068
+ },
1069
+ {
1070
+ name: "dashboard",
1071
+ run: async (host) => {
1072
+ const dashboards = readDashboardProcesses(host);
1073
+ if (dashboards.length === 0) throw new LauncherError("no dashboard runs for this host");
1074
+ if (dashboards.length > 1) {
1075
+ throw new LauncherError(`${dashboards.length} dashboards run for this host`);
1076
+ }
1077
+ const [dashboard] = dashboards;
1078
+ const harness = harnessDir(join(host, ".devboard"));
1079
+ if (!isDashboardFrom(dashboard.pid, harness)) {
1080
+ throw new LauncherError(`pid ${dashboard.pid} does not run the installed package`);
1081
+ }
1082
+ if (dashboard.port === undefined || !(await httpOk(dashboard.port))) {
1083
+ throw new LauncherError("the dashboard does not answer HTTP 200");
1084
+ }
1085
+ return `pid ${dashboard.pid} on http://127.0.0.1:${dashboard.port}/`;
1086
+ },
1087
+ },
1088
+ {
1089
+ name: "no symlink",
1090
+ run: (host) => {
1091
+ const prefix = join(host, ".devboard");
1092
+ const info = lstatOrUndefined(prefix);
1093
+ if (info === undefined) throw new LauncherError(`${prefix} is missing`);
1094
+ if (info.isSymbolicLink()) throw new LauncherError(`${prefix} is a symlink`);
1095
+ for (const name of ["commands", "extensions"]) {
1096
+ const path = join(host, ".omp", name);
1097
+ if (lstatOrUndefined(path)?.isSymbolicLink() ?? false) {
1098
+ throw new LauncherError(`${path} is a symlink`);
1099
+ }
1100
+ }
1101
+ return "the harness paths are real";
1102
+ },
1103
+ },
1104
+ {
1105
+ name: "updates",
1106
+ run: (host, prefix) => {
1107
+ const failure = lastUpdateFailure(prefix, host);
1108
+ if (failure !== undefined) throw new LauncherError(failure);
1109
+ return "no recorded update failure";
1110
+ },
1111
+ },
1112
+ ];
1113
+
1114
+ async function doctorCommand(options) {
1115
+ const host = resolveHost(options.host);
1116
+ const prefix = join(host, ".devboard");
1117
+ let failures = 0;
1118
+ for (const check of CHECKS) {
1119
+ try {
1120
+ const detail = await check.run(host, prefix);
1121
+ console.log(`PASS ${check.name}: ${detail}`);
1122
+ } catch (error) {
1123
+ failures += 1;
1124
+ const detail = error instanceof Error ? error.message : String(error);
1125
+ console.log(`FAIL ${check.name}: ${firstLine(detail)}`);
1126
+ }
1127
+ }
1128
+ return failures === 0 ? 0 : 1;
1129
+ }
1130
+
1131
+ async function main() {
1132
+ let options;
1133
+ try {
1134
+ options = parseArgs(process.argv.slice(2));
1135
+ } catch (error) {
1136
+ console.error(`devboard: ${error instanceof Error ? error.message : String(error)}`);
1137
+ return 2;
1138
+ }
1139
+ if (options.help || options.command === undefined) {
1140
+ console.log(usage);
1141
+ return options.help ? 0 : 2;
1142
+ }
1143
+ if (options.command === "install") await installCommand(options);
1144
+ else if (options.command === "update") await updateCommand(options);
1145
+ else if (options.command === "doctor") return doctorCommand(options);
1146
+ else {
1147
+ console.error(`devboard: unknown command: ${options.command}`);
1148
+ console.error(usage);
1149
+ return 2;
1150
+ }
1151
+ return 0;
1152
+ }
1153
+
1154
+ if (process.argv.includes("--version")) {
1155
+ console.log(ownVersion());
1156
+ process.exit(0);
1157
+ }
1158
+
1159
+ main()
1160
+ .then((code) => process.exit(code))
1161
+ .catch((error) => {
1162
+ console.error(`devboard: ${firstLine(error instanceof Error ? error.message : String(error))}`);
1163
+ process.exit(1);
1164
+ });
package/package.json CHANGED
@@ -1,6 +1,18 @@
1
1
  {
2
2
  "name": "@dsdenes/devboard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.147",
4
+ "description": "Launcher for the private Devboard harness: install, update, and doctor.",
5
+ "type": "module",
6
+ "bin": {
7
+ "devboard": "bin/devboard.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "files": [
13
+ "package.json",
14
+ "bin/",
15
+ "README.md"
16
+ ],
17
+ "license": "UNLICENSED"
18
+ }