@projitect/cli-internals 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/applier.d.ts +12 -0
- package/dist/applier.d.ts.map +1 -0
- package/dist/applier.js +69 -0
- package/dist/applier.js.map +1 -0
- package/dist/commands/add.d.ts +34 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +64 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/build.d.ts +39 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +108 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dispatch.d.ts +5 -0
- package/dist/commands/dispatch.d.ts.map +1 -0
- package/dist/commands/dispatch.js +211 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/explain.d.ts +10 -0
- package/dist/commands/explain.d.ts.map +1 -0
- package/dist/commands/explain.js +46 -0
- package/dist/commands/explain.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +153 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/inspect-json.d.ts +51 -0
- package/dist/commands/inspect-json.d.ts.map +1 -0
- package/dist/commands/inspect-json.js +58 -0
- package/dist/commands/inspect-json.js.map +1 -0
- package/dist/commands/inspect.d.ts +41 -0
- package/dist/commands/inspect.d.ts.map +1 -0
- package/dist/commands/inspect.js +74 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/remodel.d.ts +22 -0
- package/dist/commands/remodel.d.ts.map +1 -0
- package/dist/commands/remodel.js +49 -0
- package/dist/commands/remodel.js.map +1 -0
- package/dist/config-cascade.d.ts +25 -0
- package/dist/config-cascade.d.ts.map +1 -0
- package/dist/config-cascade.js +45 -0
- package/dist/config-cascade.js.map +1 -0
- package/dist/differ.d.ts +31 -0
- package/dist/differ.d.ts.map +1 -0
- package/dist/differ.js +134 -0
- package/dist/differ.js.map +1 -0
- package/dist/edit-pjt.d.ts +19 -0
- package/dist/edit-pjt.d.ts.map +1 -0
- package/dist/edit-pjt.js +40 -0
- package/dist/edit-pjt.js.map +1 -0
- package/dist/filesystem-impl.d.ts +25 -0
- package/dist/filesystem-impl.d.ts.map +1 -0
- package/dist/filesystem-impl.js +153 -0
- package/dist/filesystem-impl.js.map +1 -0
- package/dist/git.d.ts +29 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +63 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/client.d.ts +11 -0
- package/dist/ipc/client.d.ts.map +1 -0
- package/dist/ipc/client.js +45 -0
- package/dist/ipc/client.js.map +1 -0
- package/dist/ipc/index.d.ts +5 -0
- package/dist/ipc/index.d.ts.map +1 -0
- package/dist/ipc/index.js +5 -0
- package/dist/ipc/index.js.map +1 -0
- package/dist/ipc/protocol.d.ts +251 -0
- package/dist/ipc/protocol.d.ts.map +1 -0
- package/dist/ipc/protocol.js +41 -0
- package/dist/ipc/protocol.js.map +1 -0
- package/dist/ipc/server.d.ts +9 -0
- package/dist/ipc/server.d.ts.map +1 -0
- package/dist/ipc/server.js +23 -0
- package/dist/ipc/server.js.map +1 -0
- package/dist/ipc/transport.d.ts +12 -0
- package/dist/ipc/transport.d.ts.map +1 -0
- package/dist/ipc/transport.js +19 -0
- package/dist/ipc/transport.js.map +1 -0
- package/dist/loader.d.ts +45 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +65 -0
- package/dist/loader.js.map +1 -0
- package/dist/lockfile.d.ts +22 -0
- package/dist/lockfile.d.ts.map +1 -0
- package/dist/lockfile.js +59 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/permissions.d.ts +20 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +86 -0
- package/dist/permissions.js.map +1 -0
- package/dist/plan.d.ts +278 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +212 -0
- package/dist/plan.js.map +1 -0
- package/dist/platform/file-system.d.ts +3 -0
- package/dist/platform/file-system.d.ts.map +1 -0
- package/dist/platform/file-system.js +109 -0
- package/dist/platform/file-system.js.map +1 -0
- package/dist/platform/index.d.ts +17 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +20 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/process-runner.d.ts +41 -0
- package/dist/platform/process-runner.d.ts.map +1 -0
- package/dist/platform/process-runner.js +120 -0
- package/dist/platform/process-runner.js.map +1 -0
- package/dist/platform/stdio.d.ts +9 -0
- package/dist/platform/stdio.d.ts.map +1 -0
- package/dist/platform/stdio.js +11 -0
- package/dist/platform/stdio.js.map +1 -0
- package/dist/platform/terminal.d.ts +3 -0
- package/dist/platform/terminal.d.ts.map +1 -0
- package/dist/platform/terminal.js +81 -0
- package/dist/platform/terminal.js.map +1 -0
- package/dist/pm.d.ts +36 -0
- package/dist/pm.d.ts.map +1 -0
- package/dist/pm.js +95 -0
- package/dist/pm.js.map +1 -0
- package/dist/post-apply.d.ts +20 -0
- package/dist/post-apply.d.ts.map +1 -0
- package/dist/post-apply.js +29 -0
- package/dist/post-apply.js.map +1 -0
- package/dist/project-file.d.ts +8 -0
- package/dist/project-file.d.ts.map +1 -0
- package/dist/project-file.js +12 -0
- package/dist/project-file.js.map +1 -0
- package/dist/region.d.ts +105 -0
- package/dist/region.d.ts.map +1 -0
- package/dist/region.js +94 -0
- package/dist/region.js.map +1 -0
- package/dist/remover.d.ts +16 -0
- package/dist/remover.d.ts.map +1 -0
- package/dist/remover.js +88 -0
- package/dist/remover.js.map +1 -0
- package/package.json +52 -3
- package/src/applier.ts +91 -0
- package/src/commands/add.ts +88 -0
- package/src/commands/build.ts +156 -0
- package/src/commands/dispatch.ts +345 -0
- package/src/commands/explain.ts +81 -0
- package/src/commands/init.ts +233 -0
- package/src/commands/inspect-json.ts +68 -0
- package/src/commands/inspect.ts +106 -0
- package/src/commands/remodel.ts +72 -0
- package/src/config-cascade.ts +60 -0
- package/src/differ.ts +188 -0
- package/src/edit-pjt.ts +56 -0
- package/src/filesystem-impl.ts +230 -0
- package/src/git.ts +87 -0
- package/src/index.ts +54 -0
- package/src/ipc/client.ts +108 -0
- package/src/ipc/index.ts +12 -0
- package/src/ipc/protocol.ts +48 -0
- package/src/ipc/server.ts +62 -0
- package/src/ipc/transport.ts +28 -0
- package/src/loader.ts +115 -0
- package/src/lockfile.ts +81 -0
- package/src/permissions.ts +106 -0
- package/src/plan.ts +459 -0
- package/src/platform/file-system.ts +155 -0
- package/src/platform/index.ts +26 -0
- package/src/platform/process-runner.ts +150 -0
- package/src/platform/stdio.ts +11 -0
- package/src/platform/terminal.ts +89 -0
- package/src/pm.ts +163 -0
- package/src/post-apply.ts +45 -0
- package/src/project-file.ts +15 -0
- package/src/region.ts +139 -0
- package/src/remover.ts +127 -0
- 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"
|