@beastmode-develeap/beastmode 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4583,6 +4583,47 @@ function proxyToBoard(boardUrl, method, path, body, query) {
4583
4583
  req.end();
4584
4584
  });
4585
4585
  }
4586
+ function proxyBinaryToBoard(boardUrl, path, query) {
4587
+ return new Promise((resolve20, reject) => {
4588
+ const url = new URL(path, boardUrl);
4589
+ if (query) {
4590
+ for (const [k, v] of Object.entries(query)) {
4591
+ if (v !== void 0 && v !== null && v !== "") {
4592
+ url.searchParams.set(k, v);
4593
+ }
4594
+ }
4595
+ }
4596
+ const req = http2.request(
4597
+ url,
4598
+ { method: "GET" },
4599
+ (res) => {
4600
+ if (res.statusCode && res.statusCode >= 400) {
4601
+ reject(
4602
+ new Error(
4603
+ `Board proxy error: HTTP ${res.statusCode} for ${path}`
4604
+ )
4605
+ );
4606
+ return;
4607
+ }
4608
+ const chunks = [];
4609
+ res.on("data", (chunk) => chunks.push(chunk));
4610
+ res.on("end", () => {
4611
+ const body = Buffer.concat(chunks);
4612
+ const contentType = res.headers["content-type"] || "application/octet-stream";
4613
+ const cd = res.headers["content-disposition"] || "";
4614
+ const match = /filename="([^"]+)"/.exec(cd);
4615
+ const filename = match ? match[1] : void 0;
4616
+ resolve20(new BinaryResponse(body, contentType, filename));
4617
+ });
4618
+ }
4619
+ );
4620
+ req.on(
4621
+ "error",
4622
+ (err) => reject(new Error(`Board proxy error: ${err.message}`))
4623
+ );
4624
+ req.end();
4625
+ });
4626
+ }
4586
4627
  function scopedQuery(query) {
4587
4628
  if (!query) return void 0;
4588
4629
  const b = query.board;
@@ -4974,6 +5015,40 @@ function getBoardRoutes(factoryDir) {
4974
5015
  return proxyToBoard(boardUrl, "POST", `/api/items/${params.id}/updates`, body, scopedQuery(query));
4975
5016
  }
4976
5017
  },
5018
+ // ── Attachments (Gap 8a — 2026-04-15) ──
5019
+ // The board service stores per-item attachments (screenshots from
5020
+ // verify_production.py, uploaded files from the "new task"
5021
+ // dialog, etc.) at /app/data/attachments/<project>/<item>/<id>_<name>.
5022
+ // Before this proxy existed, the UI had no way to list or fetch
5023
+ // them, which meant Story 4's Done Evidence message said "2
5024
+ // screenshots attached" but the UI showed nothing. See
5025
+ // docs/zero-to-productive-readiness.md Gap 8a.
5026
+ {
5027
+ method: "GET",
5028
+ pattern: "/api/board/items/:id/attachments",
5029
+ handler: async (_body, params, query) => {
5030
+ const boardUrl = getBoardUrl2(factoryDir);
5031
+ return proxyToBoard(
5032
+ boardUrl,
5033
+ "GET",
5034
+ `/api/items/${params.id}/attachments`,
5035
+ void 0,
5036
+ scopedQuery(query)
5037
+ );
5038
+ }
5039
+ },
5040
+ {
5041
+ method: "GET",
5042
+ pattern: "/api/board/attachments/:id/download",
5043
+ handler: async (_body, params, query) => {
5044
+ const boardUrl = getBoardUrl2(factoryDir);
5045
+ return proxyBinaryToBoard(
5046
+ boardUrl,
5047
+ `/api/attachments/${params.id}/download`,
5048
+ scopedQuery(query)
5049
+ );
5050
+ }
5051
+ },
4977
5052
  // ── Replies ──
4978
5053
  {
4979
5054
  method: "GET",
@@ -6103,13 +6178,20 @@ function matchBoardRoute(routes, method, url) {
6103
6178
  }
6104
6179
  return null;
6105
6180
  }
6106
- var _TERMINAL_STAGES;
6181
+ var BinaryResponse, _TERMINAL_STAGES;
6107
6182
  var init_board_api_routes = __esm({
6108
6183
  "src/cli/ui/board-api-routes.ts"() {
6109
6184
  "use strict";
6110
6185
  init_archival();
6111
6186
  init_chat_handler();
6112
6187
  init_engine();
6188
+ BinaryResponse = class {
6189
+ constructor(body, contentType, filename) {
6190
+ this.body = body;
6191
+ this.contentType = contentType;
6192
+ this.filename = filename;
6193
+ }
6194
+ };
6113
6195
  _TERMINAL_STAGES = /* @__PURE__ */ new Set([
6114
6196
  "done",
6115
6197
  "shipped",
@@ -6350,7 +6432,19 @@ async function startServer(options = {}) {
6350
6432
  body = await parseBody(req);
6351
6433
  }
6352
6434
  const result = await boardMatch.route.handler(body, boardMatch.params, query);
6353
- sendJson(res, 200, result);
6435
+ if (result instanceof BinaryResponse) {
6436
+ const headers = {
6437
+ "Content-Type": result.contentType,
6438
+ "Content-Length": result.body.length
6439
+ };
6440
+ if (result.filename) {
6441
+ headers["Content-Disposition"] = `inline; filename="${result.filename.replace(/"/g, "")}"`;
6442
+ }
6443
+ res.writeHead(200, headers);
6444
+ res.end(result.body);
6445
+ } else {
6446
+ sendJson(res, 200, result);
6447
+ }
6354
6448
  } catch (err) {
6355
6449
  const message = err instanceof Error ? err.message : "Internal server error";
6356
6450
  sendJson(res, 500, { error: message });
@@ -8647,6 +8741,301 @@ function checkDocker() {
8647
8741
  fix: "Install Docker Desktop or start the Docker daemon"
8648
8742
  };
8649
8743
  }
8744
+ function checkDockerComposeVersion() {
8745
+ const v2 = tryExec("docker compose version --short 2>/dev/null");
8746
+ if (v2) {
8747
+ const major = parseInt(v2.split(".")[0], 10);
8748
+ if (!isNaN(major) && major >= 2) {
8749
+ return {
8750
+ label: "Docker Compose",
8751
+ status: "pass",
8752
+ detail: `v${v2} (plugin)`
8753
+ };
8754
+ }
8755
+ return {
8756
+ label: "Docker Compose",
8757
+ status: "fail",
8758
+ detail: `v${v2} \u2014 need v2.0 or newer`,
8759
+ fix: "Update Docker Desktop to the latest version"
8760
+ };
8761
+ }
8762
+ const v1 = tryExec("docker-compose version --short 2>/dev/null");
8763
+ if (v1) {
8764
+ return {
8765
+ label: "Docker Compose",
8766
+ status: "fail",
8767
+ detail: `legacy v${v1} \u2014 beastmode requires v2.0+ (plugin form)`,
8768
+ fix: "Install Docker Compose v2 (bundled with modern Docker Desktop)"
8769
+ };
8770
+ }
8771
+ return {
8772
+ label: "Docker Compose",
8773
+ status: "fail",
8774
+ detail: "not installed",
8775
+ fix: "Install Docker Compose v2 (bundled with Docker Desktop)"
8776
+ };
8777
+ }
8778
+ function checkProjectDirEnv(factoryDir) {
8779
+ if (!factoryDir) {
8780
+ return {
8781
+ label: "PROJECT_DIR (.env)",
8782
+ status: "warn",
8783
+ detail: "no factory in scope"
8784
+ };
8785
+ }
8786
+ const envPath = join22(factoryDir, ".env");
8787
+ if (!existsSync24(envPath)) {
8788
+ return {
8789
+ label: "PROJECT_DIR (.env)",
8790
+ status: "fail",
8791
+ detail: `.env not found at ${envPath}`,
8792
+ fix: "Run `beastmode init` in this directory to generate .env"
8793
+ };
8794
+ }
8795
+ let content;
8796
+ try {
8797
+ content = readFileSync21(envPath, "utf-8");
8798
+ } catch {
8799
+ return {
8800
+ label: "PROJECT_DIR (.env)",
8801
+ status: "fail",
8802
+ detail: ".env unreadable"
8803
+ };
8804
+ }
8805
+ const lines = content.split("\n");
8806
+ const line = lines.find(
8807
+ (l) => l.trim().startsWith("PROJECT_DIR=") && !l.trim().startsWith("#")
8808
+ );
8809
+ if (!line) {
8810
+ return {
8811
+ label: "PROJECT_DIR (.env)",
8812
+ status: "fail",
8813
+ detail: "PROJECT_DIR not set (or commented out)",
8814
+ fix: "Re-run `beastmode init --project <path>` to populate it"
8815
+ };
8816
+ }
8817
+ const value = line.split("=").slice(1).join("=").trim();
8818
+ if (!value) {
8819
+ return {
8820
+ label: "PROJECT_DIR (.env)",
8821
+ status: "fail",
8822
+ detail: "PROJECT_DIR is empty",
8823
+ fix: "Re-run `beastmode init --project <path>` to populate it"
8824
+ };
8825
+ }
8826
+ if (!existsSync24(value)) {
8827
+ return {
8828
+ label: "PROJECT_DIR (.env)",
8829
+ status: "fail",
8830
+ detail: `PROJECT_DIR=${value} does not exist`,
8831
+ fix: `Clone your project to ${value} or re-run 'beastmode init --project <path>'`
8832
+ };
8833
+ }
8834
+ if (!existsSync24(join22(value, ".git"))) {
8835
+ return {
8836
+ label: "PROJECT_DIR (.env)",
8837
+ status: "fail",
8838
+ detail: `PROJECT_DIR=${value} is not a git repo`,
8839
+ fix: "Run `git init` in the project dir, or point PROJECT_DIR at an existing git clone"
8840
+ };
8841
+ }
8842
+ const remote = tryExec(`git -C "${value}" remote get-url origin 2>/dev/null`);
8843
+ if (!remote) {
8844
+ return {
8845
+ label: "PROJECT_DIR (.env)",
8846
+ status: "warn",
8847
+ detail: `${value} has no origin remote \u2014 daemon can't auto-resolve github repo (Gap 2)`,
8848
+ fix: `cd ${value} && git remote add origin <url>, or set PROJECT_REPO in .env as a last-resort override`
8849
+ };
8850
+ }
8851
+ return {
8852
+ label: "PROJECT_DIR (.env)",
8853
+ status: "pass",
8854
+ detail: `${value} (origin: ${remote})`
8855
+ };
8856
+ }
8857
+ async function checkFactoryContainers(factoryDir) {
8858
+ if (!factoryDir) {
8859
+ return {
8860
+ label: "Factory containers",
8861
+ status: "warn",
8862
+ detail: "no factory in scope"
8863
+ };
8864
+ }
8865
+ const composePath = join22(factoryDir, "docker-compose.yml");
8866
+ if (!existsSync24(composePath)) {
8867
+ return {
8868
+ label: "Factory containers",
8869
+ status: "warn",
8870
+ detail: "no docker-compose.yml in factory",
8871
+ fix: "Run `beastmode init` to generate one"
8872
+ };
8873
+ }
8874
+ const out = tryExec(
8875
+ `docker compose -f "${composePath}" --project-directory "${factoryDir}" ps --format json 2>/dev/null`,
8876
+ 15e3
8877
+ );
8878
+ if (out === null) {
8879
+ return {
8880
+ label: "Factory containers",
8881
+ status: "warn",
8882
+ detail: "docker compose ps failed \u2014 factory may be down",
8883
+ fix: "Run `docker compose up -d` in the factory directory"
8884
+ };
8885
+ }
8886
+ if (!out.trim()) {
8887
+ return {
8888
+ label: "Factory containers",
8889
+ status: "warn",
8890
+ detail: "no containers running",
8891
+ fix: "Run `docker compose up -d` in the factory directory"
8892
+ };
8893
+ }
8894
+ const rows = [];
8895
+ for (const line of out.split("\n")) {
8896
+ const t = line.trim();
8897
+ if (!t) continue;
8898
+ try {
8899
+ const parsed = JSON.parse(t);
8900
+ if (Array.isArray(parsed)) {
8901
+ for (const p of parsed) rows.push(p);
8902
+ } else {
8903
+ rows.push(parsed);
8904
+ }
8905
+ } catch {
8906
+ }
8907
+ }
8908
+ if (rows.length === 0) {
8909
+ return {
8910
+ label: "Factory containers",
8911
+ status: "warn",
8912
+ detail: "docker compose ps output not parseable"
8913
+ };
8914
+ }
8915
+ const required = ["board", "ui", "daemon"];
8916
+ const byService = /* @__PURE__ */ new Map();
8917
+ for (const r of rows) {
8918
+ if (r.Service) {
8919
+ byService.set(r.Service, {
8920
+ state: r.State,
8921
+ health: r.Health,
8922
+ name: r.Name
8923
+ });
8924
+ }
8925
+ }
8926
+ const missing = required.filter((s) => !byService.has(s));
8927
+ if (missing.length > 0) {
8928
+ return {
8929
+ label: "Factory containers",
8930
+ status: "fail",
8931
+ detail: `missing services: ${missing.join(", ")}`,
8932
+ fix: "Run `docker compose up -d` in the factory directory"
8933
+ };
8934
+ }
8935
+ const unhealthy = [];
8936
+ for (const svc of required) {
8937
+ const info5 = byService.get(svc);
8938
+ if (!info5) continue;
8939
+ if (info5.state !== "running") {
8940
+ unhealthy.push(`${svc}(${info5.state})`);
8941
+ } else if (info5.health && info5.health !== "healthy" && info5.health !== "") {
8942
+ unhealthy.push(`${svc}(${info5.health})`);
8943
+ }
8944
+ }
8945
+ if (unhealthy.length > 0) {
8946
+ return {
8947
+ label: "Factory containers",
8948
+ status: "fail",
8949
+ detail: `unhealthy: ${unhealthy.join(", ")}`,
8950
+ fix: "Check `docker compose logs <service>` for the unhealthy container"
8951
+ };
8952
+ }
8953
+ return {
8954
+ label: "Factory containers",
8955
+ status: "pass",
8956
+ detail: `${required.join(", ")} running & healthy`
8957
+ };
8958
+ }
8959
+ async function checkUiServesBoard() {
8960
+ const ports = [8420, 8080, 3e3];
8961
+ for (const port of ports) {
8962
+ const code = await httpGet(`http://127.0.0.1:${port}/board`, 3e3);
8963
+ if (code !== null && code >= 200 && code < 400) {
8964
+ return {
8965
+ label: "UI /board endpoint",
8966
+ status: "pass",
8967
+ detail: `http://127.0.0.1:${port}/board \u2192 ${code}`
8968
+ };
8969
+ }
8970
+ }
8971
+ return {
8972
+ label: "UI /board endpoint",
8973
+ status: "warn",
8974
+ detail: "no UI server responding to /board on 8420, 8080, or 3000",
8975
+ fix: "Check `docker compose logs ui` \u2014 the Node process may have crashed after startup"
8976
+ };
8977
+ }
8978
+ function checkDaemonConfigLoad() {
8979
+ const container = tryExec(
8980
+ `docker ps --filter 'label=com.docker.compose.service=daemon' --format '{{.Names}}' 2>/dev/null | head -n1`
8981
+ );
8982
+ if (!container) {
8983
+ return {
8984
+ label: "Daemon config",
8985
+ status: "warn",
8986
+ detail: "no daemon container running",
8987
+ fix: "Run `docker compose up -d` in the factory directory"
8988
+ };
8989
+ }
8990
+ const pyScript = `
8991
+ from beastmode_daemon.config import DaemonConfig
8992
+ try:
8993
+ c = DaemonConfig.load()
8994
+ errors = c.validate_for_startup()
8995
+ print('REPO=' + (c.github.project_repo or ''))
8996
+ print('DIR=' + str(c.project_dir or ''))
8997
+ print('ERRORS=' + ('; '.join(errors) if errors else 'none'))
8998
+ except Exception as e:
8999
+ print('EXC=' + type(e).__name__ + ': ' + str(e))
9000
+ `.trim();
9001
+ const out = tryExec(
9002
+ `docker exec ${container} python -c "${pyScript.replace(/"/g, '\\"')}" 2>/dev/null`,
9003
+ 12e3
9004
+ );
9005
+ if (!out) {
9006
+ return {
9007
+ label: "Daemon config",
9008
+ status: "warn",
9009
+ detail: `could not execute python inside ${container}`
9010
+ };
9011
+ }
9012
+ const lines = out.split("\n");
9013
+ const repo = lines.find((l) => l.startsWith("REPO="))?.slice(5) ?? "";
9014
+ const dir = lines.find((l) => l.startsWith("DIR="))?.slice(4) ?? "";
9015
+ const errors = lines.find((l) => l.startsWith("ERRORS="))?.slice(7) ?? "";
9016
+ const exc = lines.find((l) => l.startsWith("EXC="))?.slice(4) ?? "";
9017
+ if (exc) {
9018
+ return {
9019
+ label: "Daemon config",
9020
+ status: "fail",
9021
+ detail: `exception: ${exc}`,
9022
+ fix: "Check docker logs + daemon/beastmode_daemon/config.py"
9023
+ };
9024
+ }
9025
+ if (errors && errors !== "none") {
9026
+ return {
9027
+ label: "Daemon config",
9028
+ status: "fail",
9029
+ detail: errors,
9030
+ fix: "See docs/zero-to-productive-readiness.md Gap 2"
9031
+ };
9032
+ }
9033
+ return {
9034
+ label: "Daemon config",
9035
+ status: "pass",
9036
+ detail: `repo=${repo || "<empty>"}, dir=${dir || "<empty>"}`
9037
+ };
9038
+ }
8650
9039
  function checkGhcrAuth() {
8651
9040
  if (isGhcrAuthenticated()) {
8652
9041
  return {
@@ -8971,10 +9360,15 @@ var doctorCommand = new Command11("doctor").description("Health check \u2014 val
8971
9360
  checks.push(...await checkClaudeAuth(env.ANTHROPIC_API_KEY));
8972
9361
  checks.push(checkGithubToken(env));
8973
9362
  checks.push(checkDocker());
9363
+ checks.push(checkDockerComposeVersion());
8974
9364
  checks.push(checkGhcrAuth());
8975
9365
  checks.push(checkGitHubCli());
9366
+ checks.push(checkProjectDirEnv(hasFactory ? factoryDir : null));
8976
9367
  checks.push(checkProjectDirectory(hasFactory ? factoryDir : null));
8977
9368
  checks.push(await checkBoardServer(hasFactory ? factoryDir : null));
9369
+ checks.push(await checkFactoryContainers(hasFactory ? factoryDir : null));
9370
+ checks.push(await checkUiServesBoard());
9371
+ checks.push(checkDaemonConfigLoad());
8978
9372
  checks.push(checkStack(hasFactory ? factoryDir : null));
8979
9373
  checks.push(checkPlaywright());
8980
9374
  checks.push(checkBoardPassword(env, hasFactory ? factoryDir : null));