@vimhead.dev/norn-cli 0.1.0-tip.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.
Files changed (171) hide show
  1. package/README.md +13 -0
  2. package/assets/README.md +157 -0
  3. package/assets/docs/README.md +23 -0
  4. package/assets/docs/agents.md +64 -0
  5. package/assets/docs/cli.md +183 -0
  6. package/assets/docs/composition.md +76 -0
  7. package/assets/docs/persistence.md +56 -0
  8. package/assets/docs/projects.md +75 -0
  9. package/assets/docs/recovery.md +56 -0
  10. package/assets/docs/resources.md +61 -0
  11. package/assets/docs/workflows.md +57 -0
  12. package/assets/examples/agent-then-analysis/README.md +103 -0
  13. package/assets/examples/agent-then-analysis/input.json +5 -0
  14. package/assets/examples/agent-then-analysis/norn.project.json +4 -0
  15. package/assets/examples/agent-then-analysis/plugin.ts +89 -0
  16. package/assets/examples/coordinating-multiple-agents/README.md +56 -0
  17. package/assets/examples/coordinating-multiple-agents/input.json +10 -0
  18. package/assets/examples/coordinating-multiple-agents/norn.project.json +4 -0
  19. package/assets/examples/coordinating-multiple-agents/plugin.ts +102 -0
  20. package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +52 -0
  21. package/assets/examples/coordinating-multiple-agents/work-queue.ts +153 -0
  22. package/assets/examples/minimal-workflow/README.md +71 -0
  23. package/assets/examples/minimal-workflow/norn.project.json +4 -0
  24. package/assets/examples/minimal-workflow/plugin.ts +29 -0
  25. package/assets/examples/shared-state/README.md +19 -0
  26. package/assets/examples/shared-state/input.json +1 -0
  27. package/assets/examples/shared-state/norn.project.json +4 -0
  28. package/assets/examples/shared-state/plugin.ts +47 -0
  29. package/assets/examples/worktree-development-loop/README.md +66 -0
  30. package/assets/examples/worktree-development-loop/index.ts +1 -0
  31. package/assets/examples/worktree-development-loop/manifest.ts +26 -0
  32. package/assets/examples/worktree-development-loop/norn.project.json +9 -0
  33. package/assets/examples/worktree-development-loop/plugin.ts +27 -0
  34. package/assets/examples/worktree-development-loop/shared/commands.ts +6 -0
  35. package/assets/examples/worktree-development-loop/state.ts +23 -0
  36. package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +8 -0
  37. package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -0
  38. package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -0
  39. package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +22 -0
  40. package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +14 -0
  41. package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +8 -0
  42. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +54 -0
  43. package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +3 -0
  44. package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +12 -0
  45. package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +8 -0
  46. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +28 -0
  47. package/assets/examples/worktree-development-loop/workflows/planning/index.ts +3 -0
  48. package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +12 -0
  49. package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +8 -0
  50. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +53 -0
  51. package/assets/examples/worktree-development-loop/workflows/review/index.ts +10 -0
  52. package/assets/examples/worktree-development-loop/workflows/review/schema.ts +23 -0
  53. package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +12 -0
  54. package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -0
  55. package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +3 -0
  56. package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +12 -0
  57. package/assets/package.json +1 -0
  58. package/assets/packages/cli/src/build-info.ts +36 -0
  59. package/assets/packages/cli/src/bun/cli.ts +16 -0
  60. package/assets/packages/cli/src/cli.ts +1135 -0
  61. package/assets/packages/cli/src/client.ts +167 -0
  62. package/assets/packages/cli/src/documentation-intro.ts +30 -0
  63. package/assets/packages/cli/src/documentation.ts +149 -0
  64. package/assets/packages/cli/src/generated-build-info.ts +12 -0
  65. package/assets/packages/cli/src/internal/agent-directory.ts +5 -0
  66. package/assets/packages/cli/src/internal/agent-response-tool.ts +96 -0
  67. package/assets/packages/cli/src/internal/agents.ts +365 -0
  68. package/assets/packages/cli/src/internal/artifacts.ts +26 -0
  69. package/assets/packages/cli/src/internal/commands.ts +180 -0
  70. package/assets/packages/cli/src/internal/documentation-bundle.ts +49 -0
  71. package/assets/packages/cli/src/internal/engine.ts +501 -0
  72. package/assets/packages/cli/src/internal/errors.ts +39 -0
  73. package/assets/packages/cli/src/internal/file-names.ts +3 -0
  74. package/assets/packages/cli/src/internal/launch-request.ts +94 -0
  75. package/assets/packages/cli/src/internal/logs.ts +41 -0
  76. package/assets/packages/cli/src/internal/metrics.ts +356 -0
  77. package/assets/packages/cli/src/internal/pi-assets.ts +95 -0
  78. package/assets/packages/cli/src/internal/resource-bindings.ts +35 -0
  79. package/assets/packages/cli/src/internal/run-lease.ts +158 -0
  80. package/assets/packages/cli/src/internal/run-log.ts +59 -0
  81. package/assets/packages/cli/src/internal/run-names.ts +36 -0
  82. package/assets/packages/cli/src/internal/run-resources.ts +23 -0
  83. package/assets/packages/cli/src/internal/run-state.ts +380 -0
  84. package/assets/packages/cli/src/internal/run-store.ts +323 -0
  85. package/assets/packages/cli/src/internal/run.ts +133 -0
  86. package/assets/packages/cli/src/internal/state-store.ts +75 -0
  87. package/assets/packages/cli/src/internal/usage.ts +70 -0
  88. package/assets/packages/cli/src/internal/workflow-registry.ts +176 -0
  89. package/assets/packages/cli/src/plugin-loader.ts +412 -0
  90. package/assets/packages/cli/src/resources.ts +67 -0
  91. package/assets/packages/core/src/agent-protocol.ts +1 -0
  92. package/assets/packages/core/src/atomic-files.ts +24 -0
  93. package/assets/packages/core/src/errors.ts +3 -0
  94. package/assets/packages/sdk/src/agent-resource-adapter.ts +11 -0
  95. package/assets/packages/sdk/src/api.ts +821 -0
  96. package/assets/packages/sdk/src/files.ts +136 -0
  97. package/assets/packages/sdk/src/index.ts +6 -0
  98. package/assets/packages/sdk/src/resources.ts +20 -0
  99. package/assets/packages/sdk/src/schema.ts +48 -0
  100. package/assets/packages/sdk/src/seer/config.ts +62 -0
  101. package/assets/packages/sdk/src/seer/index.ts +7 -0
  102. package/assets/packages/sdk/src/state-adapter.ts +75 -0
  103. package/assets/setup/providers.md +128 -0
  104. package/assets/setup/releases.md +76 -0
  105. package/assets/tests/workflow-ref.test.ts +113 -0
  106. package/bin/norn.mjs +10 -0
  107. package/dist/build-info.d.ts +30 -0
  108. package/dist/build-info.js +6 -0
  109. package/dist/cli.d.ts +2 -0
  110. package/dist/cli.js +1032 -0
  111. package/dist/client.d.ts +48 -0
  112. package/dist/client.js +118 -0
  113. package/dist/documentation-intro.d.ts +5 -0
  114. package/dist/documentation-intro.js +29 -0
  115. package/dist/documentation.d.ts +33 -0
  116. package/dist/documentation.js +132 -0
  117. package/dist/generated-build-info.d.ts +10 -0
  118. package/dist/generated-build-info.js +14 -0
  119. package/dist/internal/agent-directory.d.ts +4 -0
  120. package/dist/internal/agent-directory.js +8 -0
  121. package/dist/internal/agent-response-tool.d.ts +21 -0
  122. package/dist/internal/agent-response-tool.js +79 -0
  123. package/dist/internal/agents.d.ts +29 -0
  124. package/dist/internal/agents.js +336 -0
  125. package/dist/internal/artifacts.d.ts +10 -0
  126. package/dist/internal/artifacts.js +29 -0
  127. package/dist/internal/commands.d.ts +18 -0
  128. package/dist/internal/commands.js +147 -0
  129. package/dist/internal/documentation-bundle.d.ts +16 -0
  130. package/dist/internal/documentation-bundle.js +42 -0
  131. package/dist/internal/engine.d.ts +44 -0
  132. package/dist/internal/engine.js +399 -0
  133. package/dist/internal/errors.d.ts +14 -0
  134. package/dist/internal/errors.js +38 -0
  135. package/dist/internal/file-names.d.ts +1 -0
  136. package/dist/internal/file-names.js +7 -0
  137. package/dist/internal/launch-request.d.ts +33 -0
  138. package/dist/internal/launch-request.js +110 -0
  139. package/dist/internal/logs.d.ts +16 -0
  140. package/dist/internal/logs.js +38 -0
  141. package/dist/internal/metrics.d.ts +19 -0
  142. package/dist/internal/metrics.js +282 -0
  143. package/dist/internal/pi-assets.d.ts +13 -0
  144. package/dist/internal/pi-assets.js +94 -0
  145. package/dist/internal/resource-bindings.d.ts +13 -0
  146. package/dist/internal/resource-bindings.js +34 -0
  147. package/dist/internal/run-lease.d.ts +32 -0
  148. package/dist/internal/run-lease.js +166 -0
  149. package/dist/internal/run-log.d.ts +30 -0
  150. package/dist/internal/run-log.js +71 -0
  151. package/dist/internal/run-names.d.ts +1 -0
  152. package/dist/internal/run-names.js +144 -0
  153. package/dist/internal/run-resources.d.ts +6 -0
  154. package/dist/internal/run-resources.js +26 -0
  155. package/dist/internal/run-state.d.ts +95 -0
  156. package/dist/internal/run-state.js +323 -0
  157. package/dist/internal/run-store.d.ts +35 -0
  158. package/dist/internal/run-store.js +314 -0
  159. package/dist/internal/run.d.ts +51 -0
  160. package/dist/internal/run.js +101 -0
  161. package/dist/internal/state-store.d.ts +22 -0
  162. package/dist/internal/state-store.js +97 -0
  163. package/dist/internal/usage.d.ts +5 -0
  164. package/dist/internal/usage.js +70 -0
  165. package/dist/internal/workflow-registry.d.ts +35 -0
  166. package/dist/internal/workflow-registry.js +129 -0
  167. package/dist/plugin-loader.d.ts +55 -0
  168. package/dist/plugin-loader.js +353 -0
  169. package/dist/resources.d.ts +11 -0
  170. package/dist/resources.js +98 -0
  171. package/package.json +52 -0
@@ -0,0 +1,176 @@
1
+ import { z } from "zod";
2
+ import { isWorkflowComplete, isWorkflowFail, isWorkflowNext, type NornAnyWorkflowDeclaration, type NornInspectedWorkflowInfo, type NornJsonSchema, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornDispose, type NornRunFail, type NornWorkflowGateInfo, type NornWorkflowImplementation, type NornRunNext, type NornRunFor, type NornWorkflowParams, type NornWorkflowPluginInfo } from "@vimhead.dev/norn";
3
+ import { assertWorkflowMetadata, isPlainObject, schemaShape, schemaType, unwrapSchema } from "@vimhead.dev/norn/schema";
4
+
5
+ export type NornRegisteredWorkflow = {
6
+ workflow: NornAnyWorkflowDeclaration;
7
+ implementation: NornWorkflowImplementation<NornAnyWorkflowDeclaration, unknown>;
8
+ configSchema?: z.ZodType;
9
+ config: unknown;
10
+ plugin?: NornWorkflowPluginInfo;
11
+ };
12
+
13
+ export type NornWorkflowStepResult =
14
+ | NornRunNext
15
+ | { readonly type: "complete"; readonly workflow: NornAnyWorkflowDeclaration; readonly metadata?: NornRunComplete["metadata"] }
16
+ | { readonly type: "fail"; readonly workflow: NornAnyWorkflowDeclaration; readonly metadata: NornRunFail["metadata"] };
17
+
18
+ export class NornWorkflowRegistry {
19
+ private readonly entries = new Map<string, NornRegisteredWorkflow>();
20
+
21
+ register<TWorkflow extends NornAnyWorkflowDeclaration>(
22
+ workflow: TWorkflow,
23
+ implementation: NornWorkflowImplementation<TWorkflow, unknown>,
24
+ metadata: { readonly plugin?: NornWorkflowPluginInfo; readonly configSchema?: z.ZodType; readonly config?: unknown } = {},
25
+ ): NornDispose {
26
+ if (this.entries.has(workflow.id)) throw new Error(`Workflow already registered: ${workflow.id}`);
27
+
28
+ const entry: NornRegisteredWorkflow = {
29
+ workflow,
30
+ implementation: implementation as NornWorkflowImplementation<NornAnyWorkflowDeclaration, unknown>,
31
+ configSchema: metadata.configSchema,
32
+ config: metadata.configSchema ? metadata.configSchema.parse(defaultConfigInput(metadata.configSchema, metadata.config)) : undefined,
33
+ plugin: metadata.plugin,
34
+ };
35
+ assertWorkflowMetadata(workflow);
36
+ assertGateWorkflow(workflow);
37
+ this.entries.set(workflow.id, entry);
38
+
39
+ return () => {
40
+ if (this.entries.get(workflow.id) === entry) this.entries.delete(workflow.id);
41
+ };
42
+ }
43
+
44
+ list(options: { readonly entrypointsOnly?: boolean } = {}): NornRegisteredWorkflowInfo[] {
45
+ const entries = options.entrypointsOnly ? this.launchableEntries() : this.sortedEntries();
46
+ return entries.map((entry) => workflowInfo(entry));
47
+ }
48
+
49
+ inspect(workflowId: string): NornInspectedWorkflowInfo | undefined {
50
+ const entry = this.entries.get(workflowId);
51
+ return entry ? inspectedWorkflowInfo(entry) : undefined;
52
+ }
53
+
54
+ launchableEntries(): NornRegisteredWorkflow[] {
55
+ return this.sortedEntries().filter(({ workflow }) => workflow.isEntrypoint);
56
+ }
57
+
58
+ workflowById(workflowId: string): NornAnyWorkflowDeclaration | undefined {
59
+ return this.entries.get(workflowId)?.workflow;
60
+ }
61
+
62
+ async describeGate<TWorkflow extends NornAnyWorkflowDeclaration>(
63
+ workflow: TWorkflow,
64
+ run: NornRunFor<TWorkflow>,
65
+ params: unknown,
66
+ configOverride?: unknown,
67
+ ): Promise<string> {
68
+ const entry = this.entries.get(workflow.id);
69
+ if (!entry) throw new Error(`Unknown workflow: ${workflow.id}`);
70
+ if (!workflow.gate) throw new Error(`Workflow is not gated: ${workflow.id}`);
71
+ const parsedParams = workflow.params.parse(params) as NornWorkflowParams<TWorkflow>;
72
+ const parsedConfig = parseExecutionConfig(entry, configOverride);
73
+ const description = await (entry.implementation as NornWorkflowImplementation<TWorkflow, unknown>).gate?.describe(run, parsedParams, parsedConfig);
74
+ return validateGateDescription(description ?? workflow.id, workflow.id);
75
+ }
76
+
77
+ async execute<TWorkflow extends NornAnyWorkflowDeclaration>(
78
+ workflow: TWorkflow,
79
+ run: NornRunFor<TWorkflow>,
80
+ params: unknown,
81
+ configOverride?: unknown,
82
+ ): Promise<NornWorkflowStepResult> {
83
+ const entry = this.entries.get(workflow.id);
84
+ if (!entry) throw new Error(`Unknown workflow: ${workflow.id}`);
85
+
86
+ const parsedParams = workflow.params.parse(params) as Parameters<NornWorkflowImplementation<TWorkflow, unknown>["execute"]>[1];
87
+ const parsedConfig = parseExecutionConfig(entry, configOverride);
88
+ const result = await (entry.implementation as NornWorkflowImplementation<TWorkflow, unknown>).execute(run, parsedParams, parsedConfig);
89
+ if (isWorkflowNext(result)) return result;
90
+ if (isWorkflowComplete(result)) return { type: "complete", workflow, metadata: result.metadata };
91
+ if (isWorkflowFail(result)) return { type: "fail", workflow, metadata: result.metadata };
92
+ throw new Error(`Workflow returned invalid control result: ${workflow.id}`);
93
+ }
94
+
95
+ private sortedEntries(): NornRegisteredWorkflow[] {
96
+ return Array.from(this.entries.values()).sort((left, right) => left.workflow.id.localeCompare(right.workflow.id));
97
+ }
98
+ }
99
+
100
+ function defaultConfigInput(configSchema: z.ZodType, config: unknown): unknown {
101
+ if (config !== undefined) return config;
102
+ return schemaType(unwrapSchema(configSchema)) === "object" ? {} : undefined;
103
+ }
104
+
105
+ function parseExecutionConfig(entry: NornRegisteredWorkflow, configOverride: unknown): unknown {
106
+ const pluginConfigOverride = pluginConfigOverrideInput(entry, configOverride);
107
+ if (!entry.configSchema) {
108
+ if (pluginConfigOverride !== undefined) throw new Error(`Run config override provided for plugin without config schema: ${entry.plugin?.id ?? entry.workflow.id}`);
109
+ return undefined;
110
+ }
111
+ if (pluginConfigOverride === undefined) return entry.config;
112
+ const rawConfig = isPlainObject(entry.config) && isPlainObject(pluginConfigOverride)
113
+ ? deepMerge(entry.config, pluginConfigOverride)
114
+ : pluginConfigOverride;
115
+ return entry.configSchema.parse(rawConfig);
116
+ }
117
+
118
+ function pluginConfigOverrideInput(entry: NornRegisteredWorkflow, configOverride: unknown): unknown {
119
+ if (configOverride === undefined) return undefined;
120
+ if (!isPlainObject(configOverride)) throw new Error("Run config override must be an object keyed by plugin id");
121
+ const pluginId = entry.plugin?.id;
122
+ return pluginId ? configOverride[pluginId] : undefined;
123
+ }
124
+
125
+ function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
126
+ const result: Record<string, unknown> = { ...base };
127
+ for (const [key, value] of Object.entries(override)) {
128
+ const baseValue = result[key];
129
+ result[key] = isPlainObject(baseValue) && isPlainObject(value) ? deepMerge(baseValue, value) : value;
130
+ }
131
+ return result;
132
+ }
133
+
134
+ function assertGateWorkflow(workflow: NornAnyWorkflowDeclaration): void {
135
+ if (!workflow.gate) return;
136
+ if (workflow.gate.enabled !== true) throw new Error(`Workflow gate must be enabled with true: ${workflow.id}`);
137
+ if (!workflow.gate.fields) return;
138
+ const paramsSchema = unwrapSchema(workflow.params);
139
+ if (schemaType(paramsSchema) !== "object") throw new Error(`Workflow gate fields require object params: ${workflow.id}`);
140
+ const paramsShape = schemaShape(paramsSchema);
141
+ for (const field of workflow.gate.fields) {
142
+ if (!Object.prototype.hasOwnProperty.call(paramsShape, field)) throw new Error(`Unknown workflow gate field ${field}: ${workflow.id}`);
143
+ }
144
+ }
145
+
146
+ function validateGateDescription(description: string, workflowId: string): string {
147
+ const trimmed = description.trim();
148
+ if (trimmed.length === 0) throw new Error(`Workflow gate description must not be empty: ${workflowId}`);
149
+ return trimmed;
150
+ }
151
+
152
+ function inspectedWorkflowInfo(entry: NornRegisteredWorkflow): NornInspectedWorkflowInfo {
153
+ return {
154
+ ...workflowInfo(entry),
155
+ paramsSchema: jsonSchema(entry.workflow.params),
156
+ gate: gateInfo(entry.workflow),
157
+ };
158
+ }
159
+
160
+ function workflowInfo(entry: NornRegisteredWorkflow): NornRegisteredWorkflowInfo {
161
+ return {
162
+ id: entry.workflow.id,
163
+ instructions: entry.workflow.instructions,
164
+ isEntrypoint: entry.workflow.isEntrypoint,
165
+ isolation: entry.workflow.isolation,
166
+ plugin: entry.plugin,
167
+ };
168
+ }
169
+
170
+ function gateInfo(workflow: NornAnyWorkflowDeclaration): NornWorkflowGateInfo | null {
171
+ return workflow.gate ? { enabled: true, fields: workflow.gate.fields } : null;
172
+ }
173
+
174
+ function jsonSchema(schema: z.ZodType): NornJsonSchema {
175
+ return z.toJSONSchema(schema, { io: "input" }) as NornJsonSchema;
176
+ }
@@ -0,0 +1,412 @@
1
+ import { access, readFile, readdir } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { createJiti } from "jiti/static";
5
+ import * as typeboxModule from "typebox";
6
+ import { z } from "zod";
7
+ import * as zodModule from "zod";
8
+ import * as nornModule from "@vimhead.dev/norn";
9
+ import * as nornFilesModule from "@vimhead.dev/norn/files";
10
+ import * as nornSchemaModule from "@vimhead.dev/norn/schema";
11
+ import * as nornSeerModule from "@vimhead.dev/norn/seer";
12
+ import { isWorkflowPlugin, type NornDispose, type NornPluginDiagnostic, type NornProjectInfo, type NornProjectInspection, type NornProjectPluginInfo, type NornWorkflowCatalogInfo, type NornWorkflowInspection, type NornWorkflowPlugin, type NornWorkflowPluginInfo } from "@vimhead.dev/norn";
13
+ import { errorMessage, NornProjectLoadError } from "./internal/errors.ts";
14
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
15
+ import { NornMemoryWorkflowState } from "./internal/state-store.ts";
16
+ import { NornWorkflowRegistry, type NornRegisteredWorkflow } from "./internal/workflow-registry.ts";
17
+ import { schemaType, unwrapSchema } from "@vimhead.dev/norn/schema";
18
+ import { resolveSeerModeConfig, type NornResolvedSeerModeConfig } from "@vimhead.dev/norn/seer";
19
+
20
+ export const NORN_PROJECT_FILE_NAME = "norn.project.json";
21
+
22
+ const seerModeConfigSchema = z.object({
23
+ writableRoots: z.array(z.string().min(1)).min(1),
24
+ });
25
+ const nornConfigSchema = z.object({
26
+ plugins: z.array(z.string().min(1)).default([]),
27
+ includes: z.array(z.string().min(1)).default([]),
28
+ config: z.record(z.string(), z.unknown()).default({}),
29
+ });
30
+ const nornProjectConfigSchema = nornConfigSchema.extend({
31
+ version: z.literal(1).default(1),
32
+ seerMode: seerModeConfigSchema.optional(),
33
+ });
34
+
35
+ type NornConfig = z.output<typeof nornConfigSchema> & { readonly seerMode?: never };
36
+ type NornProjectConfig = z.output<typeof nornProjectConfigSchema>;
37
+
38
+ type NornConfigFile = {
39
+ readonly path: string;
40
+ readonly root: string;
41
+ readonly config: NornConfig;
42
+ };
43
+
44
+ export type NornProject = {
45
+ readonly cwd: string;
46
+ readonly projectPath: string;
47
+ readonly projectRoot: string;
48
+ readonly configPath: string;
49
+ readonly configRoot: string;
50
+ readonly config: NornProjectConfig;
51
+ readonly configFiles: readonly NornConfigFile[];
52
+ readonly projectConfig: Record<string, unknown>;
53
+ readonly seerMode?: NornResolvedSeerModeConfig;
54
+ };
55
+
56
+ export type NornLoadedProject = NornProject & {
57
+ readonly plugins: readonly NornWorkflowPlugin[];
58
+ readonly pluginInfos: readonly NornProjectPluginInfo[];
59
+ readonly registry: NornWorkflowRegistry;
60
+ readonly workflows: readonly NornRegisteredWorkflow[];
61
+ readonly state: NornMemoryWorkflowState;
62
+ };
63
+
64
+ type LoadedNornWorkflowPlugin = {
65
+ readonly plugin: NornWorkflowPlugin;
66
+ readonly info: NornProjectPluginInfo;
67
+ };
68
+
69
+ export async function loadNornProject(cwd: string): Promise<NornLoadedProject> {
70
+ const { project, diagnostics } = await collectNornProject(cwd);
71
+ if (diagnostics.length > 0) throw new NornProjectLoadError({ diagnostics });
72
+ return project;
73
+ }
74
+
75
+ export async function discoverNornProject(cwd: string): Promise<NornProjectInspection & NornWorkflowCatalogInfo> {
76
+ const { project, diagnostics } = await collectNornProject(cwd);
77
+ return { project: buildProjectInfo(project), workflows: project.registry.list(), isComplete: diagnostics.length === 0, diagnostics };
78
+ }
79
+
80
+ export async function inspectNornWorkflow(input: { readonly cwd: string; readonly workflowId: string }): Promise<NornWorkflowInspection> {
81
+ const { project, diagnostics } = await collectNornProject(input.cwd);
82
+ const summary = project.registry.list().find(workflow => workflow.id === input.workflowId);
83
+ if (!summary) {
84
+ if (diagnostics.length === 0) throw new Error(`Unknown workflow: ${input.workflowId}`);
85
+ return { workflow: null, isComplete: false, diagnostics };
86
+ }
87
+ const info = summary.plugin;
88
+ if (!info?.path || !info.configPath) throw new Error(`Loaded workflow lacks plugin source: ${input.workflowId}`);
89
+ try {
90
+ return { workflow: project.registry.inspect(input.workflowId) ?? null, isComplete: diagnostics.length === 0, diagnostics };
91
+ } catch (error) {
92
+ return { workflow: null, isComplete: false, diagnostics: [...diagnostics, createPluginDiagnostic({
93
+ source: { configPath: info.configPath, pluginPath: info.path, pluginId: info.id },
94
+ workflowId: input.workflowId, stage: "schema", error,
95
+ })] };
96
+ }
97
+ }
98
+
99
+ function buildProjectInfo(project: NornLoadedProject): NornProjectInfo {
100
+ return {
101
+ cwd: project.cwd, projectPath: project.projectPath, projectRoot: project.projectRoot,
102
+ configPath: project.configPath, configRoot: project.configRoot,
103
+ configFiles: project.configFiles.map(file => file.path), plugins: project.pluginInfos,
104
+ seerMode: project.seerMode ?? null,
105
+ };
106
+ }
107
+
108
+ export async function findNornProject(cwd: string): Promise<NornProject> {
109
+ const projectPath = await findNearestNornProject(cwd);
110
+ const projectRootConfig = await readNornProjectConfigFile(projectPath);
111
+ const configFiles = await loadIncludedNornConfigFiles(projectRootConfig, new Set([projectRootConfig.path]));
112
+ return {
113
+ cwd: resolve(cwd),
114
+ projectPath: projectRootConfig.path,
115
+ projectRoot: projectRootConfig.root,
116
+ configPath: projectRootConfig.path,
117
+ configRoot: projectRootConfig.root,
118
+ config: projectRootConfig.config,
119
+ configFiles,
120
+ projectConfig: mergeProjectConfig(configFiles),
121
+ seerMode: resolveSeerModeConfig({ configPath: projectRootConfig.path, configRoot: projectRootConfig.root, seerMode: projectRootConfig.config.seerMode }),
122
+ };
123
+ }
124
+
125
+ async function findNearestNornProject(cwd: string): Promise<string> {
126
+ let current = resolve(cwd);
127
+ while (true) {
128
+ const projectPath = join(current, NORN_PROJECT_FILE_NAME);
129
+ try {
130
+ await access(projectPath);
131
+ return projectPath;
132
+ } catch (error) {
133
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
134
+ }
135
+ const parent = dirname(current);
136
+ if (parent === current) throw new Error(`Could not find ${NORN_PROJECT_FILE_NAME} from ${cwd}`);
137
+ current = parent;
138
+ }
139
+ }
140
+
141
+ type NornProjectConfigFile = {
142
+ readonly path: string;
143
+ readonly root: string;
144
+ readonly config: NornProjectConfig;
145
+ };
146
+
147
+ async function loadIncludedNornConfigFiles(projectFile: NornProjectConfigFile, visitedPaths: Set<string>): Promise<NornConfigFile[]> {
148
+ const includedConfigFiles = await Promise.all(projectFile.config.includes.map(async (includePath) => {
149
+ const configPaths = await expandIncludePath(projectFile.root, includePath);
150
+ return Promise.all(configPaths.map(readNornConfigFile));
151
+ }));
152
+ const descendants = await Promise.all(includedConfigFiles.flat().map((includedConfigFile) => loadNornConfigTree(includedConfigFile, visitedPaths)));
153
+ return [createProjectConfigEntry(projectFile), ...descendants.flat()];
154
+ }
155
+
156
+ async function loadNornConfigTree(configFile: NornConfigFile, visitedPaths: Set<string>): Promise<NornConfigFile[]> {
157
+ if (visitedPaths.has(configFile.path)) return [];
158
+ visitedPaths.add(configFile.path);
159
+ const includedConfigFiles = await Promise.all(configFile.config.includes.map(async (includePath) => {
160
+ const configPaths = await expandIncludePath(configFile.root, includePath);
161
+ return Promise.all(configPaths.map(readNornConfigFile));
162
+ }));
163
+ const descendants = await Promise.all(includedConfigFiles.flat().map((includedConfigFile) => loadNornConfigTree(includedConfigFile, visitedPaths)));
164
+ return [configFile, ...descendants.flat()];
165
+ }
166
+
167
+ async function readNornProjectConfigFile(path: string): Promise<NornProjectConfigFile> {
168
+ const config = nornProjectConfigSchema.parse(JSON.parse(await readFile(path, "utf8")));
169
+ return { path, root: dirname(path), config };
170
+ }
171
+
172
+ async function readNornConfigFile(path: string): Promise<NornConfigFile> {
173
+ const config = nornConfigSchema.parse(JSON.parse(await readFile(path, "utf8")));
174
+ return { path, root: dirname(path), config };
175
+ }
176
+
177
+ function createProjectConfigEntry(projectFile: NornProjectConfigFile): NornConfigFile {
178
+ return {
179
+ path: projectFile.path,
180
+ root: projectFile.root,
181
+ config: {
182
+ plugins: projectFile.config.plugins,
183
+ includes: projectFile.config.includes,
184
+ config: projectFile.config.config,
185
+ },
186
+ };
187
+ }
188
+
189
+ type NornPluginSource = Pick<NornPluginDiagnostic, "configPath" | "pluginPath" | "pluginId">;
190
+ type ImportedNornPlugin = { readonly plugin: NornWorkflowPlugin; readonly source: NornPluginSource };
191
+
192
+ async function collectNornProject(cwd: string): Promise<{ readonly project: NornLoadedProject; readonly diagnostics: NornPluginDiagnostic[] }> {
193
+ const project = await findNornProject(cwd);
194
+ const imported = await importWorkflowPlugins(project);
195
+ const diagnostics = [...imported.diagnostics];
196
+ const registry = new NornWorkflowRegistry();
197
+ const state = new NornMemoryWorkflowState();
198
+ const loaded: LoadedNornWorkflowPlugin[] = [];
199
+ const conflicts = collectPluginConflicts(imported.plugins);
200
+ for (const candidate of imported.plugins) {
201
+ const pluginConflicts = conflicts.get(candidate);
202
+ if (pluginConflicts) {
203
+ diagnostics.push(...pluginConflicts);
204
+ continue;
205
+ }
206
+ const result = registerProjectPlugin({ candidate, project, registry, state });
207
+ diagnostics.push(...result.diagnostics);
208
+ if (result.loaded) loaded.push(result.loaded);
209
+ }
210
+ return {
211
+ project: { ...project, plugins: loaded.map(entry => entry.plugin), pluginInfos: loaded.map(entry => entry.info), registry, workflows: registry.launchableEntries(), state },
212
+ diagnostics,
213
+ };
214
+ }
215
+
216
+ function collectPluginConflicts(plugins: readonly ImportedNornPlugin[]): Map<ImportedNornPlugin, NornPluginDiagnostic[]> {
217
+ const byPluginId = new Map<string, ImportedNornPlugin[]>();
218
+ const byWorkflowId = new Map<string, ImportedNornPlugin[]>();
219
+ for (const candidate of plugins) {
220
+ const id = candidate.plugin.manifest.id;
221
+ byPluginId.set(id, [...(byPluginId.get(id) ?? []), candidate]);
222
+ for (const workflowId of new Set(Object.values(candidate.plugin.manifest.workflows).map(workflow => workflow.id))) {
223
+ byWorkflowId.set(workflowId, [...(byWorkflowId.get(workflowId) ?? []), candidate]);
224
+ }
225
+ }
226
+ const conflicts = new Map<ImportedNornPlugin, NornPluginDiagnostic[]>();
227
+ for (const [pluginId, candidates] of byPluginId) {
228
+ if (candidates.length < 2) continue;
229
+ for (const candidate of candidates) conflicts.set(candidate, [createPluginDiagnostic({
230
+ source: candidate.source, workflowId: null, stage: "duplicate",
231
+ error: new Error(`Duplicate Norn plugin id: ${pluginId} (${candidates.map(other => other.source.pluginPath).join(", ")})`),
232
+ })]);
233
+ }
234
+ for (const [workflowId, candidates] of byWorkflowId) {
235
+ const distinctPlugins = candidates.filter(candidate => byPluginId.get(candidate.plugin.manifest.id)?.length === 1);
236
+ if (distinctPlugins.length < 2) continue;
237
+ for (const candidate of distinctPlugins) conflicts.set(candidate, [...(conflicts.get(candidate) ?? []), createPluginDiagnostic({
238
+ source: candidate.source, workflowId, stage: "duplicate",
239
+ error: new Error(`Workflow already registered: ${workflowId} (${distinctPlugins.map(other => other.source.pluginPath).join(", ")})`),
240
+ })]);
241
+ }
242
+ return conflicts;
243
+ }
244
+
245
+ async function importWorkflowPlugins(project: NornProject): Promise<{ readonly plugins: ImportedNornPlugin[]; readonly diagnostics: NornPluginDiagnostic[] }> {
246
+ const plugins: ImportedNornPlugin[] = [];
247
+ const diagnostics: NornPluginDiagnostic[] = [];
248
+ for (const configFile of project.configFiles) {
249
+ const jiti = createJiti(pathToFileURL(configFile.path).href, { moduleCache: false, virtualModules: nornWorkflowVirtualModules() });
250
+ for (const pluginPath of configFile.config.plugins) {
251
+ const source: NornPluginSource = { configPath: configFile.path, pluginPath: resolveConfigPath(configFile.root, pluginPath), pluginId: null };
252
+ let stage: NornPluginDiagnostic["stage"] = "import";
253
+ try {
254
+ const module = await jiti.import(pathToFileURL(source.pluginPath).href) as { default?: unknown };
255
+ stage = "declaration";
256
+ if (!isWorkflowPlugin(module?.default)) throw new Error(`Norn plugin must be the default export: ${source.pluginPath}`);
257
+ plugins.push({ plugin: module.default, source: { ...source, pluginId: module.default.manifest.id } });
258
+ } catch (error) {
259
+ diagnostics.push(createPluginDiagnostic({ source, workflowId: null, stage, error }));
260
+ }
261
+ }
262
+ }
263
+ return { plugins, diagnostics };
264
+ }
265
+
266
+ function registerProjectPlugin(input: {
267
+ readonly candidate: ImportedNornPlugin;
268
+ readonly project: NornProject;
269
+ readonly registry: NornWorkflowRegistry;
270
+ readonly state: NornMemoryWorkflowState;
271
+ }): { readonly loaded: LoadedNornWorkflowPlugin | null; readonly diagnostics: NornPluginDiagnostic[] } {
272
+ const { plugin, source } = input.candidate;
273
+ const diagnostics: NornPluginDiagnostic[] = [];
274
+ const unregister: NornDispose[] = [];
275
+ let stage: NornPluginDiagnostic["stage"] = "config";
276
+ try {
277
+ const configInput = input.project.projectConfig[plugin.manifest.id];
278
+ if (!plugin.manifest.config && configInput !== undefined) throw new Error(`Norn config provided for plugin without config schema: ${plugin.manifest.id}`);
279
+ const config = plugin.manifest.config ? plugin.manifest.config.parse(defaultConfigInput(plugin.manifest.config, configInput)) : undefined;
280
+ stage = "schema";
281
+ const info: NornProjectPluginInfo = {
282
+ id: plugin.manifest.id, path: source.pluginPath, configPath: source.configPath,
283
+ configSchema: plugin.manifest.config ? z.toJSONSchema(plugin.manifest.config, { io: "input" }) : null, config,
284
+ };
285
+ stage = "implementation";
286
+ const implementation = typeof plugin.implementation === "function" ? plugin.implementation({ cwd: input.project.cwd, state: input.state }) : plugin.implementation;
287
+ for (const [key, workflow] of Object.entries(plugin.manifest.workflows)) {
288
+ stage = "implementation";
289
+ try {
290
+ const workflowImplementation = implementation.workflows?.[key];
291
+ if (typeof workflowImplementation?.execute !== "function") throw new Error(`Missing implementation for workflow ${workflow.id}`);
292
+ stage = "duplicate";
293
+ if (input.registry.workflowById(workflow.id)) throw new Error(`Workflow already registered: ${workflow.id}`);
294
+ stage = "declaration";
295
+ unregister.push(input.registry.register(workflow, workflowImplementation, { plugin: workflowPluginInfo(info), configSchema: plugin.manifest.config, config }));
296
+ } catch (error) {
297
+ diagnostics.push(createPluginDiagnostic({ source, workflowId: workflow.id, stage: error instanceof z.ZodError ? "config" : stage, error }));
298
+ }
299
+ }
300
+ if (diagnostics.length === 0) return { loaded: { plugin, info }, diagnostics };
301
+ } catch (error) {
302
+ diagnostics.push(createPluginDiagnostic({ source, workflowId: null, stage, error }));
303
+ }
304
+ for (const dispose of unregister) dispose();
305
+ return { loaded: null, diagnostics };
306
+ }
307
+
308
+ function createPluginDiagnostic(input: {
309
+ readonly source: NornPluginSource;
310
+ readonly workflowId: string | null;
311
+ readonly stage: NornPluginDiagnostic["stage"];
312
+ readonly error: unknown;
313
+ }): NornPluginDiagnostic {
314
+ return {
315
+ ...input.source, workflowId: input.workflowId, stage: input.stage, message: errorMessage(input.error),
316
+ issues: input.error instanceof z.ZodError ? input.error.issues.map(issue => ({
317
+ path: issue.path.map(part => typeof part === "symbol" ? String(part) : part), code: issue.code, message: issue.message,
318
+ })) : [],
319
+ };
320
+ }
321
+
322
+ function workflowPluginInfo(info: NornProjectPluginInfo): NornWorkflowPluginInfo {
323
+ return { id: info.id, path: info.path, configPath: info.configPath };
324
+ }
325
+
326
+ function nornWorkflowVirtualModules(): Record<string, unknown> {
327
+ return {
328
+ "@vimhead.dev/norn": nornModule,
329
+ "@vimhead.dev/norn/files": nornFilesModule,
330
+ "@vimhead.dev/norn/schema": nornSchemaModule,
331
+ "@vimhead.dev/norn/seer": nornSeerModule,
332
+ typebox: typeboxModule,
333
+ zod: zodModule,
334
+ };
335
+ }
336
+
337
+ function mergeProjectConfig(configFiles: readonly NornConfigFile[]): Record<string, unknown> {
338
+ const [projectFile, ...reusableConfigFiles] = configFiles;
339
+ let reusableConfig: Record<string, unknown> = {};
340
+ for (const configFile of reusableConfigFiles) {
341
+ reusableConfig = mergeReusableConfigObjects(reusableConfig, configFile.config.config, ["config"]);
342
+ }
343
+ return mergeProjectConfigObjects(reusableConfig, projectFile?.config.config ?? {});
344
+ }
345
+
346
+ function mergeReusableConfigObjects(base: Record<string, unknown>, override: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
347
+ const result: Record<string, unknown> = { ...base };
348
+ for (const [key, overrideValue] of Object.entries(override)) {
349
+ result[key] = Object.prototype.hasOwnProperty.call(result, key)
350
+ ? mergeReusableConfigValue(result[key], overrideValue, [...path, key])
351
+ : overrideValue;
352
+ }
353
+ return result;
354
+ }
355
+
356
+ function mergeReusableConfigValue(base: unknown, override: unknown, path: readonly string[]): unknown {
357
+ if (isPlainObject(base) && isPlainObject(override)) return mergeReusableConfigObjects(base, override, path);
358
+ if (JSON.stringify(base) === JSON.stringify(override)) return base;
359
+ throw new Error(`Conflicting Norn reusable config at ${path.join(".")}`);
360
+ }
361
+
362
+ function mergeProjectConfigObjects(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
363
+ const result: Record<string, unknown> = { ...base };
364
+ for (const [key, overrideValue] of Object.entries(override)) {
365
+ result[key] = isPlainObject(result[key]) && isPlainObject(overrideValue)
366
+ ? mergeProjectConfigObjects(result[key], overrideValue)
367
+ : overrideValue;
368
+ }
369
+ return result;
370
+ }
371
+
372
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
373
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
374
+ }
375
+
376
+ function defaultConfigInput(configSchema: z.ZodType, config: unknown): unknown {
377
+ if (config !== undefined) return config;
378
+ return schemaType(unwrapSchema(configSchema)) === "object" ? {} : undefined;
379
+ }
380
+
381
+ async function expandIncludePath(configRoot: string, includePath: string): Promise<string[]> {
382
+ const resolvedIncludePath = resolveConfigPath(configRoot, includePath);
383
+ if (!hasGlobSegment(includePath)) return [resolvedIncludePath];
384
+ return expandGlobSegments(isAbsolute(includePath) ? sep : configRoot, splitPathSegments(includePath));
385
+ }
386
+
387
+ async function expandGlobSegments(root: string, segments: readonly string[]): Promise<string[]> {
388
+ if (segments.length === 0) return [root];
389
+ const [segment, ...remainingSegments] = segments;
390
+ if (segment === "*") {
391
+ const entries = await readdir(root, { withFileTypes: true });
392
+ const expanded = await Promise.all(entries
393
+ .filter((entry) => entry.isDirectory())
394
+ .sort((left, right) => left.name.localeCompare(right.name))
395
+ .map((entry) => expandGlobSegments(join(root, entry.name), remainingSegments)));
396
+ return expanded.flat();
397
+ }
398
+ return expandGlobSegments(join(root, segment), remainingSegments);
399
+ }
400
+
401
+ function resolveConfigPath(configRoot: string, path: string): string {
402
+ if (path.length === 0) throw new Error("Norn path must not be empty");
403
+ return isAbsolute(path) ? path : resolve(configRoot, path);
404
+ }
405
+
406
+ function hasGlobSegment(path: string): boolean {
407
+ return splitPathSegments(path).includes("*");
408
+ }
409
+
410
+ function splitPathSegments(path: string): string[] {
411
+ return path.split(/[\\/]+/).filter((segment) => segment.length > 0 && segment !== ".");
412
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import { z } from "zod";
5
+ import { createRunFileCoordinator, type NornFileCoordinator } from "@vimhead.dev/norn/files";
6
+ import type { NornResourceDefinition, NornResources } from "@vimhead.dev/norn";
7
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
8
+ import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
9
+
10
+ const definitionSchema = z.strictObject({ name: z.string(), kind: z.string().min(1), configuration: z.json() });
11
+ const recordSchema = definitionSchema.extend({ isInitialized: z.boolean() });
12
+
13
+ export class NornRunResources implements NornResources {
14
+ readonly files: NornFileCoordinator;
15
+ private readonly entries = new Map<string, { readonly definition: z.output<typeof definitionSchema>; readonly value: Promise<unknown> }>();
16
+
17
+ private constructor(private readonly runRoot: string) {
18
+ this.files = createRunFileCoordinator(runRoot);
19
+ }
20
+
21
+ static async initialize(runRoot: string): Promise<NornRunResources> {
22
+ const resources = new NornRunResources(resolve(runRoot));
23
+ await mkdir(join(resources.runRoot, "current", "resources"), { recursive: true });
24
+ return resources;
25
+ }
26
+
27
+ async ensure<T>(definition: NornResourceDefinition<T>): Promise<T> {
28
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(definition.name)) throw new Error(`Invalid resource name: ${definition.name}`);
29
+ const identity = definitionSchema.parse({ name: definition.name, kind: definition.kind, configuration: definition.configuration });
30
+ const existing = this.entries.get(definition.name);
31
+ if (existing) {
32
+ if (!isDeepStrictEqual(existing.definition, identity)) throw new Error(`Incompatible resource definition: ${definition.name}`);
33
+ return existing.value as Promise<T>;
34
+ }
35
+ const value = this.initializeResource(definition, identity);
36
+ const entry = { definition: identity, value };
37
+ this.entries.set(definition.name, entry);
38
+ try {
39
+ return await value;
40
+ } catch (error) {
41
+ if (this.entries.get(definition.name) === entry) this.entries.delete(definition.name);
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ private async initializeResource<T>(definition: NornResourceDefinition<T>, identity: z.output<typeof definitionSchema>): Promise<T> {
47
+ const directory = join(this.runRoot, "current", "resources", definition.name);
48
+ await mkdir(directory, { recursive: true });
49
+ return this.files.withExclusiveLock(join(directory, "definition.json"), async (path) => {
50
+ let stored: z.output<typeof recordSchema> | undefined;
51
+ try {
52
+ stored = recordSchema.parse(JSON.parse(await readFile(path, "utf8")));
53
+ } catch (error) {
54
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
55
+ }
56
+ if (stored) {
57
+ const { isInitialized: _isInitialized, ...storedIdentity } = stored;
58
+ if (!isDeepStrictEqual(storedIdentity, identity)) throw new Error(`Incompatible resource definition: ${definition.name}`);
59
+ } else {
60
+ await writeJsonAtomically(path, { ...identity, isInitialized: false });
61
+ }
62
+ const value = await definition.initialize({ directory, files: this.files, mode: stored?.isInitialized ? "open" : "create" });
63
+ if (!stored?.isInitialized) await writeJsonAtomically(path, { ...identity, isInitialized: true });
64
+ return value;
65
+ });
66
+ }
67
+ }
@@ -0,0 +1 @@
1
+ export const AGENT_RESPONSE_TOOL_NAME = "pi_workflows_agent_response";
@@ -0,0 +1,24 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { isNodeError } from "./errors.ts";
5
+
6
+ export async function writeJsonAtomically(path: string, value: unknown): Promise<void> {
7
+ await writeTextAtomically(path, `${JSON.stringify(value, null, 2)}\n`);
8
+ }
9
+
10
+ export async function writeTextAtomically(path: string, content: string): Promise<void> {
11
+ await mkdir(dirname(path), { recursive: true });
12
+ let existingMode: number | undefined;
13
+ try { existingMode = (await stat(path)).mode & 0o777; }
14
+ catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") throw error; }
15
+ const tmpPath = `${path}.${randomUUID()}.tmp`;
16
+ try {
17
+ await writeFile(tmpPath, content, { encoding: "utf8", mode: existingMode });
18
+ if (existingMode !== undefined) await chmod(tmpPath, existingMode);
19
+ await rename(tmpPath, path);
20
+ } catch (error) {
21
+ await rm(tmpPath, { force: true }).catch(() => undefined);
22
+ throw error;
23
+ }
24
+ }