skillrepo 4.10.0 → 4.11.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 +13 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +18 -4
- package/src/lib/errors.mjs +23 -0
- package/src/lib/file-write.mjs +15 -0
- package/src/lib/skillset-declaration.mjs +477 -0
- package/src/lib/sync.mjs +65 -3
- package/src/test/commands/add.test.mjs +24 -0
- package/src/test/commands/get.test.mjs +24 -0
- package/src/test/commands/init.test.mjs +37 -0
- package/src/test/commands/update.test.mjs +143 -0
- package/src/test/lib/errors.test.mjs +28 -0
- package/src/test/lib/skillset-declaration.test.mjs +514 -0
- package/src/test/lib/sync.test.mjs +286 -0
package/README.md
CHANGED
|
@@ -183,6 +183,18 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
|
|
|
183
183
|
at the repo root is gitignored: the skillset declaration only works as
|
|
184
184
|
a committed file, so remove it from `.gitignore` and commit it.
|
|
185
185
|
|
|
186
|
+
A repository can declare a skillset in a root `skillrepo.json`:
|
|
187
|
+
`{"skillset": {"version": 1, "name": "<repo-identity>", "use":
|
|
188
|
+
"owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
|
|
189
|
+
finds the file from any subdirectory (it walks up to the repository
|
|
190
|
+
root) and validates it before syncing. Skillset delivery is not yet
|
|
191
|
+
available: a repository with a declaration fails its sync closed
|
|
192
|
+
(exit code `6`) rather than receiving the whole library — a declared
|
|
193
|
+
repo only ever receives its declared skillset. Keep the declaration
|
|
194
|
+
while your team prepares for skillsets, or remove the `skillset`
|
|
195
|
+
block to sync your whole library. A `skillrepo.json` without a
|
|
196
|
+
`skillset` key is plain configuration and does not change sync.
|
|
197
|
+
|
|
186
198
|
### `get` — fetch a single skill
|
|
187
199
|
|
|
188
200
|
```sh
|
|
@@ -574,6 +586,7 @@ citations on each agent's read paths.
|
|
|
574
586
|
| 3 | Disk error (cannot read or write a file/directory) |
|
|
575
587
|
| 4 | Scope error (key lacks the required `registry:write` scope) |
|
|
576
588
|
| 5 | Validation error (bad flag, malformed identifier, unknown vendor) |
|
|
589
|
+
| 6 | Unresolvable skillset declaration — the repo's root `skillrepo.json` declares a skillset that cannot be honored (invalid or nested declaration, reserved `@` version syntax, or skillset delivery not yet available). Fail-closed: nothing was written or removed. |
|
|
577
590
|
|
|
578
591
|
Pass `--verbose` to any command to print stack traces and retry
|
|
579
592
|
attempts on failure.
|
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
authError,
|
|
95
95
|
validationError,
|
|
96
96
|
EXIT_AUTH,
|
|
97
|
+
EXIT_UNRESOLVABLE,
|
|
97
98
|
} from "../lib/errors.mjs";
|
|
98
99
|
import { cliAuthUrl } from "../lib/constants.mjs";
|
|
99
100
|
import {
|
|
@@ -658,10 +659,23 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
658
659
|
// rethrow) as an inconsistent contract: the warning told users
|
|
659
660
|
// to retry with `update`, but the non-zero exit code made it
|
|
660
661
|
// look like the whole init had failed.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
662
|
+
// Declaration-gated repos (#2362) get tailored copy: "run
|
|
663
|
+
// update later to retry" is wrong advice when the sync failed
|
|
664
|
+
// closed on the repo's skillset declaration — retrying changes
|
|
665
|
+
// nothing until the declaration is fixed/removed (or, for the
|
|
666
|
+
// not-yet-available case, until skillset delivery ships). The
|
|
667
|
+
// error message + hint already carry why and what to do.
|
|
668
|
+
if (err instanceof CliError && err.exitCode === EXIT_UNRESOLVABLE) {
|
|
669
|
+
p.warning(
|
|
670
|
+
`Config saved, but this repository's library sync is blocked: ${err.message}` +
|
|
671
|
+
(err.hint ? ` ${err.hint}` : ""),
|
|
672
|
+
);
|
|
673
|
+
} else {
|
|
674
|
+
p.warning(
|
|
675
|
+
`Config saved but first sync failed: ${err.message}. ` +
|
|
676
|
+
`Run \`skillrepo update\` later to retry.`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
665
679
|
syncFailedReason = err.message;
|
|
666
680
|
// Synthesize a zero-delta summary matching the SyncSummary
|
|
667
681
|
// typedef in sync.mjs (added, updated, removed, notModified,
|
package/src/lib/errors.mjs
CHANGED
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
* 3 — disk error (cannot read or write a file/directory)
|
|
10
10
|
* 4 — scope error (key lacks the required scope for the requested action)
|
|
11
11
|
* 5 — validation error (bad CLI input — invalid flag, malformed identifier, etc.)
|
|
12
|
+
* 6 — unresolvable skillset declaration (#2362, design doc D9): the
|
|
13
|
+
* repo's `skillrepo.json` skillset declaration cannot be honored
|
|
14
|
+
* (unparseable file, invalid sub-schema, nested declarations,
|
|
15
|
+
* reserved `@` version syntax, or skillset delivery not yet
|
|
16
|
+
* available). Fail-closed: nothing was written or removed. This
|
|
17
|
+
* one code covers the WHOLE D9 fail-closed class, locally AND —
|
|
18
|
+
* once H8 (#2365) wires the server — the server's typed 422
|
|
19
|
+
* `harness_unresolvable` response, so CI scripts get one stable
|
|
20
|
+
* "declaration cannot be honored" signal.
|
|
12
21
|
*
|
|
13
22
|
* These mirror the documented behavior in #683 and are the contract
|
|
14
23
|
* shell users and CI scripts can rely on.
|
|
@@ -40,6 +49,7 @@ export const EXIT_AUTH = 2;
|
|
|
40
49
|
export const EXIT_DISK = 3;
|
|
41
50
|
export const EXIT_SCOPE = 4;
|
|
42
51
|
export const EXIT_VALIDATION = 5;
|
|
52
|
+
export const EXIT_UNRESOLVABLE = 6;
|
|
43
53
|
|
|
44
54
|
/**
|
|
45
55
|
* Base error class for typed CLI errors. Carries an exit code and
|
|
@@ -89,6 +99,19 @@ export function validationError(message, options) {
|
|
|
89
99
|
return new CliError(message, EXIT_VALIDATION, options);
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Unresolvable skillset declaration (#2362). Distinct from
|
|
104
|
+
* `validationError` on purpose: exit 5 means "your CLI input is bad",
|
|
105
|
+
* and one member of this class — skillset delivery not yet being
|
|
106
|
+
* available — is not the user's fault at all (the declaration file may
|
|
107
|
+
* be perfectly valid). D9's fail-closed contract holds for every
|
|
108
|
+
* member: nothing was written or removed, and the sync NEVER falls
|
|
109
|
+
* back to whole-library delivery.
|
|
110
|
+
*/
|
|
111
|
+
export function unresolvableError(message, options) {
|
|
112
|
+
return new CliError(message, EXIT_UNRESOLVABLE, options);
|
|
113
|
+
}
|
|
114
|
+
|
|
92
115
|
// ── Retry helper ───────────────────────────────────────────────────────
|
|
93
116
|
|
|
94
117
|
/**
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -160,6 +160,21 @@ export function escapeControlChars(value) {
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* True when the value contains any control character (C0, DEL, or C1 —
|
|
165
|
+
* the same class `escapeControlChars` escapes). Exported (#2362) so
|
|
166
|
+
* validators can REJECT control characters up front with the one
|
|
167
|
+
* canonical class instead of hand-rolling a second, narrower regex —
|
|
168
|
+
* the skillset-declaration validator is the first consumer. Single
|
|
169
|
+
* source per the shared-logic rule.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} value
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
export function hasControlChars(value) {
|
|
175
|
+
return CONTROL_CHARS.test(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
163
178
|
/**
|
|
164
179
|
* Validate a single file path inside a skill directory.
|
|
165
180
|
*
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo skillset-declaration resolution (#2362, epic #2357).
|
|
3
|
+
*
|
|
4
|
+
* The committed, human-owned governance contract lives at the repo
|
|
5
|
+
* root in `skillrepo.json`, nested under a `skillset` key (design doc
|
|
6
|
+
* D6 amendment + sub-schema note, 2026-08-07):
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* "skillset": {
|
|
10
|
+
* "version": 1,
|
|
11
|
+
* "name": "checkout-service",
|
|
12
|
+
* "use": "acme/backend-core",
|
|
13
|
+
* "extra": ["acme/db-migrations"]
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* This module DISCOVERS the declaration (walk-up from cwd) and
|
|
18
|
+
* VALIDATES it (imperative, fail-closed per D9). It performs no
|
|
19
|
+
* network work and never writes anything. Resolution against the
|
|
20
|
+
* server-side skillset entity is H8 (#2365); until that lands, a
|
|
21
|
+
* well-formed declaration is unresolvable BY DEFINITION and sync
|
|
22
|
+
* fails closed with the "not yet available" copy below — the error
|
|
23
|
+
* must never imply the user's file is wrong (H5 design note under D9).
|
|
24
|
+
*
|
|
25
|
+
* WALK-UP SEMANTICS (H5 design note under D6, 2026-08-07):
|
|
26
|
+
*
|
|
27
|
+
* - Directories are probed from cwd upward. The walk's boundary is
|
|
28
|
+
* the first directory containing `.git` (a directory OR a file —
|
|
29
|
+
* worktrees and submodules use a `gitdir:` file), probed
|
|
30
|
+
* INCLUSIVE, so the repo root itself is checked and parent repos
|
|
31
|
+
* are never consulted. Outside any git repo the walk continues to
|
|
32
|
+
* the filesystem root (also inclusive) — ancestor-config
|
|
33
|
+
* semantics, same family as `.gitignore`/`.editorconfig`.
|
|
34
|
+
* - Exactly ONE `skillrepo.json` may exist on the walk path. Two or
|
|
35
|
+
* more is the deferred monorepo-nesting shape and fails closed
|
|
36
|
+
* with a "not yet supported" error (the documented FUTURE
|
|
37
|
+
* semantic is nearest-file-wins; erroring today holds that door
|
|
38
|
+
* open without guessing). Nesting is detected on file PRESENCE,
|
|
39
|
+
* not parsed content — a config-only `skillrepo.json` below a
|
|
40
|
+
* declared root still errors, because silently picking one of two
|
|
41
|
+
* governance-relevant files is exactly the ambiguity D9 forbids.
|
|
42
|
+
* - The single file's directory is the DETECTED ROOT. It — not
|
|
43
|
+
* cwd — anchors everything repo-scoped that follows: H8's
|
|
44
|
+
* skillset-scoped placement writes, D12's realpath-hash state
|
|
45
|
+
* key, and (already in H5) the gitignored-declaration probe and
|
|
46
|
+
* its warn-on-new seen key. Repos with no declaration keep
|
|
47
|
+
* today's cwd-anchored behavior byte-identical (D2).
|
|
48
|
+
*
|
|
49
|
+
* A `skillrepo.json` whose top level parses but has NO `skillset` key
|
|
50
|
+
* is plain tool config, not a declaration — sync proceeds on today's
|
|
51
|
+
* whole-library path. Unknown TOP-LEVEL keys are tolerated (future
|
|
52
|
+
* `skillrepo.json` features version themselves); unknown keys INSIDE
|
|
53
|
+
* the `skillset` block fail closed, because a typo like `"extras"`
|
|
54
|
+
* silently ignored would mean silently mis-delivering what the user
|
|
55
|
+
* believes they declared.
|
|
56
|
+
*
|
|
57
|
+
* Error class: every fail-closed outcome here throws/carries a
|
|
58
|
+
* `CliError` with EXIT_UNRESOLVABLE (6) — see errors.mjs for why this
|
|
59
|
+
* is one code for the whole D9 class rather than a validation error.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
63
|
+
import { dirname, join, relative, resolve, isAbsolute } from "node:path";
|
|
64
|
+
|
|
65
|
+
import { SKILLSET_DECLARATION_FILE } from "./constants.mjs";
|
|
66
|
+
import { unresolvableError } from "./errors.mjs";
|
|
67
|
+
import { escapeControlChars, hasControlChars } from "./file-write.mjs";
|
|
68
|
+
|
|
69
|
+
/** Top-level key under which the declaration lives (D6 sub-schema). */
|
|
70
|
+
export const DECLARATION_KEY = "skillset";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The declaration-schema version this CLI understands. Versions the
|
|
74
|
+
* BLOCK, not the whole file — future top-level `skillrepo.json` keys
|
|
75
|
+
* version themselves (D6 sub-schema note).
|
|
76
|
+
*/
|
|
77
|
+
export const DECLARATION_SCHEMA_VERSION = 1;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {Object} SkillsetDeclaration
|
|
81
|
+
* @property {number} version - Declaration-schema version (=== 1).
|
|
82
|
+
* @property {string} name - Repo self-declared identity (D12 keys
|
|
83
|
+
* per-repo server state on it).
|
|
84
|
+
* @property {string} use - `"owner/skillset-name"` pointer to the
|
|
85
|
+
* server-side named skillset.
|
|
86
|
+
* @property {string[]} extra - Optional per-repo additions, normalized
|
|
87
|
+
* to `[]` when absent.
|
|
88
|
+
*
|
|
89
|
+
* @typedef {Object} DeclarationResolution
|
|
90
|
+
* @property {"absent" | "config-only" | "declared" | "invalid"} status
|
|
91
|
+
* @property {string} [filePath] - Absolute path of the found file.
|
|
92
|
+
* Present on config-only / declared and on every "invalid"
|
|
93
|
+
* outcome EXCEPT multiple-files: with two or more files on
|
|
94
|
+
* the walk path there is no single file (and no single
|
|
95
|
+
* detected root) to name, so both fields are deliberately
|
|
96
|
+
* absent rather than normalized to an arbitrary winner —
|
|
97
|
+
* H6/H8 consumers must not assume them on "invalid".
|
|
98
|
+
* @property {string} [rootDir] - The DETECTED ROOT: directory of the
|
|
99
|
+
* found file. Anchors repo-scoped behavior (see module doc).
|
|
100
|
+
* Same multiple-files exception as `filePath`.
|
|
101
|
+
* @property {import("./errors.mjs").CliError} [error] - Only on
|
|
102
|
+
* status "invalid" — the typed EXIT_UNRESOLVABLE error the
|
|
103
|
+
* caller throws. Constructed here so copy lives with the
|
|
104
|
+
* schema it describes.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Collect every `skillrepo.json` on the walk path, cwd upward to the
|
|
109
|
+
* repo boundary (first `.git`-containing dir, inclusive) or the
|
|
110
|
+
* filesystem root (inclusive). Pure discovery — no parsing.
|
|
111
|
+
*
|
|
112
|
+
* Directory entries named `skillrepo.json` are skipped (a directory
|
|
113
|
+
* cannot be a declaration; probing it as one would fail-close every
|
|
114
|
+
* sync under a bizarre-but-harmless tree shape).
|
|
115
|
+
*
|
|
116
|
+
* @param {object} [options]
|
|
117
|
+
* @param {string} [options.cwd] - Test seam; defaults to process.cwd().
|
|
118
|
+
* @returns {string[]} Absolute file paths, nearest (cwd-most) first.
|
|
119
|
+
*/
|
|
120
|
+
export function findDeclarationFiles({ cwd = process.cwd() } = {}) {
|
|
121
|
+
const files = [];
|
|
122
|
+
let dir = resolve(cwd);
|
|
123
|
+
for (;;) {
|
|
124
|
+
const candidate = join(dir, SKILLSET_DECLARATION_FILE);
|
|
125
|
+
// lstat (never existsSync/stat, which FOLLOW symlinks): a DANGLING
|
|
126
|
+
// symlink named skillrepo.json must be collected, not skipped —
|
|
127
|
+
// existsSync reports it false, which silently classified the repo
|
|
128
|
+
// as undeclared and delivered the whole library (fail-OPEN; the
|
|
129
|
+
// 2026-08-07 prod-readiness QA audit caught this inverting D9).
|
|
130
|
+
// A dangling entry flows into resolveDeclaration's readFileSync,
|
|
131
|
+
// which throws there and routes into the fail-closed cannot-read
|
|
132
|
+
// branch; a WORKING file symlink reads through like a plain file.
|
|
133
|
+
let entry = null;
|
|
134
|
+
try {
|
|
135
|
+
entry = lstatSync(candidate);
|
|
136
|
+
} catch {
|
|
137
|
+
// No directory entry at this path at all.
|
|
138
|
+
}
|
|
139
|
+
if (entry && (entry.isFile() || entry.isSymbolicLink())) {
|
|
140
|
+
files.push(candidate);
|
|
141
|
+
}
|
|
142
|
+
// Plain directories (and sockets etc.) stay skipped, as before.
|
|
143
|
+
// `.git` may be a directory (normal clone) or a file (worktree /
|
|
144
|
+
// submodule `gitdir:` pointer) — existsSync covers both. The
|
|
145
|
+
// boundary dir itself was just probed above (inclusive).
|
|
146
|
+
if (existsSync(join(dir, ".git"))) break;
|
|
147
|
+
const parent = dirname(dir);
|
|
148
|
+
if (parent === dir) break; // filesystem root reached (probed above)
|
|
149
|
+
dir = parent;
|
|
150
|
+
}
|
|
151
|
+
return files;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Discover and validate the repo's skillset declaration.
|
|
156
|
+
*
|
|
157
|
+
* Read-only and local-only: no network, no writes. Returns a
|
|
158
|
+
* discriminated resolution rather than throwing so callers on
|
|
159
|
+
* non-gating paths (e.g. the governance-warning plumbing) can consume
|
|
160
|
+
* `rootDir` without try/catch; the sync gate throws `resolution.error`
|
|
161
|
+
* itself.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} [options]
|
|
164
|
+
* @param {string} [options.cwd] - Test seam; defaults to process.cwd().
|
|
165
|
+
* @returns {DeclarationResolution}
|
|
166
|
+
*/
|
|
167
|
+
export function resolveDeclaration({ cwd = process.cwd() } = {}) {
|
|
168
|
+
const files = findDeclarationFiles({ cwd });
|
|
169
|
+
|
|
170
|
+
if (files.length === 0) {
|
|
171
|
+
return { status: "absent" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (files.length > 1) {
|
|
175
|
+
// Detection is by file PRESENCE, so one of these may be plain
|
|
176
|
+
// config rather than a declaration — the copy says "at most one
|
|
177
|
+
// is supported" instead of branding both as declarations.
|
|
178
|
+
const shown = files.map((f) => displayPath(f, cwd)).join(", ");
|
|
179
|
+
return {
|
|
180
|
+
status: "invalid",
|
|
181
|
+
error: unresolvableError(
|
|
182
|
+
`found ${files.length} ${SKILLSET_DECLARATION_FILE} files between this directory and the repository root (${shown}). ` +
|
|
183
|
+
`At most one is supported today (nested and monorepo declarations are not yet supported), and choosing between them silently could deliver the wrong skillset, so nothing was synced.`,
|
|
184
|
+
{
|
|
185
|
+
hint: `Keep a single ${SKILLSET_DECLARATION_FILE} at the repository root and remove the others.`,
|
|
186
|
+
},
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const filePath = files[0];
|
|
192
|
+
const rootDir = dirname(filePath);
|
|
193
|
+
const shownPath = displayPath(filePath, cwd);
|
|
194
|
+
|
|
195
|
+
let raw;
|
|
196
|
+
try {
|
|
197
|
+
raw = readFileSync(filePath, "utf8");
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// The file exists but cannot be read — we cannot rule out a
|
|
200
|
+
// declaration, so ignoring it could deliver the whole library
|
|
201
|
+
// into a deliberately-scoped repo (the exact D9 rejection).
|
|
202
|
+
return {
|
|
203
|
+
status: "invalid",
|
|
204
|
+
filePath,
|
|
205
|
+
rootDir,
|
|
206
|
+
error: unresolvableError(
|
|
207
|
+
`cannot read ${shownPath} (${escapeControlChars(err.message)}), so this repository's skillset declaration cannot be checked and nothing was synced.`,
|
|
208
|
+
{ cause: err, hint: `Fix the file's permissions (or its symlink target), or remove it to resume whole-library sync.` },
|
|
209
|
+
),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
// Strip a UTF-8 BOM — Windows editors add one and JSON.parse
|
|
216
|
+
// rejects it (Windows compat is mandatory for CLI work).
|
|
217
|
+
parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return {
|
|
220
|
+
status: "invalid",
|
|
221
|
+
filePath,
|
|
222
|
+
rootDir,
|
|
223
|
+
error: unresolvableError(
|
|
224
|
+
// JSON.parse errors can quote raw file bytes — escape them,
|
|
225
|
+
// same as the path (#2402 class).
|
|
226
|
+
`${shownPath} is not valid JSON (${escapeControlChars(err.message)}), so this repository's skillset declaration cannot be read and nothing was synced.`,
|
|
227
|
+
{ cause: err, hint: `Fix the JSON, or remove the file to resume whole-library sync.` },
|
|
228
|
+
),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
233
|
+
return {
|
|
234
|
+
status: "invalid",
|
|
235
|
+
filePath,
|
|
236
|
+
rootDir,
|
|
237
|
+
error: unresolvableError(
|
|
238
|
+
`${shownPath} must contain a JSON object at the top level (found ${describeType(parsed)}), so this repository's skillset declaration cannot be read and nothing was synced.`,
|
|
239
|
+
{ hint: `Fix the file, or remove it to resume whole-library sync.` },
|
|
240
|
+
),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!(DECLARATION_KEY in parsed)) {
|
|
245
|
+
// Plain tool config — not a declaration. Sync proceeds on the
|
|
246
|
+
// whole-library path; rootDir still anchors the gitignored probe.
|
|
247
|
+
return { status: "config-only", filePath, rootDir };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const blockError = validateDeclarationBlock(parsed[DECLARATION_KEY], shownPath);
|
|
251
|
+
if (blockError) {
|
|
252
|
+
return { status: "invalid", filePath, rootDir, error: blockError };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const block = parsed[DECLARATION_KEY];
|
|
256
|
+
return {
|
|
257
|
+
status: "declared",
|
|
258
|
+
filePath,
|
|
259
|
+
rootDir,
|
|
260
|
+
declaration: {
|
|
261
|
+
version: block.version,
|
|
262
|
+
name: block.name,
|
|
263
|
+
use: block.use,
|
|
264
|
+
extra: Array.isArray(block.extra) ? [...block.extra] : [],
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The pre-H8 dark-shipping error (H5 design note under D9): a
|
|
271
|
+
* well-formed declaration cannot resolve because skillset delivery
|
|
272
|
+
* does not exist yet — server feature (H6) and client wiring (H8) are
|
|
273
|
+
* both unshipped. The copy therefore says "not yet available" and
|
|
274
|
+
* must NEVER imply the user's file is wrong. H8 replaces the call
|
|
275
|
+
* site with real resolution; a server-side `harness_unresolvable`
|
|
276
|
+
* 422 then maps onto this same exit code.
|
|
277
|
+
*
|
|
278
|
+
* @param {DeclarationResolution} resolution - A "declared" resolution.
|
|
279
|
+
* @returns {import("./errors.mjs").CliError}
|
|
280
|
+
*/
|
|
281
|
+
export function declarationUnavailableError(resolution) {
|
|
282
|
+
// `use` passed shape validation, but it is still file-sourced text
|
|
283
|
+
// headed for a terminal — escape control characters (#2402 class).
|
|
284
|
+
const use = escapeControlChars(resolution.declaration.use);
|
|
285
|
+
return unresolvableError(
|
|
286
|
+
`this repository declares skillset "${use}" in ${SKILLSET_DECLARATION_FILE}, but skillset delivery is not yet available. ` +
|
|
287
|
+
`Nothing was synced: a repository that declares a skillset receives only that skillset, never the whole library.`,
|
|
288
|
+
{
|
|
289
|
+
hint:
|
|
290
|
+
`Keep the declaration while your team prepares for skillsets, or remove the ` +
|
|
291
|
+
`"${DECLARATION_KEY}" block from ${SKILLSET_DECLARATION_FILE} to sync your whole library here.`,
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Internals ──────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fields the version-1 sub-schema defines. Anything else inside the
|
|
300
|
+
* block fails closed (typo guard — see module doc).
|
|
301
|
+
*/
|
|
302
|
+
const KNOWN_BLOCK_FIELDS = new Set(["version", "name", "use", "extra"]);
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Validate the `skillset` block against the D6 sub-schema. Returns
|
|
307
|
+
* null when valid, or the typed EXIT_UNRESOLVABLE error to surface.
|
|
308
|
+
* These messages DO describe problems with the user's file — that is
|
|
309
|
+
* exactly their job; only the not-yet-available error above must not.
|
|
310
|
+
*
|
|
311
|
+
* @param {unknown} block
|
|
312
|
+
* @param {string} shownPath - Display path for error copy.
|
|
313
|
+
* @returns {import("./errors.mjs").CliError | null}
|
|
314
|
+
*/
|
|
315
|
+
function validateDeclarationBlock(block, shownPath) {
|
|
316
|
+
const fail = (problem, hint) =>
|
|
317
|
+
unresolvableError(
|
|
318
|
+
`${shownPath}: ${problem} Nothing was synced — a repository with a skillset declaration only receives its declared skillset once the declaration is valid.`,
|
|
319
|
+
{ hint },
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
|
323
|
+
return fail(
|
|
324
|
+
`the "${DECLARATION_KEY}" value must be an object (found ${describeType(block)}).`,
|
|
325
|
+
`Use {"${DECLARATION_KEY}": {"version": 1, "name": "<repo-identity>", "use": "owner/skillset-name"}}.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const unknown = Object.keys(block).filter((k) => !KNOWN_BLOCK_FIELDS.has(k));
|
|
330
|
+
if (unknown.length > 0) {
|
|
331
|
+
const shown = unknown.map((k) => `"${escapeControlChars(k)}"`).join(", ");
|
|
332
|
+
return fail(
|
|
333
|
+
`the "${DECLARATION_KEY}" block has unknown field${unknown.length > 1 ? "s" : ""} ${shown}.`,
|
|
334
|
+
`Supported fields are "version", "name", "use", and "extra" — check for typos (a misspelled field would otherwise be silently ignored).`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!Number.isInteger(block.version)) {
|
|
339
|
+
return fail(
|
|
340
|
+
`the "${DECLARATION_KEY}" block needs an integer "version" field (found ${describeType(block.version)}).`,
|
|
341
|
+
`Set "version": ${DECLARATION_SCHEMA_VERSION}.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (block.version !== DECLARATION_SCHEMA_VERSION) {
|
|
345
|
+
return fail(
|
|
346
|
+
`declaration schema version ${block.version} is not supported by this CLI (it understands version ${DECLARATION_SCHEMA_VERSION}).`,
|
|
347
|
+
`Install the latest skillrepo CLI, or set "version": ${DECLARATION_SCHEMA_VERSION} if the file was written by hand.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (typeof block.name !== "string" || block.name.trim() === "") {
|
|
352
|
+
return fail(
|
|
353
|
+
`the "${DECLARATION_KEY}" block needs a non-empty string "name" field (found ${describeType(block.name)}) — it is this repository's self-declared identity.`,
|
|
354
|
+
`Set "name" to a stable identifier for this repository, e.g. "checkout-service".`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
// Control characters (C0/DEL/C1, the canonical file-write.mjs
|
|
358
|
+
// class) are never valid in an identifier — no legitimate repo
|
|
359
|
+
// identity or owner/name contains them, and letting them through
|
|
360
|
+
// would defer a certain rejection to copy that blames the wrong
|
|
361
|
+
// thing ("not yet available"). Rejected up front with an honest
|
|
362
|
+
// message; still escaped wherever echoed (#2402 class).
|
|
363
|
+
if (hasControlChars(block.name)) {
|
|
364
|
+
return fail(
|
|
365
|
+
`the "name" field ("${escapeControlChars(block.name)}") contains control characters, which are never valid in an identifier.`,
|
|
366
|
+
`Set "name" to a plain identifier for this repository, e.g. "checkout-service".`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const useError = validatePointer(block.use, "use", fail);
|
|
371
|
+
if (useError) return useError;
|
|
372
|
+
|
|
373
|
+
if (block.extra !== undefined) {
|
|
374
|
+
if (!Array.isArray(block.extra)) {
|
|
375
|
+
return fail(
|
|
376
|
+
`the "extra" field must be an array of "owner/skill" strings (found ${describeType(block.extra)}).`,
|
|
377
|
+
`Use "extra": ["owner/skill", ...], or remove the field.`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
for (let i = 0; i < block.extra.length; i++) {
|
|
381
|
+
const entryError = validatePointer(block.extra[i], `extra[${i}]`, fail);
|
|
382
|
+
if (entryError) return entryError;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Shared shape check for the `use` pointer and each `extra` entry:
|
|
391
|
+
* a non-empty `"owner/name"` string with exactly one slash and no
|
|
392
|
+
* reserved `@` version syntax (D3 reservation).
|
|
393
|
+
*
|
|
394
|
+
* @param {unknown} value
|
|
395
|
+
* @param {string} field - Field label for error copy.
|
|
396
|
+
* @param {(problem: string, hint: string) => import("./errors.mjs").CliError} fail
|
|
397
|
+
* @returns {import("./errors.mjs").CliError | null}
|
|
398
|
+
*/
|
|
399
|
+
function validatePointer(value, field, fail) {
|
|
400
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
401
|
+
return fail(
|
|
402
|
+
`the "${field}" field must be a non-empty "owner/name" string (found ${describeType(value)}).`,
|
|
403
|
+
field === "use"
|
|
404
|
+
? `Set "use" to the skillset this repository runs, e.g. "acme/backend-core".`
|
|
405
|
+
: `Each "extra" entry names one library skill, e.g. "acme/db-migrations".`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const shown = escapeControlChars(value);
|
|
409
|
+
if (hasControlChars(value)) {
|
|
410
|
+
return fail(
|
|
411
|
+
`"${field}": "${shown}" contains control characters, which are never valid in an owner/name identifier.`,
|
|
412
|
+
field === "use"
|
|
413
|
+
? `Set "use" to the skillset this repository runs, e.g. "acme/backend-core".`
|
|
414
|
+
: `Each "extra" entry names one library skill, e.g. "acme/db-migrations".`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (value.includes("@")) {
|
|
418
|
+
// D3: one version-control point in v1 (the library pin). The
|
|
419
|
+
// `@range` syntax is reserved, not silently ignored.
|
|
420
|
+
return fail(
|
|
421
|
+
`"${field}": "${shown}" uses "@" version syntax, which is not yet supported — versions are controlled by library pins in v1.`,
|
|
422
|
+
`Remove the "@..." suffix; the approved version is decided in your library.`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const slashAt = value.indexOf("/");
|
|
426
|
+
if (slashAt <= 0 || slashAt !== value.lastIndexOf("/") || slashAt === value.length - 1) {
|
|
427
|
+
return fail(
|
|
428
|
+
`"${field}": "${shown}" must have the form "owner/name" (one slash, both parts non-empty).`,
|
|
429
|
+
field === "use"
|
|
430
|
+
? `Set "use" to the skillset this repository runs, e.g. "acme/backend-core".`
|
|
431
|
+
: `Each "extra" entry names one library skill, e.g. "acme/db-migrations".`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Human-facing path for error copy: relative to cwd when the file is
|
|
439
|
+
* at or below it (the common run-at-root case reads as plain
|
|
440
|
+
* `skillrepo.json`), absolute otherwise — a `../../..`-chain is worse
|
|
441
|
+
* than an absolute path.
|
|
442
|
+
*
|
|
443
|
+
* Control characters are escaped HERE, at the single point display
|
|
444
|
+
* paths are produced: every segment comes from real directory names
|
|
445
|
+
* on disk — filesystem-sourced, attacker-influenceable strings (a
|
|
446
|
+
* cloned repo chooses its own directory names), the exact #2402
|
|
447
|
+
* injection class — and the messages carrying them are printed
|
|
448
|
+
* verbatim by the dispatcher and the session-hook one-liner.
|
|
449
|
+
*
|
|
450
|
+
* @param {string} filePath - Absolute path.
|
|
451
|
+
* @param {string} cwd
|
|
452
|
+
* @returns {string}
|
|
453
|
+
*/
|
|
454
|
+
function displayPath(filePath, cwd) {
|
|
455
|
+
const rel = relative(resolve(cwd), filePath);
|
|
456
|
+
if (rel === "") return SKILLSET_DECLARATION_FILE;
|
|
457
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return escapeControlChars(filePath);
|
|
458
|
+
return escapeControlChars(rel);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Type description for error copy. Distinguishes null and arrays from
|
|
463
|
+
* plain objects because "found object" for a null is actively
|
|
464
|
+
* misleading in a JSON context.
|
|
465
|
+
*
|
|
466
|
+
* @param {unknown} value
|
|
467
|
+
* @returns {string}
|
|
468
|
+
*/
|
|
469
|
+
function describeType(value) {
|
|
470
|
+
if (value === null) return "null";
|
|
471
|
+
if (value === undefined) return "nothing";
|
|
472
|
+
if (Array.isArray(value)) return "an array";
|
|
473
|
+
if (typeof value === "string") return "a string";
|
|
474
|
+
if (typeof value === "number") return "a number";
|
|
475
|
+
if (typeof value === "boolean") return "a boolean";
|
|
476
|
+
return `${typeof value === "object" ? "an" : "a"} ${typeof value}`;
|
|
477
|
+
}
|