silvermoon 0.2.1 → 0.3.0-rc.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/src/adoption.js CHANGED
@@ -15,6 +15,7 @@ import { traceAsync } from "./trace.js";
15
15
 
16
16
  const packageRoot = fileURLToPath(new URL("..", import.meta.url));
17
17
  const packageJson = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
18
+ const DEFAULT_FILESYSTEM = { lstat, readFile, readdir };
18
19
  const packagedSkillsRoot = resolve(packageRoot, "skills");
19
20
  const packagedSkill = resolve(packagedSkillsRoot, "silvermoon");
20
21
  const localSkillsRoot = "./node_modules/silvermoon/skills";
@@ -95,7 +96,7 @@ function packageManagerDescriptor(manager, workspace, reason = null) {
95
96
  };
96
97
  }
97
98
 
98
- async function detectPackageManager(root, manifest) {
99
+ async function detectPackageManager(root, manifest, filesystem) {
99
100
  const workspace = workspaceRoot(manifest);
100
101
  if (Object.hasOwn(manifest, "packageManager")) {
101
102
  const declaration = manifest.packageManager;
@@ -116,7 +117,7 @@ async function detectPackageManager(root, manifest) {
116
117
  const uncertain = [];
117
118
  for (const [filename, manager] of lockfileManagers) {
118
119
  try {
119
- const metadata = await lstat(resolve(root, filename));
120
+ const metadata = await filesystem.lstat(resolve(root, filename));
120
121
  if (metadata.isFile()) detected.push({ filename, manager });
121
122
  else uncertain.push(filename);
122
123
  } catch (caught) {
@@ -141,13 +142,13 @@ async function detectPackageManager(root, manifest) {
141
142
  return packageManagerDescriptor(managers[0] ?? "npm", workspace);
142
143
  }
143
144
 
144
- export function dependencyInstallCommand(manager, workspace, version = SILVERMOON_VERSION) {
145
+ export function dependencyInstallCommands(manager, workspace, version = SILVERMOON_VERSION) {
145
146
  const packageSpecifier = `silvermoon@^${version}`;
146
147
  if (manager === "npm") {
147
- return { executable: "npm", args: ["install", "--save-dev", packageSpecifier] };
148
+ return [{ executable: "npm", args: ["install", "--save-dev", packageSpecifier] }];
148
149
  }
149
150
  if (manager === "pnpm") {
150
- return {
151
+ return [{
151
152
  executable: "pnpm",
152
153
  args: [
153
154
  "add",
@@ -155,21 +156,19 @@ export function dependencyInstallCommand(manager, workspace, version = SILVERMOO
155
156
  packageSpecifier,
156
157
  ...(workspace ? ["--workspace-root"] : []),
157
158
  ],
158
- };
159
+ }];
159
160
  }
160
161
  if (manager === "yarn") {
161
- return {
162
- executable: "yarn",
163
- args: [
164
- "add",
165
- packageSpecifier,
166
- "--dev",
167
- ...(workspace ? ["--ignore-workspace-root-check"] : []),
168
- ],
169
- };
162
+ return [
163
+ {
164
+ executable: "npm",
165
+ args: ["pkg", "set", `devDependencies.silvermoon=^${version}`],
166
+ },
167
+ { executable: "yarn", args: ["install"] },
168
+ ];
170
169
  }
171
170
  if (manager === "bun") {
172
- return { executable: "bun", args: ["add", packageSpecifier, "--dev"] };
171
+ return [{ executable: "bun", args: ["add", packageSpecifier, "--dev"] }];
173
172
  }
174
173
  return null;
175
174
  }
@@ -183,12 +182,12 @@ function renderCommand({ executable, args }) {
183
182
  }
184
183
 
185
184
  export function renderDependencyCommand(manager, workspace, version = SILVERMOON_VERSION) {
186
- const command = dependencyInstallCommand(manager, workspace, version);
187
- return command ? renderCommand(command) : null;
185
+ const commands = dependencyInstallCommands(manager, workspace, version);
186
+ return commands ? commands.map(renderCommand).join("\n") : null;
188
187
  }
189
188
 
190
189
  function dependencyInstruction(packageManager) {
191
- const command = packageManager.manager
190
+ const renderedCommands = packageManager.manager
192
191
  ? renderDependencyCommand(
193
192
  packageManager.manager,
194
193
  packageManager.workspace,
@@ -198,10 +197,14 @@ function dependencyInstruction(packageManager) {
198
197
  if (!expectedDependency) {
199
198
  return `The running Silvermoon version ${JSON.stringify(SILVERMOON_VERSION)} is not a supported SemVer version; repair the package before declaring its dependency.`;
200
199
  }
201
- if (!command) {
200
+ if (!renderedCommands) {
202
201
  return `Do not guess a package-manager command. ${packageManager.reason} Resolve the root package-manager/workspace configuration, then add silvermoon@${expectedDependency} to the root devDependencies.`;
203
202
  }
204
- return `From the repository root, run \`${command}\` to set root devDependencies.silvermoon to ${expectedDependency}.`;
203
+ const commands = renderedCommands.split("\n");
204
+ if (commands.length === 1) {
205
+ return `From the repository root, run \`${commands[0]}\` to set root devDependencies.silvermoon to ${expectedDependency}.`;
206
+ }
207
+ return `From the repository root, run these commands in order: ${commands.map((command) => `\`${command}\``).join(", then ")} to set root devDependencies.silvermoon to ${expectedDependency}.`;
205
208
  }
206
209
 
207
210
  function skillInstruction(skill, packageManager) {
@@ -218,12 +221,15 @@ function skillInstruction(skill, packageManager) {
218
221
  export async function inspectNpmProject(
219
222
  snapshotRoot,
220
223
  repositoryRoot,
221
- { readManifest = readFile } = {},
224
+ {
225
+ filesystem = DEFAULT_FILESYSTEM,
226
+ readManifest = filesystem.readFile,
227
+ } = {},
222
228
  ) {
223
229
  const manifestPath = resolve(snapshotRoot, "package.json");
224
230
  let metadata;
225
231
  try {
226
- metadata = await lstat(manifestPath);
232
+ metadata = await filesystem.lstat(manifestPath);
227
233
  } catch (caught) {
228
234
  if (caught.code === "ENOENT") return { npmProject: false, findings: [] };
229
235
  return {
@@ -293,7 +299,11 @@ export async function inspectNpmProject(
293
299
  };
294
300
  }
295
301
 
296
- const packageManager = await detectPackageManager(snapshotRoot, manifest);
302
+ const packageManager = await detectPackageManager(
303
+ snapshotRoot,
304
+ manifest,
305
+ filesystem,
306
+ );
297
307
  const findings = [];
298
308
  const sourceCheckout = isSilvermoonSourceCheckout(repositoryRoot, manifest);
299
309
  if (!expectedDependency) {
@@ -367,11 +377,13 @@ function canonicalSkillBytes(content) {
367
377
  }
368
378
  }
369
379
 
370
- async function directoryDigest(root) {
380
+ async function directoryDigest(root, filesystem) {
371
381
  const hash = createHash("sha256");
372
382
 
373
383
  async function visit(directory) {
374
- const entries = (await readdir(directory, { withFileTypes: true }))
384
+ const entries = (
385
+ await filesystem.readdir(directory, { withFileTypes: true })
386
+ )
375
387
  .sort((left, right) => left.name.localeCompare(right.name));
376
388
  for (const entry of entries) {
377
389
  const path = resolve(directory, entry.name);
@@ -380,7 +392,7 @@ async function directoryDigest(root) {
380
392
  } else if (entry.isFile()) {
381
393
  hash.update(relative(root, path).split(sep).join("/"));
382
394
  hash.update("\0");
383
- hash.update(canonicalSkillBytes(await readFile(path)));
395
+ hash.update(canonicalSkillBytes(await filesystem.readFile(path)));
384
396
  hash.update("\0");
385
397
  } else if (entry.isSymbolicLink()) {
386
398
  await visit(path);
@@ -394,16 +406,16 @@ async function directoryDigest(root) {
394
406
  return hash.digest("hex");
395
407
  }
396
408
 
397
- async function inspectSkill(contentRoot) {
409
+ async function inspectSkill(contentRoot, filesystem) {
398
410
  const path = resolve(contentRoot, ...REPOSITORY_SKILL_PATH.split("/"));
399
411
  const expectedDigest = await traceAsync(
400
412
  "skill.digest-packaged",
401
413
  {},
402
- () => directoryDigest(packagedSkill),
414
+ () => directoryDigest(packagedSkill, DEFAULT_FILESYSTEM),
403
415
  );
404
416
  let metadata;
405
417
  try {
406
- metadata = await lstat(path);
418
+ metadata = await filesystem.lstat(path);
407
419
  } catch (caught) {
408
420
  if (caught.code !== "ENOENT") {
409
421
  return {
@@ -442,7 +454,7 @@ async function inspectSkill(contentRoot) {
442
454
  actualDigest = await traceAsync(
443
455
  "skill.digest-repository",
444
456
  {},
445
- () => directoryDigest(path),
457
+ () => directoryDigest(path, filesystem),
446
458
  );
447
459
  } catch (caught) {
448
460
  return {
@@ -471,10 +483,14 @@ async function inspectSkill(contentRoot) {
471
483
 
472
484
  export async function inspectAdoption({
473
485
  contentRoot,
486
+ filesystem = DEFAULT_FILESYSTEM,
487
+ repositoryRoot: knownRepositoryRoot,
474
488
  root = process.cwd(),
475
489
  } = {}) {
476
490
  const requestedRoot = resolve(root);
477
- const git = runGit(requestedRoot, ["rev-parse", "--show-toplevel"]);
491
+ const git = knownRepositoryRoot === undefined
492
+ ? runGit(requestedRoot, ["rev-parse", "--show-toplevel"])
493
+ : { ok: true, stdout: resolve(knownRepositoryRoot) };
478
494
  const repositoryRoot = git.ok ? resolve(git.stdout) : requestedRoot;
479
495
  const snapshotRoot = resolve(contentRoot ?? repositoryRoot);
480
496
  const findings = [];
@@ -493,7 +509,7 @@ export async function inspectAdoption({
493
509
  const loadedConfig = await traceAsync(
494
510
  "config.load",
495
511
  {},
496
- () => loadConfig({ root: snapshotRoot }),
512
+ () => loadConfig({ filesystem, root: snapshotRoot }),
497
513
  );
498
514
  for (const diagnostic of loadedConfig.diagnostics) {
499
515
  findings.push({
@@ -507,7 +523,7 @@ export async function inspectAdoption({
507
523
  const npm = await traceAsync(
508
524
  "npm-project.inspect",
509
525
  {},
510
- () => inspectNpmProject(snapshotRoot, repositoryRoot),
526
+ () => inspectNpmProject(snapshotRoot, repositoryRoot, { filesystem }),
511
527
  );
512
528
  findings.push(...npm.findings);
513
529
  const skillSource = npm.npmProject && !npm.sourceCheckout
@@ -516,7 +532,7 @@ export async function inspectAdoption({
516
532
  const skill = await traceAsync(
517
533
  "skill.inspect",
518
534
  {},
519
- () => inspectSkill(snapshotRoot, skillSource),
535
+ () => inspectSkill(snapshotRoot, filesystem),
520
536
  );
521
537
  if (!skill.ok) {
522
538
  findings.push({
package/src/cli.js CHANGED
@@ -15,7 +15,7 @@ import { listIdeas } from "./list-ideas.js";
15
15
  import { checkRepository } from "./index.js";
16
16
  import { createIdea } from "./create-idea.js";
17
17
  import { renderResponse } from "./response.js";
18
- import { withTraceFile } from "./trace.js";
18
+ import { traceAsync, withTraceFile } from "./trace.js";
19
19
  import { whatsNext } from "./whatsnext.js";
20
20
 
21
21
  const { version: VERSION } = JSON.parse(
@@ -23,6 +23,7 @@ const { version: VERSION } = JSON.parse(
23
23
  );
24
24
 
25
25
  const TRACE_FILE_SUFFIX = ".trace.jsonl";
26
+ const AUDIENCES = ["human", "agent"];
26
27
 
27
28
  function write(method, value) {
28
29
  const text = value.replace(/\n$/, "");
@@ -38,6 +39,14 @@ export function normalizeTraceFileName(value) {
38
39
  function addCommonOptions(command) {
39
40
  return command
40
41
  .option("--json", "serialize all four command projections as JSON")
42
+ .addOption(
43
+ new Option(
44
+ "--audience <audience>",
45
+ "select human TUI or agent Markdown output (default: human)",
46
+ )
47
+ .choices(AUDIENCES)
48
+ .conflicts("json"),
49
+ )
41
50
  .option(
42
51
  "--trace <file.trace.jsonl>",
43
52
  "write domain and timing events as JSONL (suffix is appended when omitted)",
@@ -46,11 +55,106 @@ function addCommonOptions(command) {
46
55
  .option("-r, --root <path>", "repository root", process.cwd());
47
56
  }
48
57
 
49
- export function render(report, json, io) {
50
- io.log(
51
- json
52
- ? JSON.stringify(report, null, 2)
53
- : renderResponse(report.response),
58
+ export function selectOutputRenderer({
59
+ audience = "human",
60
+ json = false,
61
+ stdinIsTTY = false,
62
+ stdoutIsTTY = false,
63
+ }) {
64
+ if (json) return "json";
65
+ if (audience === "human" && stdinIsTTY && stdoutIsTTY) return "tui";
66
+ return "markdown";
67
+ }
68
+
69
+ async function defaultTuiRenderer(content, terminal) {
70
+ const { renderTuiMarkdown } = await import("./tui.js");
71
+ await renderTuiMarkdown(content, terminal);
72
+ }
73
+
74
+ export async function render(
75
+ report,
76
+ { audience = "human", json = false } = {},
77
+ io = console,
78
+ {
79
+ renderTui = defaultTuiRenderer,
80
+ terminal = { stdin: process.stdin, stdout: process.stdout },
81
+ } = {},
82
+ ) {
83
+ const renderer = selectOutputRenderer({
84
+ audience,
85
+ json,
86
+ stdinIsTTY: terminal.stdin.isTTY === true,
87
+ stdoutIsTTY: terminal.stdout.isTTY === true,
88
+ });
89
+ if (renderer === "json") {
90
+ io.log(JSON.stringify(report, null, 2));
91
+ return;
92
+ }
93
+
94
+ const markdown = renderResponse(report.response);
95
+ if (renderer === "tui") {
96
+ await renderTui(markdown, terminal);
97
+ return;
98
+ }
99
+ io.log(markdown);
100
+ }
101
+
102
+ function outputRenderer(options, runtime) {
103
+ const terminal = runtime.terminal ?? {
104
+ stdin: process.stdin,
105
+ stdout: process.stdout,
106
+ };
107
+ return selectOutputRenderer({
108
+ audience: options.audience,
109
+ json: options.json,
110
+ stdinIsTTY: terminal.stdin.isTTY === true,
111
+ stdoutIsTTY: terminal.stdout.isTTY === true,
112
+ });
113
+ }
114
+
115
+ function bootstrapDuration(runtime) {
116
+ if (typeof runtime.bootstrapStartedAt !== "bigint") return {};
117
+ const elapsed = process.hrtime.bigint() - runtime.bootstrapStartedAt;
118
+ return {
119
+ bootstrapDurationMs: Math.round(Number(elapsed) / 1_000) / 1_000,
120
+ };
121
+ }
122
+
123
+ async function runCommand({
124
+ attributes,
125
+ execute,
126
+ name,
127
+ options,
128
+ }, io, runtime) {
129
+ const renderer = outputRenderer(options, runtime);
130
+ const traceAttributes = {
131
+ ...attributes,
132
+ outputRenderer: renderer,
133
+ ...bootstrapDuration(runtime),
134
+ };
135
+ if (renderer === "tui") {
136
+ const report = await withTraceFile(
137
+ options.trace,
138
+ name,
139
+ traceAttributes,
140
+ execute,
141
+ );
142
+ await render(report, options, io, runtime);
143
+ return report;
144
+ }
145
+ return withTraceFile(
146
+ options.trace,
147
+ name,
148
+ traceAttributes,
149
+ async () => {
150
+ const report = await execute();
151
+ await traceAsync(
152
+ "output.render",
153
+ { renderer },
154
+ () => render(report, options, io, runtime),
155
+ );
156
+ return report;
157
+ },
54
158
  );
55
159
  }
56
160
 
@@ -74,7 +178,7 @@ function collectOption(value, previous = []) {
74
178
  return [...previous, value];
75
179
  }
76
180
 
77
- export function createProgram(io = console) {
181
+ export function createProgram(io = console, runtime = {}) {
78
182
  const program = new Command();
79
183
  program
80
184
  .name("silvermoon")
@@ -93,10 +197,12 @@ export function createProgram(io = console) {
93
197
  .addHelpText("after", `
94
198
  Examples:
95
199
  $ silvermoon list-ideas
200
+ $ silvermoon list-ideas --language zh-CN
96
201
  $ silvermoon list-ideas --state completed --sort oldest --limit 10
97
202
  $ silvermoon whats-next
98
203
  $ silvermoon whats-next <idea>
99
204
  $ silvermoon whats-next <idea> --language zh-CN
205
+ $ silvermoon whats-next <idea> --audience agent
100
206
  $ silvermoon whats-next <idea> --json
101
207
  $ silvermoon whats-next <idea> --trace whats-next.trace.jsonl
102
208
  $ silvermoon create-idea
@@ -111,6 +217,11 @@ Examples:
111
217
  program
112
218
  .command("list-ideas")
113
219
  .description("query the current local idea inventory")
220
+ .option(
221
+ "--language <tag>",
222
+ "use a built-in output language (en, en-US, zh, or zh-CN) for this invocation",
223
+ outputLanguageArgument,
224
+ )
114
225
  .option(
115
226
  "--state <state>",
116
227
  "include a lifecycle state or active (repeatable)",
@@ -138,20 +249,21 @@ Examples:
138
249
  sort: options.sort,
139
250
  states: options.state,
140
251
  });
141
- const report = await withTraceFile(
142
- options.trace,
143
- "command.list-ideas",
144
- {
252
+ const report = await runCommand({
253
+ name: "command.list-ideas",
254
+ options,
255
+ attributes: {
145
256
  command: "list-ideas",
146
257
  hasQuery: query.query !== null,
258
+ outputLanguage: options.language ?? null,
147
259
  stateCount: query.states.length,
148
260
  },
149
- () => listIdeas({
261
+ execute: () => listIdeas({
150
262
  ...query,
263
+ language: options.language,
151
264
  root: options.root,
152
265
  }),
153
- );
154
- render(report, options.json, io);
266
+ }, io, runtime);
155
267
  program.setOptionValue(
156
268
  "resultCode",
157
269
  report.observation.state === "ideas-listed" ? 0 : 1,
@@ -164,24 +276,23 @@ Examples:
164
276
  .description("observe project, repository, and idea readiness")
165
277
  .option(
166
278
  "--language <tag>",
167
- "use a built-in output language (en-US or zh-CN) for this invocation",
279
+ "use a built-in output language (en, en-US, zh, or zh-CN) for this invocation",
168
280
  outputLanguageArgument,
169
281
  ),
170
282
  ).action(async (idea, options) => {
171
- const report = await withTraceFile(
172
- options.trace,
173
- "command.whats-next",
174
- {
283
+ const report = await runCommand({
284
+ name: "command.whats-next",
285
+ options,
286
+ attributes: {
175
287
  command: "whats-next",
176
288
  outputLanguage: options.language ?? null,
177
289
  },
178
- () => whatsNext({
290
+ execute: () => whatsNext({
179
291
  idea,
180
292
  language: options.language,
181
293
  root: options.root,
182
294
  }),
183
- );
184
- render(report, options.json, io);
295
+ }, io, runtime);
185
296
  program.setOptionValue("resultCode", 0);
186
297
  });
187
298
 
@@ -195,19 +306,18 @@ Examples:
195
306
  contentLanguageArgument,
196
307
  ),
197
308
  ).action(async (options) => {
198
- const report = await withTraceFile(
199
- options.trace,
200
- "command.create-idea",
201
- {
309
+ const report = await runCommand({
310
+ name: "command.create-idea",
311
+ options,
312
+ attributes: {
202
313
  command: "create-idea",
203
314
  contentLanguage: options.language ?? null,
204
315
  },
205
- () => createIdea({
316
+ execute: () => createIdea({
206
317
  language: options.language,
207
318
  root: options.root,
208
319
  }),
209
- );
210
- render(report, options.json, io);
320
+ }, io, runtime);
211
321
  program.setOptionValue("resultCode", 0);
212
322
  });
213
323
 
@@ -217,7 +327,7 @@ Examples:
217
327
  .description("validate Silvermoon configuration and idea state")
218
328
  .option(
219
329
  "--language <tag>",
220
- "use a built-in output language (en-US or zh-CN) for this invocation",
330
+ "use a built-in output language (en, en-US, zh, or zh-CN) for this invocation",
221
331
  outputLanguageArgument,
222
332
  )
223
333
  .addOption(new Option("--remote", "fetch and validate the configured primary tip").conflicts(["commit", "staged", "worktree"]))
@@ -225,14 +335,14 @@ Examples:
225
335
  .addOption(new Option("--staged", "validate the index snapshot").conflicts(["remote", "commit", "worktree"]))
226
336
  .addOption(new Option("--worktree", "validate HEAD plus staged, unstaged, and untracked changes").conflicts(["remote", "commit", "staged"])),
227
337
  ).action(async (options) => {
228
- const report = await withTraceFile(
229
- options.trace,
230
- "command.check",
231
- {
338
+ const report = await runCommand({
339
+ name: "command.check",
340
+ options,
341
+ attributes: {
232
342
  command: "check",
233
343
  outputLanguage: options.language ?? null,
234
344
  },
235
- () => checkRepository({
345
+ execute: () => checkRepository({
236
346
  commit: options.commit,
237
347
  language: options.language,
238
348
  remote: options.remote,
@@ -240,8 +350,7 @@ Examples:
240
350
  staged: options.staged,
241
351
  worktree: options.worktree,
242
352
  }),
243
- );
244
- render(report, options.json, io);
353
+ }, io, runtime);
245
354
  program.setOptionValue(
246
355
  "resultCode",
247
356
  report.observation.state === "project-ready" ? 0 : 1,
@@ -251,8 +360,8 @@ Examples:
251
360
  return program;
252
361
  }
253
362
 
254
- export async function runCli(args, io = console) {
255
- const program = createProgram(io);
363
+ export async function runCli(args, io = console, runtime = {}) {
364
+ const program = createProgram(io, runtime);
256
365
  try {
257
366
  await program.parseAsync(args.length === 0 ? ["--help"] : args, { from: "user" });
258
367
  } catch (caught) {
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { lstat, readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
 
4
+ import { inspectTreeLineage, readGitBlobs } from "./git.js";
4
5
  import { CONFIG_PATH, METADATA_ROOT } from "./layout.js";
5
6
  import { isCanonicalLanguageTag } from "./language.js";
6
7
  import { validBranchName, validRepository } from "./repository.js";
@@ -14,28 +15,32 @@ const CONFIG_KEYS = [
14
15
  "primaryBranch",
15
16
  "preferredLanguage",
16
17
  ];
18
+ const DEFAULT_FILESYSTEM = { lstat, readFile };
17
19
 
18
20
  function configDiagnostic(code, path, message, remediation) {
19
21
  return { code, level: "error", path, message, remediation };
20
22
  }
21
23
 
22
- async function regularRepositoryFile(root) {
24
+ async function regularRepositoryFile(root, filesystem) {
23
25
  const metadataRoot = resolve(root, METADATA_ROOT);
24
26
  const absolutePath = resolve(root, CONFIG_PATH);
25
27
  try {
26
- const directoryMetadata = await lstat(metadataRoot);
28
+ const directoryMetadata = await filesystem.lstat(metadataRoot);
27
29
  if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
28
30
  throw Object.assign(new Error(`${METADATA_ROOT} must be a regular directory`), {
29
31
  code: "EINVAL",
30
32
  });
31
33
  }
32
- const fileMetadata = await lstat(absolutePath);
34
+ const fileMetadata = await filesystem.lstat(absolutePath);
33
35
  if (!fileMetadata.isFile() || fileMetadata.isSymbolicLink()) {
34
36
  throw Object.assign(new Error("Configuration must be a regular file"), {
35
37
  code: "EINVAL",
36
38
  });
37
39
  }
38
- return { absolutePath, source: await readFile(absolutePath, "utf8") };
40
+ return {
41
+ absolutePath,
42
+ source: await filesystem.readFile(absolutePath, "utf8"),
43
+ };
39
44
  } catch (error) {
40
45
  const missing = error.code === "ENOENT";
41
46
  return {
@@ -70,17 +75,8 @@ export function serializeConfig(config) {
70
75
  return stringifyCanonicalYaml(canonical);
71
76
  }
72
77
 
73
- export async function loadConfig({ root }) {
74
- const loaded = await regularRepositoryFile(root);
75
- if (loaded.diagnostic) {
76
- return {
77
- config: null,
78
- configPath: loaded.absolutePath,
79
- diagnostics: [loaded.diagnostic],
80
- };
81
- }
82
-
83
- const normalizedSource = loaded.source.replaceAll("\r\n", "\n");
78
+ function parseConfigSource({ absolutePath, root, source }) {
79
+ const normalizedSource = source.replaceAll("\r\n", "\n");
84
80
  let value;
85
81
  try {
86
82
  value = parseStrictYaml(normalizedSource);
@@ -187,7 +183,71 @@ export async function loadConfig({ root }) {
187
183
 
188
184
  return {
189
185
  config: diagnostics.length === 0 ? value : null,
190
- configPath: loaded.absolutePath,
186
+ configPath: absolutePath,
191
187
  diagnostics,
192
188
  };
193
189
  }
190
+
191
+ export async function loadConfig({
192
+ filesystem = DEFAULT_FILESYSTEM,
193
+ root,
194
+ }) {
195
+ const loaded = await regularRepositoryFile(root, filesystem);
196
+ if (loaded.diagnostic) {
197
+ return {
198
+ config: null,
199
+ configPath: loaded.absolutePath,
200
+ diagnostics: [loaded.diagnostic],
201
+ };
202
+ }
203
+ return parseConfigSource({
204
+ absolutePath: loaded.absolutePath,
205
+ root,
206
+ source: loaded.source,
207
+ });
208
+ }
209
+
210
+ export async function loadConfigSnapshot({ gitRoot, tree }) {
211
+ const absolutePath = resolve(gitRoot, CONFIG_PATH);
212
+ const entries = inspectTreeLineage(gitRoot, tree, CONFIG_PATH);
213
+ const metadataRoot = entries.find(({ name }) => name === METADATA_ROOT);
214
+ const config = entries.find(({ name }) => name === CONFIG_PATH);
215
+ if (metadataRoot === undefined || config === undefined) {
216
+ return {
217
+ config: null,
218
+ configPath: absolutePath,
219
+ diagnostics: [configDiagnostic(
220
+ "config.missing",
221
+ CONFIG_PATH,
222
+ `Missing Silvermoon configuration: ${CONFIG_PATH}`,
223
+ `Create ${CONFIG_PATH}.`,
224
+ )],
225
+ };
226
+ }
227
+ if (
228
+ metadataRoot.type !== "tree"
229
+ || metadataRoot.mode !== "040000"
230
+ || config.type !== "blob"
231
+ || !["100644", "100755"].includes(config.mode)
232
+ ) {
233
+ return {
234
+ config: null,
235
+ configPath: absolutePath,
236
+ diagnostics: [configDiagnostic(
237
+ "config.invalid-file",
238
+ CONFIG_PATH,
239
+ "Cannot read Silvermoon configuration: configuration paths must be regular",
240
+ `Replace ${METADATA_ROOT} and ${CONFIG_PATH} with repository-owned regular paths.`,
241
+ )],
242
+ };
243
+ }
244
+ const source = readGitBlobs(gitRoot, [config.object]).get(config.object);
245
+ if (source === undefined) {
246
+ throw new Error(`Cannot read Silvermoon configuration blob ${config.object}`);
247
+ }
248
+ return parseConfigSource({
249
+ absolutePath,
250
+ root: gitRoot,
251
+ source: source.toString("utf8"),
252
+ });
253
+ }