@goodboyjs/cli 0.1.0 → 0.2.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/commands/add.js +1 -1
- package/dist/commands/install.js +6 -2
- package/dist/commands/skill-version.d.ts +1 -0
- package/dist/commands/skill-version.js +71 -11
- package/dist/commands/upgrade.d.ts +5 -0
- package/dist/commands/upgrade.js +8 -4
- package/dist/lib/consent.js +18 -5
- package/dist/lib/manifest.d.ts +19 -0
- package/dist/lib/manifest.js +145 -8
- package/dist/lib/skill-validator.d.ts +1 -1
- package/dist/lib/skill-validator.js +17 -2
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -43,7 +43,7 @@ export const addCommand = new Command('add')
|
|
|
43
43
|
formatValidationResult(result, dirName);
|
|
44
44
|
throw new HandledFailure();
|
|
45
45
|
}
|
|
46
|
-
if (result.issues.some((i) => i.severity === 'warning')) {
|
|
46
|
+
if (result.issues.some((i) => i.severity === 'warning' || i.severity === 'info')) {
|
|
47
47
|
spinner.stop();
|
|
48
48
|
formatValidationResult(result, dirName);
|
|
49
49
|
spinner.start('Continuing...');
|
package/dist/commands/install.js
CHANGED
|
@@ -4,7 +4,7 @@ import { appendFile, readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { join, sep } from 'node:path';
|
|
5
5
|
import ora from 'ora';
|
|
6
6
|
import { createRegistryAdapter } from '../lib/registry-adapter.js';
|
|
7
|
-
import { readManifest,
|
|
7
|
+
import { readManifest, validateManifestDetailed } from '../lib/manifest.js';
|
|
8
8
|
import { requestConsent } from '../lib/consent.js';
|
|
9
9
|
import { scanForSymlinks } from '../lib/fs-security.js';
|
|
10
10
|
import { logger, sanitiseError } from '../lib/logger.js';
|
|
@@ -73,7 +73,11 @@ export async function installNamed(name, options, cwd) {
|
|
|
73
73
|
let manifest;
|
|
74
74
|
try {
|
|
75
75
|
const data = await readManifest(join(skillPath, 'manifest.json'));
|
|
76
|
-
|
|
76
|
+
const detailed = validateManifestDetailed(data);
|
|
77
|
+
manifest = detailed.manifest;
|
|
78
|
+
for (const warning of detailed.warnings) {
|
|
79
|
+
logger.warn(warning);
|
|
80
|
+
}
|
|
77
81
|
}
|
|
78
82
|
catch (err) {
|
|
79
83
|
spinner.fail('Manifest validation failed');
|
|
@@ -2,5 +2,6 @@ import { Command } from 'commander';
|
|
|
2
2
|
declare const BUMP_LEVELS: readonly ["patch", "minor", "major"];
|
|
3
3
|
type BumpLevel = (typeof BUMP_LEVELS)[number];
|
|
4
4
|
export declare function bumpVersion(version: string, level: BumpLevel): string;
|
|
5
|
+
export declare function assertWithin(target: string, base: string, label: string): void;
|
|
5
6
|
export declare function registerSkillVersion(program: Command): void;
|
|
6
7
|
export {};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { cp } from 'node:fs/promises';
|
|
1
|
+
import { cp, rm } from 'node:fs/promises';
|
|
2
2
|
import { join, resolve, sep } from 'node:path';
|
|
3
3
|
import ora from 'ora';
|
|
4
4
|
import { getRegistryPath } from '../lib/registry.js';
|
|
5
5
|
import { readRegistryEntry, writeRegistryEntry, resolveLatestVersion, resolveVersionPath, addVersionToEntry, } from '../lib/registry-entry.js';
|
|
6
|
-
import { readManifest, writeManifest,
|
|
6
|
+
import { readManifest, writeManifest, validateManifestDetailed, FIELD_INTRODUCED_IN, KNOWN_SCHEMA_VERSION, } from '../lib/manifest.js';
|
|
7
7
|
import { SKILL_NAME_RE } from '../lib/validation.js';
|
|
8
8
|
import { logger, sanitiseError } from '../lib/logger.js';
|
|
9
9
|
const BUMP_LEVELS = ['patch', 'minor', 'major'];
|
|
@@ -15,7 +15,10 @@ export function bumpVersion(version, level) {
|
|
|
15
15
|
case 'major': return `${major + 1}.0.0`;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// Exported for direct unit testing: this guard is the security boundary in
|
|
19
|
+
// front of the rm() cleanup on a failed version bump, so its throw path is
|
|
20
|
+
// tested directly rather than left unreached-in-practice behind an ignore.
|
|
21
|
+
export function assertWithin(target, base, label) {
|
|
19
22
|
const resolvedTarget = resolve(target);
|
|
20
23
|
const resolvedBase = resolve(base);
|
|
21
24
|
if (!resolvedTarget.startsWith(resolvedBase + sep)) {
|
|
@@ -75,14 +78,71 @@ async function createNewVersion(skillName, bump) {
|
|
|
75
78
|
assertWithin(newVersionDir, skillDir, 'new version path');
|
|
76
79
|
const spinner = ora(`Creating ${skillName}@${newVersion}...`).start();
|
|
77
80
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await
|
|
84
|
-
const
|
|
85
|
-
|
|
81
|
+
// Validate-then-act: read the SOURCE manifest (byte-identical to what cp()
|
|
82
|
+
// would copy below) and decide whether this bump is even allowed BEFORE
|
|
83
|
+
// creating anything on disk. A refusal here leaves no orphaned
|
|
84
|
+
// versions/<newVersion>/ directory behind — nothing has been written yet.
|
|
85
|
+
const sourceManifestPath = join(sourceVersionDir, 'manifest.json');
|
|
86
|
+
const rawManifest = await readManifest(sourceManifestPath);
|
|
87
|
+
const { manifest, warnings } = validateManifestDetailed(rawManifest);
|
|
88
|
+
// Refuse rather than persist a lossy write. S1's tolerant path returns a
|
|
89
|
+
// manifest with unknown top-level fields already stripped and the warning
|
|
90
|
+
// about it discarded by the thin validateManifest() wrapper — "stripped
|
|
91
|
+
// fields are invisible downstream" is a safe guarantee for read-only
|
|
92
|
+
// consumers (list, skill-status, ...), but this is the one path that
|
|
93
|
+
// WRITES a validated manifest back to disk. Persisting the stripped
|
|
94
|
+
// object here would silently delete fields a newer-minor manifest
|
|
95
|
+
// declared and silently downgrade its schema_version, with no warning
|
|
96
|
+
// ever surfaced to the author. Fail closed instead: require a real
|
|
97
|
+
// GoodBoy upgrade before this skill can be bumped.
|
|
98
|
+
if (warnings.length > 0) {
|
|
99
|
+
throw new Error(`${skillName}/manifest.json declares schema_version ${manifest.schema_version}, which is newer than ` +
|
|
100
|
+
`this GoodBoy CLI knows (${KNOWN_SCHEMA_VERSION}). Upgrade GoodBoy to bump this skill — bumping now ` +
|
|
101
|
+
`would discard fields this version does not understand.`);
|
|
102
|
+
}
|
|
103
|
+
// Everything from here on mutates the filesystem. The "already exists"
|
|
104
|
+
// check above (entry.versions[newVersion]) already guarantees no
|
|
105
|
+
// registered version lives at newVersionDir, so anything found there from
|
|
106
|
+
// this point is either created by this call's own cp() or is debris from
|
|
107
|
+
// an earlier failed attempt at this exact, still-unregistered version
|
|
108
|
+
// number — never content worth protecting. That makes cleanup on failure
|
|
109
|
+
// safe unconditionally, with no extra existence guard needed beyond the
|
|
110
|
+
// registry check that already ran.
|
|
111
|
+
try {
|
|
112
|
+
await cp(sourceVersionDir, newVersionDir, { recursive: true });
|
|
113
|
+
const manifestPath = join(newVersionDir, 'manifest.json');
|
|
114
|
+
manifest.version = newVersion;
|
|
115
|
+
// Stamp the lowest schema version this manifest actually needs. This only
|
|
116
|
+
// ever runs on a manifest that already validated strictly (no warnings,
|
|
117
|
+
// checked above) — it normalizes an already-valid, possibly over-stamped
|
|
118
|
+
// manifest down to its minimum (e.g. requires present but stamped higher
|
|
119
|
+
// than 1.1.0 -> 1.1.0; requires absent -> 1.0.0). It does NOT rescue an
|
|
120
|
+
// under-stamped, invalid manifest (schema_version below what a field it
|
|
121
|
+
// uses requires) — that already failed above, in manifest.ts's own
|
|
122
|
+
// feature-stamping gate, before this function even reached the copy step;
|
|
123
|
+
// fixing it requires a manual schema_version edit, by design.
|
|
124
|
+
manifest.schema_version = manifest.requires ? FIELD_INTRODUCED_IN['requires'] : '1.0.0';
|
|
125
|
+
await writeManifest(manifestPath, manifest);
|
|
126
|
+
const updatedEntry = addVersionToEntry(entry, newVersion, join('versions', newVersion));
|
|
127
|
+
await writeRegistryEntry(skillDir, updatedEntry);
|
|
128
|
+
}
|
|
129
|
+
catch (writeErr) {
|
|
130
|
+
// The copy (or a write after it) failed partway through. Remove whatever
|
|
131
|
+
// landed at newVersionDir so a failed bump never leaves an orphaned,
|
|
132
|
+
// unregistered versions/<newVersion>/ directory behind. Re-assert the
|
|
133
|
+
// path is still inside skillDir immediately before removing it — never
|
|
134
|
+
// remove anything outside that boundary. A cleanup failure must never
|
|
135
|
+
// replace or hide the real error: it's caught in its own try/catch and
|
|
136
|
+
// only logged, and the ORIGINAL write error is always what propagates.
|
|
137
|
+
try {
|
|
138
|
+
assertWithin(newVersionDir, skillDir, 'new version path');
|
|
139
|
+
await rm(newVersionDir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
catch (cleanupErr) {
|
|
142
|
+
logger.warn(`Failed to clean up "${newVersionDir}" after a failed bump: ${sanitiseError(cleanupErr)}`);
|
|
143
|
+
}
|
|
144
|
+
throw writeErr;
|
|
145
|
+
}
|
|
86
146
|
spinner.succeed();
|
|
87
147
|
}
|
|
88
148
|
catch (err) {
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
export interface UpgradeOptions {
|
|
3
|
+
global?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function upgradeSkill(name: string, options: UpgradeOptions, cwd: string): Promise<void>;
|
|
6
|
+
export declare function upgradeAll(options: UpgradeOptions, cwd: string): Promise<void>;
|
|
2
7
|
export declare const upgradeCommand: Command;
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -3,7 +3,7 @@ import { cpSync, existsSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import { createRegistryAdapter } from '../lib/registry-adapter.js';
|
|
6
|
-
import { readManifest,
|
|
6
|
+
import { readManifest, validateManifestDetailed } from '../lib/manifest.js';
|
|
7
7
|
import { scanForSymlinks } from '../lib/fs-security.js';
|
|
8
8
|
import { logger, sanitiseError } from '../lib/logger.js';
|
|
9
9
|
import { SKILL_NAME_RE } from '../lib/validation.js';
|
|
@@ -12,7 +12,7 @@ import { getStorePath, getGoodboyHome } from '../lib/store.js';
|
|
|
12
12
|
function getProjectSkillsPath(cwd) {
|
|
13
13
|
return join(cwd, '.claude', 'skills');
|
|
14
14
|
}
|
|
15
|
-
async function upgradeSkill(name, options, cwd) {
|
|
15
|
+
export async function upgradeSkill(name, options, cwd) {
|
|
16
16
|
if (!SKILL_NAME_RE.test(name)) {
|
|
17
17
|
throw new Error(`Invalid skill name: "${name}". Must match ^[a-z0-9-]+$.`);
|
|
18
18
|
}
|
|
@@ -29,7 +29,11 @@ async function upgradeSkill(name, options, cwd) {
|
|
|
29
29
|
let manifest;
|
|
30
30
|
try {
|
|
31
31
|
const data = await readManifest(join(skillPath, 'manifest.json'));
|
|
32
|
-
|
|
32
|
+
const detailed = validateManifestDetailed(data);
|
|
33
|
+
manifest = detailed.manifest;
|
|
34
|
+
for (const warning of detailed.warnings) {
|
|
35
|
+
logger.warn(warning);
|
|
36
|
+
}
|
|
33
37
|
}
|
|
34
38
|
catch (err) {
|
|
35
39
|
spinner.fail('Manifest validation failed');
|
|
@@ -67,7 +71,7 @@ async function upgradeSkill(name, options, cwd) {
|
|
|
67
71
|
const from = lockedVersion !== null ? `${lockedVersion} → ` : '';
|
|
68
72
|
spinner.succeed(`Upgraded "${name}" (${from}${manifest.version})`);
|
|
69
73
|
}
|
|
70
|
-
async function upgradeAll(options, cwd) {
|
|
74
|
+
export async function upgradeAll(options, cwd) {
|
|
71
75
|
const dir = options.global ? getGoodboyHome() : cwd;
|
|
72
76
|
const goodboy = await readGoodBoyJson(dir);
|
|
73
77
|
if (!goodboy) {
|
package/dist/lib/consent.js
CHANGED
|
@@ -32,13 +32,26 @@ export function summarizePermissions(manifest) {
|
|
|
32
32
|
.map((p) => PERMISSION_LABELS[p]);
|
|
33
33
|
}
|
|
34
34
|
export async function requestConsent(manifest) {
|
|
35
|
-
const
|
|
36
|
-
|
|
35
|
+
const permissionLines = summarizePermissions(manifest);
|
|
36
|
+
const secretNames = manifest.requires?.secrets ?? [];
|
|
37
|
+
// Explicit, not inferred: declared secrets imply the "env" permission (enforced
|
|
38
|
+
// as a hard error in manifest.ts), but this check must not rely on that — it
|
|
39
|
+
// decides whether to prompt at all, so it names both conditions directly.
|
|
40
|
+
if (permissionLines.length === 0 && secretNames.length === 0)
|
|
37
41
|
return true;
|
|
38
42
|
logger.info('');
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
if (permissionLines.length > 0) {
|
|
44
|
+
logger.info(`Skill "${manifest.name}" requests the following permissions:`);
|
|
45
|
+
for (const line of permissionLines) {
|
|
46
|
+
logger.info(` • ${line}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (secretNames.length > 0) {
|
|
50
|
+
logger.info('');
|
|
51
|
+
logger.info('Required secrets (names only — never resolved or read during install):');
|
|
52
|
+
for (const name of secretNames) {
|
|
53
|
+
logger.info(` • ${name}`);
|
|
54
|
+
}
|
|
42
55
|
}
|
|
43
56
|
logger.info('');
|
|
44
57
|
return confirm({
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import type { GoodBoyManifest } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The manifest schema version this CLI validates strictly against. Manifests
|
|
4
|
+
* declaring a newer minor are tolerated (unknown top-level fields stripped,
|
|
5
|
+
* with a warning); a newer major is rejected. See validateManifestDetailed().
|
|
6
|
+
*/
|
|
7
|
+
export declare const KNOWN_SCHEMA_VERSION = "1.1.0";
|
|
8
|
+
export declare const FIELD_INTRODUCED_IN: Record<string, string>;
|
|
2
9
|
export declare function readManifest(filePath: string): Promise<unknown>;
|
|
10
|
+
export interface ManifestValidationResult {
|
|
11
|
+
manifest: GoodBoyManifest;
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validates a manifest, tolerating a newer-minor `schema_version` than this
|
|
16
|
+
* CLI knows: unknown top-level fields are stripped (never mutating the
|
|
17
|
+
* caller's object) and a warning is returned instead of a hard failure. A
|
|
18
|
+
* newer major is always rejected. Manifests at or below the known minor take
|
|
19
|
+
* the exact same strict path as before this tolerance existed.
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateManifestDetailed(data: unknown): ManifestValidationResult;
|
|
3
22
|
export declare function validateManifest(data: unknown): GoodBoyManifest;
|
|
4
23
|
export declare function writeManifest(filePath: string, data: GoodBoyManifest): Promise<void>;
|
package/dist/lib/manifest.js
CHANGED
|
@@ -7,16 +7,100 @@ const addFormats = addFormatsPkg.default;
|
|
|
7
7
|
const _require = createRequire(import.meta.url);
|
|
8
8
|
const MAX_MANIFEST_BYTES = 512 * 1024; // 512 KB
|
|
9
9
|
const MAX_NESTING_DEPTH = 10;
|
|
10
|
+
/**
|
|
11
|
+
* The manifest schema version this CLI validates strictly against. Manifests
|
|
12
|
+
* declaring a newer minor are tolerated (unknown top-level fields stripped,
|
|
13
|
+
* with a warning); a newer major is rejected. See validateManifestDetailed().
|
|
14
|
+
*/
|
|
15
|
+
export const KNOWN_SCHEMA_VERSION = '1.1.0';
|
|
16
|
+
// Feature-driven stamping: a top-level field may only be used by a manifest
|
|
17
|
+
// that declares a schema_version at or above the version that introduced it.
|
|
18
|
+
// Without this, a manifest stamped e.g. "1.0.0" that uses a field only the
|
|
19
|
+
// known schema (now on a later minor) recognizes would validate successfully
|
|
20
|
+
// under Ajv — but an older, tolerant CLI that has never heard of the field
|
|
21
|
+
// would reject it with a confusing additionalProperties error. Enforced here,
|
|
22
|
+
// in code, because remediation text like this needs to name the exact field
|
|
23
|
+
// and the exact version to stamp — not something Ajv's own error shapes give us.
|
|
24
|
+
export const FIELD_INTRODUCED_IN = {
|
|
25
|
+
requires: '1.1.0',
|
|
26
|
+
};
|
|
27
|
+
const SCHEMA_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
28
|
+
let _schema = null;
|
|
10
29
|
let _validator = null;
|
|
30
|
+
function getSchema() {
|
|
31
|
+
if (_schema)
|
|
32
|
+
return _schema;
|
|
33
|
+
_schema = _require('@goodboyjs/schema/src/manifest.schema.json');
|
|
34
|
+
return _schema;
|
|
35
|
+
}
|
|
11
36
|
function getValidator() {
|
|
12
37
|
if (_validator)
|
|
13
38
|
return _validator;
|
|
14
39
|
const ajv = new Ajv({ strict: true, allErrors: true });
|
|
15
40
|
addFormats(ajv);
|
|
16
|
-
|
|
17
|
-
_validator = ajv.compile(schema);
|
|
41
|
+
_validator = ajv.compile(getSchema());
|
|
18
42
|
return _validator;
|
|
19
43
|
}
|
|
44
|
+
// Top-level property names the known schema recognizes. Used only to strip
|
|
45
|
+
// unknown top-level fields from a newer-minor manifest before strict
|
|
46
|
+
// validation — never to loosen validation of known fields.
|
|
47
|
+
function getKnownTopLevelKeys() {
|
|
48
|
+
/* c8 ignore next -- the shipped schema always has a root "properties" key; ?? fallback is unreachable. Fail-closed if ever violated: an empty Set would strip every field, and every existing manifest test would fail immediately. */
|
|
49
|
+
const properties = (getSchema()['properties'] ?? {});
|
|
50
|
+
return new Set(Object.keys(properties));
|
|
51
|
+
}
|
|
52
|
+
function throwValidationError(validate) {
|
|
53
|
+
/* c8 ignore next 2 -- ajv always populates errors[] after a failed validate(); ?? fallbacks are unreachable */
|
|
54
|
+
const lines = (validate.errors ?? []).map((e) => ` ${e.instancePath || '(root)'}: ${e.message ?? 'validation failed'}`);
|
|
55
|
+
throw new Error(`Invalid manifest:\n${lines.join('\n')}`);
|
|
56
|
+
}
|
|
57
|
+
function parseSemverTriple(version) {
|
|
58
|
+
const match = SCHEMA_VERSION_PATTERN.exec(version);
|
|
59
|
+
/* c8 ignore next -- both call sites only ever pass an already-valid version string; null is a defensive return, not a reachable case */
|
|
60
|
+
if (!match)
|
|
61
|
+
return null;
|
|
62
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
63
|
+
}
|
|
64
|
+
function isVersionAtLeast(declared, required) {
|
|
65
|
+
for (let i = 0; i < 3; i++) {
|
|
66
|
+
if (declared[i] !== required[i])
|
|
67
|
+
return declared[i] > required[i];
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// Runs after a manifest has already passed Ajv validation (so schema_version
|
|
72
|
+
// is guaranteed to match the schema's own pattern). Rejects a manifest that
|
|
73
|
+
// uses a field its declared schema_version predates.
|
|
74
|
+
function assertFeatureStamping(manifest) {
|
|
75
|
+
const declared = parseSemverTriple(manifest.schema_version);
|
|
76
|
+
/* c8 ignore next -- schema_version already satisfied Ajv's own pattern check by this point, so it always parses */
|
|
77
|
+
if (!declared)
|
|
78
|
+
return;
|
|
79
|
+
for (const key of Object.keys(manifest)) {
|
|
80
|
+
const introducedIn = FIELD_INTRODUCED_IN[key];
|
|
81
|
+
if (!introducedIn)
|
|
82
|
+
continue;
|
|
83
|
+
const required = parseSemverTriple(introducedIn);
|
|
84
|
+
if (!isVersionAtLeast(declared, required)) {
|
|
85
|
+
throw new Error(`manifest declares schema_version ${manifest.schema_version} but uses "${key}", which needs ${introducedIn}.\n` +
|
|
86
|
+
`Set "schema_version": "${introducedIn}" in manifest.json.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Hard-error consistency rule (concept doc §7.1): requires.secrets is only
|
|
91
|
+
// ever visible to a skill-installing user via the "env" permission on a
|
|
92
|
+
// tolerant older CLI that doesn't know about `requires` at all — so the two
|
|
93
|
+
// fields must never disagree. Fail closed rather than silently trusting one.
|
|
94
|
+
function assertPermissionsConsistency(manifest) {
|
|
95
|
+
const secrets = manifest.requires?.secrets;
|
|
96
|
+
if (!secrets || secrets.length === 0)
|
|
97
|
+
return;
|
|
98
|
+
const permissions = manifest.permissions ?? [];
|
|
99
|
+
if (!permissions.includes('env')) {
|
|
100
|
+
throw new Error('manifest declares requires.secrets but "permissions" does not include "env".\n' +
|
|
101
|
+
'Secrets are delivered as environment variables; add "env" to permissions.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
20
104
|
// Heuristic nesting depth check: counts opening brackets/braces.
|
|
21
105
|
// This is intentionally fast and runs before JSON.parse() to guard against
|
|
22
106
|
// deeply nested payloads that could exhaust the stack. Brackets inside
|
|
@@ -66,14 +150,67 @@ export async function readManifest(filePath) {
|
|
|
66
150
|
throw new Error(`manifest.json contains invalid JSON`);
|
|
67
151
|
}
|
|
68
152
|
}
|
|
69
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Validates a manifest, tolerating a newer-minor `schema_version` than this
|
|
155
|
+
* CLI knows: unknown top-level fields are stripped (never mutating the
|
|
156
|
+
* caller's object) and a warning is returned instead of a hard failure. A
|
|
157
|
+
* newer major is always rejected. Manifests at or below the known minor take
|
|
158
|
+
* the exact same strict path as before this tolerance existed.
|
|
159
|
+
*/
|
|
160
|
+
export function validateManifestDetailed(data) {
|
|
70
161
|
const validate = getValidator();
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
162
|
+
const knownParts = KNOWN_SCHEMA_VERSION.split('.').map(Number);
|
|
163
|
+
const knownMajor = knownParts[0];
|
|
164
|
+
const knownMinor = knownParts[1];
|
|
165
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
166
|
+
const rawVersion = data['schema_version'];
|
|
167
|
+
// The length gate (mirrors the schema's own maxLength: 32) must run before any
|
|
168
|
+
// interpolation of rawVersion into a message: an overlong value falls straight
|
|
169
|
+
// through to strict Ajv validation, which rejects it via maxLength without ever
|
|
170
|
+
// embedding the value in the error text.
|
|
171
|
+
if (typeof rawVersion === 'string' && rawVersion.length <= 32) {
|
|
172
|
+
const match = SCHEMA_VERSION_PATTERN.exec(rawVersion);
|
|
173
|
+
if (match) {
|
|
174
|
+
const major = Number(match[1]);
|
|
175
|
+
const minor = Number(match[2]);
|
|
176
|
+
if (major !== knownMajor) {
|
|
177
|
+
const upgradeHint = major > knownMajor ? ' Upgrade GoodBoy to use this skill.' : '';
|
|
178
|
+
throw new Error(`manifest declares schema ${rawVersion}; this version of GoodBoy supports ${knownMajor}.x manifests.${upgradeHint}`);
|
|
179
|
+
}
|
|
180
|
+
if (minor > knownMinor) {
|
|
181
|
+
const known = getKnownTopLevelKeys();
|
|
182
|
+
const stripped = {};
|
|
183
|
+
for (const [key, value] of Object.entries(data)) {
|
|
184
|
+
if (known.has(key))
|
|
185
|
+
stripped[key] = value;
|
|
186
|
+
}
|
|
187
|
+
if (!validate(stripped))
|
|
188
|
+
throwValidationError(validate);
|
|
189
|
+
const manifest = stripped;
|
|
190
|
+
assertFeatureStamping(manifest);
|
|
191
|
+
assertPermissionsConsistency(manifest);
|
|
192
|
+
return {
|
|
193
|
+
manifest,
|
|
194
|
+
warnings: [
|
|
195
|
+
`manifest uses schema ${rawVersion}; this GoodBoy CLI knows ${KNOWN_SCHEMA_VERSION}. Unknown fields were ignored — upgrade GoodBoy to use them.`,
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// minor <= knownMinor: fall through to strict validation below, unchanged.
|
|
200
|
+
}
|
|
201
|
+
// non-matching string: fall through; Ajv's pattern check reports the standard error.
|
|
202
|
+
}
|
|
203
|
+
// missing/non-string schema_version: fall through; Ajv's required/type check reports the standard error.
|
|
75
204
|
}
|
|
76
|
-
|
|
205
|
+
if (!validate(data))
|
|
206
|
+
throwValidationError(validate);
|
|
207
|
+
const manifest = data;
|
|
208
|
+
assertFeatureStamping(manifest);
|
|
209
|
+
assertPermissionsConsistency(manifest);
|
|
210
|
+
return { manifest, warnings: [] };
|
|
211
|
+
}
|
|
212
|
+
export function validateManifest(data) {
|
|
213
|
+
return validateManifestDetailed(data).manifest;
|
|
77
214
|
}
|
|
78
215
|
export async function writeManifest(filePath, data) {
|
|
79
216
|
const resolved = resolve(filePath);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { readManifest,
|
|
4
|
+
import { readManifest, validateManifestDetailed } from './manifest.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
6
|
function parseFrontmatter(content) {
|
|
7
7
|
const lines = content.split('\n');
|
|
@@ -40,7 +40,11 @@ export async function validateSkillDirectory(skillPath) {
|
|
|
40
40
|
try {
|
|
41
41
|
const raw = await readManifest(manifestPath);
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
const detailed = validateManifestDetailed(raw);
|
|
44
|
+
manifest = detailed.manifest;
|
|
45
|
+
for (const warning of detailed.warnings) {
|
|
46
|
+
issues.push({ severity: 'warning', message: warning });
|
|
47
|
+
}
|
|
44
48
|
}
|
|
45
49
|
catch (err) {
|
|
46
50
|
/* c8 ignore next */
|
|
@@ -68,6 +72,13 @@ export async function validateSkillDirectory(skillPath) {
|
|
|
68
72
|
if (!manifest.tags || manifest.tags.length === 0) {
|
|
69
73
|
issues.push({ severity: 'warning', message: 'manifest has no tags' });
|
|
70
74
|
}
|
|
75
|
+
const secretCount = manifest.requires?.secrets.length ?? 0;
|
|
76
|
+
if (secretCount > 0) {
|
|
77
|
+
issues.push({
|
|
78
|
+
severity: 'info',
|
|
79
|
+
message: `declares ${secretCount} required secret${secretCount === 1 ? '' : 's'}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
71
82
|
}
|
|
72
83
|
// --- SKILL.md checks ---
|
|
73
84
|
const skillMdPath = join(skillPath, 'SKILL.md');
|
|
@@ -107,6 +118,7 @@ export async function validateSkillDirectory(skillPath) {
|
|
|
107
118
|
export function formatValidationResult(result, skillName) {
|
|
108
119
|
const errors = result.issues.filter((i) => i.severity === 'error');
|
|
109
120
|
const warnings = result.issues.filter((i) => i.severity === 'warning');
|
|
121
|
+
const infos = result.issues.filter((i) => i.severity === 'info');
|
|
110
122
|
if (errors.length > 0) {
|
|
111
123
|
logger.error(`Validation errors for "${skillName}":`);
|
|
112
124
|
for (const issue of errors) {
|
|
@@ -119,4 +131,7 @@ export function formatValidationResult(result, skillName) {
|
|
|
119
131
|
logger.warn(` • ${issue.message}`);
|
|
120
132
|
}
|
|
121
133
|
}
|
|
134
|
+
for (const issue of infos) {
|
|
135
|
+
logger.success(issue.message);
|
|
136
|
+
}
|
|
122
137
|
}
|