@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.
- 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 +33 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +128 -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 +31 -0
- package/dist/pm.d.ts.map +1 -0
- package/dist/pm.js +91 -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 +193 -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 +131 -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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { Effect, Layer } from "effect"
|
|
4
|
+
import { BlueprintFileSystem, Errors } from "@projitect/core"
|
|
5
|
+
import type { Permission, BlueprintFileSystemShape } from "@projitect/core"
|
|
6
|
+
import { isPermitted } from "./permissions.js"
|
|
7
|
+
import type { FsOp } from "./permissions.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the permission-gated `BlueprintFileSystem` Layer for one blueprint. The CLI provides a
|
|
11
|
+
* fresh layer per blueprint, baking in that blueprint's `id` and declared `permissions`. All FS
|
|
12
|
+
* paths get resolved relative to `projectRoot`; absolute paths or paths escaping `projectRoot`
|
|
13
|
+
* are rejected with `pjt.fs.permission-denied`.
|
|
14
|
+
*
|
|
15
|
+
* This is the **soft sandbox**. A worker-process replacement is a tracked v2 follow-up; this
|
|
16
|
+
* implementation enforces the same interface so the swap will be transparent to blueprint
|
|
17
|
+
* authors.
|
|
18
|
+
*/
|
|
19
|
+
export const makeRealLayer = (config: {
|
|
20
|
+
readonly blueprintId: string
|
|
21
|
+
readonly permissions: readonly Permission.Permission[]
|
|
22
|
+
readonly projectRoot: string
|
|
23
|
+
}): Layer.Layer<BlueprintFileSystem> =>
|
|
24
|
+
makeDiskLayer({
|
|
25
|
+
blueprintId: config.blueprintId,
|
|
26
|
+
projectRoot: config.projectRoot,
|
|
27
|
+
isAllowed: (op, p) => isPermitted(config.permissions, op, p),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The ungated project-root `BlueprintFileSystem` the engine itself uses to read and write the
|
|
32
|
+
* lockfile, diff the plan and apply changes. Same disk implementation as `makeRealLayer` with no
|
|
33
|
+
* permission check; paths that are absolute or escape `projectRoot` are still rejected.
|
|
34
|
+
*/
|
|
35
|
+
export const makeProjectRootLayer = (projectRoot: string): Layer.Layer<BlueprintFileSystem> =>
|
|
36
|
+
makeDiskLayer({ blueprintId: "pjt:engine", projectRoot, isAllowed: () => true })
|
|
37
|
+
|
|
38
|
+
const makeDiskLayer = (config: {
|
|
39
|
+
readonly blueprintId: string
|
|
40
|
+
readonly projectRoot: string
|
|
41
|
+
readonly isAllowed: (op: FsOp, p: string) => boolean
|
|
42
|
+
}): Layer.Layer<BlueprintFileSystem> => {
|
|
43
|
+
const { blueprintId, projectRoot, isAllowed } = config
|
|
44
|
+
const resolved = path.resolve(projectRoot)
|
|
45
|
+
|
|
46
|
+
const denied = (p: string, op: FsOp, message: string) =>
|
|
47
|
+
new Errors.FsPermissionDenied({
|
|
48
|
+
id: "pjt.fs.permission-denied",
|
|
49
|
+
path: p,
|
|
50
|
+
operation: op,
|
|
51
|
+
blueprintId,
|
|
52
|
+
message,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Real path of `full`, or — when it does not exist yet (a write or mkdir target) — of its nearest
|
|
56
|
+
// existing ancestor with the missing remainder re-appended. A dangling symlink is followed to its
|
|
57
|
+
// (missing) target rather than treated as a missing name, so a link to a not-yet-created file
|
|
58
|
+
// outside the project cannot slip through; `hops` bounds circular links.
|
|
59
|
+
const maxLinkHops = 40
|
|
60
|
+
const realPathOf = async (full: string, hops = 0): Promise<string> => {
|
|
61
|
+
try {
|
|
62
|
+
return await fs.realpath(full)
|
|
63
|
+
} catch {
|
|
64
|
+
const parent = path.dirname(full)
|
|
65
|
+
const stat = await fs.lstat(full).catch(() => undefined)
|
|
66
|
+
if (stat?.isSymbolicLink() === true && hops < maxLinkHops) {
|
|
67
|
+
const target = await fs.readlink(full)
|
|
68
|
+
return realPathOf(path.resolve(await realPathOf(parent, hops), target), hops + 1)
|
|
69
|
+
}
|
|
70
|
+
if (parent === full) {
|
|
71
|
+
return full
|
|
72
|
+
}
|
|
73
|
+
return path.join(await realPathOf(parent, hops), path.basename(full))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Accident-prevention, not hostile-code containment: the real path is resolved at check time, so
|
|
78
|
+
// a link swapped between the check and the FS call is not caught.
|
|
79
|
+
const resolvePath = (p: string, op: FsOp): Effect.Effect<string, Errors.FsPermissionDenied> =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
if (path.isAbsolute(p)) {
|
|
82
|
+
return yield* denied(p, op, `Absolute paths are not allowed in blueprints: ${p}`)
|
|
83
|
+
}
|
|
84
|
+
const full = path.resolve(resolved, p)
|
|
85
|
+
if (!full.startsWith(`${resolved}${path.sep}`) && full !== resolved) {
|
|
86
|
+
return yield* denied(p, op, `Path escapes project root: ${p}`)
|
|
87
|
+
}
|
|
88
|
+
if (!isAllowed(op, p)) {
|
|
89
|
+
return yield* denied(
|
|
90
|
+
p,
|
|
91
|
+
op,
|
|
92
|
+
`Blueprint ${blueprintId} did not declare ${op} permission for ${p}`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
const [realRoot, realFull] = yield* Effect.promise(() =>
|
|
96
|
+
Promise.all([realPathOf(resolved), realPathOf(full)]),
|
|
97
|
+
)
|
|
98
|
+
const realRelative = path.relative(realRoot, realFull).split(path.sep).join("/")
|
|
99
|
+
if (
|
|
100
|
+
realRelative === ".." ||
|
|
101
|
+
realRelative.startsWith("../") ||
|
|
102
|
+
path.isAbsolute(realRelative)
|
|
103
|
+
) {
|
|
104
|
+
return yield* denied(p, op, `Path resolves outside project root via symlink: ${p}`)
|
|
105
|
+
}
|
|
106
|
+
if (!isAllowed(op, realRelative)) {
|
|
107
|
+
return yield* denied(
|
|
108
|
+
p,
|
|
109
|
+
op,
|
|
110
|
+
`Blueprint ${blueprintId} did not declare ${op} permission for ${p} (resolves to ${realRelative})`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
return full
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const shape: BlueprintFileSystemShape = {
|
|
117
|
+
readFile: (p) =>
|
|
118
|
+
resolvePath(p, "read").pipe(
|
|
119
|
+
Effect.flatMap((full) =>
|
|
120
|
+
Effect.tryPromise({
|
|
121
|
+
try: () => fs.readFile(full, "utf8"),
|
|
122
|
+
catch: (e) =>
|
|
123
|
+
new Errors.FsReadFailed({
|
|
124
|
+
id: "pjt.fs.read-failed",
|
|
125
|
+
path: p,
|
|
126
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
127
|
+
message: `Failed to read ${p}`,
|
|
128
|
+
}),
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
writeFile: (p, content) =>
|
|
133
|
+
resolvePath(p, "write").pipe(
|
|
134
|
+
Effect.flatMap((full) =>
|
|
135
|
+
Effect.tryPromise({
|
|
136
|
+
try: async () => {
|
|
137
|
+
await fs.mkdir(path.dirname(full), { recursive: true })
|
|
138
|
+
await fs.writeFile(full, content, "utf8")
|
|
139
|
+
},
|
|
140
|
+
catch: (e) =>
|
|
141
|
+
new Errors.FsWriteFailed({
|
|
142
|
+
id: "pjt.fs.write-failed",
|
|
143
|
+
path: p,
|
|
144
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
145
|
+
message: `Failed to write ${p}`,
|
|
146
|
+
}),
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
exists: (p) =>
|
|
151
|
+
resolvePath(p, "exists").pipe(
|
|
152
|
+
Effect.flatMap((full) =>
|
|
153
|
+
Effect.tryPromise({
|
|
154
|
+
try: () =>
|
|
155
|
+
fs.access(full).then(
|
|
156
|
+
() => true,
|
|
157
|
+
() => false,
|
|
158
|
+
),
|
|
159
|
+
catch: () => false as never,
|
|
160
|
+
}).pipe(Effect.orElseSucceed(() => false)),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
// Non-recursive on purpose: removes a file or symlink, and fails on a directory so user content
|
|
164
|
+
// that now sits at the path survives.
|
|
165
|
+
remove: (p) =>
|
|
166
|
+
resolvePath(p, "remove").pipe(
|
|
167
|
+
Effect.flatMap((full) =>
|
|
168
|
+
Effect.tryPromise({
|
|
169
|
+
try: () => fs.rm(full, { force: true }),
|
|
170
|
+
catch: (e) =>
|
|
171
|
+
new Errors.FsWriteFailed({
|
|
172
|
+
id: "pjt.fs.write-failed",
|
|
173
|
+
path: p,
|
|
174
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
175
|
+
message: `Failed to remove ${p}`,
|
|
176
|
+
}),
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
// Non-recursive on purpose: `rmdir` is atomic about emptiness, so an entry created after the
|
|
181
|
+
// caller last looked is never deleted.
|
|
182
|
+
removeEmptyDir: (p) =>
|
|
183
|
+
resolvePath(p, "removeEmptyDir").pipe(
|
|
184
|
+
Effect.flatMap((full) =>
|
|
185
|
+
Effect.tryPromise({
|
|
186
|
+
try: () => fs.rmdir(full),
|
|
187
|
+
catch: (e) =>
|
|
188
|
+
new Errors.FsWriteFailed({
|
|
189
|
+
id: "pjt.fs.write-failed",
|
|
190
|
+
path: p,
|
|
191
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
192
|
+
message: `Failed to remove empty directory ${p}`,
|
|
193
|
+
}),
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
mkdir: (p) =>
|
|
198
|
+
resolvePath(p, "mkdir").pipe(
|
|
199
|
+
Effect.flatMap((full) =>
|
|
200
|
+
Effect.tryPromise({
|
|
201
|
+
try: () => fs.mkdir(full, { recursive: true }).then(() => undefined),
|
|
202
|
+
catch: (e) =>
|
|
203
|
+
new Errors.FsWriteFailed({
|
|
204
|
+
id: "pjt.fs.write-failed",
|
|
205
|
+
path: p,
|
|
206
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
207
|
+
message: `Failed to mkdir ${p}`,
|
|
208
|
+
}),
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
listDir: (p) =>
|
|
213
|
+
resolvePath(p, "listDir").pipe(
|
|
214
|
+
Effect.flatMap((full) =>
|
|
215
|
+
Effect.tryPromise({
|
|
216
|
+
try: () => fs.readdir(full),
|
|
217
|
+
catch: (e) =>
|
|
218
|
+
new Errors.FsReadFailed({
|
|
219
|
+
id: "pjt.fs.read-failed",
|
|
220
|
+
path: p,
|
|
221
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
222
|
+
message: `Failed to list ${p}`,
|
|
223
|
+
}),
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return Layer.succeed(BlueprintFileSystem, shape)
|
|
230
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import { Errors } from "@projitect/core"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns true if `<projectRoot>/.git` exists (file or directory). Used by `pjt init` and
|
|
9
|
+
* `pjt build --force` as a hard prerequisite.
|
|
10
|
+
*/
|
|
11
|
+
export const isGitRepo = (projectRoot: string): Effect.Effect<boolean> =>
|
|
12
|
+
Effect.promise(() =>
|
|
13
|
+
fs.access(path.join(projectRoot, ".git")).then(
|
|
14
|
+
() => true,
|
|
15
|
+
() => false,
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Result of `git status --porcelain`. `clean === true` means there are no uncommitted changes
|
|
21
|
+
* (including untracked files); `lines` is the parsed porcelain output for diagnostics.
|
|
22
|
+
*/
|
|
23
|
+
export interface GitStatus {
|
|
24
|
+
readonly clean: boolean
|
|
25
|
+
readonly lines: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Shell out to `git status --porcelain` and parse. Wraps process spawn failures (non-zero exit,
|
|
30
|
+
* git not installed) as `pjt.git.command-failed`. Callers ensure `.git/` exists first.
|
|
31
|
+
*/
|
|
32
|
+
export const gitStatus = (params: {
|
|
33
|
+
readonly projectRoot: string
|
|
34
|
+
}): Effect.Effect<GitStatus, Errors.GitCommandFailed> => {
|
|
35
|
+
const { projectRoot } = params
|
|
36
|
+
return Effect.tryPromise({
|
|
37
|
+
try: () =>
|
|
38
|
+
new Promise<GitStatus>((resolve, reject) => {
|
|
39
|
+
const child = spawn("git", ["status", "--porcelain"], { cwd: projectRoot })
|
|
40
|
+
let stdout = ""
|
|
41
|
+
let stderr = ""
|
|
42
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
43
|
+
stdout = stdout + chunk.toString("utf8")
|
|
44
|
+
})
|
|
45
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
46
|
+
stderr = stderr + chunk.toString("utf8")
|
|
47
|
+
})
|
|
48
|
+
child.on("error", reject)
|
|
49
|
+
child.on("close", (code) => {
|
|
50
|
+
if (code !== 0) {
|
|
51
|
+
reject(new Error(`git status exited with ${String(code)}: ${stderr.trim()}`))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const lines = stdout
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((l) => l.trim())
|
|
57
|
+
.filter((l) => l.length > 0)
|
|
58
|
+
resolve({ clean: lines.length === 0, lines })
|
|
59
|
+
})
|
|
60
|
+
}),
|
|
61
|
+
catch: (e) =>
|
|
62
|
+
new Errors.GitCommandFailed({
|
|
63
|
+
id: "pjt.git.command-failed",
|
|
64
|
+
command: "git status --porcelain",
|
|
65
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
66
|
+
message: `Failed to run \`git status --porcelain\` in ${projectRoot}`,
|
|
67
|
+
}),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Raise `pjt.git.not-a-repo` if `<projectRoot>/.git` is missing.
|
|
73
|
+
*/
|
|
74
|
+
export const ensureGitRepo = (params: {
|
|
75
|
+
readonly projectRoot: string
|
|
76
|
+
}): Effect.Effect<void, Errors.GitNotARepo> =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const ok = yield* isGitRepo(params.projectRoot)
|
|
79
|
+
if (ok) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
return yield* new Errors.GitNotARepo({
|
|
83
|
+
id: "pjt.git.not-a-repo",
|
|
84
|
+
projectRoot: params.projectRoot,
|
|
85
|
+
message: `No \`.git\` directory at ${params.projectRoot}. \`pjt build --force\` requires a git repository so destructive operations are recoverable. Run \`git init\` (or \`pjt init --yes\`), commit, then re-run.`,
|
|
86
|
+
})
|
|
87
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export { rootCommand, type RootCommand } from "./commands/dispatch.js"
|
|
2
|
+
export { inspect, type InspectResult } from "./commands/inspect.js"
|
|
3
|
+
export { remodel, type RemodelResult } from "./commands/remodel.js"
|
|
4
|
+
export { init, type InitResult } from "./commands/init.js"
|
|
5
|
+
export { explain } from "./commands/explain.js"
|
|
6
|
+
export { build } from "./commands/build.js"
|
|
7
|
+
export { add } from "./commands/add.js"
|
|
8
|
+
export { resolveConfig, parseEnv } from "./config-cascade.js"
|
|
9
|
+
export {
|
|
10
|
+
loadBlueprintFile,
|
|
11
|
+
pjt,
|
|
12
|
+
isEffectTree,
|
|
13
|
+
type ProjitectFile,
|
|
14
|
+
type BlueprintTree,
|
|
15
|
+
} from "./loader.js"
|
|
16
|
+
export {
|
|
17
|
+
buildPlan,
|
|
18
|
+
diffLockfile,
|
|
19
|
+
type ProjectPlan,
|
|
20
|
+
type FilePlan,
|
|
21
|
+
type ByBlueprint,
|
|
22
|
+
type UpgradeRecord,
|
|
23
|
+
} from "./plan.js"
|
|
24
|
+
export {
|
|
25
|
+
diffPlan,
|
|
26
|
+
renderPlanDiff,
|
|
27
|
+
renderInspectReport,
|
|
28
|
+
type PlanDiff,
|
|
29
|
+
type FileDiff,
|
|
30
|
+
} from "./differ.js"
|
|
31
|
+
export { applyPlan } from "./applier.js"
|
|
32
|
+
export { applyRemovals } from "./remover.js"
|
|
33
|
+
export { readLockfile, writeLockfile, blueprintIds } from "./lockfile.js"
|
|
34
|
+
export { makeRealLayer, makeProjectRootLayer } from "./filesystem-impl.js"
|
|
35
|
+
export {
|
|
36
|
+
NodePlatformLive,
|
|
37
|
+
FileSystemLive,
|
|
38
|
+
StdioLive,
|
|
39
|
+
TerminalLive,
|
|
40
|
+
runCommand,
|
|
41
|
+
nodeSpawner,
|
|
42
|
+
type ProcessSpawner,
|
|
43
|
+
type CommandOutput,
|
|
44
|
+
} from "./platform/index.js"
|
|
45
|
+
export { splice } from "./edit-pjt.js"
|
|
46
|
+
export {
|
|
47
|
+
detect as detectPackageManager,
|
|
48
|
+
installDev,
|
|
49
|
+
readProjitectMetadata,
|
|
50
|
+
type PackageManager,
|
|
51
|
+
} from "./pm.js"
|
|
52
|
+
export type { ProjitectPackageMetadata } from "@projitect/blueprint"
|
|
53
|
+
export { isGitRepo, gitStatus, ensureGitRepo } from "./git.js"
|
|
54
|
+
export * as Ipc from "./ipc/index.js"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Effect, Layer, Match } from "effect"
|
|
2
|
+
import { BlueprintFileSystem } from "@projitect/core"
|
|
3
|
+
import type { BlueprintFileSystemShape } from "@projitect/core"
|
|
4
|
+
import { decodeResponse, encodeRequest } from "./protocol.js"
|
|
5
|
+
import type { FsError, Request, Response } from "./protocol.js"
|
|
6
|
+
import type { Transport } from "./transport.js"
|
|
7
|
+
|
|
8
|
+
type Pending = (response: Response) => void
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Child side: a `BlueprintFileSystemShape` that forwards each call to the parent-side server over
|
|
12
|
+
* `transport` and decodes the reply, re-raising failures as the original typed error.
|
|
13
|
+
*/
|
|
14
|
+
export const makeClient = (transport: Transport): BlueprintFileSystemShape => {
|
|
15
|
+
const pending = new Map<number, Pending>()
|
|
16
|
+
let nextId = 0
|
|
17
|
+
|
|
18
|
+
transport.onMessage((frame) => {
|
|
19
|
+
Effect.runFork(
|
|
20
|
+
decodeResponse(frame).pipe(
|
|
21
|
+
Effect.tap((response) =>
|
|
22
|
+
Effect.sync(() => {
|
|
23
|
+
pending.get(response.id)?.(response)
|
|
24
|
+
pending.delete(response.id)
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
Effect.ignore,
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const call = (
|
|
33
|
+
build: (id: number) => Request,
|
|
34
|
+
): Effect.Effect<Exclude<Response, { readonly _tag: "Failure" }>, FsError> =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const id = nextId++
|
|
37
|
+
const encoded = yield* Effect.orDie(encodeRequest(build(id)))
|
|
38
|
+
const response = yield* Effect.callback<Response>((resume) => {
|
|
39
|
+
pending.set(id, (reply) => {
|
|
40
|
+
resume(Effect.succeed(reply))
|
|
41
|
+
})
|
|
42
|
+
transport.send(encoded)
|
|
43
|
+
return Effect.sync(() => pending.delete(id))
|
|
44
|
+
})
|
|
45
|
+
return yield* Match.value(response).pipe(
|
|
46
|
+
Match.tag("Failure", ({ error }) => Effect.fail(error)),
|
|
47
|
+
Match.orElse((ok) => Effect.succeed(ok)),
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// The server only emits a method's own failure tags; the other tag is unreachable by protocol.
|
|
52
|
+
const noRead = <A>(effect: Effect.Effect<A, FsError>) =>
|
|
53
|
+
Effect.catch(effect, (error) =>
|
|
54
|
+
Match.value(error).pipe(
|
|
55
|
+
Match.tag("FsReadFailed", (unreachable) => Effect.die(unreachable)),
|
|
56
|
+
Match.orElse((rest) => Effect.fail(rest)),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
const noWrite = <A>(effect: Effect.Effect<A, FsError>) =>
|
|
60
|
+
Effect.catch(effect, (error) =>
|
|
61
|
+
Match.value(error).pipe(
|
|
62
|
+
Match.tag("FsWriteFailed", (unreachable) => Effect.die(unreachable)),
|
|
63
|
+
Match.orElse((rest) => Effect.fail(rest)),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
const onlyDenied = <A>(effect: Effect.Effect<A, FsError>) =>
|
|
67
|
+
Effect.catch(effect, (error) =>
|
|
68
|
+
Match.value(error).pipe(
|
|
69
|
+
Match.tag("FsReadFailed", "FsWriteFailed", (unreachable) => Effect.die(unreachable)),
|
|
70
|
+
Match.orElse((rest) => Effect.fail(rest)),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
readFile: (path) =>
|
|
76
|
+
call((id) => ({ _tag: "ReadFile", id, path })).pipe(
|
|
77
|
+
Effect.flatMap((r) =>
|
|
78
|
+
r._tag === "ReadFileOk" ? Effect.succeed(r.value) : Effect.die(unexpected(r)),
|
|
79
|
+
),
|
|
80
|
+
noWrite,
|
|
81
|
+
),
|
|
82
|
+
writeFile: (path, content) =>
|
|
83
|
+
call((id) => ({ _tag: "WriteFile", id, path, content })).pipe(Effect.asVoid, noRead),
|
|
84
|
+
exists: (path) =>
|
|
85
|
+
call((id) => ({ _tag: "Exists", id, path })).pipe(
|
|
86
|
+
Effect.flatMap((r) =>
|
|
87
|
+
r._tag === "ExistsOk" ? Effect.succeed(r.value) : Effect.die(unexpected(r)),
|
|
88
|
+
),
|
|
89
|
+
onlyDenied,
|
|
90
|
+
),
|
|
91
|
+
remove: (path) => call((id) => ({ _tag: "Remove", id, path })).pipe(Effect.asVoid, noRead),
|
|
92
|
+
removeEmptyDir: (path) =>
|
|
93
|
+
call((id) => ({ _tag: "RemoveEmptyDir", id, path })).pipe(Effect.asVoid, noRead),
|
|
94
|
+
mkdir: (path) => call((id) => ({ _tag: "Mkdir", id, path })).pipe(Effect.asVoid, noRead),
|
|
95
|
+
listDir: (path) =>
|
|
96
|
+
call((id) => ({ _tag: "ListDir", id, path })).pipe(
|
|
97
|
+
Effect.flatMap((r) =>
|
|
98
|
+
r._tag === "ListDirOk" ? Effect.succeed(r.value) : Effect.die(unexpected(r)),
|
|
99
|
+
),
|
|
100
|
+
noWrite,
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const unexpected = (response: Response): string => `Unexpected IPC response: ${response._tag}`
|
|
106
|
+
|
|
107
|
+
export const makeClientLayer = (transport: Transport): Layer.Layer<BlueprintFileSystem> =>
|
|
108
|
+
Layer.succeed(BlueprintFileSystem, makeClient(transport))
|
package/src/ipc/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { makeClient, makeClientLayer } from "./client.js"
|
|
2
|
+
export { serve } from "./server.js"
|
|
3
|
+
export { makeInProcessPair, type Transport } from "./transport.js"
|
|
4
|
+
export {
|
|
5
|
+
Request,
|
|
6
|
+
Response,
|
|
7
|
+
FsError,
|
|
8
|
+
encodeRequest,
|
|
9
|
+
decodeRequest,
|
|
10
|
+
encodeResponse,
|
|
11
|
+
decodeResponse,
|
|
12
|
+
} from "./protocol.js"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import { Errors } from "@projitect/core"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wire frames for marshalling `BlueprintFileSystem` calls across a process boundary. Every frame
|
|
6
|
+
* carries a correlation `id`; one request form exists per service method. Failures travel as the
|
|
7
|
+
* existing `Schema.TaggedError` classes, so class, id and fields survive the round trip.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const Request = Schema.Union([
|
|
11
|
+
Schema.TaggedStruct("ReadFile", { id: Schema.Number, path: Schema.String }),
|
|
12
|
+
Schema.TaggedStruct("WriteFile", {
|
|
13
|
+
id: Schema.Number,
|
|
14
|
+
path: Schema.String,
|
|
15
|
+
content: Schema.String,
|
|
16
|
+
}),
|
|
17
|
+
Schema.TaggedStruct("Exists", { id: Schema.Number, path: Schema.String }),
|
|
18
|
+
Schema.TaggedStruct("Remove", { id: Schema.Number, path: Schema.String }),
|
|
19
|
+
Schema.TaggedStruct("RemoveEmptyDir", { id: Schema.Number, path: Schema.String }),
|
|
20
|
+
Schema.TaggedStruct("Mkdir", { id: Schema.Number, path: Schema.String }),
|
|
21
|
+
Schema.TaggedStruct("ListDir", { id: Schema.Number, path: Schema.String }),
|
|
22
|
+
])
|
|
23
|
+
export type Request = typeof Request.Type
|
|
24
|
+
|
|
25
|
+
export const FsError = Schema.Union([
|
|
26
|
+
Errors.FsPermissionDenied,
|
|
27
|
+
Errors.FsReadFailed,
|
|
28
|
+
Errors.FsWriteFailed,
|
|
29
|
+
])
|
|
30
|
+
export type FsError = typeof FsError.Type
|
|
31
|
+
|
|
32
|
+
export const Response = Schema.Union([
|
|
33
|
+
Schema.TaggedStruct("ReadFileOk", { id: Schema.Number, value: Schema.String }),
|
|
34
|
+
Schema.TaggedStruct("WriteFileOk", { id: Schema.Number }),
|
|
35
|
+
Schema.TaggedStruct("ExistsOk", { id: Schema.Number, value: Schema.Boolean }),
|
|
36
|
+
Schema.TaggedStruct("RemoveOk", { id: Schema.Number }),
|
|
37
|
+
Schema.TaggedStruct("RemoveEmptyDirOk", { id: Schema.Number }),
|
|
38
|
+
Schema.TaggedStruct("MkdirOk", { id: Schema.Number }),
|
|
39
|
+
Schema.TaggedStruct("ListDirOk", { id: Schema.Number, value: Schema.Array(Schema.String) }),
|
|
40
|
+
Schema.TaggedStruct("Failure", { id: Schema.Number, error: FsError }),
|
|
41
|
+
])
|
|
42
|
+
export type Response = typeof Response.Type
|
|
43
|
+
|
|
44
|
+
/** Frames cross the transport in their encoded (JSON-compatible) form. */
|
|
45
|
+
export const encodeRequest = Schema.encodeEffect(Request)
|
|
46
|
+
export const decodeRequest = Schema.decodeUnknownEffect(Request)
|
|
47
|
+
export const encodeResponse = Schema.encodeEffect(Response)
|
|
48
|
+
export const decodeResponse = Schema.decodeUnknownEffect(Response)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Effect, Match, pipe } from "effect"
|
|
2
|
+
import type { BlueprintFileSystemShape } from "@projitect/core"
|
|
3
|
+
import { decodeRequest, encodeResponse } from "./protocol.js"
|
|
4
|
+
import type { Request, Response } from "./protocol.js"
|
|
5
|
+
import type { Transport } from "./transport.js"
|
|
6
|
+
|
|
7
|
+
const respond = (shape: BlueprintFileSystemShape, request: Request): Effect.Effect<Response> => {
|
|
8
|
+
const failure = (error: Extract<Response, { readonly _tag: "Failure" }>["error"]): Response => ({
|
|
9
|
+
_tag: "Failure",
|
|
10
|
+
id: request.id,
|
|
11
|
+
error,
|
|
12
|
+
})
|
|
13
|
+
return pipe(
|
|
14
|
+
request,
|
|
15
|
+
Match.value,
|
|
16
|
+
Match.tag("ReadFile", ({ id, path }) =>
|
|
17
|
+
Effect.map(shape.readFile(path), (value): Response => ({ _tag: "ReadFileOk", id, value })),
|
|
18
|
+
),
|
|
19
|
+
Match.tag("WriteFile", ({ id, path, content }) =>
|
|
20
|
+
Effect.map(shape.writeFile(path, content), (): Response => ({ _tag: "WriteFileOk", id })),
|
|
21
|
+
),
|
|
22
|
+
Match.tag("Exists", ({ id, path }) =>
|
|
23
|
+
Effect.map(shape.exists(path), (value): Response => ({ _tag: "ExistsOk", id, value })),
|
|
24
|
+
),
|
|
25
|
+
Match.tag("Remove", ({ id, path }) =>
|
|
26
|
+
Effect.map(shape.remove(path), (): Response => ({ _tag: "RemoveOk", id })),
|
|
27
|
+
),
|
|
28
|
+
Match.tag("RemoveEmptyDir", ({ id, path }) =>
|
|
29
|
+
Effect.map(shape.removeEmptyDir(path), (): Response => ({ _tag: "RemoveEmptyDirOk", id })),
|
|
30
|
+
),
|
|
31
|
+
Match.tag("Mkdir", ({ id, path }) =>
|
|
32
|
+
Effect.map(shape.mkdir(path), (): Response => ({ _tag: "MkdirOk", id })),
|
|
33
|
+
),
|
|
34
|
+
Match.tag("ListDir", ({ id, path }) =>
|
|
35
|
+
Effect.map(shape.listDir(path), (value): Response => ({ _tag: "ListDirOk", id, value })),
|
|
36
|
+
),
|
|
37
|
+
Match.exhaustive,
|
|
38
|
+
Effect.catch((error) => Effect.succeed(failure(error))),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parent side: decode each request frame, run it against the (permission-gated) `shape` and send
|
|
44
|
+
* the encoded response back. Frames that fail to decode are dropped — the client only ever sends
|
|
45
|
+
* well-formed frames, and a correlation id cannot be recovered from a malformed one.
|
|
46
|
+
*/
|
|
47
|
+
export const serve = (transport: Transport, shape: BlueprintFileSystemShape): void => {
|
|
48
|
+
transport.onMessage((frame) => {
|
|
49
|
+
Effect.runFork(
|
|
50
|
+
decodeRequest(frame).pipe(
|
|
51
|
+
Effect.flatMap((request) => respond(shape, request)),
|
|
52
|
+
Effect.flatMap(encodeResponse),
|
|
53
|
+
Effect.tap((encoded) =>
|
|
54
|
+
Effect.sync(() => {
|
|
55
|
+
transport.send(encoded)
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
Effect.ignore,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal message channel between the parent-side server and the child-side client. Frames are
|
|
3
|
+
* JSON-compatible values, so any process IPC channel can implement it; `makeInProcessPair` backs
|
|
4
|
+
* the tests.
|
|
5
|
+
*/
|
|
6
|
+
export interface Transport {
|
|
7
|
+
readonly send: (frame: unknown) => void
|
|
8
|
+
readonly onMessage: (handler: (frame: unknown) => void) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Two connected transports: whatever one end sends, the other end's handler receives. */
|
|
12
|
+
export const makeInProcessPair = (): readonly [Transport, Transport] => {
|
|
13
|
+
const handlers: [((frame: unknown) => void)[], ((frame: unknown) => void)[]] = [[], []]
|
|
14
|
+
const endpoint = (self: 0 | 1): Transport => ({
|
|
15
|
+
send: (frame) => {
|
|
16
|
+
const peer = self === 0 ? 1 : 0
|
|
17
|
+
queueMicrotask(() => {
|
|
18
|
+
for (const handler of handlers[peer]) {
|
|
19
|
+
handler(frame)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
},
|
|
23
|
+
onMessage: (handler) => {
|
|
24
|
+
handlers[self].push(handler)
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
return [endpoint(0), endpoint(1)]
|
|
28
|
+
}
|