spur-board 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/cli.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { R as configSchema, s as OPERATIONS, z as SLACK_BOT_SCOPES } from "./src-VWyX7Nwm.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-BnkZmz1i.mjs";
3
- import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-UyXnaekw.mjs";
1
+ import { B as SLACK_BOT_SCOPES, R as configSchema, s as OPERATIONS, z as requiredSecretKeys } from "./src-BFgTh55w.mjs";
2
+ import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-Ln76BdDz.mjs";
3
+ import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-B_6fA4Au.mjs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir, hostname, userInfo } from "node:os";
7
- import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
8
8
  import { Command, CommanderError } from "@commander-js/extra-typings";
9
9
  import { z } from "zod";
10
10
  import YAML from "yaml";
@@ -190,12 +190,7 @@ function localSecrets(config, path, ports) {
190
190
  } catch {
191
191
  names = {};
192
192
  }
193
- const slackConfigured = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
194
- const required = [
195
- "GITHUB_TOKEN",
196
- ...config.projects.some((project) => project.tracker.type === "jira") ? ["JIRA_EMAIL", "JIRA_API_TOKEN"] : [],
197
- ...slackConfigured ? ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] : []
198
- ];
193
+ const required = requiredSecretKeys(config);
199
194
  const missing = required.filter((name) => !names[name]);
200
195
  return {
201
196
  ...result("secrets", missing.length === 0, `Add ${missing.join(", ")} to the private secrets.env file.`),
@@ -406,8 +401,6 @@ function renderUserUnit(paths) {
406
401
  return [
407
402
  "[Unit]",
408
403
  "Description=spur-board daemon",
409
- "After=network-online.target",
410
- "Wants=network-online.target",
411
404
  "",
412
405
  "[Service]",
413
406
  "Type=simple",
@@ -618,14 +611,48 @@ async function targetDirectory(path) {
618
611
  throw error;
619
612
  }
620
613
  }
621
- async function checkTarget(home, agent, destination) {
622
- const parent = join(home, agent, "skills");
623
- await targetDirectory(join(home, agent));
624
- await targetDirectory(parent);
625
- return targetDirectory(destination);
614
+ async function resolvedParent(path) {
615
+ let entry;
616
+ try {
617
+ entry = await lstat(path);
618
+ } catch (error) {
619
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
620
+ const ancestor = await resolvedParent(dirname(path));
621
+ const parent = join(ancestor.parent, basename(path));
622
+ return {
623
+ parent,
624
+ missing: [...ancestor.missing, parent]
625
+ };
626
+ }
627
+ throw error;
628
+ }
629
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) throw new Error(`Skill install parent must be a directory: ${path}`);
630
+ const canonical = await realpath(path);
631
+ if (!(await stat(canonical)).isDirectory()) throw new Error(`Skill install parent must be a directory: ${path}`);
632
+ return {
633
+ parent: canonical,
634
+ missing: []
635
+ };
636
+ }
637
+ async function createMissingParents(paths) {
638
+ for (const path of paths) {
639
+ try {
640
+ await mkdir(path, { mode: 448 });
641
+ } catch (error) {
642
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
643
+ }
644
+ const entry = await lstat(path);
645
+ if (!entry.isDirectory() || entry.isSymbolicLink() || await realpath(path) !== path) throw new Error(`Skill install parent must be a directory, not a new symlink: ${path}`);
646
+ }
626
647
  }
627
- async function withInstallLock(home, action) {
628
- const fd = openSync(join(home, ".spur-board-skills-install.lock"), constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 384);
648
+ async function withInstallLock(home, env, action) {
649
+ const configured = env["XDG_STATE_HOME"];
650
+ const stateDir = join(configured && isAbsolute(configured) ? configured : join(home, ".local", "state"), "spur-board");
651
+ await mkdir(stateDir, {
652
+ recursive: true,
653
+ mode: 448
654
+ });
655
+ const fd = openSync(join(stateDir, "skills-install.lock"), constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 384);
629
656
  try {
630
657
  await new Promise((resolveLock, reject) => {
631
658
  const child = spawn("flock", [
@@ -707,88 +734,96 @@ async function recoverSkill(parent, destination, name) {
707
734
  await rm(owner);
708
735
  }
709
736
  }
710
- async function installIntoHome(home, packageRoot, version, options) {
711
- for (const agent of agents) for (const skill of skills) await checkTarget(home, agent, join(home, agent, "skills", skill.name));
737
+ async function installIntoHome(home, packageRoot, version, options, env) {
738
+ const targets = await Promise.all(agents.map(async (agent) => {
739
+ const configured = agent === ".codex" ? env["CODEX_HOME"] : void 0;
740
+ return {
741
+ agent,
742
+ ...await resolvedParent(join(configured ? resolve(configured) : join(home, agent), "skills"))
743
+ };
744
+ }));
745
+ for (const { parent } of targets) for (const skill of skills) await targetDirectory(join(parent, skill.name));
712
746
  const installedAt = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
747
+ for (const { parent, missing } of targets) {
748
+ await createMissingParents(missing);
749
+ if (await realpath(parent) !== parent) throw new Error(`Skill install parent changed after preflight: ${parent}`);
750
+ }
713
751
  const results = [];
714
- for (const agent of agents) {
715
- const parent = join(home, agent, "skills");
716
- for (const skill of skills) {
717
- const destination = join(parent, skill.name);
718
- await recoverSkill(parent, destination, skill.name);
719
- const changed = !await currentVersion(destination, version);
720
- if (changed) {
721
- await mkdir(parent, { recursive: true });
722
- await checkTarget(home, agent, destination);
723
- const stageId = randomUUID();
724
- const staged = join(parent, `.${skill.name}-stage-${stageId}`);
725
- const owner = `${staged}.owner`;
726
- await writeFile(owner, `spur-board stage ${stageId}\n`, {
727
- flag: "wx",
728
- mode: 384
729
- });
730
- let stagedCreated = false;
731
- try {
732
- await mkdir(staged, { mode: 448 });
733
- stagedCreated = true;
734
- const source = join(packageRoot, "skills", skill.source);
735
- if (options.copySkill) await options.copySkill(source, staged);
736
- else await cp(source, staged, { recursive: true });
737
- const stamp = {
738
- version,
739
- installedAt
740
- };
741
- await writeFile(join(staged, stampName), `${JSON.stringify(stamp, null, 2)}\n`);
742
- if (await checkTarget(home, agent, destination)) {
743
- const backupId = randomUUID();
744
- const backup = join(parent, `.${skill.name}-backup-${backupId}`);
745
- const backupOwner = `${backup}.owner`;
746
- const original = await lstat(destination, { bigint: true });
747
- await writeFile(backupOwner, `spur-board backup ${backupId} ${`${String(original.dev)}:${String(original.ino)}`}\n`, {
748
- flag: "wx",
749
- mode: 384
750
- });
751
- let backupContainsSkill = false;
752
+ for (const { agent, parent } of targets) for (const skill of skills) {
753
+ const destination = join(parent, skill.name);
754
+ await recoverSkill(parent, destination, skill.name);
755
+ const changed = !await currentVersion(destination, version);
756
+ if (changed) {
757
+ await targetDirectory(destination);
758
+ const stageId = randomUUID();
759
+ const staged = join(parent, `.${skill.name}-stage-${stageId}`);
760
+ const owner = `${staged}.owner`;
761
+ await writeFile(owner, `spur-board stage ${stageId}\n`, {
762
+ flag: "wx",
763
+ mode: 384
764
+ });
765
+ let stagedCreated = false;
766
+ try {
767
+ await mkdir(staged, { mode: 448 });
768
+ stagedCreated = true;
769
+ const source = join(packageRoot, "skills", skill.source);
770
+ if (options.copySkill) await options.copySkill(source, staged);
771
+ else await cp(source, staged, { recursive: true });
772
+ const stamp = {
773
+ version,
774
+ installedAt
775
+ };
776
+ await writeFile(join(staged, stampName), `${JSON.stringify(stamp, null, 2)}\n`);
777
+ if (await targetDirectory(destination)) {
778
+ const backupId = randomUUID();
779
+ const backup = join(parent, `.${skill.name}-backup-${backupId}`);
780
+ const backupOwner = `${backup}.owner`;
781
+ const original = await lstat(destination, { bigint: true });
782
+ await writeFile(backupOwner, `spur-board backup ${backupId} ${`${String(original.dev)}:${String(original.ino)}`}\n`, {
783
+ flag: "wx",
784
+ mode: 384
785
+ });
786
+ let backupContainsSkill = false;
787
+ try {
788
+ if (await targetDirectory(backup)) throw new Error(`Skill backup path already exists: ${backup}`);
789
+ await rename(destination, backup);
790
+ backupContainsSkill = true;
752
791
  try {
753
- if (await targetDirectory(backup)) throw new Error(`Skill backup path already exists: ${backup}`);
754
- await rename(destination, backup);
755
- backupContainsSkill = true;
756
- try {
757
- await rename(staged, destination);
758
- } catch (error) {
759
- await rename(backup, destination);
760
- backupContainsSkill = false;
761
- throw error;
762
- }
763
- await rm(backup, { recursive: true });
792
+ await rename(staged, destination);
793
+ } catch (error) {
794
+ await rename(backup, destination);
764
795
  backupContainsSkill = false;
765
- } finally {
766
- if (!backupContainsSkill) await rm(backupOwner);
796
+ throw error;
767
797
  }
768
- } else await rename(staged, destination);
769
- } finally {
770
- if (stagedCreated) await rm(staged, {
771
- recursive: true,
772
- force: true
773
- });
774
- await rm(owner);
775
- }
798
+ await rm(backup, { recursive: true });
799
+ backupContainsSkill = false;
800
+ } finally {
801
+ if (!backupContainsSkill) await rm(backupOwner);
802
+ }
803
+ } else await rename(staged, destination);
804
+ } finally {
805
+ if (stagedCreated) await rm(staged, {
806
+ recursive: true,
807
+ force: true
808
+ });
809
+ await rm(owner);
776
810
  }
777
- results.push({
778
- agent: agent === ".claude" ? "claude" : "codex",
779
- skill: skill.name,
780
- changed
781
- });
782
811
  }
812
+ results.push({
813
+ agent: agent === ".claude" ? "claude" : "codex",
814
+ skill: skill.name,
815
+ changed
816
+ });
783
817
  }
784
818
  return results;
785
819
  }
786
820
  async function installSkills(options = {}) {
787
821
  const packageRoot = resolve(options.packageRoot ?? fileURLToPath(new URL("../", import.meta.url)));
788
822
  const home = await realpath(resolve(options.home ?? homedir()));
823
+ const env = options.env ?? process.env;
789
824
  const version = await packageVersion(packageRoot);
790
825
  await Promise.all(skills.map((skill) => requireSource(join(packageRoot, "skills", skill.source))));
791
- return withInstallLock(home, () => installIntoHome(home, packageRoot, version, options));
826
+ return withInstallLock(home, env, () => installIntoHome(home, packageRoot, version, options, env));
792
827
  }
793
828
  //#endregion
794
829
  //#region src/cli.ts
@@ -973,7 +1008,10 @@ function registerSetupCommands(program, options, io, env) {
973
1008
  if (checks.some((check) => check.blocking)) throw new CommanderError(1, "board.error", "Doctor found blocking failures.");
974
1009
  });
975
1010
  commandFor(program, "skills install").description("Install bundled skills for Claude Code and Codex.").action(async () => {
976
- print(await runSetup(() => installSkills(options.setup)));
1011
+ print(await runSetup(() => installSkills({
1012
+ ...options.setup,
1013
+ env
1014
+ })));
977
1015
  });
978
1016
  commandFor(program, "service install").description("Install and start the systemd user service.").action(async () => {
979
1017
  print(await runSetup(() => installService(serviceOptions)));
@@ -1,4 +1,4 @@
1
- import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-VWyX7Nwm.mjs";
1
+ import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-BFgTh55w.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { request } from "node:http";
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as requestTokenEnrollment, t as requestOperation } from "./client-BnkZmz1i.mjs";
1
+ import { n as requestTokenEnrollment, t as requestOperation } from "./client-Ln76BdDz.mjs";
2
2
  export { requestOperation, requestTokenEnrollment };
@@ -1,5 +1,5 @@
1
- import { R as configSchema } from "./src-VWyX7Nwm.mjs";
2
- import { r as CliError } from "./client-BnkZmz1i.mjs";
1
+ import { R as configSchema } from "./src-BFgTh55w.mjs";
2
+ import { r as CliError } from "./client-Ln76BdDz.mjs";
3
3
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
package/dist/daemon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as taskKeySchema, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as configSchema, S as prRefSchema, T as actorIdSchema, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES, z as SLACK_BOT_SCOPES } from "./src-VWyX7Nwm.mjs";
1
+ import { A as taskKeySchema, B as SLACK_BOT_SCOPES, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as configSchema, S as prRefSchema, T as actorIdSchema, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES, z as requiredSecretKeys } from "./src-BFgTh55w.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { closeSync, existsSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, writeSync } from "node:fs";
@@ -12736,6 +12736,11 @@ function retryAt(headers, now) {
12736
12736
  }
12737
12737
  //#endregion
12738
12738
  //#region ../adapters/src/github/client.ts
12739
+ function teamNameScopeDenied(error, query) {
12740
+ if (error.type !== "INSUFFICIENT_SCOPES" || !query.includes("... on Team { name }")) return false;
12741
+ if (error.path !== void 0) return error.path.includes("reviewRequests") && error.path.includes("requestedReviewer") && error.path.at(-1) === "name";
12742
+ return /\bname\b/i.test(error.message) && /\bread:(?:org|discussion)\b/i.test(error.message);
12743
+ }
12739
12744
  function record$2(value) {
12740
12745
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new AdapterError("transient", "Malformed GitHub response");
12741
12746
  return value;
@@ -12824,6 +12829,8 @@ var GithubClient = class {
12824
12829
  status: 429,
12825
12830
  headers: response.headers
12826
12831
  }, Date.now());
12832
+ if (errors.some((error) => teamNameScopeDenied(error, query))) throw new AdapterError("auth", "GitHub token needs read:org for team review requests");
12833
+ if (errors.some((error) => error.type === "INSUFFICIENT_SCOPES")) throw new AdapterError("auth", "GitHub token lacks a required GraphQL read scope");
12827
12834
  const data = record$2(wire.data);
12828
12835
  const rate = record$2(data.rateLimit);
12829
12836
  const remaining = rate.remaining;
@@ -13056,6 +13063,12 @@ function githubSources(config, options) {
13056
13063
  function scoped(errors, root) {
13057
13064
  return errors.filter((error) => error.path?.[0] === root);
13058
13065
  }
13066
+ function checkAccessDenied(errors) {
13067
+ return errors.some((error) => error.type === "FORBIDDEN" && error.path?.includes("statusCheckRollup") === true && error.path.includes("contexts"));
13068
+ }
13069
+ function requireCheckAccess(errors) {
13070
+ if (checkAccessDenied(errors)) throw new AdapterError("auth", "GitHub token lacks Checks read permission");
13071
+ }
13059
13072
  function rejectUnscoped(errors, roots) {
13060
13073
  if (errors.some((error) => error.path === void 0 || !roots.includes(String(error.path[0])))) throw new AdapterError("transient", "Unscoped GitHub GraphQL error");
13061
13074
  }
@@ -13097,6 +13110,7 @@ async function fullPr(client, pr, owner, name, number, signal, budget) {
13097
13110
  }, signal);
13098
13111
  budget(response.budget);
13099
13112
  rejectUnscoped(response.errors, ["repository"]);
13113
+ requireCheckAccess(response.errors);
13100
13114
  if (response.errors.length > 0) return void 0;
13101
13115
  const pagePr = record$2(nested(response.data, "repository", "pullRequest"));
13102
13116
  const page = field === "contexts" ? contexts(pagePr) : record$2(pagePr[field]);
@@ -13154,6 +13168,7 @@ async function pollRepository({ id, repo, login, prefixes, client, ctx }) {
13154
13168
  const found = discover(nodes, ctx.cursor, login);
13155
13169
  const bootstrapCursor = bootstrap && discoveryOk ? latestUpdatedAt(connectionNodes(nested(response.data, "repository", "mergedBaseline")), found.cursor) ?? sourceCursorSchema.parse("1970-01-01T00:00:00.000Z") : void 0;
13156
13170
  const process = async (number, wire, errors, ref) => {
13171
+ requireCheckAccess(errors);
13157
13172
  const error = errors[0];
13158
13173
  if (error !== void 0) {
13159
13174
  const direct = error.path === void 0 || error.path.length <= 2;
@@ -13457,6 +13472,7 @@ function createGithubIssuesAdapter(options) {
13457
13472
  //#endregion
13458
13473
  //#region ../adapters/src/jira/jira.ts
13459
13474
  const SEARCH_PAGE_LIMIT = 20;
13475
+ const MAX_GET_SEARCH_URL_LENGTH = 2e3;
13460
13476
  const OUTSIDE_BATCH_SIZE = 50;
13461
13477
  const FALLBACK_REQUEST_LIMIT = 100;
13462
13478
  const SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint";
@@ -13571,6 +13587,7 @@ function createJiraAdapter(options) {
13571
13587
  if (signal.aborted) throw new AdapterError("transient", "Jira poll aborted");
13572
13588
  throw new AdapterError("transient", "Jira request failed");
13573
13589
  }
13590
+ if (response.status === 414 && options.searchMethod === "GET") throw new AdapterError("config", "Jira GET search URL is too long");
13574
13591
  if (!response.ok) throw new JiraHttpError(response.status, classifyHttpFailure({
13575
13592
  status: response.status,
13576
13593
  headers: response.headers
@@ -13612,19 +13629,28 @@ function createJiraAdapter(options) {
13612
13629
  const seen = /* @__PURE__ */ new Set();
13613
13630
  for (let page = 0; page < SEARCH_PAGE_LIMIT; page += 1) {
13614
13631
  beforePage?.();
13615
- const result = object$1(await request(tracker.baseUrl, "/rest/api/3/search/jql", signal, {
13632
+ const fields = [
13633
+ "summary",
13634
+ "status",
13635
+ "assignee",
13636
+ "labels",
13637
+ "updated",
13638
+ ...sprintField === null ? [] : [sprintField]
13639
+ ];
13640
+ const body = {
13616
13641
  jql,
13617
13642
  maxResults: 100,
13618
- fields: [
13619
- "summary",
13620
- "status",
13621
- "assignee",
13622
- "labels",
13623
- "updated",
13624
- ...sprintField === null ? [] : [sprintField]
13625
- ],
13643
+ fields,
13626
13644
  ...token === void 0 ? {} : { nextPageToken: token }
13627
- }));
13645
+ };
13646
+ const path = options.searchMethod === "GET" ? `/rest/api/3/search/jql?${new URLSearchParams({
13647
+ jql,
13648
+ maxResults: "100",
13649
+ fields: fields.join(","),
13650
+ ...token === void 0 ? {} : { nextPageToken: token }
13651
+ }).toString()}` : "/rest/api/3/search/jql";
13652
+ if (options.searchMethod === "GET" && new URL(path, tracker.baseUrl).href.length > MAX_GET_SEARCH_URL_LENGTH) throw new AdapterError("config", `Jira GET search URL is too long (over ${String(MAX_GET_SEARCH_URL_LENGTH)} characters)`);
13653
+ const result = object$1(await request(tracker.baseUrl, path, signal, options.searchMethod === "GET" ? void 0 : body));
13628
13654
  issues.push(...array(result["issues"]));
13629
13655
  const next = result["nextPageToken"];
13630
13656
  if (next == null || next === "") {
@@ -13878,9 +13904,15 @@ function applyEntityOutcomes(input) {
13878
13904
  function startRunner(options) {
13879
13905
  const startedAt = (options.now ?? Date.now)();
13880
13906
  const sources = options.entries.map((entry) => runSource(entry, options, startedAt));
13881
- return { async stop() {
13882
- await Promise.all(sources.map((source) => source.stop()));
13883
- } };
13907
+ return {
13908
+ firstAttempts: Promise.all(sources.map(async (source) => ({
13909
+ source: source.id,
13910
+ outcome: await source.firstAttempt
13911
+ }))),
13912
+ async stop() {
13913
+ await Promise.all(sources.map((source) => source.stop()));
13914
+ }
13915
+ };
13884
13916
  }
13885
13917
  function runSource(entry, options, runnerStartMs) {
13886
13918
  const { adapter, reserveRemaining } = entry;
@@ -13954,7 +13986,7 @@ function runSource(entry, options, runnerStartMs) {
13954
13986
  if (stopping) return;
13955
13987
  pollTimer = setTimeout(() => {
13956
13988
  pollTimer = void 0;
13957
- current = poll();
13989
+ current = poll().then(() => void 0);
13958
13990
  }, delayMs);
13959
13991
  }
13960
13992
  async function poll() {
@@ -14050,22 +14082,28 @@ function runSource(entry, options, runnerStartMs) {
14050
14082
  now: now()
14051
14083
  });
14052
14084
  failures = delay.failures;
14053
- armDeadline();
14085
+ if (!options.oneShot) armDeadline();
14054
14086
  await report();
14055
- schedule(delay.delayMs);
14087
+ if (!options.oneShot) schedule(delay.delayMs);
14088
+ return outcome;
14056
14089
  }
14057
- armDeadline();
14058
- current = poll();
14059
- return { async stop() {
14060
- stopping = true;
14061
- clearTimeout(pollTimer);
14062
- clearTimeout(deadlineTimer);
14063
- clearTimeout(hardBoundTimer);
14064
- pollTimer = deadlineTimer = hardBoundTimer = void 0;
14065
- controller?.abort();
14066
- await current;
14067
- await reports;
14068
- } };
14090
+ if (!options.oneShot) armDeadline();
14091
+ const firstAttempt = poll();
14092
+ current = firstAttempt.then(() => void 0);
14093
+ return {
14094
+ id: source,
14095
+ firstAttempt,
14096
+ async stop() {
14097
+ stopping = true;
14098
+ clearTimeout(pollTimer);
14099
+ clearTimeout(deadlineTimer);
14100
+ clearTimeout(hardBoundTimer);
14101
+ pollTimer = deadlineTimer = hardBoundTimer = void 0;
14102
+ controller?.abort();
14103
+ await current;
14104
+ await reports;
14105
+ }
14106
+ };
14069
14107
  }
14070
14108
  /**
14071
14109
  * Settles with `work`, or with undefined as soon as `signal` aborts, so `stop()` never waits
@@ -14325,7 +14363,8 @@ function startAdapters(config, core, options = {}) {
14325
14363
  },
14326
14364
  intervalMs: config.adapters.jira.intervalMs,
14327
14365
  ...options.fetch === void 0 ? {} : { fetch: options.fetch },
14328
- warn: options.warn ?? (() => void 0)
14366
+ warn: options.warn ?? (() => void 0),
14367
+ ...options.jiraSearchMethod === void 0 ? {} : { searchMethod: options.jiraSearchMethod }
14329
14368
  }) : blindSource(id, config.adapters.jira.intervalMs, "Jira credential is missing") });
14330
14369
  } else {
14331
14370
  const id = `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
@@ -14362,12 +14401,16 @@ function startAdapters(config, core, options = {}) {
14362
14401
  }) }],
14363
14402
  core,
14364
14403
  config,
14365
- ...options.now === void 0 ? {} : { now: options.now }
14404
+ ...options.now === void 0 ? {} : { now: options.now },
14405
+ ...options.oneShot === void 0 ? {} : { oneShot: options.oneShot }
14366
14406
  });
14367
- return { async stop() {
14368
- await runner.stop();
14369
- await Promise.allSettled([...notices]);
14370
- } };
14407
+ return {
14408
+ firstAttempts: runner.firstAttempts,
14409
+ async stop() {
14410
+ await runner.stop();
14411
+ await Promise.allSettled([...notices]);
14412
+ }
14413
+ };
14371
14414
  }
14372
14415
  //#endregion
14373
14416
  //#region ../gateway-slack/src/api.ts
@@ -15518,11 +15561,7 @@ async function runDaemonDoctor(ctx, productVersion, fetcher = fetch) {
15518
15561
  const checks = [];
15519
15562
  const jiraTrackers = config.projects.flatMap((project) => project.tracker.type === "jira" ? [project.tracker] : []);
15520
15563
  const slackConfigured = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
15521
- const missing = [
15522
- "GITHUB_TOKEN",
15523
- ...jiraTrackers.length > 0 ? ["JIRA_EMAIL", "JIRA_API_TOKEN"] : [],
15524
- ...slackConfigured ? ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] : []
15525
- ].filter((name) => !secrets[name]);
15564
+ const missing = requiredSecretKeys(config).filter((name) => !secrets[name]);
15526
15565
  checks.push(check("secrets", missing.length === 0, `Add ${missing.join(", ")} to the private secrets.env file.`));
15527
15566
  const jiraEmail = secrets["JIRA_EMAIL"];
15528
15567
  const jiraToken = secrets["JIRA_API_TOKEN"];
package/dist/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-VWyX7Nwm.mjs";
2
- import { r as CliError, t as requestOperation } from "./client-BnkZmz1i.mjs";
3
- import { n as loadClientConfig } from "./config-UyXnaekw.mjs";
1
+ import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-BFgTh55w.mjs";
2
+ import { r as CliError, t as requestOperation } from "./client-Ln76BdDz.mjs";
3
+ import { n as loadClientConfig } from "./config-B_6fA4Au.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { z } from "zod";
@@ -303,6 +303,49 @@ const configSchema = z.strictObject({
303
303
  }).prefault({})
304
304
  }).prefault({})
305
305
  });
306
+ /** Recognized names in the private secrets.env file; values never belong in config.yaml. */
307
+ const SECRET_ENV_KEYS = [
308
+ {
309
+ name: "GITHUB_TOKEN",
310
+ requiredWhen: "always",
311
+ purpose: "Read GitHub repositories and check runs."
312
+ },
313
+ {
314
+ name: "JIRA_EMAIL",
315
+ requiredWhen: "jira",
316
+ purpose: "Jira account email."
317
+ },
318
+ {
319
+ name: "JIRA_API_TOKEN",
320
+ requiredWhen: "jira",
321
+ purpose: "Jira API token."
322
+ },
323
+ {
324
+ name: "SLACK_BOT_TOKEN",
325
+ requiredWhen: "slack",
326
+ purpose: "Slack bot Web API token."
327
+ },
328
+ {
329
+ name: "SLACK_APP_TOKEN",
330
+ requiredWhen: "slack",
331
+ purpose: "Slack app-level Socket Mode token."
332
+ },
333
+ {
334
+ name: "SLACK_APP_CONFIG_TOKEN",
335
+ requiredWhen: "optional",
336
+ purpose: "Verify the installed slash command through the Slack app manifest API."
337
+ },
338
+ {
339
+ name: "SLACK_API_URL",
340
+ requiredWhen: "optional",
341
+ purpose: "Override the Slack API URL for local testing."
342
+ }
343
+ ];
344
+ function requiredSecretKeys(config) {
345
+ const jira = config.projects.some((project) => project.tracker.type === "jira");
346
+ const slack = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
347
+ return SECRET_ENV_KEYS.filter(({ requiredWhen }) => requiredWhen === "always" || requiredWhen === "jira" && jira || requiredWhen === "slack" && slack).map(({ name }) => name);
348
+ }
306
349
  //#endregion
307
350
  //#region ../contracts/src/errors.ts
308
351
  /**
@@ -485,7 +528,7 @@ const ERRORS = {
485
528
  status: null,
486
529
  exit: EXIT_CODES.notApplied,
487
530
  message: "The daemon is not running or cannot be reached; nothing was applied.",
488
- hint: "Ask in your own session; the operator can run `board service start`. Retry the same command with its existing idempotency key if it has one once the daemon is available.",
531
+ hint: "Ask in your own session; the operator can check `board service status`, then run `board service restart`, or `board service install` if the service was never installed. Retry the same command with its existing idempotency key if it has one once the daemon is available.",
489
532
  recovery: retry("Ask in your own session, then retry the same command with its existing idempotency key if it has one.", "After the daemon is available; wait briefly between retries.")
490
533
  }),
491
534
  config: spec("config", {
@@ -2635,4 +2678,4 @@ var AdapterError = class extends Error {
2635
2678
  }
2636
2679
  };
2637
2680
  //#endregion
2638
- export { taskKeySchema as A, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, DEFAULT_SLACK_APP_NAME as I, DEFAULT_SLACK_COMMAND as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, configSchema as R, prRefSchema as S, actorIdSchema as T, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y, SLACK_BOT_SCOPES as z };
2681
+ export { taskKeySchema as A, SLACK_BOT_SCOPES as B, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, DEFAULT_SLACK_APP_NAME as I, DEFAULT_SLACK_COMMAND as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, configSchema as R, prRefSchema as S, actorIdSchema as T, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y, requiredSecretKeys as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "^24.12.0 || ^26.0.0"