@spunto/build 0.9.0 → 0.10.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/README.md CHANGED
@@ -122,6 +122,15 @@ orchestrates setup phase by phase instead of a container running one long CMD. T
122
122
  because they share every helper in the file; splitting them out would fork the helpers, which is
123
123
  the duplication this package exists to remove.
124
124
 
125
+ **Skills** (0.10.0): `buildSkillsScript({ sources, discoveryDirs })` clones git *skill sources*
126
+ into `~/.spunto/skills/<id>/` and links each retained skill (a directory with a `SKILL.md`) into
127
+ the harness's discovery directories — one symlink per skill, never over an existing entry, and a
128
+ refresh only removes the links it posed itself (a ledger records them). The same script is the
129
+ `skills` setup phase (`skills` param of `buildSetupScript` / `buildSetupPlan`, after `dotfiles`,
130
+ before `clone`) and a refresh a control plane can run later. Which skills are retained is decided
131
+ by the caller (`linkByDefault` + `include` / `exclude`); the script only applies it against what
132
+ the ref actually holds. It never fails the setup: problems are printed with a `[skills]` prefix.
133
+
125
134
  ### `@spunto/build/catalogs` — what a project picker offers
126
135
 
127
136
  `AVAILABLE_IMAGES`, `AVAILABLE_FEATURES`, `SUGGESTED_EXTENSIONS`. Data, but shared data: the two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,4 +27,9 @@ export {
27
27
  } from "./setup-script"
28
28
  export type { ResolvedFeature, SetupPhase, SetupScriptParams, StartScriptParams } from "./setup-script"
29
29
 
30
+ // Organization skills (RFC 0033): the same script is a setup phase and, before each delegated
31
+ // task, a refresh the control plane runs on its own.
32
+ export { buildSkillsScript, skillFetchUrlEnv, SKILLS_DIR } from "./skills"
33
+ export type { SkillSource, SkillsScriptParams } from "./skills"
34
+
30
35
  export { LOCAL_FEATURE_SCRIPTS, localFeatureScript } from "./local-features"
@@ -9,6 +9,7 @@ import {
9
9
  import { stepEndLine, stepStartLine, type BuildStep, type BuildStepKind } from "../steps/build-steps"
10
10
  import type { SetupStatus } from "../types/index"
11
11
  import { localFeatureScript } from "./local-features"
12
+ import { buildSkillsScript, type SkillsScriptParams } from "./skills"
12
13
 
13
14
  // ─── Internal helpers ──────────────────────────────────────────────────────────
14
15
 
@@ -880,6 +881,37 @@ export type SetupScriptParams = {
880
881
  // Git branch chosen when this worker was created (workers.branch): checked out for every repo
881
882
  // of the project, overriding each repository's own default branch. Null/absent → default branch.
882
883
  branch?: string | null
884
+ /**
885
+ * Skills the organization makes available in this worker (Spunto Cloud RFC 0033), already
886
+ * resolved: which sources, which ref, which skills to link. Materialized in their own phase,
887
+ * after the dotfiles (so a dotfiles install cannot overwrite a link, and a skill of the member's
888
+ * own wins a name collision) and before the project's clones. Absent → no phase at all.
889
+ */
890
+ skills?: SkillsScriptParams
891
+ }
892
+
893
+ /**
894
+ * The `skills` phase body: writes the skills script to a temp file and runs it as the user, with
895
+ * the worker's secrets in its environment (a member may set `CLAUDE_CONFIG_DIR` as one, and the
896
+ * discovery directory is derived from it). Never fails the setup — see ./skills.ts.
897
+ */
898
+ function skillsBlock(params: {
899
+ homeDir: string
900
+ username: string
901
+ skills?: SkillsScriptParams
902
+ userEnvSecrets?: Record<string, string>
903
+ }): string[] {
904
+ const { homeDir, username, skills, userEnvSecrets } = params
905
+ if (!skills || skills.sources.length === 0) return []
906
+ const script = buildSkillsScript({ ...skills, homeDir })
907
+ if (!script) return []
908
+ return [
909
+ ...banner(`SETUP: SKILLS (${skills.sources.length})`),
910
+ `echo ${JSON.stringify(b64(script))} | base64 -d > /tmp/spunto-skills.sh`,
911
+ `chmod 644 /tmp/spunto-skills.sh`,
912
+ `su ${username} -c "cd ${homeDir} && ${envPrefix(userEnvSecrets)}bash /tmp/spunto-skills.sh" 2>&1 || echo "[skills] ✗ skills script exited with $?"`,
913
+ `rm -f /tmp/spunto-skills.sh`,
914
+ ]
883
915
  }
884
916
 
885
917
  export function buildSetupScript(params: SetupScriptParams): { script: string } {
@@ -1052,6 +1084,13 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
1052
1084
  )
1053
1085
  }
1054
1086
 
1087
+ // ── 4b. Organization skills (RFC 0033) ─────────────────────────────────────
1088
+ const skillLines = skillsBlock({ homeDir, username, skills: params.skills, userEnvSecrets })
1089
+ if (skillLines.length > 0) {
1090
+ mpAt(mkStatus(dotfilesRepo ? "dotfiles" : hasCredentials ? "credentials" : "initializing", allReposPending, pc0, null), "skills")
1091
+ push(...skillLines)
1092
+ }
1093
+
1055
1094
  // ── 5. Clone repos ────────────────────────────────────────────────────────
1056
1095
  if (project.repositories.length > 0) {
1057
1096
  push(...banner(`SETUP: CLONE REPOSITORIES (${project.repositories.length})`))
@@ -1432,7 +1471,7 @@ export function buildStartScript(params: StartScriptParams): { script: string; h
1432
1471
  /** One setup phase the agent execs via `docker exec`, wrapped in a native OTel span. */
1433
1472
  export type SetupPhase = {
1434
1473
  /** Timing-key / span-suffix (`setup.<id>`) and the phase's short identity. */
1435
- id: "credentials" | "shell" | "dotfiles" | "clone" | "postCreate" | "postStart"
1474
+ id: "credentials" | "shell" | "dotfiles" | "skills" | "clone" | "postCreate" | "postStart"
1436
1475
  /** Human label for logs. */
1437
1476
  label: string
1438
1477
  /** Self-contained bash (runs as root via `docker exec`; `su vscode` inside where needed). */
@@ -1976,6 +2015,17 @@ export function buildSetupPlan(params: SetupScriptParams): {
1976
2015
  phases.push({ id: "dotfiles", label: "Dotfiles", script: l.join("\n"), runsOnRestart: false, statusStart: st, statusEnd: st })
1977
2016
  }
1978
2017
 
2018
+ // ── skills (RFC 0033) ──────────────────────────────────────────────────────
2019
+ // Re-run on every start: the script fast-forwards the clones through the credential helper
2020
+ // (the URL minted for the first boot has expired by then) and re-links, and it is idempotent.
2021
+ {
2022
+ const l = skillsBlock({ homeDir, username, skills: params.skills, userEnvSecrets })
2023
+ if (l.length > 0) {
2024
+ const st = status(dotfilesRepo ? "dotfiles" : hasCredentials ? "credentials" : "initializing", allReposPending, pc0, ps0)
2025
+ phases.push({ id: "skills", label: "Skills", script: ["set -e", ...l].join("\n"), runsOnRestart: true, statusStart: st, statusEnd: st })
2026
+ }
2027
+ }
2028
+
1979
2029
  // ── clone repos ────────────────────────────────────────────────────────────
1980
2030
  if (project.repositories.length > 0) {
1981
2031
  const l: string[] = ["set -e", ...banner(`SETUP: CLONE REPOSITORIES (${project.repositories.length})`)]
@@ -0,0 +1,225 @@
1
+ // Organization skills in a worker (Spunto Cloud RFC 0033).
2
+ //
3
+ // A *skill* is a directory holding a `SKILL.md` (plus whatever scripts and references sit next to
4
+ // it). A *source* is a git repository that holds several of them under one path. This file turns a
5
+ // list of sources — already resolved by the control plane: which ref, what to link by default, what
6
+ // a project switched on or off — into one shell script that:
7
+ //
8
+ // 1. clones each source into `~/.spunto/skills/<id>/` (or fast-forwards an existing clone);
9
+ // 2. links every skill it retains into each **discovery directory** the harness reads, one symlink
10
+ // per skill — never the whole directory, never over an entry that is already there;
11
+ // 3. removes the links it posed on an earlier run and no longer wants, and only those.
12
+ //
13
+ // The same script serves the first boot (a setup phase) and every later refresh (before each
14
+ // delegated task): it is idempotent, and what it did before is recorded in a ledger next to the
15
+ // clones rather than guessed from the filesystem.
16
+ //
17
+ // It never fails. A skill that could not be fetched is a worse worker, not a broken one: every
18
+ // failure is printed with a `[skills]` prefix and the script exits 0.
19
+
20
+ /** Single-quotes a value for safe embedding in a shell command. */
21
+ function shQuote(v: string): string {
22
+ return `'${v.replace(/'/g, `'\\''`)}'`
23
+ }
24
+
25
+ /** One source, as the control plane resolved it for this worker. */
26
+ export type SkillSource = {
27
+ /**
28
+ * Stable identity, used as the clone's directory name. Restricted to `[A-Za-z0-9_-]` — anything
29
+ * else is refused rather than escaped, since it names a path the script removes.
30
+ */
31
+ id: string
32
+ /** What the log calls it (`owner/name`). Never a URL: it must not carry a credential. */
33
+ label: string
34
+ /**
35
+ * Where to clone or fetch from, credential included if there is one. `null` fetches through
36
+ * `origin` — for a hosted repository that is the worker's git credential helper.
37
+ *
38
+ * Overridable at run time through `skillFetchUrlEnv(index)`: a caller that must not write a
39
+ * credential into the command it runs (a command history is kept) passes it in the environment.
40
+ */
41
+ fetchUrl: string | null
42
+ /**
43
+ * What `origin` is left pointing at after a clone. Credential-free, so that whatever happens
44
+ * next in the worker (an agent fixing a skill and pushing a branch) goes through the credential
45
+ * helper as the member rather than reusing the organization's clone token. Defaults to
46
+ * `fetchUrl`.
47
+ */
48
+ originUrl?: string | null
49
+ /** Branch or tag. Empty → the remote's default branch. */
50
+ ref?: string | null
51
+ /** Directory inside the repository whose sub-directories are skills. Empty → the root. */
52
+ path?: string | null
53
+ /** Link a skill this source holds unless it is listed in `exclude`. */
54
+ linkByDefault: boolean
55
+ /** Always linked (wins over `exclude`). */
56
+ include?: string[]
57
+ /** Never linked, unless also in `include`. */
58
+ exclude?: string[]
59
+ }
60
+
61
+ export type SkillsScriptParams = {
62
+ sources: SkillSource[]
63
+ /**
64
+ * Where the harness discovers user-level skills, as **shell expressions** evaluated in the
65
+ * worker — `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills` for Claude Code. Evaluated there
66
+ * because only the worker knows its environment: a member can set `CLAUDE_CONFIG_DIR` as a
67
+ * secret. Trusted input (a harness pack's own declaration), interpolated as is.
68
+ */
69
+ discoveryDirs: string[]
70
+ /** The workspace user's home. */
71
+ homeDir?: string
72
+ }
73
+
74
+ /** Where clones live, relative to the user's home. Harness-neutral on purpose. */
75
+ export const SKILLS_DIR = ".spunto/skills"
76
+
77
+ /** The environment variable that overrides `sources[index].fetchUrl` at run time. */
78
+ export function skillFetchUrlEnv(index: number): string {
79
+ return `SPUNTO_SKILL_FETCH_URL_${index}`
80
+ }
81
+
82
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/
83
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
84
+ const SAFE_PATH = /^[A-Za-z0-9._/-]*$/
85
+ /** A branch or tag. No leading `-`: it is passed to `git fetch` as a positional argument. */
86
+ const SAFE_REF = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/
87
+
88
+ /**
89
+ * The script, run **as the workspace user** (never root: every file it creates is theirs).
90
+ *
91
+ * Returns `null` when there is nothing to do — no source *and* nothing to take down. Since the
92
+ * script cannot know the second half without running, a caller that had sources before should
93
+ * still run it with an empty list; `buildSkillsScript` only returns `null` when there are no
94
+ * discovery directories either.
95
+ */
96
+ export function buildSkillsScript(params: SkillsScriptParams): string | null {
97
+ const homeDir = params.homeDir ?? "/home/vscode"
98
+ if (params.discoveryDirs.length === 0) return null
99
+
100
+ for (const s of params.sources) {
101
+ if (!SAFE_ID.test(s.id)) throw new Error(`Invalid skill source id: ${s.id}`)
102
+ if (s.path && (!SAFE_PATH.test(s.path) || s.path.split("/").includes(".."))) {
103
+ throw new Error(`Invalid skill source path: ${s.path}`)
104
+ }
105
+ if (s.ref && !SAFE_REF.test(s.ref)) throw new Error(`Invalid skill source ref: ${s.ref}`)
106
+ for (const u of [s.fetchUrl, s.originUrl]) {
107
+ if (u && u.startsWith("-")) throw new Error(`Invalid skill source URL for ${s.id}`)
108
+ }
109
+ for (const n of [...(s.include ?? []), ...(s.exclude ?? [])]) {
110
+ if (!SAFE_NAME.test(n)) throw new Error(`Invalid skill name: ${n}`)
111
+ }
112
+ }
113
+
114
+ const root = `${homeDir}/${SKILLS_DIR}`
115
+ const lines: string[] = [
116
+ `export HOME=${shQuote(homeDir)}`,
117
+ `export GIT_TERMINAL_PROMPT=0`,
118
+ `_sk_root=${shQuote(root)}`,
119
+ `_sk_ledger="$_sk_root/.links"`,
120
+ `mkdir -p "$_sk_root" && touch "$_sk_ledger" || { echo "[skills] ✗ cannot write $_sk_root"; exit 0; }`,
121
+ `_sk_want=$(mktemp)`,
122
+ `_sk_dirs=(${params.discoveryDirs.map((d) => `"${d}"`).join(" ")})`,
123
+ `_sk_ids=" "`,
124
+ ``,
125
+ // Clone or fast-forward one source. A ref that changed since the clone is a re-clone: a
126
+ // fast-forward between two branches means nothing.
127
+ `_sk_sync() {`,
128
+ ` local id="$1" label="$2" fetch="$3" origin="$4" ref="$5" dest="$_sk_root/$1"`,
129
+ ` if [ -d "$dest/.git" ] && [ "$(cat "$dest/.git/spunto-ref" 2>/dev/null)" != "$ref" ]; then`,
130
+ ` echo "[skills] $label: ref changed to '\${ref:-default branch}' — cloning again"`,
131
+ ` rm -rf "$dest"`,
132
+ ` fi`,
133
+ ` if [ ! -d "$dest/.git" ]; then`,
134
+ ` rm -rf "$dest"`,
135
+ ` if [ -z "$fetch" ]; then echo "[skills] ✗ $label: nothing to clone from"; return 1; fi`,
136
+ ` local b=(); [ -n "$ref" ] && b=(--branch "$ref")`,
137
+ ` if ! git clone --quiet --depth 1 "\${b[@]}" "$fetch" "$dest" 2>&1; then`,
138
+ ` echo "[skills] ✗ $label: clone failed — its skills are not linked"`,
139
+ ` rm -rf "$dest"`,
140
+ ` return 1`,
141
+ ` fi`,
142
+ ` if [ -n "$origin" ] && [ "$origin" != "$fetch" ]; then git -C "$dest" remote set-url origin "$origin"; fi`,
143
+ ` printf '%s' "$ref" > "$dest/.git/spunto-ref"`,
144
+ ` else`,
145
+ // A plain fetch on a shallow clone brings the new commits down to the existing boundary, so
146
+ // the fast-forward can be checked. Through the given URL first (the organization's read
147
+ // credential), then through origin (the credential helper) — a URL minted for the first boot
148
+ // has long expired on a restart.
149
+ ` local r="\${ref:-HEAD}" ok=""`,
150
+ ` if [ -n "$fetch" ] && git -C "$dest" fetch --quiet "$fetch" "$r" 2>/dev/null; then ok=1`,
151
+ ` elif git -C "$dest" fetch --quiet origin "$r" 2>&1; then ok=1; fi`,
152
+ ` if [ -z "$ok" ]; then`,
153
+ ` echo "[skills] ⚠ $label: fetch failed — keeping $(git -C "$dest" rev-parse --short HEAD)"`,
154
+ ` elif ! git -C "$dest" merge --ff-only --quiet FETCH_HEAD 2>&1; then`,
155
+ ` echo "[skills] ⚠ $label: cannot fast-forward (local changes?) — keeping $(git -C "$dest" rev-parse --short HEAD)"`,
156
+ ` fi`,
157
+ ` fi`,
158
+ // The exact commit in use, for the audit trail: these scripts run with the worker's secrets.
159
+ ` echo "[skills] $label @ \${ref:-default branch} → $(git -C "$dest" rev-parse HEAD)"`,
160
+ `}`,
161
+ ``,
162
+ // Link the retained skills of one source. A name already taken — by the dotfiles, by the
163
+ // member, by another source in this same run — is left alone and reported.
164
+ `_sk_link() {`,
165
+ ` local id="$1" sub="$2" def="$3" inc=" $4 " exc=" $5 " base="$_sk_root/$1" d name want dir t src`,
166
+ ` [ -n "$sub" ] && base="$base/$sub"`,
167
+ ` if [ ! -d "$base" ]; then echo "[skills] ⚠ $id: no directory '$sub' in the source"; return; fi`,
168
+ ` for d in "$base"/*/; do`,
169
+ ` [ -f "$d/SKILL.md" ] || continue`,
170
+ ` src="\${d%/}"; name=$(basename "$src"); want="$def"`,
171
+ ` case "$exc" in *" $name "*) want=0 ;; esac`,
172
+ ` case "$inc" in *" $name "*) want=1 ;; esac`,
173
+ ` [ "$want" = 1 ] || continue`,
174
+ ` for dir in "\${_sk_dirs[@]}"; do`,
175
+ ` mkdir -p "$dir" || continue`,
176
+ ` t="$dir/$name"`,
177
+ ` if grep -qxF "$t" "$_sk_want"; then echo "[skills] ⚠ $name: already linked from another source — skipped"; continue; fi`,
178
+ ` if [ -L "$t" ] && grep -qxF "$t" "$_sk_ledger"; then`,
179
+ ` [ "$(readlink "$t")" = "$src" ] || ln -sfn "$src" "$t"`,
180
+ ` elif [ -e "$t" ] || [ -L "$t" ]; then`,
181
+ ` echo "[skills] ⚠ $name: $t already exists — kept, not linked"; continue`,
182
+ ` else`,
183
+ ` ln -s "$src" "$t" || continue`,
184
+ ` echo "[skills] linked $name → $t"`,
185
+ ` fi`,
186
+ ` echo "$t" >> "$_sk_want"`,
187
+ ` done`,
188
+ ` done`,
189
+ `}`,
190
+ ``,
191
+ ]
192
+
193
+ params.sources.forEach((s, i) => {
194
+ const env = skillFetchUrlEnv(i)
195
+ const fetch = s.fetchUrl ?? ""
196
+ const origin = s.originUrl ?? s.fetchUrl ?? ""
197
+ lines.push(
198
+ `_sk_ids="\${_sk_ids}${s.id} "`,
199
+ `_sk_f=${shQuote(fetch)}; [ -n "\${${env}:-}" ] && _sk_f="$${env}"`,
200
+ `if _sk_sync ${shQuote(s.id)} ${shQuote(s.label)} "$_sk_f" ${shQuote(origin)} ${shQuote(s.ref ?? "")}; then`,
201
+ ` _sk_link ${shQuote(s.id)} ${shQuote((s.path ?? "").replace(/^\/+|\/+$/g, ""))} ${s.linkByDefault ? 1 : 0} ${shQuote((s.include ?? []).join(" "))} ${shQuote((s.exclude ?? []).join(" "))}`,
202
+ `fi`,
203
+ )
204
+ })
205
+
206
+ lines.push(
207
+ ``,
208
+ // Take down what this script posed before and no longer wants — only symlinks it recorded,
209
+ // so nothing a person put there is ever touched.
210
+ `while IFS= read -r _t; do`,
211
+ ` [ -n "$_t" ] || continue`,
212
+ ` grep -qxF "$_t" "$_sk_want" && continue`,
213
+ ` if [ -L "$_t" ]; then rm -f "$_t" && echo "[skills] unlinked $(basename "$_t")"; fi`,
214
+ `done < "$_sk_ledger"`,
215
+ `mv "$_sk_want" "$_sk_ledger"`,
216
+ // A source the organization removed: its clone goes too. Only directories, only by id.
217
+ `for _d in "$_sk_root"/*/; do`,
218
+ ` [ -d "$_d" ] || continue`,
219
+ ` _id=$(basename "$_d")`,
220
+ ` case "$_sk_ids" in *" $_id "*) ;; *) rm -rf "$_d" && echo "[skills] removed source $_id" ;; esac`,
221
+ `done`,
222
+ `exit 0`,
223
+ )
224
+ return lines.join("\n")
225
+ }