create-next-hydra 0.3.0 → 0.4.2

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 (207) hide show
  1. package/README.md +126 -27
  2. package/dist/add-command.d.ts +11 -0
  3. package/dist/add-command.d.ts.map +1 -0
  4. package/dist/add-command.js +75 -0
  5. package/dist/addition.d.ts +67 -0
  6. package/dist/addition.d.ts.map +1 -0
  7. package/dist/addition.js +422 -0
  8. package/dist/application-git.d.ts +20 -0
  9. package/dist/application-git.d.ts.map +1 -0
  10. package/dist/application-git.js +57 -0
  11. package/dist/baseline.d.ts +22 -0
  12. package/dist/baseline.d.ts.map +1 -0
  13. package/dist/baseline.js +90 -0
  14. package/dist/cli.js +5 -18
  15. package/dist/commands.d.ts +22 -0
  16. package/dist/commands.d.ts.map +1 -0
  17. package/dist/commands.js +197 -0
  18. package/dist/composition-lint.d.ts +20 -0
  19. package/dist/composition-lint.d.ts.map +1 -0
  20. package/dist/composition-lint.js +288 -0
  21. package/dist/composition.d.ts +24 -0
  22. package/dist/composition.d.ts.map +1 -0
  23. package/dist/composition.js +420 -0
  24. package/dist/create-command.d.ts +21 -0
  25. package/dist/create-command.d.ts.map +1 -0
  26. package/dist/create-command.js +128 -0
  27. package/dist/demo-instrumentation.d.ts +5 -0
  28. package/dist/demo-instrumentation.d.ts.map +1 -0
  29. package/dist/demo-instrumentation.js +300 -0
  30. package/dist/environment.d.ts +7 -0
  31. package/dist/environment.d.ts.map +1 -0
  32. package/dist/environment.js +81 -0
  33. package/dist/errors.d.ts +156 -0
  34. package/dist/errors.d.ts.map +1 -0
  35. package/dist/errors.js +140 -0
  36. package/dist/file-policy.d.ts +6 -0
  37. package/dist/file-policy.d.ts.map +1 -0
  38. package/dist/file-policy.js +38 -0
  39. package/dist/files.d.ts +7 -0
  40. package/dist/files.d.ts.map +1 -0
  41. package/dist/files.js +66 -0
  42. package/dist/hosting.d.ts +5 -0
  43. package/dist/hosting.d.ts.map +1 -0
  44. package/dist/hosting.js +107 -0
  45. package/dist/index.d.ts +4 -6
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +17 -89
  48. package/dist/initialization.d.ts +14 -0
  49. package/dist/initialization.d.ts.map +1 -0
  50. package/dist/initialization.js +266 -0
  51. package/dist/lint-workspaces.d.ts +2 -0
  52. package/dist/lint-workspaces.d.ts.map +1 -0
  53. package/dist/lint-workspaces.js +62 -0
  54. package/dist/model.d.ts +143 -0
  55. package/dist/model.d.ts.map +1 -0
  56. package/dist/model.js +61 -0
  57. package/dist/package-catalog.d.ts +8 -0
  58. package/dist/package-catalog.d.ts.map +1 -0
  59. package/dist/package-catalog.js +61 -0
  60. package/dist/packages.d.ts +57 -0
  61. package/dist/packages.d.ts.map +1 -0
  62. package/dist/packages.js +237 -0
  63. package/dist/process-group.d.ts +5 -0
  64. package/dist/process-group.d.ts.map +1 -0
  65. package/dist/process-group.js +23 -0
  66. package/dist/reference-workspace-bindings.d.ts +38 -0
  67. package/dist/reference-workspace-bindings.d.ts.map +1 -0
  68. package/dist/reference-workspace-bindings.js +41 -0
  69. package/dist/reference-workspace.d.ts +6 -0
  70. package/dist/reference-workspace.d.ts.map +1 -0
  71. package/dist/reference-workspace.js +62 -0
  72. package/dist/registry.d.ts +187 -0
  73. package/dist/registry.d.ts.map +1 -0
  74. package/dist/registry.js +107 -0
  75. package/dist/runtime.d.ts +9 -0
  76. package/dist/runtime.d.ts.map +1 -0
  77. package/dist/runtime.js +22 -0
  78. package/dist/selection.d.ts +704 -0
  79. package/dist/selection.d.ts.map +1 -0
  80. package/dist/selection.js +424 -0
  81. package/dist/shadcn-protocol.d.ts +22 -0
  82. package/dist/shadcn-protocol.d.ts.map +1 -0
  83. package/dist/shadcn-protocol.js +13 -0
  84. package/dist/shadcn-worker.d.ts +2 -0
  85. package/dist/shadcn-worker.d.ts.map +1 -0
  86. package/dist/shadcn-worker.js +43 -0
  87. package/dist/shadcn.d.ts +22 -0
  88. package/dist/shadcn.d.ts.map +1 -0
  89. package/dist/shadcn.js +101 -0
  90. package/dist/source-changes.d.ts +55 -0
  91. package/dist/source-changes.d.ts.map +1 -0
  92. package/dist/source-changes.js +225 -0
  93. package/dist/source-inventory.d.ts +25 -0
  94. package/dist/source-inventory.d.ts.map +1 -0
  95. package/dist/source-inventory.js +212 -0
  96. package/dist/source-root.d.ts +4 -0
  97. package/dist/source-root.d.ts.map +1 -0
  98. package/dist/source-root.js +20 -0
  99. package/dist/staging.d.ts +19 -0
  100. package/dist/staging.d.ts.map +1 -0
  101. package/dist/staging.js +42 -0
  102. package/dist/tasks.d.ts +44 -0
  103. package/dist/tasks.d.ts.map +1 -0
  104. package/dist/tasks.js +137 -0
  105. package/dist/templates.d.ts +217 -0
  106. package/dist/templates.d.ts.map +1 -0
  107. package/dist/templates.js +161 -0
  108. package/dist/test-reference-workspace.d.ts +2 -0
  109. package/dist/test-reference-workspace.d.ts.map +1 -0
  110. package/dist/test-reference-workspace.js +6 -0
  111. package/dist/types.d.ts +2 -9
  112. package/dist/types.d.ts.map +1 -1
  113. package/dist/typescript-paths.d.ts +10 -0
  114. package/dist/typescript-paths.d.ts.map +1 -0
  115. package/dist/typescript-paths.js +52 -0
  116. package/dist/workspace-dependencies.d.ts +26 -0
  117. package/dist/workspace-dependencies.d.ts.map +1 -0
  118. package/dist/workspace-dependencies.js +478 -0
  119. package/dist/workspace-files.d.ts +24 -0
  120. package/dist/workspace-files.d.ts.map +1 -0
  121. package/dist/workspace-files.js +395 -0
  122. package/dist/workspace-lock.d.ts +12 -0
  123. package/dist/workspace-lock.d.ts.map +1 -0
  124. package/dist/workspace-lock.js +104 -0
  125. package/dist/workspace-snapshots.d.ts +24 -0
  126. package/dist/workspace-snapshots.d.ts.map +1 -0
  127. package/dist/workspace-snapshots.js +218 -0
  128. package/dist/workspace-sources.d.ts +27 -0
  129. package/dist/workspace-sources.d.ts.map +1 -0
  130. package/dist/workspace-sources.js +88 -0
  131. package/dist/workspace-state.d.ts +325 -0
  132. package/dist/workspace-state.d.ts.map +1 -0
  133. package/dist/workspace-state.js +571 -0
  134. package/dist/workspaces.d.ts +73 -0
  135. package/dist/workspaces.d.ts.map +1 -0
  136. package/dist/workspaces.js +396 -0
  137. package/package.json +27 -20
  138. package/schema/selection-definition.json +143 -4
  139. package/dist/clone.d.ts +0 -9
  140. package/dist/clone.d.ts.map +0 -1
  141. package/dist/clone.js +0 -19
  142. package/dist/composition/add.d.ts +0 -14
  143. package/dist/composition/add.d.ts.map +0 -1
  144. package/dist/composition/add.js +0 -484
  145. package/dist/composition/catalog.d.ts +0 -26
  146. package/dist/composition/catalog.d.ts.map +0 -1
  147. package/dist/composition/catalog.js +0 -317
  148. package/dist/composition/errors.d.ts +0 -5
  149. package/dist/composition/errors.d.ts.map +0 -1
  150. package/dist/composition/errors.js +0 -8
  151. package/dist/composition/format.d.ts +0 -6
  152. package/dist/composition/format.d.ts.map +0 -1
  153. package/dist/composition/format.js +0 -65
  154. package/dist/composition/install.d.ts +0 -16
  155. package/dist/composition/install.d.ts.map +0 -1
  156. package/dist/composition/install.js +0 -175
  157. package/dist/composition/packages.d.ts +0 -6
  158. package/dist/composition/packages.d.ts.map +0 -1
  159. package/dist/composition/packages.js +0 -55
  160. package/dist/composition/paths.d.ts +0 -4
  161. package/dist/composition/paths.d.ts.map +0 -1
  162. package/dist/composition/paths.js +0 -47
  163. package/dist/composition/planner.d.ts +0 -4
  164. package/dist/composition/planner.d.ts.map +0 -1
  165. package/dist/composition/planner.js +0 -366
  166. package/dist/composition/schema.d.ts +0 -294
  167. package/dist/composition/schema.d.ts.map +0 -1
  168. package/dist/composition/schema.js +0 -166
  169. package/dist/composition/types.d.ts +0 -111
  170. package/dist/composition/types.d.ts.map +0 -1
  171. package/dist/composition/types.js +0 -6
  172. package/dist/composition/typescript-paths.d.ts +0 -6
  173. package/dist/composition/typescript-paths.d.ts.map +0 -1
  174. package/dist/composition/typescript-paths.js +0 -157
  175. package/dist/composition/use.d.ts +0 -23
  176. package/dist/composition/use.d.ts.map +0 -1
  177. package/dist/composition/use.js +0 -187
  178. package/dist/composition/workspace.d.ts +0 -10
  179. package/dist/composition/workspace.d.ts.map +0 -1
  180. package/dist/composition/workspace.js +0 -210
  181. package/dist/constants.d.ts +0 -11
  182. package/dist/constants.d.ts.map +0 -1
  183. package/dist/constants.js +0 -39
  184. package/dist/fs-utils.d.ts +0 -9
  185. package/dist/fs-utils.d.ts.map +0 -1
  186. package/dist/fs-utils.js +0 -64
  187. package/dist/git.d.ts +0 -28
  188. package/dist/git.d.ts.map +0 -1
  189. package/dist/git.js +0 -107
  190. package/dist/instruction-renderer.d.ts +0 -17
  191. package/dist/instruction-renderer.d.ts.map +0 -1
  192. package/dist/instruction-renderer.js +0 -132
  193. package/dist/logger.d.ts +0 -13
  194. package/dist/logger.d.ts.map +0 -1
  195. package/dist/logger.js +0 -26
  196. package/dist/prompts.d.ts +0 -7
  197. package/dist/prompts.d.ts.map +0 -1
  198. package/dist/prompts.js +0 -54
  199. package/dist/sanitize.d.ts +0 -10
  200. package/dist/sanitize.d.ts.map +0 -1
  201. package/dist/sanitize.js +0 -110
  202. package/dist/scaffold.d.ts +0 -7
  203. package/dist/scaffold.d.ts.map +0 -1
  204. package/dist/scaffold.js +0 -330
  205. package/dist/version.d.ts +0 -2
  206. package/dist/version.d.ts.map +0 -1
  207. package/dist/version.js +0 -2
@@ -0,0 +1,395 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { Context, Effect, FileSystem, Layer, Path, Redacted } from "effect";
4
+ import { MaterializationFailed, WorkspaceConflict, WorkspaceRecoveryRequired, } from "./errors.js";
5
+ import { writeFile } from "./files.js";
6
+ import { snapshotTarget } from "./workspace-snapshots.js";
7
+ import { sameFingerprint as same } from "./workspace-state.js";
8
+ const fingerprint = (content, mode) => ({
9
+ hash: createHash("sha256").update(content).digest("hex"),
10
+ mode,
11
+ });
12
+ function compareFile(previous, wanted, before, returning = false) {
13
+ const desired = wanted ? fingerprint(wanted.content, wanted.mode) : null;
14
+ const after = previous &&
15
+ same(desired, previous.desired) &&
16
+ same(before, previous.applied)
17
+ ? previous.applied
18
+ : desired;
19
+ let conflict = !same(before, desired);
20
+ if (!returning) {
21
+ // A clean checkout can already contain the committed generated task policy.
22
+ // Adopt only exact output; differing unowned files still require reconciliation.
23
+ conflict = previous
24
+ ? !same(before, previous.applied) && !same(before, after)
25
+ : before !== null &&
26
+ !(wanted?.target === "turbo.json" &&
27
+ wanted.origin.kind === "policy" &&
28
+ wanted.origin.policy === "application-tasks" &&
29
+ same(before, desired));
30
+ }
31
+ return { after, conflict, desired };
32
+ }
33
+ export class WorkspaceFiles extends Context.Service()("create-next-hydra/WorkspaceFiles") {
34
+ static layer = Layer.effect(WorkspaceFiles, Effect.gen(function* () {
35
+ const fs = yield* FileSystem.FileSystem;
36
+ const path = yield* Path.Path;
37
+ const observe = (directory, target) => Effect.gen(function* () {
38
+ const absolute = path.join(directory, target);
39
+ if (!(yield* fs.exists(absolute))) {
40
+ return null;
41
+ }
42
+ const stat = yield* fs.stat(absolute);
43
+ if (stat.type !== "File") {
44
+ return yield* new WorkspaceConflict({ paths: [target] });
45
+ }
46
+ return fingerprint(yield* fs.readFile(absolute), stat.mode % 0o1000);
47
+ }).pipe(Effect.uninterruptible);
48
+ const blockingAncestor = (directory, target, targets) => Effect.gen(function* () {
49
+ const parts = target.split("/");
50
+ for (let end = 1; end < parts.length; end += 1) {
51
+ const ancestor = parts.slice(0, end).join("/");
52
+ if (targets.has(ancestor) ||
53
+ ((yield* fs.exists(path.join(directory, ancestor))) &&
54
+ (yield* fs.stat(path.join(directory, ancestor))).type !==
55
+ "Directory")) {
56
+ return ancestor;
57
+ }
58
+ }
59
+ return null;
60
+ });
61
+ const read = Effect.fn("WorkspaceFiles.read")(function* (directory, targets) {
62
+ const files = [];
63
+ const conflicts = new Set();
64
+ for (const target of targets.filter(snapshotTarget)) {
65
+ const ancestor = yield* blockingAncestor(directory, target, new Set());
66
+ if (ancestor !== null) {
67
+ conflicts.add(ancestor);
68
+ continue;
69
+ }
70
+ const absolute = path.join(directory, target);
71
+ if (!(yield* fs.exists(absolute))) {
72
+ continue;
73
+ }
74
+ const stat = yield* fs.stat(absolute);
75
+ if (stat.type !== "File") {
76
+ conflicts.add(target);
77
+ continue;
78
+ }
79
+ files.push({
80
+ content: yield* fs.readFile(absolute),
81
+ mode: stat.mode % 0o1000,
82
+ target,
83
+ });
84
+ }
85
+ return { conflicts: [...conflicts], files };
86
+ });
87
+ const capture = Effect.fn("WorkspaceFiles.capture")(function* (directory, entries) {
88
+ const { files, conflicts } = yield* read(directory, entries.map((entry) => entry.target));
89
+ const observed = new Map(files.map((file) => [
90
+ file.target,
91
+ fingerprint(file.content, file.mode),
92
+ ]));
93
+ const changed = entries
94
+ .filter((entry) => snapshotTarget(entry.target) &&
95
+ !same(entry.applied, observed.get(entry.target) ?? null))
96
+ .map((entry) => entry.target);
97
+ if (conflicts.length || changed.length) {
98
+ return yield* new WorkspaceConflict({
99
+ paths: [...new Set([...conflicts, ...changed])],
100
+ });
101
+ }
102
+ return files;
103
+ });
104
+ const preservation = Effect.fn("WorkspaceFiles.preservation")(function* (observation, files, preserve) {
105
+ const desired = new Map(files.map((file) => [file.target, file]));
106
+ const previouslyManaged = new Set(observation.entries.map((entry) => entry.target));
107
+ const handedOff = new Set(observation.preserved);
108
+ const preserved = preserve.filter((target) => desired.has(target) ||
109
+ previouslyManaged.has(target) ||
110
+ handedOff.has(target));
111
+ const initialization = [];
112
+ for (const target of preserved) {
113
+ // A completed handoff includes local deletions. Do not inspect these files again.
114
+ if (previouslyManaged.has(target) || handedOff.has(target)) {
115
+ continue;
116
+ }
117
+ const ancestor = yield* blockingAncestor(observation.directory, target, new Set());
118
+ if (ancestor !== null) {
119
+ return yield* new WorkspaceConflict({ paths: [ancestor] });
120
+ }
121
+ const absolute = path.join(observation.directory, target);
122
+ if (yield* fs.exists(absolute)) {
123
+ if ((yield* fs.stat(absolute)).type !== "File") {
124
+ return yield* new WorkspaceConflict({ paths: [target] });
125
+ }
126
+ }
127
+ else {
128
+ const file = desired.get(target);
129
+ if (file) {
130
+ initialization.push(file);
131
+ }
132
+ }
133
+ }
134
+ return { initialization, preserved };
135
+ });
136
+ const inspect = Effect.fn("WorkspaceFiles.inspect")(function* (observation, files, preserve = []) {
137
+ const handoff = yield* preservation(observation, files, preserve);
138
+ const desired = new Map(files.map((file) => [file.target, file]));
139
+ const applied = new Map(observation.entries.map((entry) => [entry.target, entry]));
140
+ const changes = new Map();
141
+ const targets = new Set([
142
+ ...applied.keys(),
143
+ ...desired.keys(),
144
+ ...observation.preserved,
145
+ ]);
146
+ for (const target of targets) {
147
+ if (handoff.preserved.includes(target)) {
148
+ if (!observation.preserved.includes(target)) {
149
+ changes.set(target, {
150
+ kind: handoff.initialization.some((file) => file.target === target)
151
+ ? "create"
152
+ : "record",
153
+ target,
154
+ });
155
+ }
156
+ continue;
157
+ }
158
+ if (observation.preserved.includes(target) && !desired.has(target)) {
159
+ changes.set(target, { kind: "record", target });
160
+ continue;
161
+ }
162
+ if (changes.get(target)?.kind === "conflict") {
163
+ continue;
164
+ }
165
+ const ancestor = yield* blockingAncestor(observation.directory, target, targets);
166
+ if (ancestor !== null) {
167
+ changes.set(ancestor, { kind: "conflict", target: ancestor });
168
+ continue;
169
+ }
170
+ const before = yield* observe(observation.directory, target).pipe(Effect.catchTag("WorkspaceConflict", () => Effect.void));
171
+ if (before === undefined) {
172
+ changes.set(target, { kind: "conflict", target });
173
+ continue;
174
+ }
175
+ const previous = applied.get(target);
176
+ const { after, conflict, desired: wanted, } = compareFile(previous, desired.get(target), before, observation.preserved.includes(target));
177
+ if (conflict) {
178
+ changes.set(target, {
179
+ kind: previous || observation.preserved.includes(target)
180
+ ? "conflict"
181
+ : "unregistered",
182
+ target,
183
+ });
184
+ }
185
+ else if (!same(before, after)) {
186
+ let kind = "update";
187
+ if (after === null) {
188
+ kind = "remove";
189
+ }
190
+ else if (before === null) {
191
+ kind = "create";
192
+ }
193
+ changes.set(target, { kind, target });
194
+ }
195
+ else if (previous
196
+ ? !same(previous.desired, wanted) ||
197
+ !same(previous.applied, after) ||
198
+ !isDeepStrictEqual(previous.origin, desired.get(target)?.origin)
199
+ : wanted !== null) {
200
+ changes.set(target, { kind: "record", target });
201
+ }
202
+ }
203
+ return [...changes.values()];
204
+ });
205
+ const recover = Effect.fn("WorkspaceFiles.recover")(function* (access) {
206
+ const observation = yield* access.observation;
207
+ const { pending } = observation;
208
+ if (pending === null) {
209
+ return;
210
+ }
211
+ if (pending.kind !== "files") {
212
+ return yield* new WorkspaceRecoveryRequired({
213
+ directory: access.directory,
214
+ });
215
+ }
216
+ // Initial-only files become workspace-owned after creation. Never read or
217
+ // fingerprint credentials, or mistake an existing partial write for success.
218
+ for (const file of pending.initialization) {
219
+ const ancestor = yield* blockingAncestor(access.directory, file.target, new Set());
220
+ if (ancestor !== null) {
221
+ return yield* new WorkspaceConflict({ paths: [ancestor] });
222
+ }
223
+ const absolute = path.join(access.directory, file.target);
224
+ if (!(yield* fs.exists(absolute))) {
225
+ continue;
226
+ }
227
+ if (file.status === "started") {
228
+ return yield* new WorkspaceRecoveryRequired({
229
+ directory: access.directory,
230
+ paths: [file.target],
231
+ });
232
+ }
233
+ if ((yield* fs.stat(absolute)).type !== "File") {
234
+ return yield* new WorkspaceConflict({ paths: [file.target] });
235
+ }
236
+ }
237
+ const observed = new Map();
238
+ const temporary = new Map();
239
+ const conflicts = [];
240
+ for (const file of pending.files) {
241
+ const ancestor = yield* blockingAncestor(access.directory, file.target, new Set());
242
+ if (ancestor !== null) {
243
+ conflicts.push(ancestor);
244
+ continue;
245
+ }
246
+ const current = yield* observe(access.directory, file.target);
247
+ observed.set(file.target, current);
248
+ if (!same(current, file.before) && !same(current, file.after)) {
249
+ conflicts.push(file.target);
250
+ }
251
+ if (file.temporary !== null) {
252
+ const saved = yield* observe(access.directory, file.temporary);
253
+ if (saved !== null) {
254
+ if (same(saved, file.after)) {
255
+ temporary.set(file.temporary, saved);
256
+ }
257
+ else {
258
+ conflicts.push(file.temporary);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ if (conflicts.length > 0) {
264
+ return yield* new WorkspaceConflict({
265
+ paths: [...new Set(conflicts)],
266
+ });
267
+ }
268
+ // No cleanup until every owned path and retained replacement is explained.
269
+ // A retry can repeat this after cleanup fails; the receipt still has intent.
270
+ for (const [target, expected] of temporary) {
271
+ yield* Effect.gen(function* () {
272
+ if (!same(yield* observe(access.directory, target), expected)) {
273
+ return yield* new WorkspaceConflict({ paths: [target] });
274
+ }
275
+ yield* fs.remove(path.join(access.directory, target));
276
+ }).pipe(Effect.uninterruptible);
277
+ }
278
+ for (const [target, expected] of observed) {
279
+ if (!same(yield* observe(access.directory, target), expected)) {
280
+ return yield* new WorkspaceConflict({ paths: [target] });
281
+ }
282
+ }
283
+ yield* access.recoverFiles(observed, observation.revision);
284
+ });
285
+ const apply = Effect.fn("WorkspaceFiles.apply")(function* (access, files, initialization, preserve = []) {
286
+ const observation = yield* access.observation;
287
+ const handoff = yield* preservation(observation, files, preserve);
288
+ const desired = new Map(files.map((file) => [file.target, file]));
289
+ const applied = new Map((yield* access.appliedEntries).map((entry) => [entry.target, entry]));
290
+ const targets = new Set([...applied.keys(), ...desired.keys()]);
291
+ const operation = yield* Effect.sync(randomUUID);
292
+ const intent = [];
293
+ const conflicts = [];
294
+ for (const target of targets) {
295
+ if (handoff.preserved.includes(target)) {
296
+ continue;
297
+ }
298
+ // File-to-directory transitions require a separate, explicitly designed
299
+ // operation. Never remove parents recursively during ordinary refresh.
300
+ const ancestor = yield* blockingAncestor(access.directory, target, targets);
301
+ if (ancestor !== null) {
302
+ return yield* new WorkspaceConflict({ paths: [ancestor, target] });
303
+ }
304
+ const before = yield* observe(access.directory, target);
305
+ const wanted = desired.get(target);
306
+ const previous = applied.get(target);
307
+ const { after, conflict, desired: desiredFingerprint, } = compareFile(previous, wanted, before, observation.preserved.includes(target));
308
+ if (conflict) {
309
+ conflicts.push(target);
310
+ }
311
+ const temporary = before !== null && after !== null && !same(before, after)
312
+ ? `${target}.${operation}.pending`
313
+ : null;
314
+ if (temporary !== null &&
315
+ (targets.has(temporary) ||
316
+ (yield* fs.exists(path.join(access.directory, temporary))))) {
317
+ conflicts.push(temporary);
318
+ }
319
+ intent.push({
320
+ after,
321
+ before,
322
+ desired: desiredFingerprint,
323
+ origin: wanted?.origin ?? null,
324
+ target,
325
+ temporary,
326
+ });
327
+ }
328
+ if (conflicts.length) {
329
+ return yield* new WorkspaceConflict({ paths: conflicts });
330
+ }
331
+ // The entire before/after set, including unchanged files, is durable before
332
+ // the first application write. A failure leaves that intent for inspection.
333
+ yield* access.begin(intent, [
334
+ ...initialization,
335
+ ...handoff.initialization.map((file) => file.target),
336
+ ], handoff.preserved);
337
+ const completedFiles = [];
338
+ const removedFiles = [];
339
+ for (const item of intent) {
340
+ if (same(item.before, item.after)) {
341
+ continue;
342
+ }
343
+ yield* Effect.gen(function* () {
344
+ const file = desired.get(item.target);
345
+ if (file && item.temporary) {
346
+ yield* writeFile(access.directory, { ...file, target: item.temporary }, true);
347
+ }
348
+ if (!same(yield* observe(access.directory, item.target), item.before)) {
349
+ return yield* new WorkspaceConflict({ paths: [item.target] });
350
+ }
351
+ if (!file) {
352
+ yield* fs.remove(path.join(access.directory, item.target));
353
+ removedFiles.push(item.target);
354
+ }
355
+ else if (item.temporary) {
356
+ yield* fs.rename(path.join(access.directory, item.temporary), path.join(access.directory, item.target));
357
+ }
358
+ else {
359
+ // Exclusive creation can leave a partial file on an I/O failure;
360
+ // the pending receipt prevents a retry from adopting it as complete.
361
+ yield* writeFile(access.directory, file, true);
362
+ }
363
+ completedFiles.push(item.target);
364
+ }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.uninterruptible, Effect.mapError((error) => new MaterializationFailed({
365
+ completedFiles: [...completedFiles],
366
+ diagnostic: Redacted.make(error),
367
+ directory: access.directory,
368
+ failedFile: item.target,
369
+ })));
370
+ }
371
+ for (const file of handoff.initialization) {
372
+ yield* access.initialize(file.target, writeFile(access.directory, file, true).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.mapError((error) => new MaterializationFailed({
373
+ completedFiles,
374
+ diagnostic: Redacted.make(error),
375
+ directory: access.directory,
376
+ failedFile: file.target,
377
+ }))));
378
+ }
379
+ const complete = Effect.gen(function* () {
380
+ const changed = [];
381
+ for (const item of intent) {
382
+ if (!same(yield* observe(access.directory, item.target), item.after)) {
383
+ changed.push(item.target);
384
+ }
385
+ }
386
+ if (changed.length) {
387
+ return yield* new WorkspaceConflict({ paths: changed });
388
+ }
389
+ yield* access.complete;
390
+ });
391
+ return { complete, removedFiles };
392
+ });
393
+ return WorkspaceFiles.of({ apply, capture, inspect, read, recover });
394
+ }));
395
+ }
@@ -0,0 +1,12 @@
1
+ import { Effect, FileSystem, Path, Ref } from "effect";
2
+ import type { PlatformError } from "effect";
3
+ import { WorkspaceLockAuthorizationRequired, WorkspaceLockChanged, WorkspaceRecoveryRequired, WorkspaceStateInvalid } from "./errors.ts";
4
+ export interface WriteLock {
5
+ readonly writer: Ref.Ref<boolean>;
6
+ readonly recoveryEvidence: string | null;
7
+ readonly assertOwner: Effect.Effect<void, WorkspaceLockChanged | PlatformError.PlatformError>;
8
+ }
9
+ export declare const withWriteLock: <A, E, R>(directory: string, use: (lock: WriteLock) => Effect.Effect<A, E, R>, options?: {
10
+ readonly breakLock?: string;
11
+ }) => Effect.Effect<A, WorkspaceLockAuthorizationRequired | WorkspaceLockChanged | WorkspaceRecoveryRequired | WorkspaceStateInvalid | PlatformError.PlatformError | E, FileSystem.FileSystem | Path.Path | R>;
12
+ //# sourceMappingURL=workspace-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-lock.d.ts","sourceRoot":"","sources":["../src/workspace-lock.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,MAAM,EACN,UAAU,EAEV,IAAI,EACJ,GAAG,EAEJ,MAAM,QAAQ,CAAC;AAChB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C,OAAO,EACL,kCAAkC,EAClC,oBAAoB,EACpB,yBAAyB,EACzB,qBAAqB,EACtB,MAAM,aAAa,CAAC;AAoBrB,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAClC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,CACjC,IAAI,EACJ,oBAAoB,GAAG,aAAa,CAAC,aAAa,CACnD,CAAC;CACH;AAGD,eAAO,MAAM,aAAa,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EACnC,WAAW,MAAM,EACjB,KAAK,CAAC,IAAI,EAAE,SAAS,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAChD,UAAU;IAAE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,6MA2HvC,CAAC"}
@@ -0,0 +1,104 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { Array as EffectArray, DateTime, Effect, FileSystem, Order, Path, Ref, Schema, } from "effect";
3
+ import { WorkspaceLockAuthorizationRequired, WorkspaceLockChanged, WorkspaceRecoveryRequired, WorkspaceStateInvalid, } from "./errors.js";
4
+ const LockOwner = Schema.fromJsonString(Schema.Struct({
5
+ operation: Schema.Literal("sync"),
6
+ pid: Schema.Int,
7
+ startedAt: Schema.String,
8
+ token: Schema.String,
9
+ }));
10
+ const LockObservation = Schema.fromJsonString(Schema.Struct({
11
+ candidate: Schema.NullOr(Schema.String),
12
+ directory: Schema.String,
13
+ names: Schema.Array(Schema.String),
14
+ ownerText: Schema.NullOr(Schema.String),
15
+ stored: Schema.NullOr(Schema.String),
16
+ }));
17
+ // Coordination only: acquiring this lock never creates a composition receipt.
18
+ export const withWriteLock = (directory, use, options) => Effect.gen(function* () {
19
+ const fs = yield* FileSystem.FileSystem;
20
+ const path = yield* Path.Path;
21
+ const lock = path.join(directory, ".workspace-composition.lock");
22
+ const ownerPath = path.join(lock, "owner.json");
23
+ const receiptPath = path.join(directory, ".workspace-composition.json");
24
+ const invalid = () => new WorkspaceStateInvalid({ directory });
25
+ const inspectLock = Effect.gen(function* () {
26
+ if (!(yield* fs.exists(lock))) {
27
+ return yield* new WorkspaceLockChanged({ directory });
28
+ }
29
+ const names = EffectArray.sort(yield* fs.readDirectory(lock), Order.String);
30
+ const ownerText = names.includes("owner.json") &&
31
+ (yield* fs.stat(ownerPath)).type === "File"
32
+ ? yield* fs.readFileString(ownerPath)
33
+ : null;
34
+ const owner = ownerText === null
35
+ ? null
36
+ : yield* Schema.decodeEffect(LockOwner)(ownerText).pipe(Effect.orElseSucceed(() => null));
37
+ const stored = (yield* fs.exists(receiptPath))
38
+ ? yield* fs.readFileString(receiptPath)
39
+ : null;
40
+ const candidatePath = path.join(lock, "receipt.json");
41
+ const candidate = names.includes("receipt.json") &&
42
+ (yield* fs.stat(candidatePath)).type === "File"
43
+ ? yield* fs.readFileString(candidatePath)
44
+ : null;
45
+ const token = createHash("sha256")
46
+ .update(yield* Schema.encodeEffect(LockObservation)({
47
+ candidate,
48
+ directory,
49
+ names,
50
+ ownerText,
51
+ stored,
52
+ }).pipe(Effect.mapError(invalid)))
53
+ .digest("hex");
54
+ return new WorkspaceLockAuthorizationRequired({
55
+ directory,
56
+ reason: owner === null
57
+ ? "another operation may be running; the lock has no readable owner"
58
+ : `process ${owner.pid} started ${owner.operation} at ${owner.startedAt} and may still be running`,
59
+ token,
60
+ });
61
+ });
62
+ const assertOwner = (owner) => Effect.gen(function* () {
63
+ const current = yield* fs.readFileString(ownerPath).pipe(Effect.catchIf((error) => error.reason._tag === "NotFound", () => Effect.succeed(null)));
64
+ if (current !== owner) {
65
+ return yield* new WorkspaceLockChanged({ directory });
66
+ }
67
+ });
68
+ return yield* Effect.acquireUseRelease(Effect.gen(function* () {
69
+ let recoveryEvidence = null;
70
+ if (options?.breakLock !== undefined) {
71
+ const observed = yield* inspectLock;
72
+ if (observed.token !== options.breakLock) {
73
+ return yield* new WorkspaceLockChanged({ directory });
74
+ }
75
+ const archive = path.join(directory, ".workspace-composition.recovery");
76
+ yield* fs.makeDirectory(archive, { mode: 0o700, recursive: true });
77
+ recoveryEvidence = path.join(archive, randomUUID());
78
+ yield* fs.rename(lock, recoveryEvidence);
79
+ }
80
+ yield* fs.makeDirectory(lock, { mode: 0o700 }).pipe(Effect.catchIf((error) => error.reason._tag === "AlreadyExists", () => inspectLock.pipe(Effect.flatMap(Effect.fail))));
81
+ const owner = yield* Schema.encodeEffect(LockOwner)({
82
+ operation: "sync",
83
+ pid: process.pid,
84
+ startedAt: DateTime.formatIso(yield* DateTime.now),
85
+ token: randomUUID(),
86
+ }).pipe(Effect.mapError(invalid));
87
+ yield* fs.writeFileString(ownerPath, owner, {
88
+ flag: "wx",
89
+ mode: 0o600,
90
+ });
91
+ return { owner, recoveryEvidence, writer: yield* Ref.make(false) };
92
+ }), (held) => use({
93
+ assertOwner: assertOwner(held.owner),
94
+ recoveryEvidence: held.recoveryEvidence,
95
+ writer: held.writer,
96
+ }), (held) => Effect.gen(function* () {
97
+ yield* assertOwner(held.owner);
98
+ if ((yield* Ref.get(held.writer)) ||
99
+ (yield* fs.readDirectory(lock)).some((name) => name !== "owner.json")) {
100
+ return yield* new WorkspaceRecoveryRequired({ directory });
101
+ }
102
+ yield* fs.remove(lock, { recursive: true });
103
+ }));
104
+ });
@@ -0,0 +1,24 @@
1
+ import { Context, Effect, FileSystem, Layer, Path } from "effect";
2
+ import type { PlatformError } from "effect";
3
+ import { ChildProcessSpawner } from "effect/unstable/process";
4
+ import { SnapshotFailed, SnapshotUnavailable } from "./errors.ts";
5
+ import type { PreparedFile } from "./model.ts";
6
+ import type { WorkspaceIdentity } from "./workspace-state.ts";
7
+ export declare const snapshotTarget: (target: string) => boolean;
8
+ export type SnapshotError = SnapshotFailed | SnapshotUnavailable | PlatformError.PlatformError;
9
+ export interface SnapshotDiff {
10
+ readonly changes: readonly {
11
+ readonly target: string;
12
+ readonly status: string;
13
+ }[];
14
+ readonly patch: string;
15
+ }
16
+ declare const WorkspaceSnapshots_base: Context.ServiceClass<WorkspaceSnapshots, "create-next-hydra/WorkspaceSnapshots", {
17
+ readonly capture: (identity: WorkspaceIdentity, files: readonly PreparedFile[], previous?: string) => Effect.Effect<string, SnapshotError>;
18
+ readonly diff: (identity: WorkspaceIdentity, commit: string, files: readonly PreparedFile[]) => Effect.Effect<SnapshotDiff, SnapshotError>;
19
+ }>;
20
+ export declare class WorkspaceSnapshots extends WorkspaceSnapshots_base {
21
+ static readonly layer: Layer.Layer<WorkspaceSnapshots, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
22
+ }
23
+ export {};
24
+ //# sourceMappingURL=workspace-snapshots.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-snapshots.d.ts","sourceRoot":"","sources":["../src/workspace-snapshots.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,OAAO,EACP,MAAM,EACN,UAAU,EACV,KAAK,EACL,IAAI,EAGL,MAAM,QAAQ,CAAC;AAChB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,EAAgB,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAE5E,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAGlE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAE9D,eAAO,MAAM,cAAc,GAAI,QAAQ,MAAM,KAAG,OAU3C,CAAC;AAEN,MAAM,MAAM,aAAa,GACrB,cAAc,GACd,mBAAmB,GACnB,aAAa,CAAC,aAAa,CAAC;AAChC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS;QACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,EAAE,CAAC;IACJ,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;;sBAKqB,CAChB,QAAQ,EAAE,iBAAiB,EAC3B,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,QAAQ,CAAC,EAAE,MAAM,KACd,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC;mBAC1B,CACb,QAAQ,EAAE,iBAAiB,EAC3B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,SAAS,YAAY,EAAE,KAC3B,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,aAAa,CAAC;;AAZnD,qBAAa,kBAAmB,SAAQ,uBAcG;IACzC,MAAM,CAAC,QAAQ,CAAC,KAAK,sHA0SnB;CACH"}