@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.
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 +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +153 -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 +36 -0
  119. package/dist/pm.d.ts.map +1 -0
  120. package/dist/pm.js +95 -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 +233 -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 +163 -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,150 @@
1
+ import { spawn } from "node:child_process"
2
+ import { Effect, Option } from "effect"
3
+ import { Errors } from "@projitect/core"
4
+ import type { Permission } from "@projitect/core"
5
+ import { isCommandPermitted } from "../permissions.js"
6
+
7
+ export interface CommandOutput {
8
+ readonly stdout: string
9
+ readonly stderr: string
10
+ }
11
+
12
+ /**
13
+ * Starts a process and resolves with its exit code and captured output. `exitCode` is `undefined`
14
+ * only when the process never started (spawn failure); the failure carries the reason. Injected
15
+ * into `runCommand` so tests can use a fake instead of a real process.
16
+ */
17
+ export type ProcessSpawner = (
18
+ program: string,
19
+ args: readonly string[],
20
+ cwd: string,
21
+ ) => Effect.Effect<CommandOutput & { readonly exitCode: number }, string>
22
+
23
+ /** Real spawner: `node:child_process` without a shell, so `&&`, `;` and pipes are plain arguments. */
24
+ export const nodeSpawner: ProcessSpawner = (program, args, cwd) =>
25
+ Effect.callback((resume) => {
26
+ const child = spawn(program, args, { cwd, shell: false })
27
+ let stdout = ""
28
+ let stderr = ""
29
+ child.stdout.on("data", (chunk: Buffer) => {
30
+ stdout = stdout + chunk.toString("utf8")
31
+ })
32
+ child.stderr.on("data", (chunk: Buffer) => {
33
+ stderr = stderr + chunk.toString("utf8")
34
+ })
35
+ child.on("error", (error) => {
36
+ resume(Effect.fail(error.message))
37
+ })
38
+ child.on("close", (code) => {
39
+ resume(Effect.succeed({ exitCode: code ?? 1, stdout, stderr }))
40
+ })
41
+ // On interruption, terminate the child we own so it doesn't outlive the fiber.
42
+ return Effect.sync(() => {
43
+ child.kill("SIGKILL")
44
+ })
45
+ })
46
+
47
+ /**
48
+ * Argv contract (no shell): arguments are separated by unquoted whitespace; `'...'` and `"..."`
49
+ * group characters (including whitespace) into one argument and the quotes are dropped. There are
50
+ * no escapes, globs, variables or operators — everything else is literal. An unterminated quote,
51
+ * or a command with no program, is unsupported and yields `None`. The permission is matched
52
+ * against the raw string, so exactly what was declared is what gets parsed and run.
53
+ */
54
+ export const parseArgv = (command: string): Option.Option<readonly string[]> => {
55
+ const argv: string[] = []
56
+ let current: string | undefined
57
+ let quote: string | undefined
58
+ for (const ch of command) {
59
+ if (quote !== undefined) {
60
+ if (ch === quote) {
61
+ quote = undefined
62
+ } else {
63
+ current = (current ?? "") + ch
64
+ }
65
+ } else if (ch === "'" || ch === '"') {
66
+ quote = ch
67
+ current = current ?? ""
68
+ } else if (/\s/u.test(ch)) {
69
+ if (current !== undefined) {
70
+ argv.push(current)
71
+ current = undefined
72
+ }
73
+ } else {
74
+ current = (current ?? "") + ch
75
+ }
76
+ }
77
+ if (quote !== undefined) {
78
+ return Option.none()
79
+ }
80
+ if (current !== undefined) {
81
+ argv.push(current)
82
+ }
83
+ return argv[0] === undefined || argv[0] === "" ? Option.none() : Option.some(argv)
84
+ }
85
+
86
+ /** The refusal for a command no declared `exec` permission names exactly. */
87
+ export const commandNotPermitted = (command: string): Errors.CommandNotPermitted =>
88
+ new Errors.CommandNotPermitted({
89
+ id: "pjt.exec.command-not-permitted",
90
+ command,
91
+ message: `No declared exec permission covers \`${command}\``,
92
+ })
93
+
94
+ /**
95
+ * Runs `command` (parsed per `parseArgv`) in `cwd`, after checking it against
96
+ * the declared `exec` permissions. A command no permission names exactly fails with
97
+ * `CommandNotPermitted` and the spawner is never called; a non-zero exit or spawn failure fails
98
+ * with `CommandFailed` carrying the exit code and captured output; so does a command `parseArgv`
99
+ * rejects (the spawner is not called).
100
+ */
101
+ export const runCommand = (params: {
102
+ readonly permissions: readonly Permission.Permission[]
103
+ readonly command: string
104
+ readonly cwd: string
105
+ readonly spawner: ProcessSpawner
106
+ }): Effect.Effect<CommandOutput, Errors.CommandFailed | Errors.CommandNotPermitted> => {
107
+ const { permissions, command, cwd, spawner } = params
108
+ return Effect.gen(function* () {
109
+ if (!isCommandPermitted(permissions, command)) {
110
+ return yield* commandNotPermitted(command)
111
+ }
112
+ const [program = "", ...args] = yield* Option.match(parseArgv(command), {
113
+ onNone: () =>
114
+ Effect.fail(
115
+ new Errors.CommandFailed({
116
+ id: "pjt.exec.command-failed",
117
+ command,
118
+ stdout: "",
119
+ stderr: "",
120
+ cause: "unsupported command syntax: empty program or unterminated quote",
121
+ message: `Cannot run \`${command}\`: empty program or unterminated quote`,
122
+ }),
123
+ ),
124
+ onSome: Effect.succeed,
125
+ })
126
+ const result = yield* Effect.mapError(
127
+ spawner(program, args, cwd),
128
+ (cause) =>
129
+ new Errors.CommandFailed({
130
+ id: "pjt.exec.command-failed",
131
+ command,
132
+ stdout: "",
133
+ stderr: "",
134
+ cause,
135
+ message: `Failed to start \`${command}\`: ${cause}`,
136
+ }),
137
+ )
138
+ if (result.exitCode !== 0) {
139
+ return yield* new Errors.CommandFailed({
140
+ id: "pjt.exec.command-failed",
141
+ command,
142
+ exitCode: result.exitCode,
143
+ stdout: result.stdout,
144
+ stderr: result.stderr,
145
+ message: `\`${command}\` exited with code ${String(result.exitCode)}`,
146
+ })
147
+ }
148
+ return { stdout: result.stdout, stderr: result.stderr }
149
+ })
150
+ }
@@ -0,0 +1,11 @@
1
+ import { Effect, Stdio } from "effect"
2
+
3
+ /**
4
+ * Node-backed `Stdio` Layer. We only need `args` to satisfy `Command.run`'s argv source;
5
+ * the stdout/stderr sinks and stdin stream from `layerTest` (drain sinks + empty stream) are
6
+ * adequate because all our output goes through `Terminal.display` and we don't currently read
7
+ * from stdin outside the `Prompt` module (which uses `Terminal.readLine` / `Terminal.readInput`).
8
+ */
9
+ export const StdioLive = Stdio.layerTest({
10
+ args: Effect.succeed(process.argv.slice(2)),
11
+ })
@@ -0,0 +1,89 @@
1
+ import * as keypress from "node:readline"
2
+ import * as readline from "node:readline/promises"
3
+ import { Effect, Layer, Option, Queue, Terminal } from "effect"
4
+ import type { Cause } from "effect"
5
+ import * as PlatformError from "effect/PlatformError"
6
+
7
+ /**
8
+ * Node-backed `Terminal` Layer.
9
+ *
10
+ * - `columns` / `rows` read from `process.stdout` (fall back to sensible defaults when not a TTY)
11
+ * - `display(text)` writes to `process.stdout` synchronously
12
+ * - `readLine` uses `node:readline/promises` so `Prompt.text` and `Prompt.confirm` work
13
+ * - `readInput` (the low-level keypress stream used by `Prompt.select` / `Prompt.multiSelect`)
14
+ * puts a TTY stdin into raw mode for the lifetime of the enclosing scope and queues one
15
+ * `UserInput` per keypress. Raw mode means Ctrl-C arrives as a keypress rather than as SIGINT,
16
+ * so Ctrl-C ends the queue and the prompt fails with `QuitError`.
17
+ */
18
+ const readInput: Terminal.Terminal["readInput"] = Effect.gen(function* () {
19
+ const queue = yield* Queue.unbounded<Terminal.UserInput, Cause.Done>()
20
+ yield* Effect.acquireRelease(
21
+ Effect.sync(() => {
22
+ const onKeypress = (input: string | undefined, key: keypress.Key | undefined): void => {
23
+ Queue.offerUnsafe(queue, {
24
+ input: Option.fromNullishOr(input),
25
+ key: {
26
+ name: key?.name ?? "",
27
+ ctrl: key?.ctrl ?? false,
28
+ meta: key?.meta ?? false,
29
+ shift: key?.shift ?? false,
30
+ },
31
+ })
32
+ if (key?.ctrl === true && key.name === "c") {
33
+ // Raw mode swallows SIGINT; ending the queue makes the prompt fail with `QuitError`.
34
+ Queue.endUnsafe(queue)
35
+ }
36
+ }
37
+ keypress.emitKeypressEvents(process.stdin)
38
+ if (process.stdin.isTTY) {
39
+ process.stdin.setRawMode(true)
40
+ }
41
+ process.stdin.on("keypress", onKeypress)
42
+ process.stdin.resume()
43
+ return onKeypress
44
+ }),
45
+ (onKeypress) =>
46
+ Effect.sync(() => {
47
+ process.stdin.off("keypress", onKeypress)
48
+ if (process.stdin.isTTY) {
49
+ process.stdin.setRawMode(false)
50
+ }
51
+ process.stdin.pause()
52
+ }),
53
+ )
54
+ return queue
55
+ })
56
+
57
+ const make = Terminal.make({
58
+ // @types/node types columns/rows as non-nullable, but they're undefined on non-TTY stdout.
59
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition */
60
+ columns: Effect.sync(() => process.stdout.columns ?? 80),
61
+ rows: Effect.sync(() => process.stdout.rows ?? 24),
62
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
63
+ display: (text) =>
64
+ Effect.try({
65
+ try: () => {
66
+ process.stdout.write(text)
67
+ },
68
+ catch: (cause) =>
69
+ PlatformError.badArgument({
70
+ module: "Terminal",
71
+ method: "display",
72
+ description: cause instanceof Error ? cause.message : String(cause),
73
+ }),
74
+ }),
75
+ readLine: Effect.tryPromise({
76
+ try: async () => {
77
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
78
+ try {
79
+ return await rl.question("")
80
+ } finally {
81
+ rl.close()
82
+ }
83
+ },
84
+ catch: () => new Terminal.QuitError({}),
85
+ }),
86
+ readInput,
87
+ })
88
+
89
+ export const TerminalLive: Layer.Layer<Terminal.Terminal> = Layer.succeed(Terminal.Terminal, make)
package/src/pm.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import { spawn } from "node:child_process"
4
+ import { Array, Effect, Match, Option, Predicate } from "effect"
5
+ import { PredicateX, StructX } from "@nunofyobiz/effect-extras"
6
+ import { Errors } from "@projitect/core"
7
+ import type { ProjitectPackageMetadata } from "@projitect/blueprint"
8
+
9
+ export type PackageManager = "pnpm" | "npm" | "yarn" | "bun"
10
+
11
+ const LOCKFILES: readonly (readonly [string, PackageManager])[] = [
12
+ ["pnpm-lock.yaml", "pnpm"],
13
+ ["bun.lockb", "bun"],
14
+ ["yarn.lock", "yarn"],
15
+ ["package-lock.json", "npm"],
16
+ ]
17
+
18
+ /**
19
+ * Detect the user's package manager from known lockfiles in `projectRoot`.
20
+ * Without a lockfile, uses the invoking package manager's user-agent, then `npm`.
21
+ * Used by `pjt add` and first-run bootstrap to select the install command.
22
+ */
23
+ export const detect = (params: {
24
+ readonly projectRoot: string
25
+ }): Effect.Effect<PackageManager, Errors.PmNotDetected> =>
26
+ Effect.map(
27
+ Effect.findFirst(LOCKFILES, ([filename]) =>
28
+ Effect.promise(() =>
29
+ fs.access(path.join(params.projectRoot, filename)).then(
30
+ () => true,
31
+ () => false,
32
+ ),
33
+ ),
34
+ ),
35
+ Option.match({
36
+ onSome: ([, pm]) => pm,
37
+ onNone: (): PackageManager =>
38
+ Match.value(process.env["npm_config_user_agent"] ?? "").pipe(
39
+ Match.when(
40
+ (agent) => agent.startsWith("pnpm/"),
41
+ () => "pnpm" as const,
42
+ ),
43
+ Match.when(
44
+ (agent) => agent.startsWith("yarn/"),
45
+ () => "yarn" as const,
46
+ ),
47
+ Match.when(
48
+ (agent) => agent.startsWith("bun/"),
49
+ () => "bun" as const,
50
+ ),
51
+ Match.orElse(() => "npm" as const),
52
+ ),
53
+ }),
54
+ )
55
+
56
+ /**
57
+ * Run `<pm> add -D <pkg>` in `projectRoot`. Wraps spawn failures (non-zero exit, PM not
58
+ * installed) as `pjt.pm.install-failed`.
59
+ */
60
+ export const installDev = (params: {
61
+ readonly projectRoot: string
62
+ readonly pm: PackageManager
63
+ readonly pkg: string
64
+ }): Effect.Effect<void, Errors.PmInstallFailed> =>
65
+ runInstallation({ ...params, args: installArgs(params.pm, params.pkg) })
66
+
67
+ /** Synchronize the lockfile after bootstrap has applied its managed manifest ranges. */
68
+ export const installProject = (params: {
69
+ readonly projectRoot: string
70
+ readonly pm: PackageManager
71
+ }): Effect.Effect<void, Errors.PmInstallFailed> =>
72
+ runInstallation({
73
+ ...params,
74
+ pkg: "project dependencies",
75
+ args: params.pm === "pnpm" ? ["install", "--no-frozen-lockfile"] : ["install"],
76
+ })
77
+
78
+ const runInstallation = (params: {
79
+ readonly projectRoot: string
80
+ readonly pm: PackageManager
81
+ readonly pkg: string
82
+ readonly args: readonly string[]
83
+ }): Effect.Effect<void, Errors.PmInstallFailed> =>
84
+ Effect.tryPromise({
85
+ try: () =>
86
+ new Promise<void>((resolve, reject) => {
87
+ const child = spawn(params.pm, params.args, {
88
+ cwd: params.projectRoot,
89
+ stdio: "inherit",
90
+ })
91
+ child.on("error", reject)
92
+ child.on("close", (code) => {
93
+ if (code === 0) {
94
+ resolve()
95
+ } else {
96
+ reject(new Error(`${params.pm} ${params.args.join(" ")} exited with ${String(code)}`))
97
+ }
98
+ })
99
+ }),
100
+ catch: (e) =>
101
+ new Errors.PmInstallFailed({
102
+ id: "pjt.pm.install-failed",
103
+ packageManager: params.pm,
104
+ pkg: params.pkg,
105
+ cause: e instanceof Error ? e.message : String(e),
106
+ message: `Failed to install \`${params.pkg}\` via ${params.pm}`,
107
+ }),
108
+ })
109
+
110
+ const installArgs = (pm: PackageManager, pkg: string): readonly string[] =>
111
+ Match.value(pm).pipe(
112
+ Match.whenOr("pnpm", "yarn", () => ["add", "-D", pkg]),
113
+ Match.when("bun", () => ["add", "-d", pkg]),
114
+ Match.when("npm", () => ["install", "-D", pkg]),
115
+ Match.exhaustive,
116
+ )
117
+
118
+ /**
119
+ * Read the installed package's `package.json` and return its `"projitect"` metadata, if any.
120
+ * Returns null if the package doesn't have the field; `pjt add` falls back to install-only
121
+ * behavior with a printed instruction snippet.
122
+ */
123
+ export const readProjitectMetadata = (params: {
124
+ readonly projectRoot: string
125
+ readonly pkg: string
126
+ }): Effect.Effect<ProjitectPackageMetadata | null> =>
127
+ Effect.promise(async () => {
128
+ const pkgJsonPath = path.join(params.projectRoot, "node_modules", params.pkg, "package.json")
129
+ try {
130
+ const parsed: unknown = JSON.parse(await fs.readFile(pkgJsonPath, "utf8"))
131
+ if (!PredicateX.unsafeIsRecord(parsed)) {
132
+ return null
133
+ }
134
+ const meta = parsed["projitect"]
135
+ if (!PredicateX.unsafeIsRecord(meta)) {
136
+ return null
137
+ }
138
+ const type = meta["type"]
139
+ const importStmt = meta["import"]
140
+ if (type !== "blueprint" && type !== "blueprint-set") {
141
+ return null
142
+ }
143
+ if (!Predicate.isString(importStmt)) {
144
+ return null
145
+ }
146
+ const sections = meta["sections"]
147
+ return {
148
+ type,
149
+ import: importStmt,
150
+ ...StructX.defined("call", Predicate.isString(meta["call"]) ? meta["call"] : undefined),
151
+ ...StructX.defined(
152
+ "callTemplate",
153
+ Predicate.isString(meta["callTemplate"]) ? meta["callTemplate"] : undefined,
154
+ ),
155
+ ...StructX.defined(
156
+ "sections",
157
+ Array.isArray(sections) ? Array.filter(sections, Predicate.isString) : undefined,
158
+ ),
159
+ }
160
+ } catch {
161
+ return null
162
+ }
163
+ })
@@ -0,0 +1,45 @@
1
+ import { Array, Effect, Option } from "effect"
2
+ import type { Errors } from "@projitect/core"
3
+ import { isCommandPermitted } from "./permissions.js"
4
+ import type { PlannedCommand } from "./plan.js"
5
+ import { commandNotPermitted, nodeSpawner, runCommand } from "./platform/process-runner.js"
6
+ import type { ProcessSpawner } from "./platform/process-runner.js"
7
+
8
+ /**
9
+ * The final phase of `build` and `remodel`: run the plan's `Exec` commands once every file
10
+ * operation has succeeded.
11
+ *
12
+ * Every declaring blueprint's permissions must cover its own command; that is checked for the whole
13
+ * list before any command starts, so a denial never leaves a half-run phase. Identical command
14
+ * strings run once, at their first position. Commands run sequentially from the project root, in
15
+ * blueprint tree order then op order, and the first failure stops the phase. Returns the commands
16
+ * that ran.
17
+ */
18
+ export const runPostApply = (params: {
19
+ readonly commands: readonly PlannedCommand[]
20
+ readonly projectRoot: string
21
+ readonly spawner?: ProcessSpawner
22
+ }): Effect.Effect<readonly string[], Errors.ExecError> =>
23
+ Effect.gen(function* () {
24
+ const { commands, projectRoot, spawner = nodeSpawner } = params
25
+ const denied = Array.findFirst(
26
+ commands,
27
+ (planned) => !isCommandPermitted(planned.permissions, planned.command),
28
+ )
29
+ if (Option.isSome(denied)) {
30
+ return yield* commandNotPermitted(denied.value.command)
31
+ }
32
+ const unique = Array.dedupeWith(commands, (a, b) => a.command === b.command)
33
+ yield* Effect.forEach(
34
+ unique,
35
+ (planned) =>
36
+ runCommand({
37
+ permissions: planned.permissions,
38
+ command: planned.command,
39
+ cwd: projectRoot,
40
+ spawner,
41
+ }),
42
+ { discard: true },
43
+ )
44
+ return Array.map(unique, (planned) => planned.command)
45
+ })
@@ -0,0 +1,15 @@
1
+ import { Effect } from "effect"
2
+ import { BlueprintFileSystem } from "@projitect/core"
3
+
4
+ /**
5
+ * Read a project-relative file through the `BlueprintFileSystem` service, treating any read
6
+ * failure as "absent" (`null`). Shared by the lockfile, differ, applier and remover.
7
+ */
8
+ export const readIfExists = (
9
+ file: string,
10
+ ): Effect.Effect<string | null, never, BlueprintFileSystem> =>
11
+ Effect.gen(function* () {
12
+ const fs = yield* BlueprintFileSystem
13
+ const present = yield* fs.exists(file)
14
+ return present ? yield* fs.readFile(file) : null
15
+ }).pipe(Effect.orElseSucceed(() => null))
package/src/region.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { Data, Effect, String } from "effect"
2
+ import { StringX } from "@nunofyobiz/effect-extras"
3
+ import { Errors } from "@projitect/core"
4
+
5
+ /**
6
+ * Render a region marker. The optional `suffix` covers comment styles that need a closing
7
+ * delimiter — HTML/MDX/XML need `-->`, everything else passes the empty string. Without a
8
+ * suffix the marker just runs to end-of-line (the `#` and `//` cases).
9
+ */
10
+ const START = (prefix: string, owner: string, suffix = ""): string =>
11
+ `${prefix} ${owner} start${suffix}`
12
+ const END = (prefix: string, owner: string, suffix = ""): string =>
13
+ `${prefix} ${owner} end${suffix}`
14
+
15
+ export type RegionLookup = Data.TaggedEnum<{
16
+ readonly Found: {
17
+ readonly startLine: number
18
+ readonly endLine: number
19
+ readonly content: string
20
+ }
21
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Data.taggedEnum's no-field member shape
22
+ readonly Absent: {}
23
+ }>
24
+ export const RegionLookup = Data.taggedEnum<RegionLookup>()
25
+
26
+ /**
27
+ * Find the existing region for `ownerId` inside `fileContent`. Returns its line range and
28
+ * content, or `{ kind: "absent" }`. A start marker without an end is a
29
+ * `pjt.region.missing-end` error; duplicate start markers are `pjt.region.duplicate`.
30
+ */
31
+ export const findRegion = (params: {
32
+ readonly fileContent: string
33
+ readonly ownerId: string
34
+ readonly commentPrefix: string
35
+ /**
36
+ * Optional closing delimiter for the comment style (e.g. `" -->"` for HTML/MDX). Defaults
37
+ * to the empty string so single-prefix styles (`#`, `//`) stay unchanged.
38
+ */
39
+ readonly commentSuffix?: string
40
+ readonly path: string
41
+ }): Effect.Effect<RegionLookup, Errors.RegionMissingEnd | Errors.RegionDuplicate> => {
42
+ const { fileContent, ownerId, commentPrefix, commentSuffix = "", path } = params
43
+ type Result = Effect.Effect<RegionLookup, Errors.RegionMissingEnd | Errors.RegionDuplicate>
44
+ return Effect.suspend((): Result => {
45
+ const lines = fileContent.split("\n")
46
+ const startMarker = START(commentPrefix, ownerId, commentSuffix)
47
+ const endMarker = END(commentPrefix, ownerId, commentSuffix)
48
+
49
+ let startLine = -1
50
+ let endLine = -1
51
+ let duplicateStart = false
52
+
53
+ for (const [index, line_] of lines.entries()) {
54
+ const line = line_.trimEnd()
55
+ if (line === startMarker) {
56
+ if (startLine === -1) {
57
+ startLine = index
58
+ } else {
59
+ duplicateStart = true
60
+ }
61
+ } else if (line === endMarker) {
62
+ endLine = index
63
+ }
64
+ }
65
+
66
+ if (duplicateStart) {
67
+ return Effect.fail(
68
+ new Errors.RegionDuplicate({
69
+ id: "pjt.region.duplicate",
70
+ path,
71
+ ownerId,
72
+ message: `Multiple start markers for region ${ownerId} in ${path}`,
73
+ }),
74
+ )
75
+ }
76
+
77
+ if (startLine === -1) {
78
+ return Effect.succeed(RegionLookup.Absent())
79
+ }
80
+
81
+ if (endLine === -1 || endLine < startLine) {
82
+ return Effect.fail(
83
+ new Errors.RegionMissingEnd({
84
+ id: "pjt.region.missing-end",
85
+ path,
86
+ ownerId,
87
+ startLine,
88
+ message: `Region ${ownerId} started at line ${startLine + 1} of ${path} but never closed`,
89
+ }),
90
+ )
91
+ }
92
+
93
+ return Effect.succeed(
94
+ RegionLookup.Found({
95
+ startLine,
96
+ endLine,
97
+ content: lines.slice(startLine + 1, endLine).join("\n"),
98
+ }),
99
+ )
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Render a region block (markers + content) ready to splice into a file.
105
+ */
106
+ export const renderRegion = (params: {
107
+ readonly ownerId: string
108
+ readonly commentPrefix: string
109
+ readonly commentSuffix?: string
110
+ readonly content: string
111
+ }): string => {
112
+ const suffix = params.commentSuffix ?? ""
113
+ const body = params.content.endsWith("\n") ? params.content.slice(0, -1) : params.content
114
+ return [
115
+ START(params.commentPrefix, params.ownerId, suffix),
116
+ body,
117
+ END(params.commentPrefix, params.ownerId, suffix),
118
+ ].join("\n")
119
+ }
120
+
121
+ /**
122
+ * Splice or append a region into a file's existing content. Returns the updated text.
123
+ */
124
+ export const upsertRegion = (params: {
125
+ readonly fileContent: string
126
+ readonly existing: RegionLookup
127
+ readonly rendered: string
128
+ }): string => {
129
+ const { fileContent, existing, rendered } = params
130
+ if (existing._tag === "Absent") {
131
+ if (String.isEmpty(fileContent)) {
132
+ return `${rendered}\n`
133
+ }
134
+ return fileContent.endsWith("\n")
135
+ ? `${fileContent}${rendered}\n`
136
+ : `${fileContent}\n${rendered}\n`
137
+ }
138
+ return StringX.replaceLineRange(fileContent, existing.startLine, existing.endLine, [rendered])
139
+ }