spur-board 0.6.0 → 0.7.0

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 { B as SLACK_BOT_SCOPES, R as configSchema, s as OPERATIONS, z as requiredSecretKeys } from "./src-Cy74ZQfR.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-Dw9ZovyI.mjs";
3
- import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-CeDZQlvy.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";
@@ -401,8 +401,6 @@ function renderUserUnit(paths) {
401
401
  return [
402
402
  "[Unit]",
403
403
  "Description=spur-board daemon",
404
- "After=network-online.target",
405
- "Wants=network-online.target",
406
404
  "",
407
405
  "[Service]",
408
406
  "Type=simple",
@@ -613,14 +611,48 @@ async function targetDirectory(path) {
613
611
  throw error;
614
612
  }
615
613
  }
616
- async function checkTarget(home, agent, destination) {
617
- const parent = join(home, agent, "skills");
618
- await targetDirectory(join(home, agent));
619
- await targetDirectory(parent);
620
- 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
+ };
621
636
  }
622
- async function withInstallLock(home, action) {
623
- const fd = openSync(join(home, ".spur-board-skills-install.lock"), constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 384);
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
+ }
647
+ }
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);
624
656
  try {
625
657
  await new Promise((resolveLock, reject) => {
626
658
  const child = spawn("flock", [
@@ -702,88 +734,96 @@ async function recoverSkill(parent, destination, name) {
702
734
  await rm(owner);
703
735
  }
704
736
  }
705
- async function installIntoHome(home, packageRoot, version, options) {
706
- 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));
707
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
+ }
708
751
  const results = [];
709
- for (const agent of agents) {
710
- const parent = join(home, agent, "skills");
711
- for (const skill of skills) {
712
- const destination = join(parent, skill.name);
713
- await recoverSkill(parent, destination, skill.name);
714
- const changed = !await currentVersion(destination, version);
715
- if (changed) {
716
- await mkdir(parent, { recursive: true });
717
- await checkTarget(home, agent, destination);
718
- const stageId = randomUUID();
719
- const staged = join(parent, `.${skill.name}-stage-${stageId}`);
720
- const owner = `${staged}.owner`;
721
- await writeFile(owner, `spur-board stage ${stageId}\n`, {
722
- flag: "wx",
723
- mode: 384
724
- });
725
- let stagedCreated = false;
726
- try {
727
- await mkdir(staged, { mode: 448 });
728
- stagedCreated = true;
729
- const source = join(packageRoot, "skills", skill.source);
730
- if (options.copySkill) await options.copySkill(source, staged);
731
- else await cp(source, staged, { recursive: true });
732
- const stamp = {
733
- version,
734
- installedAt
735
- };
736
- await writeFile(join(staged, stampName), `${JSON.stringify(stamp, null, 2)}\n`);
737
- if (await checkTarget(home, agent, destination)) {
738
- const backupId = randomUUID();
739
- const backup = join(parent, `.${skill.name}-backup-${backupId}`);
740
- const backupOwner = `${backup}.owner`;
741
- const original = await lstat(destination, { bigint: true });
742
- await writeFile(backupOwner, `spur-board backup ${backupId} ${`${String(original.dev)}:${String(original.ino)}`}\n`, {
743
- flag: "wx",
744
- mode: 384
745
- });
746
- 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;
747
791
  try {
748
- if (await targetDirectory(backup)) throw new Error(`Skill backup path already exists: ${backup}`);
749
- await rename(destination, backup);
750
- backupContainsSkill = true;
751
- try {
752
- await rename(staged, destination);
753
- } catch (error) {
754
- await rename(backup, destination);
755
- backupContainsSkill = false;
756
- throw error;
757
- }
758
- await rm(backup, { recursive: true });
792
+ await rename(staged, destination);
793
+ } catch (error) {
794
+ await rename(backup, destination);
759
795
  backupContainsSkill = false;
760
- } finally {
761
- if (!backupContainsSkill) await rm(backupOwner);
796
+ throw error;
762
797
  }
763
- } else await rename(staged, destination);
764
- } finally {
765
- if (stagedCreated) await rm(staged, {
766
- recursive: true,
767
- force: true
768
- });
769
- await rm(owner);
770
- }
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);
771
810
  }
772
- results.push({
773
- agent: agent === ".claude" ? "claude" : "codex",
774
- skill: skill.name,
775
- changed
776
- });
777
811
  }
812
+ results.push({
813
+ agent: agent === ".claude" ? "claude" : "codex",
814
+ skill: skill.name,
815
+ changed
816
+ });
778
817
  }
779
818
  return results;
780
819
  }
781
820
  async function installSkills(options = {}) {
782
821
  const packageRoot = resolve(options.packageRoot ?? fileURLToPath(new URL("../", import.meta.url)));
783
822
  const home = await realpath(resolve(options.home ?? homedir()));
823
+ const env = options.env ?? process.env;
784
824
  const version = await packageVersion(packageRoot);
785
825
  await Promise.all(skills.map((skill) => requireSource(join(packageRoot, "skills", skill.source))));
786
- return withInstallLock(home, () => installIntoHome(home, packageRoot, version, options));
826
+ return withInstallLock(home, env, () => installIntoHome(home, packageRoot, version, options, env));
787
827
  }
788
828
  //#endregion
789
829
  //#region src/cli.ts
@@ -968,7 +1008,10 @@ function registerSetupCommands(program, options, io, env) {
968
1008
  if (checks.some((check) => check.blocking)) throw new CommanderError(1, "board.error", "Doctor found blocking failures.");
969
1009
  });
970
1010
  commandFor(program, "skills install").description("Install bundled skills for Claude Code and Codex.").action(async () => {
971
- print(await runSetup(() => installSkills(options.setup)));
1011
+ print(await runSetup(() => installSkills({
1012
+ ...options.setup,
1013
+ env
1014
+ })));
972
1015
  });
973
1016
  commandFor(program, "service install").description("Install and start the systemd user service.").action(async () => {
974
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-Cy74ZQfR.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-Dw9ZovyI.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-Cy74ZQfR.mjs";
2
- import { r as CliError } from "./client-Dw9ZovyI.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, 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-Cy74ZQfR.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";
package/dist/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-Cy74ZQfR.mjs";
2
- import { r as CliError, t as requestOperation } from "./client-Dw9ZovyI.mjs";
3
- import { n as loadClientConfig } from "./config-CeDZQlvy.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";
@@ -528,7 +528,7 @@ const ERRORS = {
528
528
  status: null,
529
529
  exit: EXIT_CODES.notApplied,
530
530
  message: "The daemon is not running or cannot be reached; nothing was applied.",
531
- 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.",
532
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.")
533
533
  }),
534
534
  config: spec("config", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "^24.12.0 || ^26.0.0"