@awak-app/simy-cli 0.5.0 → 0.5.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.
@@ -0,0 +1,142 @@
1
+ # State and Receipt Contract
2
+
3
+ Read this reference for multi-attempt, resumable, cross-repository, concurrent,
4
+ merge, deployment, paid, or externally mutating work.
5
+
6
+ ## Delivery snapshot
7
+
8
+ Persist one controller-owned snapshot with at least:
9
+
10
+ - `run_id`, `state`, monotonic `revision`, and terminal disposition;
11
+ - immutable explicit conditions and their stable digest;
12
+ - repository, base SHA, head SHA, companion SHAs, merge SHA, image digests, and
13
+ deployed revision;
14
+ - target environments, user journey, user/tenant fixture identities, and
15
+ required artifacts;
16
+ - applicable gates and their current status;
17
+ - attempt number, retry/time/cost budgets, and failure fingerprints;
18
+ - append-only receipt IDs, evidence references, timestamps, and cleanup state.
19
+
20
+ Use compare-and-set or an equivalent single-writer protocol. Replaying the same
21
+ receipt must be a no-op. Reusing an idempotency key with different content is
22
+ an integrity failure.
23
+
24
+ ## Typed receipts
25
+
26
+ Use the smallest applicable receipts rather than free-form completion claims:
27
+
28
+ - `CharterReceipt`
29
+ - `FailureReproductionReceipt`
30
+ - `ImplementationReceipt`
31
+ - `LocalVerificationReceipt`
32
+ - `IndependentReviewReceipt`
33
+ - `PRCIReceipt`
34
+ - `IntegrationVerificationReceipt`
35
+ - `ContainerEnvironmentReceipt`
36
+ - `ChromeUserJourneyReceipt`
37
+ - `MergeReceipt`
38
+ - `DeploymentIdentityReceipt`
39
+ - `HealthReceipt`
40
+ - `LiveEvidenceReceipt`
41
+ - `CleanupReceipt`
42
+ - `CompletionReceipt`
43
+ - `FailureReceipt`
44
+
45
+ Each receipt should contain:
46
+
47
+ - schema version, Run ID, module, result ID, input digest, idempotency key, and
48
+ production time;
49
+ - `success`, `retryable_error`, or `terminal_error`;
50
+ - source revisions and environment identity;
51
+ - executed action and observed result;
52
+ - evidence references such as commands, exit codes, logs, screenshots, video,
53
+ network traces, DB rows, artifact hashes, CI/deploy URLs, and timestamps;
54
+ - a typed error category and retryability when unsuccessful.
55
+
56
+ ## Monitor verdict and policy action
57
+
58
+ The semantic monitor may emit:
59
+
60
+ ```text
61
+ status:
62
+ on_track
63
+ insufficient_evidence
64
+ goal_drift
65
+ retryable_failure
66
+ needs_hil
67
+ blocked
68
+ completed
69
+
70
+ recommended_action:
71
+ continue
72
+ retry_current
73
+ propose_replan
74
+ request_hil
75
+ abort
76
+ complete
77
+ ```
78
+
79
+ The deterministic policy gate checks authorization, applicable state routes,
80
+ revision identity, evidence completeness, idempotency, retry budget, repeated
81
+ failure fingerprints, and external-effect ambiguity before accepting the
82
+ recommendation.
83
+
84
+ Do not expose hidden chain-of-thought. Publish only the typed verdict, concise
85
+ reason, selected action, and evidence references.
86
+
87
+ ## Evidence invalidation
88
+
89
+ Evidence is revision-bound:
90
+
91
+ | Change | Invalidate |
92
+ |---|---|
93
+ | Charter or explicit success conditions | All affected downstream gates |
94
+ | Head SHA or generated source changes | Local, CI, integration, container, and live evidence |
95
+ | Base SHA or companion revision changes | Integration and later evidence |
96
+ | Container image digest changes | Container user journey and later runtime evidence |
97
+ | Merge SHA changes | Deployment identity and live evidence |
98
+ | Deployed revision changes | Health and live evidence |
99
+ | Browser profile, tenant, or fixture changes | Affected user-journey evidence |
100
+
101
+ Do not discard historical receipts; mark them superseded for the current gate.
102
+
103
+ ## Failure handling
104
+
105
+ Classify failures before retrying:
106
+
107
+ - code defect;
108
+ - stale or wrong revision;
109
+ - environment/configuration drift;
110
+ - weak or contaminated fixture;
111
+ - external provider, authorization, rate limit, or timeout;
112
+ - contract/schema drift;
113
+ - persistence, concurrency, projection, cache, or replica race;
114
+ - evidence collection failure.
115
+
116
+ Retry only when the classification and policy permit it. Reconcile ambiguous
117
+ external effects before retrying. Preserve passing work and repair the smallest
118
+ affected surface. Repeated fingerprints require root-cause repair, replan, or
119
+ human input rather than identical retries.
120
+
121
+ ## Completion predicate
122
+
123
+ For the full delivery mode:
124
+
125
+ ```text
126
+ done =
127
+ reproduction_verified
128
+ && locally_verified
129
+ && pr_ci_green
130
+ && integration_verified
131
+ && user_like_local_verified
132
+ && merged
133
+ && deployed_exact_revision
134
+ && health_verified
135
+ && live_verified
136
+ && cleanup_verified
137
+ && completion_receipt_created
138
+ ```
139
+
140
+ Remove only genuinely non-applicable terms and record why. If merge or deploy
141
+ is outside current authority, stop at the highest proven pre-authority state.
142
+
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: pr-review
3
+ description: Review the current or specified pull request for correctness, regressions, security, evidence gaps, and SIMY repository-policy violations. Use when the user asks SIMY to review a PR. Do not use for implementing fixes unless separately requested.
4
+ ---
5
+
6
+ # SIMY pull request review
7
+
8
+ Use the current Codex thread to resolve the intended behavior, then inspect the actual pull request and repository evidence.
9
+
10
+ ## Review contract
11
+
12
+ - Resolve the PR from an explicit URL/number or the current branch. If it cannot be resolved unambiguously, ask for the smallest missing identifier.
13
+ - Read applicable `AGENTS.md` instructions, the complete diff, changed-file context, tests, and current check state.
14
+ - Focus on behaviorally meaningful findings: correctness, authorization and tenant boundaries, data loss, concurrency and retries, compatibility, security, and missing regression evidence.
15
+ - Report findings first, ordered by severity, with precise file and line references. Explain impact and the smallest credible fix.
16
+ - Distinguish verified defects from questions or residual risks. If there are no findings, say so and name the remaining verification gaps.
17
+ - Do not edit code, post a review, approve, merge, deploy, or send external messages unless the user separately authorizes that action.
18
+ - Never call a PR merged merely because auto-merge is enabled; verify its remote state and `mergedAt` when merge status matters.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "SIMY PR Review"
3
+ short_description: "Review a pull request with SIMY evidence rules"
4
+ default_prompt: "Use $pr-review to review the current pull request."
5
+ policy:
6
+ allow_implicit_invocation: true
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: sqm-loop
3
+ description: Run the SIMY Software Quality Monitor against the repository and changes discussed in the current Codex thread. Use when the user asks SIMY to start SQM, quality-check the current work, or prepare a PR quality proof.
4
+ ---
5
+
6
+ # SIMY SQM Loop
7
+
8
+ Treat the current Codex thread, its attachments, and the active repository as the request context. Convert that context into explicit quality expectations, then verify the actual repository state; do not merely summarize the conversation.
9
+
10
+ ## Run the quality loop
11
+
12
+ 1. Identify the repository root and the user-visible behavior or incident discussed in the current thread.
13
+ 2. Preserve unrelated user changes and follow repository instructions.
14
+ 3. Run `simy sqm min-check` from the repository root.
15
+ 4. Run `simy sqm check --proof proof.json --signed-evidence proof.json.signed.json`.
16
+ 5. Read `proof.json` and require `outcome.status` to be `passed` before reporting success or proposing a pull request.
17
+ 6. Report the checked repository/revision, proof paths, outcome, blockers, and any evidence gap.
18
+
19
+ If implementation is required before the check can pass, obtain or confirm the user's change authorization and make the smallest relevant repair. A request to run SQM alone does not authorize unrelated edits, a PR, merge, deployment, or external messages.
20
+
21
+ Do not substitute an informal LLM review for SQM. If the CLI is unavailable, authentication is missing, or the proof is not passed, stop at the highest verified state and report the exact blocker.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "SIMY SQM Loop"
3
+ short_description: "Run SQM from the current Codex thread"
4
+ default_prompt: "Use $sqm-loop to quality-check the work in this thread."
5
+ policy:
6
+ allow_implicit_invocation: true
@@ -0,0 +1,373 @@
1
+ import { createHash } from "node:crypto";
2
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ export const SQM_AGENT_SKILL_ID = "simy-sqm-pr-check";
8
+ export const SQM_AGENT_SKILL_VERSION = "2.0.0";
9
+ export const SIMY_CODEX_PLUGIN_ID = "simy";
10
+ export const SIMY_CODEX_PLUGIN_VERSION = "0.1.0";
11
+
12
+ const BUNDLED_CODEX_PLUGIN_ROOT = fileURLToPath(
13
+ new URL(`../codex-plugins/${SIMY_CODEX_PLUGIN_ID}/`, import.meta.url),
14
+ );
15
+ const MANAGED_PLUGIN_AUTHOR = "Specialist Doctors Inc.";
16
+ const MANAGED_MARKETPLACE_SOURCE = `./plugins/${SIMY_CODEX_PLUGIN_ID}`;
17
+
18
+ const SKILL_CONTENT = `---
19
+ name: ${SQM_AGENT_SKILL_ID}
20
+ description: Run SIMY Software Quality Monitor checks before creating or proposing a pull request. Use whenever preparing, opening, or updating a PR.
21
+ ---
22
+
23
+ # SIMY SQM PR check
24
+
25
+ Before creating or proposing a pull request:
26
+
27
+ 1. Run \`simy sqm min-check\` from the repository root.
28
+ 2. Run \`simy sqm check --proof proof.json --signed-evidence proof.json.signed.json\`.
29
+ 3. Read \`proof.json\` and require \`outcome.status\` to be \`passed\`.
30
+ 4. If either command fails, SQM is unavailable, or the proof is not passed, stop. Report the exact blocker and do not create the PR.
31
+ 5. Include the proof path and SQM outcome in the PR body when the check passes.
32
+
33
+ Do not replace SQM with an LLM review, and do not claim the check passed without reading the generated proof.
34
+ `;
35
+
36
+ const HOOK_SCRIPT = `#!/usr/bin/env node
37
+ import { spawnSync } from "node:child_process";
38
+
39
+ let raw = "";
40
+ for await (const chunk of process.stdin) raw += chunk;
41
+ let payload = {};
42
+ try { payload = raw ? JSON.parse(raw) : {}; } catch { process.exit(0); }
43
+ const command = String(payload?.tool_input?.command || payload?.tool_input?.cmd || "");
44
+ if (!/(^|[;&|]\\s*)gh\\s+pr\\s+create(?:\\s|$)/.test(command)) process.exit(0);
45
+
46
+ for (const args of [["sqm", "min-check"], ["sqm", "check"]]) {
47
+ const result = spawnSync("simy", args, { cwd: process.cwd(), stdio: "inherit" });
48
+ if (result.error || result.status !== 0) {
49
+ console.error(result.error ? \`SIMY SQM PR gate failed: \${result.error.message}\` : \`SIMY SQM PR gate blocked the PR (exit \${result.status}).\`);
50
+ process.exit(2);
51
+ }
52
+ }
53
+ `;
54
+
55
+ const TARGETS = {
56
+ codex: [".agents", "skills", SQM_AGENT_SKILL_ID, "SKILL.md"],
57
+ claude: [".claude", "skills", SQM_AGENT_SKILL_ID, "SKILL.md"],
58
+ };
59
+
60
+ export function sqmAgentSkillDigest() {
61
+ return digestText(SKILL_CONTENT);
62
+ }
63
+
64
+ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
65
+ const digest = sqmAgentSkillDigest();
66
+ const codexPlugin = await inspectCodexPlugin({ home });
67
+ const hookPath = managedHookPath(home);
68
+ const installedHookDigest = await fileDigest(hookPath);
69
+ const hookMatches = installedHookDigest === digestText(HOOK_SCRIPT);
70
+ const platforms = {};
71
+ for (const [platform, segments] of Object.entries(TARGETS)) {
72
+ const target = path.join(home, ...segments);
73
+ const installedDigest = await fileDigest(target);
74
+ const skillMatches = installedDigest === digest;
75
+ const hookConfigured = await hasManagedHook(platform, home, hookPath);
76
+ const platformExtraMatches = platform !== "codex" || codexPlugin.installed;
77
+ platforms[platform] = {
78
+ path: target,
79
+ hook_path: platformHookConfigPath(platform, home),
80
+ installed: skillMatches && hookMatches && hookConfigured && platformExtraMatches,
81
+ state:
82
+ installedDigest === null ||
83
+ !hookConfigured ||
84
+ installedHookDigest === null ||
85
+ (platform === "codex" && codexPlugin.state === "missing")
86
+ ? "missing"
87
+ : skillMatches && hookMatches && platformExtraMatches
88
+ ? "installed"
89
+ : "outdated",
90
+ ...(platform === "codex" ? { plugin: codexPlugin } : {}),
91
+ };
92
+ }
93
+ return {
94
+ skill_id: SQM_AGENT_SKILL_ID,
95
+ version: SQM_AGENT_SKILL_VERSION,
96
+ digest: `sha256:${digest}`,
97
+ enforcement: "pre_tool_use",
98
+ codex_plugin: codexPlugin,
99
+ installed: Object.values(platforms).every((entry) => entry.installed),
100
+ platforms,
101
+ };
102
+ }
103
+
104
+ export async function installSqmAgentSkill({ home = homedir(), platforms = Object.keys(TARGETS) } = {}) {
105
+ const requested = [...new Set(platforms)];
106
+ if (!requested.length || requested.some((platform) => !Object.hasOwn(TARGETS, platform))) {
107
+ throw new Error("platforms must contain codex and/or claude");
108
+ }
109
+ const hookPath = managedHookPath(home);
110
+ if (requested.includes("codex")) await installCodexPlugin({ home });
111
+ await atomicText(hookPath, HOOK_SCRIPT, 0o700);
112
+ for (const platform of requested) {
113
+ await atomicText(path.join(home, ...TARGETS[platform]), SKILL_CONTENT, 0o600);
114
+ await installManagedHook(platform, home, hookPath);
115
+ }
116
+ return inspectSqmAgentSkill({ home });
117
+ }
118
+
119
+ export async function inspectCodexPlugin({ home = homedir() } = {}) {
120
+ const expectedDigest = await directoryDigest(BUNDLED_CODEX_PLUGIN_ROOT);
121
+ const target = codexPluginPath(home);
122
+ const installedDigest = await directoryDigest(target, { missing: null });
123
+ const marketplacePath = codexMarketplacePath(home);
124
+ const marketplace = await readJsonFile(marketplacePath, { missing: null, invalid: null });
125
+ const entry = Array.isArray(marketplace?.plugins)
126
+ ? marketplace.plugins.find((candidate) => candidate?.name === SIMY_CODEX_PLUGIN_ID)
127
+ : null;
128
+ const marketplaceMatches = isManagedMarketplaceEntry(entry);
129
+ const filesMatch = installedDigest === expectedDigest;
130
+ return {
131
+ plugin_id: SIMY_CODEX_PLUGIN_ID,
132
+ version: SIMY_CODEX_PLUGIN_VERSION,
133
+ path: target,
134
+ marketplace_path: marketplacePath,
135
+ digest: `sha256:${expectedDigest}`,
136
+ installed: filesMatch && marketplaceMatches,
137
+ state:
138
+ installedDigest === null || !entry
139
+ ? "missing"
140
+ : filesMatch && marketplaceMatches
141
+ ? "installed"
142
+ : "outdated",
143
+ };
144
+ }
145
+
146
+ async function installCodexPlugin({ home }) {
147
+ const target = codexPluginPath(home);
148
+ const marketplacePath = codexMarketplacePath(home);
149
+ const marketplace = await readJsonFile(marketplacePath, { missing: {}, invalid: "throw" });
150
+ if (!marketplace || typeof marketplace !== "object" || Array.isArray(marketplace)) {
151
+ throw new Error(`${marketplacePath} must contain a JSON object before SIMY can install its Codex plugin.`);
152
+ }
153
+ const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
154
+ const collision = plugins.find((entry) => entry?.name === SIMY_CODEX_PLUGIN_ID);
155
+ if (collision && !isManagedMarketplaceEntry(collision)) {
156
+ throw new Error(`Codex marketplace already contains an unmanaged ${SIMY_CODEX_PLUGIN_ID} plugin entry.`);
157
+ }
158
+ await assertManagedPluginTarget(target);
159
+
160
+ const staged = path.join(path.dirname(target), `.${SIMY_CODEX_PLUGIN_ID}.${process.pid}.tmp`);
161
+ const backup = path.join(path.dirname(target), `.${SIMY_CODEX_PLUGIN_ID}.${process.pid}.bak`);
162
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
163
+ await rm(staged, { recursive: true, force: true });
164
+ await rm(backup, { recursive: true, force: true });
165
+ await cp(BUNDLED_CODEX_PLUGIN_ROOT, staged, { recursive: true, errorOnExist: true });
166
+ let backedUp = false;
167
+ try {
168
+ if (await pathExists(target)) {
169
+ await rename(target, backup);
170
+ backedUp = true;
171
+ }
172
+ await rename(staged, target);
173
+ if (backedUp) await rm(backup, { recursive: true, force: true });
174
+ } catch (error) {
175
+ await rm(staged, { recursive: true, force: true });
176
+ if (backedUp && !(await pathExists(target))) await rename(backup, target);
177
+ throw error;
178
+ }
179
+
180
+ const nextMarketplace = {
181
+ ...marketplace,
182
+ name: typeof marketplace.name === "string" && marketplace.name.trim()
183
+ ? marketplace.name
184
+ : "personal",
185
+ interface: {
186
+ displayName: "Personal",
187
+ ...(marketplace.interface && typeof marketplace.interface === "object"
188
+ ? marketplace.interface
189
+ : {}),
190
+ },
191
+ plugins: [
192
+ ...plugins.filter((entry) => entry?.name !== SIMY_CODEX_PLUGIN_ID),
193
+ managedMarketplaceEntry(),
194
+ ],
195
+ };
196
+ await atomicText(marketplacePath, `${JSON.stringify(nextMarketplace, null, 2)}\n`, 0o600);
197
+ }
198
+
199
+ function managedMarketplaceEntry() {
200
+ return {
201
+ name: SIMY_CODEX_PLUGIN_ID,
202
+ source: { source: "local", path: MANAGED_MARKETPLACE_SOURCE },
203
+ policy: { installation: "INSTALLED_BY_DEFAULT", authentication: "ON_INSTALL" },
204
+ category: "Developer Tools",
205
+ };
206
+ }
207
+
208
+ function isManagedMarketplaceEntry(entry) {
209
+ return entry?.name === SIMY_CODEX_PLUGIN_ID &&
210
+ entry?.source?.source === "local" &&
211
+ entry?.source?.path === MANAGED_MARKETPLACE_SOURCE &&
212
+ entry?.policy?.installation === "INSTALLED_BY_DEFAULT" &&
213
+ entry?.policy?.authentication === "ON_INSTALL";
214
+ }
215
+
216
+ async function assertManagedPluginTarget(target) {
217
+ if (!(await pathExists(target))) return;
218
+ const manifest = await readJsonFile(path.join(target, ".codex-plugin", "plugin.json"), {
219
+ missing: null,
220
+ invalid: null,
221
+ });
222
+ if (manifest?.name !== SIMY_CODEX_PLUGIN_ID || manifest?.author?.name !== MANAGED_PLUGIN_AUTHOR) {
223
+ throw new Error(`${target} already exists and is not a SIMY-managed Codex plugin.`);
224
+ }
225
+ }
226
+
227
+ function codexPluginPath(home) {
228
+ return path.join(home, "plugins", SIMY_CODEX_PLUGIN_ID);
229
+ }
230
+
231
+ function codexMarketplacePath(home) {
232
+ return path.join(home, ".agents", "plugins", "marketplace.json");
233
+ }
234
+
235
+ function managedHookPath(home) {
236
+ return path.join(home, ".simy", "integrations", "sqm-agent", "pre-pr-hook.mjs");
237
+ }
238
+
239
+ function platformHookConfigPath(platform, home) {
240
+ return platform === "codex"
241
+ ? path.join(home, ".codex", "hooks.json")
242
+ : path.join(home, ".claude", "settings.json");
243
+ }
244
+
245
+ function managedHookGroup(hookPath) {
246
+ return {
247
+ matcher: "Bash",
248
+ hooks: [
249
+ {
250
+ type: "command",
251
+ command: `node ${JSON.stringify(hookPath)}`,
252
+ statusMessage: "Running SIMY SQM PR gate",
253
+ },
254
+ ],
255
+ };
256
+ }
257
+
258
+ async function installManagedHook(platform, home, hookPath) {
259
+ const target = platformHookConfigPath(platform, home);
260
+ let config = {};
261
+ try {
262
+ config = JSON.parse(await readFile(target, "utf8"));
263
+ } catch (error) {
264
+ if (error?.code !== "ENOENT") {
265
+ throw new Error(`${target} must contain valid JSON before SIMY can merge the SQM hook.`);
266
+ }
267
+ }
268
+ const hooks = config && typeof config === "object" && !Array.isArray(config)
269
+ ? { ...(config.hooks || {}) }
270
+ : {};
271
+ const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
272
+ hooks.PreToolUse = [
273
+ ...existing.filter((group) => !groupContainsManagedHook(group)),
274
+ managedHookGroup(hookPath),
275
+ ];
276
+ await atomicText(target, `${JSON.stringify({ ...config, hooks }, null, 2)}\n`, 0o600);
277
+ }
278
+
279
+ async function hasManagedHook(platform, home, hookPath) {
280
+ try {
281
+ const config = JSON.parse(await readFile(platformHookConfigPath(platform, home), "utf8"));
282
+ return Array.isArray(config?.hooks?.PreToolUse) &&
283
+ config.hooks.PreToolUse.some((group) => groupContainsManagedHook(group, hookPath));
284
+ } catch (error) {
285
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return false;
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ function groupContainsManagedHook(group, expectedPath = null) {
291
+ if (!Array.isArray(group?.hooks)) return false;
292
+ return group.hooks.some((hook) => {
293
+ const command = typeof hook?.command === "string" ? hook.command : "";
294
+ return expectedPath
295
+ ? command === `node ${JSON.stringify(expectedPath)}`
296
+ : command.includes("/.simy/integrations/sqm-agent/pre-pr-hook.mjs") ||
297
+ command.includes("\\.simy\\integrations\\sqm-agent\\pre-pr-hook.mjs");
298
+ });
299
+ }
300
+
301
+ async function fileDigest(target) {
302
+ try {
303
+ return digestText(await readFile(target, "utf8"));
304
+ } catch (error) {
305
+ if (error?.code === "ENOENT") return null;
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ async function directoryDigest(root, { missing = "throw" } = {}) {
311
+ let rootStat;
312
+ try {
313
+ rootStat = await lstat(root);
314
+ } catch (error) {
315
+ if (error?.code === "ENOENT" && missing !== "throw") return missing;
316
+ throw error;
317
+ }
318
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
319
+ throw new Error(`${root} must be a real directory.`);
320
+ }
321
+ const hash = createHash("sha256");
322
+ const visit = async (directory, prefix = "") => {
323
+ const entries = await readdir(directory, { withFileTypes: true });
324
+ entries.sort((left, right) => left.name.localeCompare(right.name));
325
+ for (const entry of entries) {
326
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
327
+ const target = path.join(directory, entry.name);
328
+ if (entry.isSymbolicLink()) throw new Error(`${target} must not be a symbolic link.`);
329
+ if (entry.isDirectory()) {
330
+ await visit(target, relative);
331
+ continue;
332
+ }
333
+ if (!entry.isFile()) throw new Error(`${target} must be a regular file.`);
334
+ hash.update(relative).update("\0").update(await readFile(target)).update("\0");
335
+ }
336
+ };
337
+ await visit(root);
338
+ return hash.digest("hex");
339
+ }
340
+
341
+ async function readJsonFile(target, { missing, invalid }) {
342
+ try {
343
+ return JSON.parse(await readFile(target, "utf8"));
344
+ } catch (error) {
345
+ if (error?.code === "ENOENT") return missing;
346
+ if (error instanceof SyntaxError) {
347
+ if (invalid !== "throw") return invalid;
348
+ throw new Error(`${target} must contain valid JSON before SIMY can update it.`);
349
+ }
350
+ throw error;
351
+ }
352
+ }
353
+
354
+ async function pathExists(target) {
355
+ try {
356
+ await lstat(target);
357
+ return true;
358
+ } catch (error) {
359
+ if (error?.code === "ENOENT") return false;
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ function digestText(content) {
365
+ return createHash("sha256").update(content).digest("hex");
366
+ }
367
+
368
+ async function atomicText(target, content, mode) {
369
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
370
+ const temporary = `${target}.${process.pid}.tmp`;
371
+ await writeFile(temporary, content, { encoding: "utf8", mode });
372
+ await rename(temporary, target);
373
+ }