silvermoon 0.0.1
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 +236 -0
- package/README.zh-CN.md +199 -0
- package/assets/silvermoon.svg +175 -0
- package/bin/silvermoon.js +5 -0
- package/package.json +65 -0
- package/schema/v1/config.schema.json +24 -0
- package/schema/v1/definitions.schema.json +25 -0
- package/schema/v1/idea-status.schema.json +41 -0
- package/skills/silvermoon/SKILL.md +209 -0
- package/skills/silvermoon/references/adoption.md +122 -0
- package/src/adoption.js +313 -0
- package/src/cli.js +197 -0
- package/src/config.js +176 -0
- package/src/create-idea.js +235 -0
- package/src/git.js +284 -0
- package/src/idea-layout.js +439 -0
- package/src/idea-templates.js +109 -0
- package/src/ideas.js +132 -0
- package/src/index.js +260 -0
- package/src/layout.js +21 -0
- package/src/repository.js +88 -0
- package/src/whatsnext.js +379 -0
- package/src/yaml.js +66 -0
package/src/adoption.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
readFile,
|
|
5
|
+
readdir,
|
|
6
|
+
realpath,
|
|
7
|
+
} from "node:fs/promises";
|
|
8
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
import { loadConfig } from "./config.js";
|
|
12
|
+
import { runGit } from "./git.js";
|
|
13
|
+
|
|
14
|
+
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
15
|
+
const packageJson = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
|
|
16
|
+
export const SILVERMOON_VERSION = packageJson.version;
|
|
17
|
+
export const REPOSITORY_SKILL_PATHS = [
|
|
18
|
+
".agents/skills/silvermoon",
|
|
19
|
+
".github/skills/silvermoon",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
async function metadata(path) {
|
|
23
|
+
try {
|
|
24
|
+
return await lstat(path);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code === "ENOENT") return null;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function jsonFile(path) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === "ENOENT") return null;
|
|
36
|
+
return { __invalid: error.message };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function directoryDigest(root) {
|
|
41
|
+
const hash = createHash("sha256");
|
|
42
|
+
async function visit(directory) {
|
|
43
|
+
const entries = (await readdir(directory, { withFileTypes: true }))
|
|
44
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const path = resolve(directory, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
await visit(path);
|
|
49
|
+
} else if (entry.isFile()) {
|
|
50
|
+
hash.update(relative(root, path).split(sep).join("/"));
|
|
51
|
+
hash.update("\0");
|
|
52
|
+
hash.update(await readFile(path));
|
|
53
|
+
hash.update("\0");
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`Skill contains a non-regular path: ${path}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await visit(root);
|
|
60
|
+
return hash.digest("hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function skillDigest(path) {
|
|
64
|
+
const entry = await lstat(path);
|
|
65
|
+
if (entry.isFile()) {
|
|
66
|
+
const pointer = (await readFile(path, "utf8")).trim();
|
|
67
|
+
if (!pointer) throw new Error(`Skill pointer is empty: ${path}`);
|
|
68
|
+
return directoryDigest(resolve(dirname(path), pointer));
|
|
69
|
+
}
|
|
70
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
71
|
+
throw new Error(`Skill discovery path is not a directory: ${path}`);
|
|
72
|
+
}
|
|
73
|
+
return directoryDigest(path);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function command(executable, args, description) {
|
|
77
|
+
return { kind: "command", executable, args, description };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function manual(path, description) {
|
|
81
|
+
return { kind: "manual", path, description };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requirement(id, title, status, blocking, dependencies, observed, remediation = null) {
|
|
85
|
+
return { id, title, status, blocking, dependencies, observed, remediation };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function detectPackageManager(root, manifest) {
|
|
89
|
+
for (const [file, name] of [
|
|
90
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
91
|
+
["yarn.lock", "yarn"],
|
|
92
|
+
["bun.lock", "bun"],
|
|
93
|
+
["bun.lockb", "bun"],
|
|
94
|
+
["package-lock.json", "npm"],
|
|
95
|
+
["npm-shrinkwrap.json", "npm"],
|
|
96
|
+
]) {
|
|
97
|
+
if (await metadata(resolve(root, file))) return name;
|
|
98
|
+
}
|
|
99
|
+
const declared = manifest?.packageManager?.split("@")[0];
|
|
100
|
+
return ["pnpm", "npm", "yarn", "bun"].includes(declared) ? declared : "npm";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function detectExecutionSource({
|
|
104
|
+
root,
|
|
105
|
+
runtimeRoot = packageRoot,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const runtime = await realpath(runtimeRoot);
|
|
108
|
+
let repository;
|
|
109
|
+
try {
|
|
110
|
+
repository = await realpath(root);
|
|
111
|
+
} catch {
|
|
112
|
+
repository = resolve(root);
|
|
113
|
+
}
|
|
114
|
+
let local = null;
|
|
115
|
+
try {
|
|
116
|
+
local = await realpath(resolve(repository, "node_modules", "silvermoon"));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error.code !== "ENOENT") throw error;
|
|
119
|
+
}
|
|
120
|
+
const normalizedRuntime = runtime.toLowerCase();
|
|
121
|
+
const normalizedRoot = repository.toLowerCase();
|
|
122
|
+
const normalizedLocal = local?.toLowerCase() ?? null;
|
|
123
|
+
let kind = "unknown";
|
|
124
|
+
if (normalizedRuntime === normalizedRoot && await metadata(resolve(runtime, ".git"))) {
|
|
125
|
+
kind = "source-checkout";
|
|
126
|
+
} else if (normalizedRuntime === normalizedLocal) {
|
|
127
|
+
kind = "project-local";
|
|
128
|
+
} else if (/(^|[\\/])_npx([\\/]|$)/i.test(runtime)) {
|
|
129
|
+
kind = "temporary";
|
|
130
|
+
} else if (normalizedRuntime.includes(`${sep}node_modules${sep}`) && !normalizedRuntime.startsWith(normalizedRoot + sep)) {
|
|
131
|
+
kind = "global";
|
|
132
|
+
}
|
|
133
|
+
return { kind, packageRoot: runtime, version: SILVERMOON_VERSION };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function installCommand(manager, version) {
|
|
137
|
+
if (manager === "pnpm") {
|
|
138
|
+
return command("pnpm", ["add", "--save-dev", "--save-exact", `silvermoon@${version}`], `Declare Silvermoon ${version} and install it locally.`);
|
|
139
|
+
}
|
|
140
|
+
if (manager === "yarn") {
|
|
141
|
+
return command("yarn", ["add", "--dev", "--exact", `silvermoon@${version}`], `Declare Silvermoon ${version} and install it locally.`);
|
|
142
|
+
}
|
|
143
|
+
if (manager === "bun") {
|
|
144
|
+
return command("bun", ["add", "--dev", "--exact", `silvermoon@${version}`], `Declare Silvermoon ${version} and install it locally.`);
|
|
145
|
+
}
|
|
146
|
+
return command("npm", ["install", "--save-dev", "--save-exact", `silvermoon@${version}`], `Declare Silvermoon ${version} and install it locally.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function inspectAdoption({ root, selector } = {}) {
|
|
150
|
+
const repositoryRoot = resolve(root ?? process.cwd());
|
|
151
|
+
const source = await detectExecutionSource({ root: repositoryRoot });
|
|
152
|
+
const manifest = await jsonFile(resolve(repositoryRoot, "package.json"));
|
|
153
|
+
const manager = await detectPackageManager(repositoryRoot, manifest);
|
|
154
|
+
const sourceCheckout = source.kind === "source-checkout";
|
|
155
|
+
const desiredVersion = source.version;
|
|
156
|
+
const declared = manifest?.devDependencies?.silvermoon ?? manifest?.dependencies?.silvermoon ?? null;
|
|
157
|
+
const installedManifest = sourceCheckout
|
|
158
|
+
? packageJson
|
|
159
|
+
: await jsonFile(resolve(repositoryRoot, "node_modules", "silvermoon", "package.json"));
|
|
160
|
+
const installedVersion = installedManifest?.version ?? null;
|
|
161
|
+
const requirements = [];
|
|
162
|
+
|
|
163
|
+
const git = runGit(repositoryRoot, ["rev-parse", "--show-toplevel"]);
|
|
164
|
+
requirements.push(requirement(
|
|
165
|
+
"repository.git",
|
|
166
|
+
"Git repository",
|
|
167
|
+
git.ok ? "satisfied" : "missing",
|
|
168
|
+
true,
|
|
169
|
+
[],
|
|
170
|
+
{ root: git.ok ? git.stdout : null },
|
|
171
|
+
git.ok ? null : command("git", ["init"], "Initialize this directory as a Git repository."),
|
|
172
|
+
));
|
|
173
|
+
|
|
174
|
+
requirements.push(requirement(
|
|
175
|
+
"runtime.execution-source",
|
|
176
|
+
"Authoritative execution source",
|
|
177
|
+
["source-checkout", "project-local", "temporary"].includes(source.kind)
|
|
178
|
+
? "satisfied"
|
|
179
|
+
: source.kind === "global" ? "conflicting" : "unknown",
|
|
180
|
+
!sourceCheckout && !["project-local", "temporary"].includes(source.kind),
|
|
181
|
+
["global", "unknown"].includes(source.kind)
|
|
182
|
+
? ["repository.git", "package.installed"]
|
|
183
|
+
: ["repository.git"],
|
|
184
|
+
source,
|
|
185
|
+
["global", "unknown"].includes(source.kind)
|
|
186
|
+
? command("npx", ["--no-install", "silvermoon", "whats-next", ...(selector ? [selector] : []), "--json"], "Re-run with the repository-local CLI.")
|
|
187
|
+
: null,
|
|
188
|
+
));
|
|
189
|
+
|
|
190
|
+
requirements.push(requirement(
|
|
191
|
+
"package.manifest",
|
|
192
|
+
"Exact project dependency",
|
|
193
|
+
sourceCheckout
|
|
194
|
+
? "inapplicable"
|
|
195
|
+
: declared === desiredVersion ? "satisfied" : declared ? "mismatched" : "missing",
|
|
196
|
+
!sourceCheckout,
|
|
197
|
+
["repository.git"],
|
|
198
|
+
{ packageManager: manager, declared, expected: sourceCheckout ? null : desiredVersion },
|
|
199
|
+
sourceCheckout ? null : installCommand(manager, desiredVersion),
|
|
200
|
+
));
|
|
201
|
+
|
|
202
|
+
const installStatus = sourceCheckout
|
|
203
|
+
? "satisfied"
|
|
204
|
+
: installedVersion === desiredVersion ? "satisfied" : installedVersion ? "mismatched" : "missing";
|
|
205
|
+
requirements.push(requirement(
|
|
206
|
+
"package.installed",
|
|
207
|
+
"Installed project CLI",
|
|
208
|
+
installStatus,
|
|
209
|
+
true,
|
|
210
|
+
sourceCheckout ? ["runtime.execution-source"] : ["package.manifest"],
|
|
211
|
+
{ installedVersion, expected: desiredVersion },
|
|
212
|
+
installStatus === "satisfied"
|
|
213
|
+
? null
|
|
214
|
+
: command(manager, ["install"], `Install the lockfile-resolved Silvermoon ${desiredVersion} dependency.`),
|
|
215
|
+
));
|
|
216
|
+
|
|
217
|
+
const packagedSkill = resolve(packageRoot, "skills", "silvermoon");
|
|
218
|
+
let skillStatus = "blocked";
|
|
219
|
+
const skillObserved = { paths: [], expectedDigest: null };
|
|
220
|
+
if (installStatus === "satisfied") {
|
|
221
|
+
const expectedDigest = await directoryDigest(packagedSkill);
|
|
222
|
+
skillObserved.expectedDigest = expectedDigest;
|
|
223
|
+
for (const path of REPOSITORY_SKILL_PATHS) {
|
|
224
|
+
try {
|
|
225
|
+
skillObserved.paths.push({
|
|
226
|
+
path,
|
|
227
|
+
digest: await skillDigest(resolve(repositoryRoot, ...path.split("/"))),
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error.code !== "ENOENT") {
|
|
231
|
+
skillObserved.paths.push({ path, error: error.message });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
skillStatus = skillObserved.paths.length === 0
|
|
236
|
+
? "missing"
|
|
237
|
+
: skillObserved.paths.every(({ digest }) => digest === expectedDigest)
|
|
238
|
+
? "satisfied"
|
|
239
|
+
: skillObserved.paths.some(({ error }) => error)
|
|
240
|
+
? "conflicting"
|
|
241
|
+
: "mismatched";
|
|
242
|
+
}
|
|
243
|
+
requirements.push(requirement(
|
|
244
|
+
"skill.repository-local",
|
|
245
|
+
"Repository-local canonical skill",
|
|
246
|
+
skillStatus,
|
|
247
|
+
true,
|
|
248
|
+
["package.installed"],
|
|
249
|
+
skillObserved,
|
|
250
|
+
skillStatus === "satisfied" || skillStatus === "blocked"
|
|
251
|
+
? null
|
|
252
|
+
: command(
|
|
253
|
+
"npx",
|
|
254
|
+
[
|
|
255
|
+
"skills",
|
|
256
|
+
"add",
|
|
257
|
+
"./node_modules/silvermoon/skills",
|
|
258
|
+
"--skill",
|
|
259
|
+
"silvermoon",
|
|
260
|
+
"--agent",
|
|
261
|
+
"github-copilot",
|
|
262
|
+
"--yes",
|
|
263
|
+
"--copy",
|
|
264
|
+
],
|
|
265
|
+
"Register the installed package's canonical skill through the supported npx skills interface.",
|
|
266
|
+
),
|
|
267
|
+
));
|
|
268
|
+
|
|
269
|
+
const loadedConfig = await loadConfig({ root: repositoryRoot });
|
|
270
|
+
const missingConfig = loadedConfig.diagnostics.some(({ code }) => code === "config.missing");
|
|
271
|
+
const configStatus = loadedConfig.config
|
|
272
|
+
? "satisfied"
|
|
273
|
+
: missingConfig ? "missing" : "conflicting";
|
|
274
|
+
requirements.push(requirement(
|
|
275
|
+
"repository.configuration",
|
|
276
|
+
"Silvermoon repository configuration",
|
|
277
|
+
configStatus,
|
|
278
|
+
true,
|
|
279
|
+
["repository.git"],
|
|
280
|
+
{ path: ".silvermoon/config.yaml", diagnostics: loadedConfig.diagnostics },
|
|
281
|
+
configStatus === "satisfied"
|
|
282
|
+
? null
|
|
283
|
+
: missingConfig
|
|
284
|
+
? manual(".silvermoon/config.yaml", "Create the documented configuration through ordinary reviewed file editing.")
|
|
285
|
+
: manual(".silvermoon/config.yaml", "Repair the reported configuration conflicts without overwriting unrelated content."),
|
|
286
|
+
));
|
|
287
|
+
|
|
288
|
+
const blockingFindings = requirements.filter(({ blocking, status }) =>
|
|
289
|
+
blocking && !["satisfied", "inapplicable"].includes(status)
|
|
290
|
+
);
|
|
291
|
+
const recommended = requirements.find(({ remediation, status, dependencies }) =>
|
|
292
|
+
remediation
|
|
293
|
+
&& !["satisfied", "inapplicable"].includes(status)
|
|
294
|
+
&& dependencies.every((id) => {
|
|
295
|
+
const dependency = requirements.find((item) => item.id === id);
|
|
296
|
+
return dependency && ["satisfied", "inapplicable"].includes(dependency.status);
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
return {
|
|
300
|
+
status: blockingFindings.length === 0 ? "ready" : "blocked",
|
|
301
|
+
executionSource: source,
|
|
302
|
+
packageManager: manager,
|
|
303
|
+
desiredVersion,
|
|
304
|
+
requirements,
|
|
305
|
+
findings: requirements.filter(({ status }) => !["satisfied", "inapplicable"].includes(status)),
|
|
306
|
+
recommendedAction: recommended?.remediation ?? null,
|
|
307
|
+
recheck: command(
|
|
308
|
+
"npx",
|
|
309
|
+
["--no-install", "silvermoon", "whats-next", ...(selector ? [selector] : []), "--json"],
|
|
310
|
+
"Re-run the complete diagnosis after an observable change.",
|
|
311
|
+
),
|
|
312
|
+
};
|
|
313
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Command, CommanderError, Option } from "commander";
|
|
3
|
+
|
|
4
|
+
import { checkRepository } from "./index.js";
|
|
5
|
+
import { createIdea } from "./create-idea.js";
|
|
6
|
+
import { whatsNext } from "./whatsnext.js";
|
|
7
|
+
|
|
8
|
+
const { version: VERSION } = JSON.parse(
|
|
9
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
function write(method, value) {
|
|
13
|
+
const text = value.replace(/\n$/, "");
|
|
14
|
+
if (text) method(text);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function addCommonOptions(command) {
|
|
18
|
+
return command
|
|
19
|
+
.option("--json", "emit the complete machine-readable report")
|
|
20
|
+
.option("-r, --root <path>", "repository root", process.cwd());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderDiagnostics(report, io) {
|
|
24
|
+
for (const item of report.diagnostics) {
|
|
25
|
+
const location = item.path ? ` ${item.path}` : "";
|
|
26
|
+
const output = item.level === "error" ? io.error : io.log;
|
|
27
|
+
output(`${item.level.toUpperCase()} ${item.code}${location}: ${item.message}`);
|
|
28
|
+
output(` Fix: ${item.remediation}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderIdeaIdentity(idea) {
|
|
33
|
+
return idea.alias === undefined ? idea.id : `${idea.id} (${idea.alias})`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderRemediation(remediation) {
|
|
37
|
+
return remediation.kind === "command"
|
|
38
|
+
? `${remediation.executable} ${remediation.args.join(" ")}`
|
|
39
|
+
: `${remediation.description} (${remediation.path})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderWhatsNext(result, io) {
|
|
43
|
+
if (result.onboarding) {
|
|
44
|
+
io.log(`onboarding ${result.onboarding.status}`);
|
|
45
|
+
io.log(` runtime ${result.onboarding.executionSource.kind} ${result.onboarding.desiredVersion}`);
|
|
46
|
+
for (const item of result.onboarding.requirements) {
|
|
47
|
+
io.log(
|
|
48
|
+
` ${item.status.padEnd(12)} ${item.id}${item.blocking ? " [blocking]" : " [non-blocking]"}: ${item.title}`,
|
|
49
|
+
);
|
|
50
|
+
io.log(` depends ${item.dependencies.length ? item.dependencies.join(", ") : "none"}`);
|
|
51
|
+
io.log(` observed ${JSON.stringify(item.observed)}`);
|
|
52
|
+
if (item.remediation) io.log(` fix ${renderRemediation(item.remediation)}`);
|
|
53
|
+
}
|
|
54
|
+
if (result.onboarding.recommendedAction) {
|
|
55
|
+
io.log(` next ${renderRemediation(result.onboarding.recommendedAction)}`);
|
|
56
|
+
}
|
|
57
|
+
io.log(` recheck ${renderRemediation(result.onboarding.recheck)}`);
|
|
58
|
+
}
|
|
59
|
+
io.log(`${result.action.code}: ${result.action.message}`);
|
|
60
|
+
if (result.observedPrimaryCommit) io.log(` primary ${result.observedPrimaryCommit}`);
|
|
61
|
+
if (result.selectedIdea) {
|
|
62
|
+
io.log(` idea ${renderIdeaIdentity(result.selectedIdea)}`);
|
|
63
|
+
io.log(` state ${result.selectedIdea.state}`);
|
|
64
|
+
io.log(` ideal ${result.selectedIdea.idealRevision}`);
|
|
65
|
+
io.log(` inner ${result.selectedIdea.implementationRevision}`);
|
|
66
|
+
io.log(` outer ${result.selectedIdea.deploymentRevision}`);
|
|
67
|
+
}
|
|
68
|
+
if (result.action.details?.world) {
|
|
69
|
+
const world = result.action.details.world;
|
|
70
|
+
io.log(` world ${world.name} (${world.displayName})`);
|
|
71
|
+
io.log(` entry ${world.documentPath}`);
|
|
72
|
+
io.log(` support ${world.auxiliaryRoot}`);
|
|
73
|
+
io.log(` decision ${world.decisionField}: ${world.revision}`);
|
|
74
|
+
io.log(` cascade ${world.cascade}`);
|
|
75
|
+
}
|
|
76
|
+
for (const idea of result.action.details?.ideas ?? []) {
|
|
77
|
+
io.log(` option ${renderIdeaIdentity(idea)} ${idea.state}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderCreateIdea(result, io) {
|
|
82
|
+
if (!result.createdIdea) {
|
|
83
|
+
renderWhatsNext(result, io);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
io.log(`created: ${result.createdIdea.id}`);
|
|
87
|
+
io.log(` idea ${result.createdIdea.ideaPath}`);
|
|
88
|
+
io.log(` ledger ${result.createdIdea.ledgerPath}`);
|
|
89
|
+
io.log(` status ${result.createdIdea.statusPath}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function render(report, json, io) {
|
|
93
|
+
if (json) {
|
|
94
|
+
io.log(JSON.stringify(report, null, 2));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
renderDiagnostics(report, io);
|
|
98
|
+
if (!report.ok) {
|
|
99
|
+
io.error("FAILED");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (report.command === "whats-next") {
|
|
103
|
+
renderWhatsNext(report.result, io);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (report.command === "create-idea") {
|
|
107
|
+
renderCreateIdea(report.result, io);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
io.log(`OK: ${report.command}`);
|
|
111
|
+
io.log(` target ${report.result.target}`);
|
|
112
|
+
if (report.result.commit) io.log(` commit ${report.result.commit}`);
|
|
113
|
+
io.log(` ideas ${report.result.checked}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createProgram(io = console) {
|
|
117
|
+
const program = new Command();
|
|
118
|
+
program
|
|
119
|
+
.name("silvermoon")
|
|
120
|
+
.description("Derive and validate repository-owned idea state.")
|
|
121
|
+
.version(VERSION, "-v, --version", "display the installed version")
|
|
122
|
+
.addHelpCommand(false)
|
|
123
|
+
.showHelpAfterError("(run with --help for usage)")
|
|
124
|
+
.showSuggestionAfterError()
|
|
125
|
+
.configureHelp({ sortOptions: true, sortSubcommands: true })
|
|
126
|
+
.configureOutput({
|
|
127
|
+
outputError: (value, output) => output(value),
|
|
128
|
+
writeErr: (value) => write(io.error, value),
|
|
129
|
+
writeOut: (value) => write(io.log, value),
|
|
130
|
+
})
|
|
131
|
+
.exitOverride()
|
|
132
|
+
.addHelpText("after", `
|
|
133
|
+
Examples:
|
|
134
|
+
$ silvermoon whats-next
|
|
135
|
+
$ silvermoon whats-next <idea>
|
|
136
|
+
$ silvermoon whats-next <idea> --json
|
|
137
|
+
$ silvermoon create-idea
|
|
138
|
+
$ silvermoon check
|
|
139
|
+
$ silvermoon check --worktree
|
|
140
|
+
$ silvermoon check --staged
|
|
141
|
+
$ silvermoon check --commit HEAD
|
|
142
|
+
$ silvermoon check --remote`);
|
|
143
|
+
|
|
144
|
+
addCommonOptions(
|
|
145
|
+
program
|
|
146
|
+
.command("whats-next [idea]")
|
|
147
|
+
.description("fetch primary and render the highest-priority next action"),
|
|
148
|
+
).action(async (idea, options) => {
|
|
149
|
+
const report = await whatsNext({ enforceOnboarding: true, idea, root: options.root });
|
|
150
|
+
render(report, options.json, io);
|
|
151
|
+
program.setOptionValue("resultCode", report.ok ? 0 : 1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
addCommonOptions(
|
|
155
|
+
program
|
|
156
|
+
.command("create-idea")
|
|
157
|
+
.description("create one structured idea scaffold after primary hygiene"),
|
|
158
|
+
).action(async (options) => {
|
|
159
|
+
const report = await createIdea({ root: options.root });
|
|
160
|
+
render(report, options.json, io);
|
|
161
|
+
program.setOptionValue("resultCode", report.ok ? 0 : 1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
addCommonOptions(
|
|
165
|
+
program
|
|
166
|
+
.command("check")
|
|
167
|
+
.description("validate Silvermoon configuration, idea state, and an optional Git target")
|
|
168
|
+
.addOption(new Option("--remote", "fetch and validate the primary tip selected by HEAD").conflicts(["commit", "staged", "worktree"]))
|
|
169
|
+
.addOption(new Option("--commit <revision>", "validate one local commit snapshot").conflicts(["remote", "staged", "worktree"]))
|
|
170
|
+
.addOption(new Option("--staged", "validate the index snapshot").conflicts(["remote", "commit", "worktree"]))
|
|
171
|
+
.addOption(new Option("--worktree", "validate HEAD plus all staged, unstaged, and untracked changes").conflicts(["remote", "commit", "staged"])),
|
|
172
|
+
).action(async (options) => {
|
|
173
|
+
const report = await checkRepository({
|
|
174
|
+
commit: options.commit,
|
|
175
|
+
remote: options.remote,
|
|
176
|
+
root: options.root,
|
|
177
|
+
staged: options.staged,
|
|
178
|
+
worktree: options.worktree,
|
|
179
|
+
});
|
|
180
|
+
render(report, options.json, io);
|
|
181
|
+
program.setOptionValue("resultCode", report.ok ? 0 : 1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return program;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function runCli(args, io = console) {
|
|
188
|
+
const program = createProgram(io);
|
|
189
|
+
try {
|
|
190
|
+
await program.parseAsync(args.length === 0 ? ["--help"] : args, { from: "user" });
|
|
191
|
+
} catch (caught) {
|
|
192
|
+
if (caught instanceof CommanderError) return caught.exitCode === 0 ? 0 : 2;
|
|
193
|
+
io.error(`ERROR command.failed: ${caught.message}`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
return program.getOptionValue("resultCode") ?? 0;
|
|
197
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CONFIG_PATH, METADATA_ROOT } from "./layout.js";
|
|
5
|
+
import { validBranchName, validRepository } from "./repository.js";
|
|
6
|
+
import { parseStrictYaml, stringifyCanonicalYaml } from "./yaml.js";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CONFIG_NAME = CONFIG_PATH;
|
|
9
|
+
|
|
10
|
+
const CONFIG_KEYS = [
|
|
11
|
+
"version",
|
|
12
|
+
"primaryRepository",
|
|
13
|
+
"primaryBranch",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function configDiagnostic(code, path, message, remediation) {
|
|
17
|
+
return { code, level: "error", path, message, remediation };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function regularRepositoryFile(root) {
|
|
21
|
+
const metadataRoot = resolve(root, METADATA_ROOT);
|
|
22
|
+
const absolutePath = resolve(root, CONFIG_PATH);
|
|
23
|
+
try {
|
|
24
|
+
const directoryMetadata = await lstat(metadataRoot);
|
|
25
|
+
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
|
|
26
|
+
throw Object.assign(new Error(`${METADATA_ROOT} must be a regular directory`), {
|
|
27
|
+
code: "EINVAL",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const fileMetadata = await lstat(absolutePath);
|
|
31
|
+
if (!fileMetadata.isFile() || fileMetadata.isSymbolicLink()) {
|
|
32
|
+
throw Object.assign(new Error("Configuration must be a regular file"), {
|
|
33
|
+
code: "EINVAL",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return { absolutePath, source: await readFile(absolutePath, "utf8") };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const missing = error.code === "ENOENT";
|
|
39
|
+
return {
|
|
40
|
+
absolutePath,
|
|
41
|
+
diagnostic: configDiagnostic(
|
|
42
|
+
missing ? "config.missing" : "config.invalid-file",
|
|
43
|
+
CONFIG_PATH,
|
|
44
|
+
missing
|
|
45
|
+
? `Missing Silvermoon configuration: ${CONFIG_PATH}`
|
|
46
|
+
: `Cannot read Silvermoon configuration: ${error.message}`,
|
|
47
|
+
missing
|
|
48
|
+
? `Create ${CONFIG_PATH}.`
|
|
49
|
+
: `Replace ${METADATA_ROOT} and ${CONFIG_PATH} with repository-owned regular paths.`,
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validPrimaryBranch(_root, value) {
|
|
56
|
+
return validBranchName(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function serializeConfig(config) {
|
|
60
|
+
return stringifyCanonicalYaml({
|
|
61
|
+
version: config.version,
|
|
62
|
+
primaryRepository: config.primaryRepository,
|
|
63
|
+
primaryBranch: config.primaryBranch,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function loadConfig({ root }) {
|
|
68
|
+
const loaded = await regularRepositoryFile(root);
|
|
69
|
+
if (loaded.diagnostic) {
|
|
70
|
+
return {
|
|
71
|
+
config: null,
|
|
72
|
+
configPath: loaded.absolutePath,
|
|
73
|
+
diagnostics: [loaded.diagnostic],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const normalizedSource = loaded.source.replaceAll("\r\n", "\n");
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = parseStrictYaml(normalizedSource);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
config: null,
|
|
84
|
+
configPath: loaded.absolutePath,
|
|
85
|
+
diagnostics: [configDiagnostic(
|
|
86
|
+
"config.invalid-yaml",
|
|
87
|
+
CONFIG_PATH,
|
|
88
|
+
`Cannot parse Silvermoon configuration: ${error.message}`,
|
|
89
|
+
`Use the strict YAML contract in ${CONFIG_PATH}.`,
|
|
90
|
+
)],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
|
95
|
+
return {
|
|
96
|
+
config: null,
|
|
97
|
+
configPath: loaded.absolutePath,
|
|
98
|
+
diagnostics: [configDiagnostic(
|
|
99
|
+
"config.invalid-type",
|
|
100
|
+
CONFIG_PATH,
|
|
101
|
+
"The Silvermoon configuration must be a YAML mapping.",
|
|
102
|
+
`Replace ${CONFIG_PATH} with the documented mapping.`,
|
|
103
|
+
)],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const diagnostics = [];
|
|
108
|
+
for (const key of Object.keys(value).sort()) {
|
|
109
|
+
if (!CONFIG_KEYS.includes(key)) {
|
|
110
|
+
diagnostics.push(configDiagnostic(
|
|
111
|
+
"config.unknown-key",
|
|
112
|
+
`${CONFIG_PATH}#${key}`,
|
|
113
|
+
`Unknown Silvermoon configuration key: ${key}`,
|
|
114
|
+
`Remove ${key}.`,
|
|
115
|
+
));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const [key, code] of [
|
|
119
|
+
["version", "config.missing-version"],
|
|
120
|
+
["primaryRepository", "config.missing-primary-repository"],
|
|
121
|
+
["primaryBranch", "config.missing-primary-branch"],
|
|
122
|
+
]) {
|
|
123
|
+
if (!Object.hasOwn(value, key)) {
|
|
124
|
+
diagnostics.push(configDiagnostic(
|
|
125
|
+
code,
|
|
126
|
+
`${CONFIG_PATH}#${key}`,
|
|
127
|
+
`Missing required key: ${key}`,
|
|
128
|
+
`Add ${key} to ${CONFIG_PATH}.`,
|
|
129
|
+
));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (Object.hasOwn(value, "version") && value.version !== 1) {
|
|
133
|
+
diagnostics.push(configDiagnostic(
|
|
134
|
+
"config.unsupported-version",
|
|
135
|
+
`${CONFIG_PATH}#version`,
|
|
136
|
+
`Unsupported Silvermoon version: ${String(value.version)}`,
|
|
137
|
+
"Use version: 1.",
|
|
138
|
+
));
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
Object.hasOwn(value, "primaryRepository") &&
|
|
142
|
+
!validRepository(value.primaryRepository)
|
|
143
|
+
) {
|
|
144
|
+
diagnostics.push(configDiagnostic(
|
|
145
|
+
"config.invalid-primary-repository",
|
|
146
|
+
`${CONFIG_PATH}#primaryRepository`,
|
|
147
|
+
"primaryRepository must be a canonical credential-free HTTPS repository URL.",
|
|
148
|
+
"Use a canonical HTTPS repository URL without credentials, query, fragment, or trailing slash.",
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
Object.hasOwn(value, "primaryBranch") &&
|
|
153
|
+
!validPrimaryBranch(root, value.primaryBranch)
|
|
154
|
+
) {
|
|
155
|
+
diagnostics.push(configDiagnostic(
|
|
156
|
+
"config.invalid-primary-branch",
|
|
157
|
+
`${CONFIG_PATH}#primaryBranch`,
|
|
158
|
+
"primaryBranch must be a valid short Git branch name.",
|
|
159
|
+
"Use a branch such as main without refs/ or remote prefixes.",
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
if (diagnostics.length === 0 && serializeConfig(value) !== normalizedSource) {
|
|
163
|
+
diagnostics.push(configDiagnostic(
|
|
164
|
+
"config.noncanonical",
|
|
165
|
+
CONFIG_PATH,
|
|
166
|
+
"The Silvermoon configuration is valid but not canonical.",
|
|
167
|
+
"Rewrite properties in version, primaryRepository, primaryBranch order with LF endings.",
|
|
168
|
+
));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
config: diagnostics.length === 0 ? value : null,
|
|
173
|
+
configPath: loaded.absolutePath,
|
|
174
|
+
diagnostics,
|
|
175
|
+
};
|
|
176
|
+
}
|