@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
@@ -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))
@@ -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
+ }