@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 +9 -0
- package/package.json +1 -1
- package/src/script/index.ts +5 -0
- package/src/script/setup-script.ts +51 -1
- package/src/script/skills.ts +225 -0
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.
|
|
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",
|
package/src/script/index.ts
CHANGED
|
@@ -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
|
+
}
|