@projitect/cli-internals 0.0.0-stage → 1.0.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/dist/applier.d.ts +12 -0
  3. package/dist/applier.d.ts.map +1 -0
  4. package/dist/applier.js +69 -0
  5. package/dist/applier.js.map +1 -0
  6. package/dist/commands/add.d.ts +34 -0
  7. package/dist/commands/add.d.ts.map +1 -0
  8. package/dist/commands/add.js +64 -0
  9. package/dist/commands/add.js.map +1 -0
  10. package/dist/commands/build.d.ts +39 -0
  11. package/dist/commands/build.d.ts.map +1 -0
  12. package/dist/commands/build.js +108 -0
  13. package/dist/commands/build.js.map +1 -0
  14. package/dist/commands/dispatch.d.ts +5 -0
  15. package/dist/commands/dispatch.d.ts.map +1 -0
  16. package/dist/commands/dispatch.js +211 -0
  17. package/dist/commands/dispatch.js.map +1 -0
  18. package/dist/commands/explain.d.ts +10 -0
  19. package/dist/commands/explain.d.ts.map +1 -0
  20. package/dist/commands/explain.js +46 -0
  21. package/dist/commands/explain.js.map +1 -0
  22. package/dist/commands/init.d.ts +33 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +128 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/inspect-json.d.ts +51 -0
  27. package/dist/commands/inspect-json.d.ts.map +1 -0
  28. package/dist/commands/inspect-json.js +58 -0
  29. package/dist/commands/inspect-json.js.map +1 -0
  30. package/dist/commands/inspect.d.ts +41 -0
  31. package/dist/commands/inspect.d.ts.map +1 -0
  32. package/dist/commands/inspect.js +74 -0
  33. package/dist/commands/inspect.js.map +1 -0
  34. package/dist/commands/remodel.d.ts +22 -0
  35. package/dist/commands/remodel.d.ts.map +1 -0
  36. package/dist/commands/remodel.js +49 -0
  37. package/dist/commands/remodel.js.map +1 -0
  38. package/dist/config-cascade.d.ts +25 -0
  39. package/dist/config-cascade.d.ts.map +1 -0
  40. package/dist/config-cascade.js +45 -0
  41. package/dist/config-cascade.js.map +1 -0
  42. package/dist/differ.d.ts +31 -0
  43. package/dist/differ.d.ts.map +1 -0
  44. package/dist/differ.js +134 -0
  45. package/dist/differ.js.map +1 -0
  46. package/dist/edit-pjt.d.ts +19 -0
  47. package/dist/edit-pjt.d.ts.map +1 -0
  48. package/dist/edit-pjt.js +40 -0
  49. package/dist/edit-pjt.js.map +1 -0
  50. package/dist/filesystem-impl.d.ts +25 -0
  51. package/dist/filesystem-impl.d.ts.map +1 -0
  52. package/dist/filesystem-impl.js +153 -0
  53. package/dist/filesystem-impl.js.map +1 -0
  54. package/dist/git.d.ts +29 -0
  55. package/dist/git.d.ts.map +1 -0
  56. package/dist/git.js +63 -0
  57. package/dist/git.js.map +1 -0
  58. package/dist/index.d.ts +22 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +21 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/ipc/client.d.ts +11 -0
  63. package/dist/ipc/client.d.ts.map +1 -0
  64. package/dist/ipc/client.js +45 -0
  65. package/dist/ipc/client.js.map +1 -0
  66. package/dist/ipc/index.d.ts +5 -0
  67. package/dist/ipc/index.d.ts.map +1 -0
  68. package/dist/ipc/index.js +5 -0
  69. package/dist/ipc/index.js.map +1 -0
  70. package/dist/ipc/protocol.d.ts +251 -0
  71. package/dist/ipc/protocol.d.ts.map +1 -0
  72. package/dist/ipc/protocol.js +41 -0
  73. package/dist/ipc/protocol.js.map +1 -0
  74. package/dist/ipc/server.d.ts +9 -0
  75. package/dist/ipc/server.d.ts.map +1 -0
  76. package/dist/ipc/server.js +23 -0
  77. package/dist/ipc/server.js.map +1 -0
  78. package/dist/ipc/transport.d.ts +12 -0
  79. package/dist/ipc/transport.d.ts.map +1 -0
  80. package/dist/ipc/transport.js +19 -0
  81. package/dist/ipc/transport.js.map +1 -0
  82. package/dist/loader.d.ts +45 -0
  83. package/dist/loader.d.ts.map +1 -0
  84. package/dist/loader.js +65 -0
  85. package/dist/loader.js.map +1 -0
  86. package/dist/lockfile.d.ts +22 -0
  87. package/dist/lockfile.d.ts.map +1 -0
  88. package/dist/lockfile.js +59 -0
  89. package/dist/lockfile.js.map +1 -0
  90. package/dist/permissions.d.ts +20 -0
  91. package/dist/permissions.d.ts.map +1 -0
  92. package/dist/permissions.js +86 -0
  93. package/dist/permissions.js.map +1 -0
  94. package/dist/plan.d.ts +278 -0
  95. package/dist/plan.d.ts.map +1 -0
  96. package/dist/plan.js +212 -0
  97. package/dist/plan.js.map +1 -0
  98. package/dist/platform/file-system.d.ts +3 -0
  99. package/dist/platform/file-system.d.ts.map +1 -0
  100. package/dist/platform/file-system.js +109 -0
  101. package/dist/platform/file-system.js.map +1 -0
  102. package/dist/platform/index.d.ts +17 -0
  103. package/dist/platform/index.d.ts.map +1 -0
  104. package/dist/platform/index.js +20 -0
  105. package/dist/platform/index.js.map +1 -0
  106. package/dist/platform/process-runner.d.ts +41 -0
  107. package/dist/platform/process-runner.d.ts.map +1 -0
  108. package/dist/platform/process-runner.js +120 -0
  109. package/dist/platform/process-runner.js.map +1 -0
  110. package/dist/platform/stdio.d.ts +9 -0
  111. package/dist/platform/stdio.d.ts.map +1 -0
  112. package/dist/platform/stdio.js +11 -0
  113. package/dist/platform/stdio.js.map +1 -0
  114. package/dist/platform/terminal.d.ts +3 -0
  115. package/dist/platform/terminal.d.ts.map +1 -0
  116. package/dist/platform/terminal.js +81 -0
  117. package/dist/platform/terminal.js.map +1 -0
  118. package/dist/pm.d.ts +31 -0
  119. package/dist/pm.d.ts.map +1 -0
  120. package/dist/pm.js +91 -0
  121. package/dist/pm.js.map +1 -0
  122. package/dist/post-apply.d.ts +20 -0
  123. package/dist/post-apply.d.ts.map +1 -0
  124. package/dist/post-apply.js +29 -0
  125. package/dist/post-apply.js.map +1 -0
  126. package/dist/project-file.d.ts +8 -0
  127. package/dist/project-file.d.ts.map +1 -0
  128. package/dist/project-file.js +12 -0
  129. package/dist/project-file.js.map +1 -0
  130. package/dist/region.d.ts +105 -0
  131. package/dist/region.d.ts.map +1 -0
  132. package/dist/region.js +94 -0
  133. package/dist/region.js.map +1 -0
  134. package/dist/remover.d.ts +16 -0
  135. package/dist/remover.d.ts.map +1 -0
  136. package/dist/remover.js +88 -0
  137. package/dist/remover.js.map +1 -0
  138. package/package.json +52 -3
  139. package/src/applier.ts +91 -0
  140. package/src/commands/add.ts +88 -0
  141. package/src/commands/build.ts +156 -0
  142. package/src/commands/dispatch.ts +345 -0
  143. package/src/commands/explain.ts +81 -0
  144. package/src/commands/init.ts +193 -0
  145. package/src/commands/inspect-json.ts +68 -0
  146. package/src/commands/inspect.ts +106 -0
  147. package/src/commands/remodel.ts +72 -0
  148. package/src/config-cascade.ts +60 -0
  149. package/src/differ.ts +188 -0
  150. package/src/edit-pjt.ts +56 -0
  151. package/src/filesystem-impl.ts +230 -0
  152. package/src/git.ts +87 -0
  153. package/src/index.ts +54 -0
  154. package/src/ipc/client.ts +108 -0
  155. package/src/ipc/index.ts +12 -0
  156. package/src/ipc/protocol.ts +48 -0
  157. package/src/ipc/server.ts +62 -0
  158. package/src/ipc/transport.ts +28 -0
  159. package/src/loader.ts +115 -0
  160. package/src/lockfile.ts +81 -0
  161. package/src/permissions.ts +106 -0
  162. package/src/plan.ts +459 -0
  163. package/src/platform/file-system.ts +155 -0
  164. package/src/platform/index.ts +26 -0
  165. package/src/platform/process-runner.ts +150 -0
  166. package/src/platform/stdio.ts +11 -0
  167. package/src/platform/terminal.ts +89 -0
  168. package/src/pm.ts +131 -0
  169. package/src/post-apply.ts +45 -0
  170. package/src/project-file.ts +15 -0
  171. package/src/region.ts +139 -0
  172. package/src/remover.ts +127 -0
  173. package/README.md +0 -3
package/src/plan.ts ADDED
@@ -0,0 +1,459 @@
1
+ import { Array, Data, Effect, Match, Option, Record, Result, Schema, pipe } from "effect"
2
+ import { NonNullableX, PredicateX, RecordX, StructX } from "@nunofyobiz/effect-extras"
3
+ import { ChangeSet, Errors, PjtLock } from "@projitect/core"
4
+ import type { Blueprint, Permission } from "@projitect/core"
5
+ import { isDirectoryBlueprint } from "@projitect/blueprint"
6
+ import type { DirectoryBlueprint } from "@projitect/blueprint"
7
+ import { makeRealLayer } from "./filesystem-impl.js"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // File-level plan shape (after reducing all blueprints' ChangeSets per path)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type FilePlan = Data.TaggedEnum<{
14
+ readonly Region: {
15
+ readonly path: string
16
+ readonly commentPrefix: string
17
+ /** Optional closing delimiter for HTML/MDX comments. Empty for `#`/`//`-style regions. */
18
+ readonly commentSuffix: string
19
+ readonly regions: readonly { readonly ownerId: string; readonly content: string }[]
20
+ }
21
+ readonly Merge: {
22
+ readonly path: string
23
+ readonly value: unknown
24
+ /** Dotted-key → ownerId. Conflicts on identical keys are caught at reduction time. */
25
+ readonly ownership: ReadonlyMap<string, string>
26
+ }
27
+ readonly Owned: { readonly path: string; readonly ownerId: string; readonly content: string }
28
+ readonly Seed: { readonly path: string; readonly ownerId: string; readonly content: string }
29
+ }>
30
+ export const FilePlan = Data.taggedEnum<FilePlan>()
31
+
32
+ export type RegionPlanFile = Extract<FilePlan, { readonly _tag: "Region" }>
33
+ export type MergePlanFile = Extract<FilePlan, { readonly _tag: "Merge" }>
34
+ export type OwnedPlanFile = Extract<FilePlan, { readonly _tag: "Owned" }>
35
+ export type SeedPlanFile = Extract<FilePlan, { readonly _tag: "Seed" }>
36
+
37
+ /**
38
+ * A post-apply command declared by a blueprint's `Exec` op, with the declaring blueprint's
39
+ * permissions so the final phase can check it before anything starts. Listed in tree order, then
40
+ * op order; duplicates are kept here and collapsed when the phase runs.
41
+ */
42
+ export interface PlannedCommand {
43
+ readonly ownerId: string
44
+ readonly command: string
45
+ /** The op's check command; carried for `pjt inspect`, never run by build or remodel. */
46
+ readonly check?: string
47
+ readonly permissions: readonly Permission.Permission[]
48
+ }
49
+
50
+ export interface ProjectPlan {
51
+ readonly files: readonly FilePlan[]
52
+ readonly commands: readonly PlannedCommand[]
53
+ }
54
+
55
+ /** Every operation that targets a file path; `Exec` is the one that does not. */
56
+ type FileOperation = Exclude<ChangeSet.Operation, ChangeSet.ExecOp>
57
+
58
+ /**
59
+ * Per-blueprint lockfile entries, derived from the same ops that fed the plan. Written to
60
+ * `.pjt.lock` after every successful apply.
61
+ */
62
+ export type ByBlueprint = Readonly<Record<string, PjtLock.BlueprintLockEntry>>
63
+
64
+ /**
65
+ * Removal computed by diffing the previous lockfile against the current `byBlueprint` map.
66
+ * A blueprint that appears in the previous lockfile but not in the current map is "left the
67
+ * tree"; each of its prior operations becomes a removal target.
68
+ */
69
+ export interface UpgradeRecord {
70
+ readonly blueprintId: string
71
+ readonly from: string
72
+ readonly to: string
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Flatten the tree (directory wrapping, sequence implicit via array order)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ type RawTree = readonly (Blueprint.Blueprint | DirectoryBlueprint)[]
80
+
81
+ interface FlatBlueprint {
82
+ readonly blueprint: Blueprint.Blueprint
83
+ readonly directoryPrefix: string
84
+ }
85
+
86
+ const flattenTree = (tree: RawTree, prefix: string): readonly FlatBlueprint[] =>
87
+ Array.flatMap(tree, (node) =>
88
+ isDirectoryBlueprint(node)
89
+ ? flattenTree(node.children, prefix === "" ? node.name : `${prefix}/${node.name}`)
90
+ : [{ blueprint: node, directoryPrefix: prefix }],
91
+ )
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Run each blueprint's plan Effect with its own permission-gated FS layer,
95
+ // collect operations, and reduce them into a ProjectPlan + per-blueprint lockfile entries
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const rebasePath = (prefix: string, p: string): string => (prefix ? `${prefix}/${p}` : p)
99
+
100
+ const rebaseOp = (prefix: string, op: ChangeSet.Operation): ChangeSet.Operation => {
101
+ // An Exec op has no path, and its command always runs from the project root.
102
+ if (!prefix || op._tag === "Exec") {
103
+ return op
104
+ }
105
+ return {
106
+ ...op,
107
+ path: rebasePath(prefix, op.path),
108
+ ...(op._tag === "Owned"
109
+ ? StructX.defined(
110
+ "ownedDir",
111
+ op.ownedDir === undefined ? undefined : rebasePath(prefix, op.ownedDir),
112
+ )
113
+ : {}),
114
+ }
115
+ }
116
+
117
+ interface AttributedOp {
118
+ readonly blueprintId: string
119
+ readonly blueprintVersion: string
120
+ readonly permissions: readonly Permission.Permission[]
121
+ readonly op: ChangeSet.Operation
122
+ }
123
+
124
+ interface AttributedFileOp extends AttributedOp {
125
+ readonly op: FileOperation
126
+ }
127
+
128
+ interface AttributedExecOp extends AttributedOp {
129
+ readonly op: ChangeSet.ExecOp
130
+ }
131
+
132
+ const asFileOp = (attributed: AttributedOp): Result.Result<AttributedFileOp, void> =>
133
+ attributed.op._tag === "Exec"
134
+ ? Result.failVoid
135
+ : Result.succeed({ ...attributed, op: attributed.op })
136
+
137
+ const asExecOp = (attributed: AttributedOp): Result.Result<AttributedExecOp, void> =>
138
+ attributed.op._tag === "Exec"
139
+ ? Result.succeed({ ...attributed, op: attributed.op })
140
+ : Result.failVoid
141
+
142
+ /**
143
+ * Build the project plan by running every blueprint, collecting their ops, reducing them with
144
+ * conflict detection, and producing the per-blueprint lockfile entries.
145
+ */
146
+ export const buildPlan = (params: {
147
+ readonly tree: RawTree
148
+ readonly projectRoot: string
149
+ }): Effect.Effect<
150
+ { readonly plan: ProjectPlan; readonly byBlueprint: ByBlueprint },
151
+ Errors.ProjitectError
152
+ > => {
153
+ const { tree, projectRoot } = params
154
+ const flat = flattenTree(tree, "")
155
+
156
+ return Effect.gen(function* () {
157
+ const attributed = yield* Effect.forEach(flat, ({ blueprint, directoryPrefix }) =>
158
+ blueprint.plan.pipe(
159
+ Effect.provide(
160
+ makeRealLayer({
161
+ blueprintId: blueprint.id,
162
+ permissions: blueprint.permissions,
163
+ projectRoot,
164
+ }),
165
+ ),
166
+ Effect.map((changeSet) =>
167
+ Array.map(
168
+ changeSet.operations,
169
+ (op): AttributedOp => ({
170
+ blueprintId: blueprint.id,
171
+ blueprintVersion: blueprint.version,
172
+ permissions: blueprint.permissions,
173
+ op: rebaseOp(directoryPrefix, op),
174
+ }),
175
+ ),
176
+ ),
177
+ ),
178
+ ).pipe(Effect.map(Array.flatten))
179
+
180
+ const fileOps = Array.filterMap(attributed, asFileOp)
181
+ const files = yield* reduceOps(Array.map(fileOps, (a) => a.op))
182
+ const commands = Array.map(
183
+ Array.filterMap(attributed, asExecOp),
184
+ (a): PlannedCommand => ({
185
+ ownerId: a.op.ownerId,
186
+ command: a.op.command,
187
+ ...StructX.defined("check", a.op.check),
188
+ permissions: a.permissions,
189
+ }),
190
+ )
191
+ // Exec ops are not recorded in the lockfile: commands re-run on every remodel.
192
+ const byBlueprint = groupByBlueprint(fileOps)
193
+ return { plan: { files: files.files, commands }, byBlueprint }
194
+ })
195
+ }
196
+
197
+ /**
198
+ * Diff the previous lockfile against the current blueprint set. Returns:
199
+ * - `removals`: ops from blueprints present in the lockfile but absent from the current tree,
200
+ * plus owned files a still-present blueprint no longer ships (a new version dropping a file).
201
+ * The applier deletes the matching regions / merge keys / owned files in a follow-up step.
202
+ * - `upgrades`: blueprints present in both, with different versions. Informational only — the
203
+ * actual content is regenerated from the current blueprint, so applying handles upgrade
204
+ * content automatically; this list is for `inspect`'s human output.
205
+ */
206
+ export const diffLockfile = (params: {
207
+ readonly previous: PjtLock.PjtLock | null
208
+ readonly current: ByBlueprint
209
+ }): {
210
+ readonly removals: readonly PjtLock.LockOperation[]
211
+ readonly upgrades: readonly UpgradeRecord[]
212
+ } => {
213
+ const { previous, current } = params
214
+ return NonNullableX.match(previous, {
215
+ whenNullable: () => ({
216
+ removals: Array.empty<PjtLock.LockOperation>(),
217
+ upgrades: Array.empty<UpgradeRecord>(),
218
+ }),
219
+ whenNotNullable: (lock) => {
220
+ const entries = Record.toEntries(lock.blueprints)
221
+ const livePaths = new Set(
222
+ Array.flatMap(Record.values(current), (live) =>
223
+ Array.map(live.operations, (op) => op.path),
224
+ ),
225
+ )
226
+ const removals = Array.filter(
227
+ Array.flatMap(entries, ([id, prior]) =>
228
+ Option.match(Record.get(current, id), {
229
+ onNone: () => prior.operations,
230
+ onSome: (live) => droppedOwnedOps(prior, live),
231
+ }),
232
+ ),
233
+ // A path some blueprint still plans (e.g. reassigned to another owner) is not an orphan.
234
+ (op) => op._tag !== "Owned" || !livePaths.has(op.path),
235
+ )
236
+ const upgrades = Array.flatMap(entries, ([id, prior]) =>
237
+ Record.get(current, id).pipe(
238
+ Option.filter((live) => live.version !== prior.version),
239
+ Option.map((live) => [{ blueprintId: id, from: prior.version, to: live.version }]),
240
+ Option.getOrElse(() => Array.empty<UpgradeRecord>()),
241
+ ),
242
+ )
243
+ return { removals, upgrades }
244
+ },
245
+ })
246
+ }
247
+
248
+ /** Owned files the prior lock entry had but the live entry no longer plans. */
249
+ const droppedOwnedOps = (
250
+ prior: PjtLock.BlueprintLockEntry,
251
+ live: PjtLock.BlueprintLockEntry,
252
+ ): readonly PjtLock.LockOperation[] =>
253
+ Array.filter(
254
+ prior.operations,
255
+ (op) => op._tag === "Owned" && !Array.some(live.operations, (l) => l.path === op.path),
256
+ )
257
+
258
+ const groupByBlueprint = (attributed: readonly AttributedFileOp[]): ByBlueprint =>
259
+ Record.map(
260
+ Array.groupBy(attributed, (a) => a.blueprintId),
261
+ (group): PjtLock.BlueprintLockEntry => ({
262
+ version: Array.headNonEmpty(group).blueprintVersion,
263
+ operations: Array.map(group, (a) => toLockOp(a.op)),
264
+ }),
265
+ )
266
+
267
+ const toLockOp = (op: FileOperation): PjtLock.LockOperation =>
268
+ Match.valueTags(op, {
269
+ Region: (region) =>
270
+ PjtLock.LockRegionOp.make({
271
+ path: region.path,
272
+ ownerId: region.ownerId,
273
+ commentPrefix: region.commentPrefix,
274
+ // Only record commentSuffix when non-empty — empty/absent decode the same per the schema.
275
+ ...StructX.defined(
276
+ "commentSuffix",
277
+ PredicateX.isNonEmptyString(region.commentSuffix) ? region.commentSuffix : undefined,
278
+ ),
279
+ }),
280
+ Merge: (merge) => PjtLock.LockMergeOp.make({ path: merge.path, ownedKeys: merge.ownedKeys }),
281
+ Owned: (owned) =>
282
+ PjtLock.LockOwnedOp.make({
283
+ path: owned.path,
284
+ ownerId: owned.ownerId,
285
+ ...StructX.defined("ownedDir", owned.ownedDir),
286
+ }),
287
+ Seed: (seed) => PjtLock.LockSeedOp.make({ path: seed.path, ownerId: seed.ownerId }),
288
+ })
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Reduce raw ops into a ProjectPlan, detecting conflicts as we go
292
+ // ---------------------------------------------------------------------------
293
+
294
+ const reduceOps = (
295
+ ops: readonly FileOperation[],
296
+ ): Effect.Effect<Pick<ProjectPlan, "files">, Errors.PlanError> =>
297
+ pipe(
298
+ Array.groupBy(ops, (op) => op.path),
299
+ Record.toEntries,
300
+ Effect.forEach(([path, group]) => reduceGroup(path, group)),
301
+ Effect.map((files) => ({ files })),
302
+ )
303
+
304
+ /** Closing sentence of every plan-conflict message: the command-level way out. */
305
+ const conflictNextStep =
306
+ "Remove one of the conflicting blueprints from `.pjt.ts`, then run `pjt remodel`."
307
+
308
+ /**
309
+ * Reduce one path's operations into a single FilePlan. Every op on a path must share an
310
+ * ownership mode (`_tag`); a mixed group is a `pjt.plan.conflict-owned`.
311
+ */
312
+ const reduceGroup = (
313
+ path: string,
314
+ group: Array.NonEmptyReadonlyArray<FileOperation>,
315
+ ): Effect.Effect<FilePlan, Errors.PlanError> => {
316
+ const head = Array.headNonEmpty(group)
317
+ return Array.findFirst(group, (op) => op._tag !== head._tag).pipe(
318
+ Option.match({
319
+ onSome: (offender) =>
320
+ Effect.fail(
321
+ new Errors.PlanConflictOwned({
322
+ id: "pjt.plan.conflict-owned",
323
+ path,
324
+ ownerA: head.ownerId,
325
+ ownerB: offender.ownerId,
326
+ message: `Blueprints ${head.ownerId} (${head._tag}) and ${offender.ownerId} (${offender._tag}) both target ${path} with incompatible modes. ${conflictNextStep}`,
327
+ }),
328
+ ),
329
+ onNone: () =>
330
+ Match.value(head).pipe(
331
+ Match.tag("Region", () =>
332
+ reduceRegion(path, Array.filter(group, Schema.is(ChangeSet.RegionOp))),
333
+ ),
334
+ Match.tag("Merge", () =>
335
+ reduceMerge(path, Array.filter(group, Schema.is(ChangeSet.MergeOp))),
336
+ ),
337
+ Match.tag("Owned", () =>
338
+ reduceSole("owned", path, Array.filter(group, Schema.is(ChangeSet.OwnedOp))),
339
+ ),
340
+ Match.tag("Seed", () =>
341
+ reduceSole("seed", path, Array.filter(group, Schema.is(ChangeSet.SeedOp))),
342
+ ),
343
+ Match.exhaustive,
344
+ ),
345
+ }),
346
+ )
347
+ }
348
+
349
+ const duplicateOwner = (ops: readonly ChangeSet.RegionOp[]): Option.Option<string> =>
350
+ pipe(
351
+ Array.groupBy(ops, (op) => op.ownerId),
352
+ Record.toEntries,
353
+ Array.findFirst(([, claims]) => claims.length > 1),
354
+ Option.map(([ownerId]) => ownerId),
355
+ )
356
+
357
+ const reduceRegion = (
358
+ path: string,
359
+ ops: readonly ChangeSet.RegionOp[],
360
+ ): Effect.Effect<FilePlan, Errors.PlanError> =>
361
+ Option.match(duplicateOwner(ops), {
362
+ onSome: (ownerId) =>
363
+ Effect.fail(
364
+ new Errors.PlanConflictRegion({
365
+ id: "pjt.plan.conflict-region",
366
+ path,
367
+ ownerA: ownerId,
368
+ ownerB: ownerId,
369
+ message: `Multiple blueprints share ownerId ${ownerId} for region in ${path}. ${conflictNextStep}`,
370
+ }),
371
+ ),
372
+ onNone: () => {
373
+ // Last-write-wins on comment style — all ops on one file should use the same anyway.
374
+ const style = Array.last(ops).pipe(
375
+ Option.map((op) => ({
376
+ commentPrefix: op.commentPrefix,
377
+ commentSuffix: op.commentSuffix ?? "",
378
+ })),
379
+ Option.getOrElse(() => ({ commentPrefix: "#", commentSuffix: "" })),
380
+ )
381
+ return Effect.succeed(
382
+ FilePlan.Region({
383
+ path,
384
+ commentPrefix: style.commentPrefix,
385
+ commentSuffix: style.commentSuffix,
386
+ regions: Array.map(ops, (op) => ({ ownerId: op.ownerId, content: op.content })),
387
+ }),
388
+ )
389
+ },
390
+ })
391
+
392
+ /**
393
+ * Fold the merge ops' `ownedKeys` into a `key → ownerId` map, failing if two distinct owners claim
394
+ * the same key — the conflict-checked keyed merge behind merge-mode ownership.
395
+ */
396
+ const reconcileOwnership = (
397
+ path: string,
398
+ ops: readonly ChangeSet.MergeOp[],
399
+ ): Effect.Effect<ReadonlyMap<string, string>, Errors.PlanConflictMerge> =>
400
+ Effect.gen(function* () {
401
+ const ownership = new Map<string, string>()
402
+ for (const op of ops) {
403
+ for (const key of op.ownedKeys) {
404
+ const prior = ownership.get(key)
405
+ if (prior !== undefined && prior !== op.ownerId) {
406
+ return yield* new Errors.PlanConflictMerge({
407
+ id: "pjt.plan.conflict-merge",
408
+ path,
409
+ key,
410
+ ownerA: prior,
411
+ ownerB: op.ownerId,
412
+ message: `Blueprints ${prior} and ${op.ownerId} both claim key "${key}" in ${path}. ${conflictNextStep}`,
413
+ })
414
+ }
415
+ ownership.set(key, op.ownerId)
416
+ }
417
+ }
418
+ return ownership
419
+ })
420
+
421
+ const reduceMerge = (
422
+ path: string,
423
+ ops: readonly ChangeSet.MergeOp[],
424
+ ): Effect.Effect<FilePlan, Errors.PlanError> =>
425
+ Effect.gen(function* () {
426
+ const ownership = yield* reconcileOwnership(path, ops)
427
+ const value = RecordX.deepMergeReducer.combineAll(Array.map(ops, (op) => op.value))
428
+ return FilePlan.Merge({ path, value, ownership })
429
+ })
430
+
431
+ /** Owned and seed both demand a single owner per path. */
432
+ const reduceSole = (
433
+ kind: "owned" | "seed",
434
+ path: string,
435
+ ops: readonly (ChangeSet.OwnedOp | ChangeSet.SeedOp)[],
436
+ ): Effect.Effect<FilePlan, Errors.PlanError> => {
437
+ const [first, second] = ops
438
+ if (first !== undefined && second !== undefined) {
439
+ return Effect.fail(
440
+ new Errors.PlanConflictOwned({
441
+ id: "pjt.plan.conflict-owned",
442
+ path,
443
+ ownerA: first.ownerId,
444
+ ownerB: second.ownerId,
445
+ message:
446
+ kind === "owned"
447
+ ? `Multiple blueprints claim full ownership of ${path}. ${conflictNextStep}`
448
+ : `Multiple blueprints try to seed ${path}. ${conflictNextStep}`,
449
+ }),
450
+ )
451
+ }
452
+ return first === undefined
453
+ ? Effect.die(new Error(`unreachable: empty ${kind} group for ${path}`))
454
+ : Effect.succeed(
455
+ kind === "owned"
456
+ ? FilePlan.Owned({ path, ownerId: first.ownerId, content: first.content })
457
+ : FilePlan.Seed({ path, ownerId: first.ownerId, content: first.content }),
458
+ )
459
+ }
@@ -0,0 +1,155 @@
1
+ import * as nodeFs from "node:fs/promises"
2
+ import nodePath from "node:path"
3
+ import * as os from "node:os"
4
+ import * as PlatformError from "effect/PlatformError"
5
+ import { Effect, FileSystem, Layer, Option, Stream } from "effect"
6
+
7
+ /**
8
+ * Node-backed `FileSystem` Layer.
9
+ *
10
+ * Implements the common methods (`readFile`, `writeFile`, `access`, `readDirectory`,
11
+ * `makeDirectory`, `remove`, `stat`, `rename`, `symlink`, `link`, `chmod`, `chown`, `glob`,
12
+ * `realPath`, `readLink`, `copy`, `copyFile`, `truncate`, `utimes`, `makeTempFile`,
13
+ * `makeTempDirectory`) using `node:fs/promises`. The derived methods (`exists`,
14
+ * `readFileString`, `writeFileString`, `stream`, `sink`) come for free from `FileSystem.make`.
15
+ *
16
+ * The complex methods (`open` returning a low-level File handle, `watch` returning a Stream of
17
+ * watch events, the `*Scoped` temp helpers requiring Effect.Scope) are wired to `Effect.die`
18
+ * with a clear "not implemented in projitect v0.1" message. The CLI flow doesn't exercise them;
19
+ * if a future consumer needs them we'll fill them in.
20
+ */
21
+
22
+ const sysError = (method: string, cause: unknown): PlatformError.PlatformError => {
23
+ const message = cause instanceof Error ? cause.message : String(cause)
24
+ const code = (cause as { code?: string }).code
25
+ return PlatformError.systemError({
26
+ _tag: code === "ENOENT" ? "NotFound" : code === "EACCES" ? "PermissionDenied" : "Unknown",
27
+ module: "FileSystem",
28
+ method,
29
+ description: message,
30
+ })
31
+ }
32
+
33
+ const tryEffect = <A>(
34
+ method: string,
35
+ p: () => Promise<A>,
36
+ ): Effect.Effect<A, PlatformError.PlatformError> =>
37
+ Effect.tryPromise({ try: p, catch: (e) => sysError(method, e) })
38
+
39
+ // Compatibility shim: older Node versions (22.12) reject an array `exclude` and only accept a
40
+ // predicate. A predicate gives the same matches on every version, so we always pass one. Delete
41
+ // this once support for the Node versions that reject an array `exclude` is dropped, and pass the
42
+ // patterns straight through.
43
+ const excludeOption = (
44
+ patterns: readonly string[] | undefined,
45
+ ): ((path: string) => boolean) | undefined =>
46
+ patterns === undefined || patterns.length === 0
47
+ ? undefined
48
+ : (path) => patterns.some((pattern) => nodePath.matchesGlob(path, pattern))
49
+
50
+ const dieNotImpl = <A>(method: string): Effect.Effect<A, PlatformError.PlatformError> =>
51
+ Effect.die(
52
+ new Error(
53
+ `FileSystem.${method} is not implemented in projitect's Node Layer (v0.1). File an issue if you need it.`,
54
+ ),
55
+ )
56
+
57
+ const make = FileSystem.make({
58
+ access: (path, options) =>
59
+ tryEffect("access", () =>
60
+ nodeFs.access(path, options?.readable === true ? nodeFs.constants.R_OK : undefined),
61
+ ),
62
+ copy: (fromPath, toPath, _options) =>
63
+ tryEffect("copy", () => nodeFs.cp(fromPath, toPath, { recursive: true })),
64
+ copyFile: (fromPath, toPath) => tryEffect("copyFile", () => nodeFs.copyFile(fromPath, toPath)),
65
+ chmod: (path, mode) => tryEffect("chmod", () => nodeFs.chmod(path, mode)),
66
+ chown: (path, uid, gid) => tryEffect("chown", () => nodeFs.chown(path, uid, gid)),
67
+ glob: (pattern, options) =>
68
+ tryEffect("glob", async () => {
69
+ const paths: string[] = []
70
+ for await (const path of nodeFs.glob(pattern, {
71
+ cwd: options?.root,
72
+ exclude: excludeOption(options?.exclude),
73
+ })) {
74
+ paths.push(path)
75
+ }
76
+ return paths
77
+ }),
78
+ link: (fromPath, toPath) => tryEffect("link", () => nodeFs.link(fromPath, toPath)),
79
+ makeDirectory: (path, options) =>
80
+ tryEffect("makeDirectory", () =>
81
+ nodeFs.mkdir(path, { recursive: options?.recursive ?? false }).then(() => undefined),
82
+ ),
83
+ makeTempDirectory: (options) =>
84
+ tryEffect("makeTempDirectory", () =>
85
+ nodeFs.mkdtemp(nodePath.join(options?.directory ?? os.tmpdir(), options?.prefix ?? "pjt-")),
86
+ ),
87
+ makeTempDirectoryScoped: () => dieNotImpl("makeTempDirectoryScoped"),
88
+ makeTempFile: (options) =>
89
+ tryEffect("makeTempFile", async () => {
90
+ const direction = await nodeFs.mkdtemp(
91
+ nodePath.join(options?.directory ?? os.tmpdir(), options?.prefix ?? "pjt-"),
92
+ )
93
+ const file = nodePath.join(direction, "tmp")
94
+ await nodeFs.writeFile(file, "")
95
+ return file
96
+ }),
97
+ makeTempFileScoped: () => dieNotImpl("makeTempFileScoped"),
98
+ open: () => dieNotImpl("open"),
99
+ readDirectory: (path, _options) => tryEffect("readDirectory", () => nodeFs.readdir(path)),
100
+ readFile: (path) =>
101
+ tryEffect("readFile", async () => {
102
+ const buf = await nodeFs.readFile(path)
103
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
104
+ }),
105
+ readLink: (path) => tryEffect("readLink", () => nodeFs.readlink(path)),
106
+ realPath: (path) => tryEffect("realPath", () => nodeFs.realpath(path)),
107
+ remove: (path, options) =>
108
+ tryEffect("remove", () =>
109
+ nodeFs.rm(path, { recursive: options?.recursive ?? false, force: options?.force ?? false }),
110
+ ),
111
+ rename: (oldPath, newPath) => tryEffect("rename", () => nodeFs.rename(oldPath, newPath)),
112
+ stat: (path) =>
113
+ tryEffect("stat", async () => {
114
+ const s = await nodeFs.stat(path)
115
+ const type = s.isFile()
116
+ ? "File"
117
+ : s.isDirectory()
118
+ ? "Directory"
119
+ : s.isSymbolicLink()
120
+ ? "SymbolicLink"
121
+ : "Other"
122
+ return {
123
+ type,
124
+ mtime: Option.some(s.mtime),
125
+ atime: Option.some(s.atime),
126
+ birthtime: Option.some(s.birthtime),
127
+ dev: s.dev,
128
+ ino: Option.some(s.ino),
129
+ mode: s.mode,
130
+ nlink: Option.some(s.nlink),
131
+ uid: Option.some(s.uid),
132
+ gid: Option.some(s.gid),
133
+ rdev: Option.some(s.rdev),
134
+ size: FileSystem.Size(BigInt(s.size)),
135
+ blksize: Option.some(FileSystem.Size(BigInt(s.blksize))),
136
+ blocks: Option.some(s.blocks),
137
+ } as FileSystem.File.Info
138
+ }),
139
+ symlink: (fromPath, toPath) => tryEffect("symlink", () => nodeFs.symlink(fromPath, toPath)),
140
+ truncate: (path, length) =>
141
+ tryEffect("truncate", () => nodeFs.truncate(path, length === undefined ? 0 : Number(length))),
142
+ utimes: (path, atime, mtime) => tryEffect("utimes", () => nodeFs.utimes(path, atime, mtime)),
143
+ watch: () =>
144
+ Stream.die(
145
+ new Error(
146
+ "FileSystem.watch is not implemented in projitect's Node Layer (v0.1). File an issue if you need it.",
147
+ ),
148
+ ),
149
+ writeFile: (path, data, _options) => tryEffect("writeFile", () => nodeFs.writeFile(path, data)),
150
+ })
151
+
152
+ export const FileSystemLive: Layer.Layer<FileSystem.FileSystem> = Layer.succeed(
153
+ FileSystem.FileSystem,
154
+ make,
155
+ )
@@ -0,0 +1,26 @@
1
+ import { Layer, Path } from "effect"
2
+ import { FileSystemLive } from "./file-system.js"
3
+ import { StdioLive } from "./stdio.js"
4
+ import { TerminalLive } from "./terminal.js"
5
+
6
+ /**
7
+ * The full Node-backed platform Layer satisfying every service `effect/unstable/cli`'s
8
+ * `Command.Environment` declares. Provided by the `pjt` bin shim to `Command.run`.
9
+ *
10
+ * `Path.layer` ships with effect core (POSIX implementation; no IO). `FileSystem`, `Terminal`,
11
+ * and `Stdio` are hand-rolled in sibling files. `ChildProcessSpawner` is intentionally absent —
12
+ * `Command.run` does not consume it for our use cases, and our own shell-out needs (git status,
13
+ * package-manager add) use `node:child_process` directly via `git.ts` / `pm.ts` Effect wrappers.
14
+ * If a future need surfaces, this is where it'll land.
15
+ */
16
+ export const NodePlatformLive = Layer.mergeAll(FileSystemLive, TerminalLive, StdioLive, Path.layer)
17
+
18
+ export { FileSystemLive } from "./file-system.js"
19
+ export { StdioLive } from "./stdio.js"
20
+ export { TerminalLive } from "./terminal.js"
21
+ export {
22
+ runCommand,
23
+ nodeSpawner,
24
+ type ProcessSpawner,
25
+ type CommandOutput,
26
+ } from "./process-runner.js"