@spec-engine/spec-engine 0.5.0 → 0.6.1

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/impl.js CHANGED
@@ -20,7 +20,7 @@ var package_default;
20
20
  var init_package = __esm(() => {
21
21
  package_default = {
22
22
  name: "@spec-engine/spec-engine",
23
- version: "0.5.0",
23
+ version: "0.6.1",
24
24
  description: "Cross-repo spec engine \u2014 durable requirements with permanent IDs, bound to code via @spec tags, checked in CI. Ships the spec CLI, the local coverage webapp (spec serve), and offline docs (spec docs).",
25
25
  license: "MIT",
26
26
  type: "module",
@@ -373,10 +373,10 @@ var init_flags = __esm(() => {
373
373
  FEATURE_KEYS = Object.keys(FEATURES);
374
374
  });
375
375
  // ../shared/src/net.ts
376
- function isLoopbackHostname(hostname) {
377
- return LOOPBACK_HOSTNAMES.has(hostname);
376
+ function isServedHostname(hostname, bindHost = DEFAULT_BIND_HOST) {
377
+ return LOOPBACK_HOSTNAMES.has(hostname) || hostname === bindHost;
378
378
  }
379
- var LOOPBACK_HOSTNAMES;
379
+ var DEFAULT_BIND_HOST = "127.0.0.1", LOOPBACK_HOSTNAMES;
380
380
  var init_net = __esm(() => {
381
381
  LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
382
382
  });
@@ -1120,6 +1120,9 @@ function readPlatformMap(dir) {
1120
1120
  return null;
1121
1121
  return { mapped: map(abs), located };
1122
1122
  }
1123
+ function platformName(platformDir) {
1124
+ return readPlatformMap(platformDir)?.mapped.name ?? basename(resolve2(platformDir));
1125
+ }
1123
1126
  function platformMode(platformDir) {
1124
1127
  return readPlatformMap(platformDir)?.mapped.mode ?? "single-repo";
1125
1128
  }
@@ -1274,6 +1277,37 @@ async function packageColumns(repoPath, prefix, repo, parentPin) {
1274
1277
  }
1275
1278
  return out;
1276
1279
  }
1280
+ function packageDirs(repo) {
1281
+ const dirs = repo.packages.map((p) => p.path);
1282
+ return dirs.some((d) => d === "" || d === ".") ? null : dirs;
1283
+ }
1284
+ function loneRootColumn(absPlatform, repo, platformVersion) {
1285
+ const dirs = packageDirs(repo);
1286
+ const { dependency_depth: _depth, ...self } = selfMember(absPlatform, platformVersion);
1287
+ if (dirs === null || self.name === CANONICAL_SPECS_DIR)
1288
+ return [];
1289
+ return [{ repo: { ...self, ignore: dirs }, node: repo }];
1290
+ }
1291
+ async function loneMembers(repo, absPlatform, platformVersion) {
1292
+ if (repo === undefined || repo.mode !== "monorepo" || repo.packages.length === 0) {
1293
+ return [selfMember(absPlatform, platformVersion)];
1294
+ }
1295
+ return toRepos([
1296
+ ...await packageColumns(absPlatform, "", repo, platformVersion),
1297
+ ...loneRootColumn(absPlatform, repo, platformVersion)
1298
+ ]);
1299
+ }
1300
+ async function memberColumns(repo, path, cfg) {
1301
+ const pin = extractPin(cfg.specs);
1302
+ if (repo.mode !== "monorepo" || repo.packages.length === 0) {
1303
+ return [column(repo.name, path, pin, repo, cfg.ignore)];
1304
+ }
1305
+ const packages = await packageColumns(path, `${repo.name}/`, repo, pin);
1306
+ const dirs = packageDirs(repo);
1307
+ if (dirs === null)
1308
+ return packages;
1309
+ return [...packages, column(repo.name, path, pin, repo, [...cfg.ignore ?? [], ...dirs])];
1310
+ }
1277
1311
  function describe(d, mapped) {
1278
1312
  const s = d.subject;
1279
1313
  switch (d.code) {
@@ -1306,8 +1340,7 @@ async function membersOf(reading, absPlatform, platformVersion) {
1306
1340
  const { mapped, located } = reading;
1307
1341
  const diagnostics2 = surfaced(mapped);
1308
1342
  if (mapped.mode !== "multi-repo") {
1309
- const repo = mapped.repos[0];
1310
- const members = repo !== undefined && repo.mode === "monorepo" && repo.packages.length > 0 ? toRepos(await packageColumns(absPlatform, "", repo, platformVersion)) : [selfMember(absPlatform, platformVersion)];
1343
+ const members = await loneMembers(mapped.repos[0], absPlatform, platformVersion);
1311
1344
  return { mode: mapped.mode, members, unpinned: [], undeclared: [], diagnostics: diagnostics2 };
1312
1345
  }
1313
1346
  if (!mapped.declared) {
@@ -1325,13 +1358,7 @@ async function membersOf(reading, absPlatform, platformVersion) {
1325
1358
  unpinned.push({ name: repo.name, path });
1326
1359
  continue;
1327
1360
  }
1328
- const cfg = await readRepoConfig(configPath);
1329
- const pin = extractPin(cfg.specs);
1330
- if (repo.mode === "monorepo" && repo.packages.length > 0) {
1331
- columns.push(...await packageColumns(path, `${repo.name}/`, repo, pin));
1332
- } else {
1333
- columns.push(column(repo.name, path, pin, repo, cfg.ignore));
1334
- }
1361
+ columns.push(...await memberColumns(repo, path, await readRepoConfig(configPath)));
1335
1362
  }
1336
1363
  const undeclared = mapped.diagnostics.filter((d) => d.code === "UNLISTED_REPO").map((d) => ({ name: d.subject, path: join3(absPlatform, d.subject) }));
1337
1364
  return { mode: "multi-repo", members: toRepos(columns), unpinned, undeclared, diagnostics: diagnostics2 };
@@ -3314,7 +3341,24 @@ import { resolve as resolve6 } from "path";
3314
3341
  function resolvePlatformDir(args) {
3315
3342
  return resolve6(args.platformDir ?? process.cwd());
3316
3343
  }
3317
- var platformDirArg, outArg, jsonArg, freshArg, noPromptArg;
3344
+ function listFlag(rawArgs, name, parsed) {
3345
+ const flag = `--${name}`;
3346
+ const raw = [];
3347
+ for (let i = 0;i < rawArgs.length; i++) {
3348
+ const arg = rawArgs[i];
3349
+ if (arg === flag && i + 1 < rawArgs.length)
3350
+ raw.push(rawArgs[++i]);
3351
+ else if (arg.startsWith(`${flag}=`))
3352
+ raw.push(arg.slice(flag.length + 1));
3353
+ }
3354
+ if (raw.length === 0) {
3355
+ if (parsed === undefined)
3356
+ return;
3357
+ raw.push(parsed);
3358
+ }
3359
+ return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
3360
+ }
3361
+ var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, livesArg;
3318
3362
  var init__args = __esm(() => {
3319
3363
  init_constants();
3320
3364
  platformDirArg = {
@@ -3335,6 +3379,10 @@ var init__args = __esm(() => {
3335
3379
  type: "boolean",
3336
3380
  description: "Suppress the interactive onboarding prompt for siblings missing spec-engine.member.json (they surface as NO_SPEC_CONFIG warnings)"
3337
3381
  };
3382
+ livesArg = {
3383
+ type: "string",
3384
+ description: "Lives in (livesIn) paths: repeat the flag or comma-separate; an empty value clears the list"
3385
+ };
3338
3386
  });
3339
3387
 
3340
3388
  // src/commands/index.ts
@@ -6448,24 +6496,27 @@ __export(exports_req, {
6448
6496
  });
6449
6497
  import { createInterface as createInterface2 } from "readline";
6450
6498
  import { defineCommand as defineCommand12 } from "citty";
6451
- async function mintFromFlags(platformDir, key, textFlag, args) {
6499
+ async function mintFromFlags(platformDir, key, textFlag, args, rawArgs) {
6452
6500
  const statement = textFlag.trim();
6453
6501
  if (statement === "") {
6454
6502
  console.error("spec req: --text must be a non-empty Requirement");
6455
6503
  process.exit(EXIT.USAGE);
6456
6504
  }
6457
6505
  await printResolvedCharter(platformDir, key);
6458
- const lives = (args.lives ?? "").trim();
6459
6506
  await mintAndReport(platformDir, key, {
6460
6507
  statement,
6461
6508
  why: (args.why ?? "").trim(),
6462
- livesIn: lives ? [lives] : [],
6509
+ livesIn: listFlag(rawArgs, "lives", args.lives) ?? [],
6463
6510
  issue: (args.issue ?? "").trim() || undefined,
6511
+ status: statusOf(args),
6464
6512
  json: Boolean(args.json)
6465
6513
  });
6466
6514
  }
6467
- async function mintInteractively(platformDir, key, id) {
6468
- console.error(`Authoring ${id} \u2014 Active`);
6515
+ function statusOf(args) {
6516
+ return args.draft ? "draft" : "active";
6517
+ }
6518
+ async function mintInteractively(platformDir, key, id, status) {
6519
+ console.error(`Authoring ${id} \u2014 ${status === "draft" ? "Draft" : "Active"}`);
6469
6520
  await printResolvedCharter(platformDir, key);
6470
6521
  const requirement = (await askLine("Requirement: ")).trim();
6471
6522
  if (requirement === "") {
@@ -6474,12 +6525,13 @@ async function mintInteractively(platformDir, key, id) {
6474
6525
  return;
6475
6526
  }
6476
6527
  const why = (await askLine("Why it matters: ")).trim();
6477
- const lives = (await askLine("Lives in: ")).trim();
6528
+ const livesIn = listFlag([], "lives", await askLine("Lives in: ")) ?? [];
6478
6529
  await mintAndReport(platformDir, key, {
6479
6530
  statement: requirement,
6480
6531
  why,
6481
- livesIn: lives ? [lives] : [],
6532
+ livesIn,
6482
6533
  issue: undefined,
6534
+ status,
6483
6535
  json: false
6484
6536
  });
6485
6537
  }
@@ -6532,10 +6584,14 @@ var init_req = __esm(() => {
6532
6584
  description: "Non-interactive authoring: the Requirement field. When set, the entry appends with zero prompts."
6533
6585
  },
6534
6586
  why: { type: "string", description: "Why it matters field (only with --text)" },
6535
- lives: { type: "string", description: "Lives in field (only with --text)" },
6587
+ lives: livesArg,
6536
6588
  issue: {
6537
6589
  type: "string",
6538
6590
  description: "Originating ticket id, recorded as created-provenance on the entry (only with --text; opaque \u2014 never a requirement id)"
6591
+ },
6592
+ draft: {
6593
+ type: "boolean",
6594
+ description: "Author the entry as Draft instead of Active (promote it later with spec accept)"
6539
6595
  }
6540
6596
  };
6541
6597
  reqCommand = defineCommand12({
@@ -6544,7 +6600,7 @@ var init_req = __esm(() => {
6544
6600
  description: "Author a new requirement interactively (TTY) or print the next unused id (non-TTY)"
6545
6601
  },
6546
6602
  args: reqArgs,
6547
- async run({ args }) {
6603
+ async run({ args, rawArgs }) {
6548
6604
  const input = args.domainPrefix;
6549
6605
  const platformDir = resolvePlatformDir(args);
6550
6606
  try {
@@ -6558,7 +6614,7 @@ var init_req = __esm(() => {
6558
6614
  const { key, nextId: id } = allocated;
6559
6615
  const textFlag = args.text;
6560
6616
  if (textFlag !== undefined) {
6561
- await mintFromFlags(platformDir, key, textFlag, args);
6617
+ await mintFromFlags(platformDir, key, textFlag, args, rawArgs);
6562
6618
  return;
6563
6619
  }
6564
6620
  const hasFieldFlags = typeof args.why === "string" || typeof args.lives === "string" || typeof args.issue === "string";
@@ -6567,6 +6623,11 @@ var init_req = __esm(() => {
6567
6623
  process.exit(EXIT.USAGE);
6568
6624
  return;
6569
6625
  }
6626
+ if (args.draft && (args.json || !process.stdin.isTTY)) {
6627
+ console.error("spec req: --draft authors an entry \u2014 pass --text, or run on a TTY");
6628
+ process.exit(EXIT.USAGE);
6629
+ return;
6630
+ }
6570
6631
  if (args.json) {
6571
6632
  console.log(JSON.stringify({ domain: key, next_id: id }));
6572
6633
  return;
@@ -6575,7 +6636,7 @@ var init_req = __esm(() => {
6575
6636
  console.log(id);
6576
6637
  return;
6577
6638
  }
6578
- await mintInteractively(platformDir, key, id);
6639
+ await mintInteractively(platformDir, key, id, statusOf(args));
6579
6640
  }
6580
6641
  });
6581
6642
  });
@@ -7122,14 +7183,13 @@ async function resolveSuccessorText(args, platformDir, id) {
7122
7183
  function splitAliases(raw) {
7123
7184
  return raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
7124
7185
  }
7125
- function inputFromArgs(args, platformDir, id, statement) {
7186
+ function inputFromArgs(args, rawArgs, platformDir, id, statement) {
7126
7187
  const input = { platformDir, id, statement, noBump: Boolean(args.noBump) };
7127
7188
  if (typeof args.why === "string")
7128
7189
  input.why = args.why;
7129
- if (typeof args.lives === "string") {
7130
- const lives = args.lives.trim();
7131
- input.livesIn = lives === "" ? [] : [lives];
7132
- }
7190
+ const livesIn = listFlag(rawArgs, "lives", args.lives);
7191
+ if (livesIn !== undefined)
7192
+ input.livesIn = livesIn;
7133
7193
  if (typeof args.term === "string")
7134
7194
  input.term = args.term;
7135
7195
  if (typeof args.aliases === "string")
@@ -7166,7 +7226,7 @@ var init_supersede2 = __esm(() => {
7166
7226
  },
7167
7227
  lives: {
7168
7228
  type: "string",
7169
- description: "Successor's Lives in (default: copied from the old entry)"
7229
+ description: "Successor's Lives in: repeat or comma-separate; empty clears (default: copied from the old entry)"
7170
7230
  },
7171
7231
  term: {
7172
7232
  type: "string",
@@ -7195,7 +7255,7 @@ var init_supersede2 = __esm(() => {
7195
7255
  description: "Supersede a shipped requirement: flip it to superseded, mint the successor, bump specVersion, and emit the retag worklist."
7196
7256
  },
7197
7257
  args: supersedeArgs,
7198
- async run({ args }) {
7258
+ async run({ args, rawArgs }) {
7199
7259
  const id = args.id;
7200
7260
  if (!ID_RE.test(id)) {
7201
7261
  console.error(`spec supersede: id must be a requirement id (KEY-NNN); got ${id}`);
@@ -7208,7 +7268,7 @@ var init_supersede2 = __esm(() => {
7208
7268
  handleNotAPlatform(e);
7209
7269
  }
7210
7270
  const statement = await resolveSuccessorText(args, platformDir, id);
7211
- const result = await supersede(inputFromArgs(args, platformDir, id, statement), coldFreshTags(platformDir));
7271
+ const result = await supersede(inputFromArgs(args, rawArgs, platformDir, id, statement), coldFreshTags(platformDir));
7212
7272
  if (!result.ok)
7213
7273
  exitOnFailure("spec supersede", result);
7214
7274
  printWarnings("spec supersede", result.warnings);
@@ -7454,16 +7514,15 @@ __export(exports_move, {
7454
7514
  moveCommand: () => moveCommand
7455
7515
  });
7456
7516
  import { defineCommand as defineCommand17 } from "citty";
7457
- function inputFromArgs2(args, platformDir, id, targetKey) {
7517
+ function inputFromArgs2(args, rawArgs, platformDir, id, targetKey) {
7458
7518
  const input = { platformDir, id, targetKey, noBump: Boolean(args.noBump) };
7459
7519
  if (typeof args.text === "string")
7460
7520
  input.statement = args.text;
7461
7521
  if (typeof args.why === "string")
7462
7522
  input.why = args.why;
7463
- if (typeof args.lives === "string") {
7464
- const lives = args.lives.trim();
7465
- input.livesIn = lives === "" ? [] : [lives];
7466
- }
7523
+ const livesIn = listFlag(rawArgs, "lives", args.lives);
7524
+ if (livesIn !== undefined)
7525
+ input.livesIn = livesIn;
7467
7526
  return input;
7468
7527
  }
7469
7528
  var moveArgs, moveCommand;
@@ -7498,7 +7557,7 @@ var init_move2 = __esm(() => {
7498
7557
  },
7499
7558
  lives: {
7500
7559
  type: "string",
7501
- description: "Rewrite the successor's Lives in (default: copied from the source)"
7560
+ description: "Rewrite the successor's Lives in: repeat or comma-separate; empty clears (default: copied from the source)"
7502
7561
  },
7503
7562
  noBump: {
7504
7563
  type: "boolean",
@@ -7515,7 +7574,7 @@ var init_move2 = __esm(() => {
7515
7574
  description: "Move a requirement to another domain: mint the successor in <NEW-DOMAIN> carrying the source's fields, mark the source superseded, bump both specVersions, and emit the retag worklist."
7516
7575
  },
7517
7576
  args: moveArgs,
7518
- async run({ args }) {
7577
+ async run({ args, rawArgs }) {
7519
7578
  const id = args.id;
7520
7579
  const rawTargetKey = args.newDomain;
7521
7580
  const platformDir = resolvePlatformDir(args);
@@ -7533,7 +7592,7 @@ var init_move2 = __esm(() => {
7533
7592
  } catch (e) {
7534
7593
  handleNotAPlatform(e);
7535
7594
  }
7536
- const result = await move(inputFromArgs2(args, platformDir, id, targetKey), coldFreshTags(platformDir));
7595
+ const result = await move(inputFromArgs2(args, rawArgs, platformDir, id, targetKey), coldFreshTags(platformDir));
7537
7596
  if (!result.ok)
7538
7597
  exitOnFailure("spec move", result);
7539
7598
  printWarnings("spec move", result.warnings);
@@ -7679,7 +7738,7 @@ function assertAmendFlags(args) {
7679
7738
  function splitAliases2(raw) {
7680
7739
  return raw === "" ? [] : raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
7681
7740
  }
7682
- function fieldsFromArgs(args) {
7741
+ function fieldsFromArgs(args, rawArgs) {
7683
7742
  assertAmendFlags(args);
7684
7743
  const fields = {};
7685
7744
  if (args.text !== undefined)
@@ -7688,10 +7747,9 @@ function fieldsFromArgs(args) {
7688
7747
  const v = args.why.trim();
7689
7748
  fields.why = v === "" ? null : v;
7690
7749
  }
7691
- if (args.lives !== undefined) {
7692
- const v = args.lives.trim();
7693
- fields.livesIn = v === "" ? [] : [v];
7694
- }
7750
+ const livesIn = listFlag(rawArgs, "lives", args.lives);
7751
+ if (livesIn !== undefined)
7752
+ fields.livesIn = livesIn;
7695
7753
  if (args.term !== undefined)
7696
7754
  fields.term = args.term.trim();
7697
7755
  if (args.issue !== undefined && args.issue.trim() !== "")
@@ -7718,7 +7776,7 @@ var init_amend2 = __esm(() => {
7718
7776
  platformDir: platformDirArg,
7719
7777
  text: { type: "string", description: "New Requirement (statement) field value" },
7720
7778
  why: { type: "string", description: "New Why it matters field value" },
7721
- lives: { type: "string", description: "New Lives in (livesIn) field value" },
7779
+ lives: livesArg,
7722
7780
  issue: {
7723
7781
  type: "string",
7724
7782
  description: "Ticket that motivated this amendment \u2014 recorded as amends-via provenance (opaque, never a requirement id)"
@@ -7736,7 +7794,7 @@ var init_amend2 = __esm(() => {
7736
7794
  description: "Revise an unshipped requirement's fields in place (same id, no specVersion bump). Supersede shipped truth instead."
7737
7795
  },
7738
7796
  args: amendArgs,
7739
- async run({ args }) {
7797
+ async run({ args, rawArgs }) {
7740
7798
  const id = args.id;
7741
7799
  if (!ID_RE.test(id)) {
7742
7800
  console.error(`spec amend: id must be a requirement id (KEY-NNN); got ${id}`);
@@ -7749,7 +7807,7 @@ var init_amend2 = __esm(() => {
7749
7807
  } catch (e) {
7750
7808
  handleNotAPlatform(e);
7751
7809
  }
7752
- const fields = fieldsFromArgs(args);
7810
+ const fields = fieldsFromArgs(args, rawArgs);
7753
7811
  const result = await amend({ platformDir, id, fields }, coldFreshTags(platformDir));
7754
7812
  if (!result.ok)
7755
7813
  exitOnFailure("spec amend", result);
@@ -7763,6 +7821,86 @@ var init_amend2 = __esm(() => {
7763
7821
  });
7764
7822
  });
7765
7823
 
7824
+ // src/operations/accept.ts
7825
+ async function accept(input) {
7826
+ const { platformDir, id } = input;
7827
+ const located = await locateEntry(platformDir, id);
7828
+ if (!located.ok)
7829
+ return located;
7830
+ const { specPath, relFile, domain: domain2, req } = located;
7831
+ if (req.status.toLowerCase() !== "draft") {
7832
+ return fail("conflict", `${id} is ${displayStatus(req.status)} \u2014 only a Draft entry accepts`);
7833
+ }
7834
+ req.status = "active";
7835
+ domain2.updated = localToday();
7836
+ const res = await validateAndWrite(specPath, domain2, relFile);
7837
+ if (!res.ok) {
7838
+ return fail("invalid_domain_file", res.diagnostics.map((d) => d.detail).join(`
7839
+ `), {
7840
+ diagnostics: res.diagnostics
7841
+ });
7842
+ }
7843
+ return { ok: true, id, file: relFile };
7844
+ }
7845
+ var init_accept = __esm(() => {
7846
+ init_src();
7847
+ init__envelope();
7848
+ init__result();
7849
+ });
7850
+
7851
+ // src/commands/accept.ts
7852
+ var exports_accept = {};
7853
+ __export(exports_accept, {
7854
+ acceptCommand: () => acceptCommand
7855
+ });
7856
+ import { defineCommand as defineCommand19 } from "citty";
7857
+ var acceptCommand;
7858
+ var init_accept2 = __esm(() => {
7859
+ init_constants();
7860
+ init_discover();
7861
+ init_accept();
7862
+ init_grammar3();
7863
+ init__args();
7864
+ init__shared();
7865
+ acceptCommand = defineCommand19({
7866
+ meta: {
7867
+ name: "accept",
7868
+ description: "Promote a Draft requirement to Active (same id, fields untouched)"
7869
+ },
7870
+ args: {
7871
+ id: {
7872
+ type: "positional",
7873
+ required: true,
7874
+ description: "Draft requirement id to accept (KEY-NNN)"
7875
+ },
7876
+ platformDir: platformDirArg,
7877
+ json: jsonArg
7878
+ },
7879
+ async run({ args }) {
7880
+ const id = args.id;
7881
+ if (!ID_RE.test(id)) {
7882
+ console.error(`spec accept: id must be a requirement id (KEY-NNN); got ${id}`);
7883
+ process.exit(EXIT.USAGE);
7884
+ return;
7885
+ }
7886
+ const platformDir = resolvePlatformDir(args);
7887
+ try {
7888
+ assertSpecPlatform(platformDir);
7889
+ } catch (e) {
7890
+ handleNotAPlatform(e);
7891
+ }
7892
+ const result = await accept({ platformDir, id });
7893
+ if (!result.ok)
7894
+ exitOnFailure("spec accept", result);
7895
+ if (args.json) {
7896
+ console.log(JSON.stringify({ id, file: result.file }));
7897
+ return;
7898
+ }
7899
+ console.log(`accepted ${id} in ${result.file} \u2014 now Active`);
7900
+ }
7901
+ });
7902
+ });
7903
+
7766
7904
  // src/server/authorPrompt.ts
7767
7905
  function renderAuthorPrompt(opts) {
7768
7906
  const charterBlock = opts.charter ? `
@@ -7800,7 +7938,7 @@ function errorResult(message) {
7800
7938
  return { content: [{ type: "text", text: message }], isError: true };
7801
7939
  }
7802
7940
  function buildMcpServer(platformDir) {
7803
- const server = new McpServer({ name: "spec", version: "0.0.6" });
7941
+ const server = new McpServer({ name: "spec", version: package_default.version });
7804
7942
  const fresh = (fn) => withIndex({ platformDir, build: "fresh" }, (h) => fn(h.storage));
7805
7943
  server.registerTool("spec_query", {
7806
7944
  title: "Full-text requirement retrieval",
@@ -7938,6 +8076,19 @@ function buildMcpServer(platformDir) {
7938
8076
  const r = await deprecate({ platformDir, id: req_id, reason: trimmed }, coldFreshTags(platformDir));
7939
8077
  return r.ok ? jsonResult({ id: r.id, file: r.file, reason: r.reason, sites: r.sites }) : errorResult(r.detail);
7940
8078
  });
8079
+ server.registerTool("spec_accept", {
8080
+ title: "Accept a draft requirement",
8081
+ description: "Promote a Draft requirement to Active, leaving every other field untouched. The same JSON as `spec accept --json`.",
8082
+ inputSchema: {
8083
+ req_id: z3.string().describe("The Draft requirement id to accept (KEY-NNN)")
8084
+ }
8085
+ }, async ({ req_id }) => {
8086
+ if (!ID_RE.test(req_id)) {
8087
+ return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
8088
+ }
8089
+ const r = await accept({ platformDir, id: req_id });
8090
+ return r.ok ? jsonResult({ id: r.id, file: r.file }) : errorResult(r.detail);
8091
+ });
7941
8092
  server.registerPrompt("author_requirements", {
7942
8093
  title: "Author requirements from a brief",
7943
8094
  description: "Turn a vague brief/ticket into well-formed Spec Engine requirements: one requirement per testable promise, placed by domain charter, deduped via spec query, drafted to the GUARD template + cold-read rubric, minted with spec req. The engine returns a static playbook template \u2014 your model runs it.",
@@ -7970,8 +8121,10 @@ function buildMcpServer(platformDir) {
7970
8121
  }
7971
8122
  var init_mcp = __esm(() => {
7972
8123
  init_src();
8124
+ init_package();
7973
8125
  init_domains();
7974
8126
  init__index();
8127
+ init_accept();
7975
8128
  init_check();
7976
8129
  init_deprecate();
7977
8130
  init_nextId();
@@ -7987,14 +8140,14 @@ __export(exports_mcp, {
7987
8140
  mcpCommand: () => mcpCommand
7988
8141
  });
7989
8142
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7990
- import { defineCommand as defineCommand19 } from "citty";
8143
+ import { defineCommand as defineCommand20 } from "citty";
7991
8144
  var mcpCommand;
7992
8145
  var init_mcp2 = __esm(() => {
7993
8146
  init_discover();
7994
8147
  init_mcp();
7995
8148
  init__args();
7996
8149
  init__shared();
7997
- mcpCommand = defineCommand19({
8150
+ mcpCommand = defineCommand20({
7998
8151
  meta: {
7999
8152
  name: "mcp",
8000
8153
  description: "Serve Spec Engine as an MCP (Model Context Protocol) server over stdio \u2014 agent-native query/resolve/check/report tools."
@@ -8010,7 +8163,7 @@ var init_mcp2 = __esm(() => {
8010
8163
  handleNotAPlatform(e);
8011
8164
  }
8012
8165
  const server = buildMcpServer(platformDir);
8013
- console.error(`spec mcp: serving ${platformDir} over stdio`);
8166
+ console.error(`spec mcp: serving ${platformName(platformDir)} over stdio`);
8014
8167
  await server.connect(new StdioServerTransport);
8015
8168
  }
8016
8169
  });
@@ -8023,7 +8176,7 @@ __export(exports_resolve, {
8023
8176
  });
8024
8177
  import { statSync as statSync5 } from "fs";
8025
8178
  import { join as join12, relative as relative2, resolve as resolve13, sep as sep3 } from "path";
8026
- import { defineCommand as defineCommand20 } from "citty";
8179
+ import { defineCommand as defineCommand21 } from "citty";
8027
8180
  function classifyArg(a) {
8028
8181
  if (a.startsWith("--")) {
8029
8182
  const eq = a.indexOf("=");
@@ -8209,7 +8362,7 @@ var init_resolve2 = __esm(() => {
8209
8362
  description: "Suppress interactive onboarding prompt for siblings missing spec-engine.member.json (defaults to NO_SPEC_CONFIG warning)"
8210
8363
  }
8211
8364
  };
8212
- resolveCommand = defineCommand20({
8365
+ resolveCommand = defineCommand21({
8213
8366
  meta: {
8214
8367
  name: "resolve",
8215
8368
  description: "Return the requirements tagged in the given files (RSLV-01). Accepts multiple positional file paths (e.g. `spec resolve api/src/renew.ts api/src/charge.ts`) and a comma-split fallback inside a single positional (`spec resolve a.ts,b.ts`). Paths may be platform-relative or absolute under the platform tree."
@@ -8374,7 +8527,7 @@ __export(exports_gate, {
8374
8527
  });
8375
8528
  import { existsSync as existsSync12 } from "fs";
8376
8529
  import { join as join13 } from "path";
8377
- import { defineCommand as defineCommand21 } from "citty";
8530
+ import { defineCommand as defineCommand22 } from "citty";
8378
8531
  function isEpipe(e) {
8379
8532
  if (e === null || typeof e !== "object" || !("code" in e))
8380
8533
  return false;
@@ -8437,7 +8590,7 @@ var init_gate2 = __esm(() => {
8437
8590
  description: "Suppress interactive onboarding prompt for siblings missing spec-engine.member.json (defaults to NO_SPEC_CONFIG warning)"
8438
8591
  }
8439
8592
  };
8440
- gateCommand = defineCommand21({
8593
+ gateCommand = defineCommand22({
8441
8594
  meta: {
8442
8595
  name: "gate",
8443
8596
  description: "Rung-3 approval primitive: passes iff <reqId> is Active and <repo>'s pinned spec_version covers its changed_at_version. Exits 0 PASS / 1 any gate failure / 2 crash or bad args."
@@ -8759,14 +8912,14 @@ var exports_guard = {};
8759
8912
  __export(exports_guard, {
8760
8913
  guardCommand: () => guardCommand
8761
8914
  });
8762
- import { defineCommand as defineCommand22 } from "citty";
8915
+ import { defineCommand as defineCommand23 } from "citty";
8763
8916
  var guardCommand;
8764
8917
  var init_guard2 = __esm(() => {
8765
8918
  init_constants();
8766
8919
  init_guard();
8767
8920
  init__args();
8768
8921
  init__shared();
8769
- guardCommand = defineCommand22({
8922
+ guardCommand = defineCommand23({
8770
8923
  meta: {
8771
8924
  name: "guard",
8772
8925
  description: "Loss detection: diff the requirement derivation at a git ref (default HEAD) against the working tree and block requirements about to be steamrolled."
@@ -9099,6 +9252,18 @@ code {
9099
9252
  display: block;
9100
9253
  }
9101
9254
 
9255
+ .sidebar-platform {
9256
+ margin-left: auto;
9257
+ min-width: 0;
9258
+ overflow: hidden;
9259
+ text-overflow: ellipsis;
9260
+ white-space: nowrap;
9261
+ font-family: var(--font-mono);
9262
+ font-weight: 500;
9263
+ font-size: 0.75rem;
9264
+ color: var(--fg-dim);
9265
+ }
9266
+
9102
9267
  .sidebar-scroll {
9103
9268
  flex: 1;
9104
9269
  overflow-y: auto;
@@ -10705,9 +10870,9 @@ var init_coverage = __esm(() => {
10705
10870
  // ../webapp/src/pages/editor.ts
10706
10871
  import { bodyLimit } from "hono/body-limit";
10707
10872
  import { html as html5, raw as raw4 } from "hono/html";
10708
- function crossOriginRejected(c) {
10873
+ function crossOriginRejected(c, bindHost) {
10709
10874
  const selfUrl = new URL(c.req.url);
10710
- if (!isLoopbackHostname(selfUrl.hostname))
10875
+ if (!isServedHostname(selfUrl.hostname, bindHost))
10711
10876
  return true;
10712
10877
  const origin = c.req.header("origin");
10713
10878
  if (origin === undefined)
@@ -10765,7 +10930,7 @@ async function renderResult(res) {
10765
10930
  <p><a href="/editor">Back to editor</a></p>
10766
10931
  `);
10767
10932
  }
10768
- function mountEditor(app) {
10933
+ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
10769
10934
  app.get("/editor", (c) => {
10770
10935
  if (!featureEnabled("editor"))
10771
10936
  return c.html(comingSoonDoc("editor", "Editor"));
@@ -10825,7 +10990,7 @@ function mountEditor(app) {
10825
10990
  app.post("/editor/create", editorBodyLimit, async (c) => {
10826
10991
  if (!featureEnabled("editor"))
10827
10992
  return c.html(rejectPage(featureDisabledMessage("editor")), 404);
10828
- if (crossOriginRejected(c))
10993
+ if (crossOriginRejected(c, bindHost))
10829
10994
  return c.html(rejectPage("cross-origin request rejected"), 403);
10830
10995
  const form = await c.req.parseBody();
10831
10996
  const statement = field(form, "statement");
@@ -10849,7 +11014,7 @@ function mountEditor(app) {
10849
11014
  app.post("/editor/amend", editorBodyLimit, async (c) => {
10850
11015
  if (!featureEnabled("editor"))
10851
11016
  return c.html(rejectPage(featureDisabledMessage("editor")), 404);
10852
- if (crossOriginRejected(c))
11017
+ if (crossOriginRejected(c, bindHost))
10853
11018
  return c.html(rejectPage("cross-origin request rejected"), 403);
10854
11019
  const form = await c.req.parseBody();
10855
11020
  const id = field(form, "id");
@@ -11603,12 +11768,27 @@ var init_setup = __esm(() => {
11603
11768
 
11604
11769
  // ../webapp/src/server.ts
11605
11770
  import { Hono } from "hono";
11771
+ import { html as htmlTemplate } from "hono/html";
11606
11772
  function createApp() {
11607
11773
  const app = new Hono;
11608
11774
  app.get("/", (c) => c.html(index_html_default));
11609
11775
  return app;
11610
11776
  }
11611
- function mountWebapp(app) {
11777
+ function labelPlatform(app, platformName2) {
11778
+ const name = htmlTemplate`${platformName2}`.toString();
11779
+ app.use("*", async (c, next) => {
11780
+ await next();
11781
+ if (!c.res.headers.get("content-type")?.startsWith("text/html"))
11782
+ return;
11783
+ const body = (await c.res.text()).replace("<title>Spec Engine", `<title>${name} \xB7 Spec Engine`).replace("<span>Spec Engine</span></a>", `<span>Spec Engine</span><span class="sidebar-platform">${name}</span></a>`);
11784
+ const headers = new Headers(c.res.headers);
11785
+ headers.delete("content-length");
11786
+ c.res = new Response(body, { status: c.res.status, headers });
11787
+ });
11788
+ }
11789
+ function mountWebapp(app, bindHost = DEFAULT_BIND_HOST, platformName2) {
11790
+ if (platformName2 !== undefined)
11791
+ labelPlatform(app, platformName2);
11612
11792
  registerErrorBoundary(app);
11613
11793
  mountCoverage(app);
11614
11794
  mountRequirements(app);
@@ -11616,13 +11796,14 @@ function mountWebapp(app) {
11616
11796
  mountQuery(app);
11617
11797
  mountRelations(app);
11618
11798
  mountProvenance(app);
11619
- mountEditor(app);
11799
+ mountEditor(app, bindHost);
11620
11800
  mountSetup(app);
11621
11801
  mountGlossary(app);
11622
11802
  mountLogs(app);
11623
11803
  return app;
11624
11804
  }
11625
11805
  var init_server = __esm(() => {
11806
+ init_src();
11626
11807
  init_index_html();
11627
11808
  init_coverage();
11628
11809
  init_data();
@@ -11672,9 +11853,9 @@ function withWriteLock(fn) {
11672
11853
  writeChain = run.catch(() => {});
11673
11854
  return run;
11674
11855
  }
11675
- function rejectCrossOrigin(c) {
11856
+ function rejectCrossOrigin(c, bindHost) {
11676
11857
  const selfUrl = new URL(c.req.url);
11677
- if (!isLoopbackHostname(selfUrl.hostname)) {
11858
+ if (!isServedHostname(selfUrl.hostname, bindHost)) {
11678
11859
  return c.json({ error: "cross-origin request rejected" }, 403);
11679
11860
  }
11680
11861
  const origin = c.req.header("origin");
@@ -11730,6 +11911,15 @@ function toLivesIn(v) {
11730
11911
  return [v];
11731
11912
  return [];
11732
11913
  }
11914
+ function mintFieldsFromBody(body) {
11915
+ return {
11916
+ statement: typeof body.statement === "string" ? body.statement : "",
11917
+ why: typeof body.why === "string" ? body.why : "",
11918
+ livesIn: toLivesIn(body.livesIn),
11919
+ issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined,
11920
+ status: body.status === "draft" ? "draft" : "active"
11921
+ };
11922
+ }
11733
11923
  function amendFieldsFromBody(body) {
11734
11924
  const fields = {};
11735
11925
  if (typeof body.statement === "string")
@@ -11768,7 +11958,7 @@ function parseQueryLimit2(c, rawLimit) {
11768
11958
  }
11769
11959
  return { ok: true, limit };
11770
11960
  }
11771
- function mountApi(app, storage2, platformDir = process.cwd()) {
11961
+ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT_BIND_HOST) {
11772
11962
  app.use("/api/*", async (c, next) => {
11773
11963
  c.header("Cache-Control", "no-store");
11774
11964
  await next();
@@ -11777,7 +11967,12 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11777
11967
  app.get("/api/repos", guarded((c) => c.json(storage2.listRepos())));
11778
11968
  app.get("/api/platform", async (c) => {
11779
11969
  const version = await derivePlatformVersion(platformDir);
11780
- const info = { version, source: "derived", mode: platformMode(platformDir) };
11970
+ const info = {
11971
+ version,
11972
+ name: platformName(platformDir),
11973
+ source: "derived",
11974
+ mode: platformMode(platformDir)
11975
+ };
11781
11976
  return c.json(info);
11782
11977
  });
11783
11978
  app.get("/api/report", guarded((c) => c.json(coverageReport(storage2).rows)));
@@ -11799,7 +11994,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11799
11994
  if (!featureEnabled("editor")) {
11800
11995
  return c.json({ error: featureDisabledMessage("editor") }, 404);
11801
11996
  }
11802
- const originReject = rejectCrossOrigin(c);
11997
+ const originReject = rejectCrossOrigin(c, bindHost);
11803
11998
  if (originReject)
11804
11999
  return originReject;
11805
12000
  const headerReject = rejectBadWriteHeaders(c);
@@ -11814,14 +12009,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11814
12009
  return c.json({ error: "unknown domain key" }, 400);
11815
12010
  }
11816
12011
  return withWriteLock(async () => {
11817
- const r = await mint({
11818
- platformDir,
11819
- key,
11820
- statement: typeof body.statement === "string" ? body.statement : "",
11821
- why: typeof body.why === "string" ? body.why : "",
11822
- livesIn: toLivesIn(body.livesIn),
11823
- issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined
11824
- });
12012
+ const r = await mint({ platformDir, key, ...mintFieldsFromBody(body) });
11825
12013
  if (!r.ok)
11826
12014
  return failureResponse(c, r);
11827
12015
  await runIndex({ platformDir, storage: storage2 });
@@ -11832,7 +12020,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11832
12020
  if (!featureEnabled("editor")) {
11833
12021
  return c.json({ error: featureDisabledMessage("editor") }, 404);
11834
12022
  }
11835
- const originReject = rejectCrossOrigin(c);
12023
+ const originReject = rejectCrossOrigin(c, bindHost);
11836
12024
  if (originReject)
11837
12025
  return originReject;
11838
12026
  const headerReject = rejectBadWriteHeaders(c);
@@ -11862,7 +12050,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11862
12050
  if (!featureEnabled("editor")) {
11863
12051
  return c.json({ error: featureDisabledMessage("editor") }, 404);
11864
12052
  }
11865
- const originReject = rejectCrossOrigin(c);
12053
+ const originReject = rejectCrossOrigin(c, bindHost);
11866
12054
  if (originReject)
11867
12055
  return originReject;
11868
12056
  const headerReject = rejectBadWriteHeaders(c);
@@ -11906,7 +12094,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11906
12094
  if (!featureEnabled("editor")) {
11907
12095
  return c.json({ error: featureDisabledMessage("editor") }, 404);
11908
12096
  }
11909
- const originReject = rejectCrossOrigin(c);
12097
+ const originReject = rejectCrossOrigin(c, bindHost);
11910
12098
  if (originReject)
11911
12099
  return originReject;
11912
12100
  const headerReject = rejectBadWriteHeaders(c);
@@ -11932,6 +12120,30 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11932
12120
  return c.json({ ok: true, id: r.id, file: r.file, reason: r.reason, sites: r.sites }, 200);
11933
12121
  });
11934
12122
  }));
12123
+ app.post("/api/requirements/:id/accept", guarded(async function acceptRequirement(c) {
12124
+ if (!featureEnabled("editor")) {
12125
+ return c.json({ error: featureDisabledMessage("editor") }, 404);
12126
+ }
12127
+ const originReject = rejectCrossOrigin(c, bindHost);
12128
+ if (originReject)
12129
+ return originReject;
12130
+ const headerReject = rejectBadWriteHeaders(c);
12131
+ if (headerReject)
12132
+ return headerReject;
12133
+ const parsed = await readJsonBody(c);
12134
+ if (!parsed.ok)
12135
+ return parsed.res;
12136
+ const id = c.req.param("id") ?? "";
12137
+ if (!ID_RE.test(id))
12138
+ return c.json({ error: "id must be a requirement id (KEY-NNN)" }, 400);
12139
+ return withWriteLock(async () => {
12140
+ const r = await accept({ platformDir, id });
12141
+ if (!r.ok)
12142
+ return failureResponse(c, r);
12143
+ await runIndex({ platformDir, storage: storage2 });
12144
+ return c.json({ ok: true, id: r.id, file: r.file }, 200);
12145
+ });
12146
+ }));
11935
12147
  app.get("/api/propagation/:id", guarded((c) => c.json(propagation2(storage2, c.req.param("id") ?? "").rows)));
11936
12148
  app.get("/api/query", guarded((c) => {
11937
12149
  if (!featureEnabled("query")) {
@@ -12012,6 +12224,7 @@ var init_api = __esm(() => {
12012
12224
  init_discover();
12013
12225
  init_pipeline();
12014
12226
  init__result();
12227
+ init_accept();
12015
12228
  init_amend();
12016
12229
  init_deprecate();
12017
12230
  init_mint();
@@ -12025,22 +12238,162 @@ var init_api = __esm(() => {
12025
12238
  writeChain = Promise.resolve();
12026
12239
  });
12027
12240
 
12241
+ // src/server/lan.ts
12242
+ import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto";
12243
+ import { isIP } from "net";
12244
+ function ipv4Prefix(ip) {
12245
+ const parts = ip.split(".");
12246
+ if (parts.length !== 4 || parts.some((p) => String(Number(p)) !== p))
12247
+ return null;
12248
+ return [Number(parts[0]), Number(parts[1])];
12249
+ }
12250
+ function isLoopbackAddress(ip) {
12251
+ if (ip === "::1")
12252
+ return true;
12253
+ return ipv4Prefix(ip)?.[0] === 127;
12254
+ }
12255
+ function isPrivateIPv4([a, b]) {
12256
+ if (a === 127 || a === 10)
12257
+ return true;
12258
+ if (a === 172 && b >= 16 && b <= 31)
12259
+ return true;
12260
+ if (a === 192 && b === 168)
12261
+ return true;
12262
+ return a === 100 && b >= 64 && b <= 127;
12263
+ }
12264
+ function bindHostRefusal(raw11) {
12265
+ const kind = isIP(raw11);
12266
+ if (kind === 0)
12267
+ return `--host must be a literal IP address, not "${raw11}"`;
12268
+ if (kind === 6) {
12269
+ return raw11 === "::1" ? null : `--host ${raw11}: the only IPv6 address accepted is ::1`;
12270
+ }
12271
+ const prefix = ipv4Prefix(raw11);
12272
+ if (prefix === null || !isPrivateIPv4(prefix)) {
12273
+ return `--host ${raw11} is not a loopback, RFC 1918, or 100.64.0.0/10 address`;
12274
+ }
12275
+ return null;
12276
+ }
12277
+ function urlHost(ip, port) {
12278
+ return `${isIP(ip) === 6 ? `[${ip}]` : ip}:${port}`;
12279
+ }
12280
+ function generateToken() {
12281
+ return randomBytes(32).toString("hex");
12282
+ }
12283
+ function pinnedTokenRefusal(token) {
12284
+ if (!/^[A-Za-z0-9_-]+$/.test(token))
12285
+ return "the access token may only contain A-Z a-z 0-9 _ -";
12286
+ if (token.length < MIN_PINNED_TOKEN_LENGTH) {
12287
+ return `the access token must be at least ${MIN_PINNED_TOKEN_LENGTH} characters`;
12288
+ }
12289
+ return null;
12290
+ }
12291
+ function digest(value) {
12292
+ return createHash2("sha256").update(value).digest();
12293
+ }
12294
+ function tokenMatches(candidate, token) {
12295
+ if (!candidate)
12296
+ return false;
12297
+ return timingSafeEqual(digest(candidate), digest(token));
12298
+ }
12299
+ function cookieValue(header, name) {
12300
+ for (const pair of (header ?? "").split(";")) {
12301
+ const eq = pair.indexOf("=");
12302
+ if (eq !== -1 && pair.slice(0, eq).trim() === name)
12303
+ return pair.slice(eq + 1).trim();
12304
+ }
12305
+ return null;
12306
+ }
12307
+ function isWrite(req, url) {
12308
+ if (req.method !== "GET" && req.method !== "HEAD")
12309
+ return true;
12310
+ if (url.pathname === "/provenance")
12311
+ return true;
12312
+ return url.pathname === "/api/provenance" && url.searchParams.has("resolve");
12313
+ }
12314
+ function plain(status, body, headers = {}) {
12315
+ return new Response(`${body}
12316
+ `, {
12317
+ status,
12318
+ headers: {
12319
+ "content-type": "text/plain; charset=utf-8",
12320
+ "cache-control": "no-store",
12321
+ ...headers
12322
+ }
12323
+ });
12324
+ }
12325
+ function lanGate(fetch2, { bindHost, token, allowWrites }) {
12326
+ return (req) => {
12327
+ const url = new URL(req.url);
12328
+ if (!isServedHostname(url.hostname, bindHost))
12329
+ return plain(403, "host rejected");
12330
+ const queryToken = url.searchParams.get("token");
12331
+ if (queryToken !== null) {
12332
+ if (!tokenMatches(queryToken, token))
12333
+ return plain(401, "invalid access token");
12334
+ url.searchParams.delete("token");
12335
+ return plain(302, "authenticated", {
12336
+ location: `${url.pathname}${url.search}`,
12337
+ "set-cookie": `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`
12338
+ });
12339
+ }
12340
+ if (!tokenMatches(cookieValue(req.headers.get("cookie"), TOKEN_COOKIE), token)) {
12341
+ return plain(401, "access token required: open the tokenized URL spec serve printed");
12342
+ }
12343
+ if (!allowWrites && isWrite(req, url)) {
12344
+ return plain(403, "read-only: restart spec serve with --allow-writes to permit changes");
12345
+ }
12346
+ return fetch2(req);
12347
+ };
12348
+ }
12349
+ var TOKEN_COOKIE = "spec_token", MIN_PINNED_TOKEN_LENGTH = 16;
12350
+ var init_lan = __esm(() => {
12351
+ init_src();
12352
+ });
12353
+
12028
12354
  // src/commands/serve.ts
12029
12355
  var exports_serve = {};
12030
12356
  __export(exports_serve, {
12031
12357
  serveCommand: () => serveCommand,
12358
+ resolveBind: () => resolveBind,
12032
12359
  composeServeApp: () => composeServeApp
12033
12360
  });
12034
12361
  import { existsSync as existsSync13, mkdirSync as mkdirSync6 } from "fs";
12035
12362
  import { dirname as dirname8 } from "path";
12036
- import { defineCommand as defineCommand23 } from "citty";
12363
+ import { defineCommand as defineCommand24 } from "citty";
12037
12364
  import { Hono as Hono2 } from "hono";
12038
- function composeServeApp(storage2, platformDir = process.cwd()) {
12365
+ function composeServeApp(storage2, platformDir = process.cwd(), bindHost = DEFAULT_BIND_HOST) {
12039
12366
  const app = new Hono2;
12040
- mountApi(app, storage2, platformDir);
12041
- mountWebapp(app);
12367
+ mountApi(app, storage2, platformDir, bindHost);
12368
+ mountWebapp(app, bindHost, platformName(platformDir));
12042
12369
  return app;
12043
12370
  }
12371
+ function usageError(message) {
12372
+ console.error(`spec serve: ${message}`);
12373
+ process.exit(EXIT.USAGE);
12374
+ }
12375
+ function resolveBind(args) {
12376
+ const host = args.host ?? DEFAULT_BIND_HOST;
12377
+ const hostRefusal = bindHostRefusal(host);
12378
+ if (hostRefusal !== null)
12379
+ usageError(hostRefusal);
12380
+ if (isLoopbackAddress(host))
12381
+ return { host, lan: null };
12382
+ const pinned = args.token ?? process.env.SPEC_SERVE_TOKEN;
12383
+ if (pinned !== undefined) {
12384
+ const tokenRefusal = pinnedTokenRefusal(pinned);
12385
+ if (tokenRefusal !== null)
12386
+ usageError(tokenRefusal);
12387
+ }
12388
+ return {
12389
+ host,
12390
+ lan: {
12391
+ bindHost: host,
12392
+ token: pinned ?? generateToken(),
12393
+ allowWrites: args.allowWrites === true
12394
+ }
12395
+ };
12396
+ }
12044
12397
  async function runProbe() {
12045
12398
  const app = createApp();
12046
12399
  const server = Bun.serve({
@@ -12073,26 +12426,41 @@ async function runProbe() {
12073
12426
  }
12074
12427
  var serveCommand;
12075
12428
  var init_serve = __esm(() => {
12429
+ init_src();
12076
12430
  init_server();
12077
12431
  init_constants();
12078
12432
  init_discover();
12079
12433
  init_pipeline();
12080
12434
  init_prompt();
12081
12435
  init_api();
12436
+ init_lan();
12082
12437
  init_sqlite();
12083
12438
  init__args();
12084
12439
  init__shared();
12085
- serveCommand = defineCommand23({
12440
+ serveCommand = defineCommand24({
12086
12441
  meta: {
12087
12442
  name: "serve",
12088
- description: "Run the local webapp. Binds 127.0.0.1 on --port (loopback only \u2014 no --host). --probe is the SERV-04 asset-embedding smoke."
12443
+ description: "Run the local webapp. Binds 127.0.0.1 on --port, or one private --host address behind an access token. --probe is the SERV-04 asset-embedding smoke."
12089
12444
  },
12090
12445
  args: {
12091
12446
  platformDir: platformDirArg,
12092
12447
  port: {
12093
12448
  type: "string",
12094
12449
  default: "4000",
12095
- description: "Port (default 4000; 0 = ephemeral). Hostname is hardcoded 127.0.0.1 (T-1-01); no --host accepted."
12450
+ description: "Port (default 4000; 0 = ephemeral)."
12451
+ },
12452
+ host: {
12453
+ type: "string",
12454
+ default: DEFAULT_BIND_HOST,
12455
+ description: "Address to bind (default 127.0.0.1). Only a literal loopback, RFC 1918, or 100.64.0.0/10 IP; a non-loopback address requires the printed access token and is read-only."
12456
+ },
12457
+ token: {
12458
+ type: "string",
12459
+ description: "Pin the access token for a non-loopback --host (else SPEC_SERVE_TOKEN, else random). At least 16 of A-Z a-z 0-9 _ -."
12460
+ },
12461
+ allowWrites: {
12462
+ type: "boolean",
12463
+ description: "On a non-loopback --host, permit editor writes and tracker resolution."
12096
12464
  },
12097
12465
  out: {
12098
12466
  type: "string",
@@ -12100,7 +12468,7 @@ var init_serve = __esm(() => {
12100
12468
  },
12101
12469
  probe: {
12102
12470
  type: "boolean",
12103
- description: "Smoke test: bind ephemeral port on 127.0.0.1, GET /, assert body contains placeholder, exit."
12471
+ description: "Smoke test: bind ephemeral port on 127.0.0.1 (ignores --host), GET /, assert body contains placeholder, exit."
12104
12472
  },
12105
12473
  noPrompt: {
12106
12474
  type: "boolean",
@@ -12124,6 +12492,7 @@ var init_serve = __esm(() => {
12124
12492
  process.exit(EXIT.USAGE);
12125
12493
  return;
12126
12494
  }
12495
+ const { host, lan } = resolveBind(args);
12127
12496
  const platformDir = resolvePlatformDir(args);
12128
12497
  const outArg2 = args.out;
12129
12498
  const dbPath = resolveDbPath(platformDir, outArg2);
@@ -12154,20 +12523,26 @@ var init_serve = __esm(() => {
12154
12523
  if (needsIndex || storage2.listRepos().length === 0) {
12155
12524
  await runIndex({ platformDir, storage: storage2 });
12156
12525
  }
12157
- const app = composeServeApp(storage2, platformDir);
12526
+ const app = composeServeApp(storage2, platformDir, host);
12158
12527
  server = Bun.serve({
12159
12528
  port,
12160
- hostname: "127.0.0.1",
12161
- fetch: app.fetch
12529
+ hostname: host,
12530
+ fetch: lan === null ? app.fetch : lanGate(app.fetch, lan)
12162
12531
  });
12163
12532
  } catch (err) {
12164
12533
  storage2.close();
12165
12534
  handleStorageUnavailable(err, dbPath);
12166
- console.error(`spec serve: failed to start on 127.0.0.1:${port}:`, err);
12535
+ console.error(`spec serve: failed to start on ${urlHost(host, port)}:`, err);
12167
12536
  process.exit(EXIT.FAILURE);
12168
12537
  return;
12169
12538
  }
12170
- console.log(`spec: serving on http://127.0.0.1:${server.port}`);
12539
+ const serving = `spec: serving ${platformName(platformDir)} on ${server.url.origin}`;
12540
+ if (lan === null) {
12541
+ console.log(serving);
12542
+ return;
12543
+ }
12544
+ console.log(`${serving}/?token=${lan.token}`);
12545
+ console.log(lan.allowWrites ? "spec: writes ALLOWED (--allow-writes)" : "spec: read-only");
12171
12546
  }
12172
12547
  });
12173
12548
  });
@@ -12185,7 +12560,7 @@ __export(exports_docs, {
12185
12560
  import { existsSync as existsSync14, statSync as statSync6 } from "fs";
12186
12561
  import { join as join15, normalize, sep as sep4 } from "path";
12187
12562
  import { fileURLToPath } from "url";
12188
- import { defineCommand as defineCommand24 } from "citty";
12563
+ import { defineCommand as defineCommand25 } from "citty";
12189
12564
  function resolveDocsRootFrom(candidates) {
12190
12565
  for (const candidate of candidates) {
12191
12566
  if (existsSync14(join15(candidate, "index.html")))
@@ -12263,7 +12638,7 @@ function exitNoDocsRoot() {
12263
12638
  var docsCommand;
12264
12639
  var init_docs2 = __esm(() => {
12265
12640
  init_constants();
12266
- docsCommand = defineCommand24({
12641
+ docsCommand = defineCommand25({
12267
12642
  meta: {
12268
12643
  name: "docs",
12269
12644
  description: "Serve the bundled documentation site offline. Binds 127.0.0.1 on --port (loopback only \u2014 no --host). --probe is the boot-and-fetch smoke."
@@ -12324,10 +12699,10 @@ __export(exports___smoke, {
12324
12699
  schemaSmokeCommand: () => schemaSmokeCommand,
12325
12700
  schemaMismatchSmokeCommand: () => schemaMismatchSmokeCommand
12326
12701
  });
12327
- import { defineCommand as defineCommand25 } from "citty";
12702
+ import { defineCommand as defineCommand26 } from "citty";
12328
12703
  var schemaSmokeCommand, schemaMismatchSmokeCommand;
12329
12704
  var init___smoke = __esm(() => {
12330
- schemaSmokeCommand = defineCommand25({
12705
+ schemaSmokeCommand = defineCommand26({
12331
12706
  meta: {
12332
12707
  name: "__schema-smoke",
12333
12708
  description: "(hidden CI) open a fresh DB, exec DDL, assert the schema shape",
@@ -12370,7 +12745,7 @@ var init___smoke = __esm(() => {
12370
12745
  console.log("__schema-smoke OK");
12371
12746
  }
12372
12747
  });
12373
- schemaMismatchSmokeCommand = defineCommand25({
12748
+ schemaMismatchSmokeCommand = defineCommand26({
12374
12749
  meta: {
12375
12750
  name: "__schema-mismatch-smoke",
12376
12751
  description: "(hidden CI) corrupt _schema_version, re-open, assert silent rebuild",
@@ -12396,8 +12771,8 @@ var init___smoke = __esm(() => {
12396
12771
 
12397
12772
  // src/cli.ts
12398
12773
  init_package();
12399
- import { defineCommand as defineCommand26, runMain, showUsage } from "citty";
12400
- var main = defineCommand26({
12774
+ import { defineCommand as defineCommand27, runMain, showUsage } from "citty";
12775
+ var main = defineCommand27({
12401
12776
  meta: {
12402
12777
  name: "spec",
12403
12778
  version: package_default.version,
@@ -12422,6 +12797,7 @@ var main = defineCommand26({
12422
12797
  deprecate: () => Promise.resolve().then(() => (init_deprecate2(), exports_deprecate)).then((m) => m.deprecateCommand),
12423
12798
  move: () => Promise.resolve().then(() => (init_move2(), exports_move)).then((m) => m.moveCommand),
12424
12799
  amend: () => Promise.resolve().then(() => (init_amend2(), exports_amend)).then((m) => m.amendCommand),
12800
+ accept: () => Promise.resolve().then(() => (init_accept2(), exports_accept)).then((m) => m.acceptCommand),
12425
12801
  mcp: () => Promise.resolve().then(() => (init_mcp2(), exports_mcp)).then((m) => m.mcpCommand),
12426
12802
  resolve: () => Promise.resolve().then(() => (init_resolve2(), exports_resolve)).then((m) => m.resolveCommand),
12427
12803
  gate: () => Promise.resolve().then(() => (init_gate2(), exports_gate)).then((m) => m.gateCommand),