@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,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,131 @@
|
|
|
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 by walking up from `projectRoot` looking for known
|
|
20
|
+
* lockfiles. Falls back to `npm` if nothing is found. Used by `pjt add` to decide which install
|
|
21
|
+
* command to shell out to.
|
|
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 => "npm",
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run `<pm> add -D <pkg>` in `projectRoot`. Wraps spawn failures (non-zero exit, PM not
|
|
43
|
+
* installed) as `pjt.pm.install-failed`.
|
|
44
|
+
*/
|
|
45
|
+
export const installDev = (params: {
|
|
46
|
+
readonly projectRoot: string
|
|
47
|
+
readonly pm: PackageManager
|
|
48
|
+
readonly pkg: string
|
|
49
|
+
}): Effect.Effect<void, Errors.PmInstallFailed> => {
|
|
50
|
+
const args = installArgs(params.pm, params.pkg)
|
|
51
|
+
return Effect.tryPromise({
|
|
52
|
+
try: () =>
|
|
53
|
+
new Promise<void>((resolve, reject) => {
|
|
54
|
+
const child = spawn(params.pm, args, {
|
|
55
|
+
cwd: params.projectRoot,
|
|
56
|
+
stdio: "inherit",
|
|
57
|
+
})
|
|
58
|
+
child.on("error", reject)
|
|
59
|
+
child.on("close", (code) => {
|
|
60
|
+
if (code === 0) {
|
|
61
|
+
resolve()
|
|
62
|
+
} else {
|
|
63
|
+
reject(new Error(`${params.pm} ${args.join(" ")} exited with ${String(code)}`))
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}),
|
|
67
|
+
catch: (e) =>
|
|
68
|
+
new Errors.PmInstallFailed({
|
|
69
|
+
id: "pjt.pm.install-failed",
|
|
70
|
+
packageManager: params.pm,
|
|
71
|
+
pkg: params.pkg,
|
|
72
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
73
|
+
message: `Failed to install \`${params.pkg}\` via ${params.pm}`,
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const installArgs = (pm: PackageManager, pkg: string): readonly string[] =>
|
|
79
|
+
Match.value(pm).pipe(
|
|
80
|
+
Match.whenOr("pnpm", "yarn", () => ["add", "-D", pkg]),
|
|
81
|
+
Match.when("bun", () => ["add", "-d", pkg]),
|
|
82
|
+
Match.when("npm", () => ["install", "-D", pkg]),
|
|
83
|
+
Match.exhaustive,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the installed package's `package.json` and return its `"projitect"` metadata, if any.
|
|
88
|
+
* Returns null if the package doesn't have the field; `pjt add` falls back to install-only
|
|
89
|
+
* behavior with a printed instruction snippet.
|
|
90
|
+
*/
|
|
91
|
+
export const readProjitectMetadata = (params: {
|
|
92
|
+
readonly projectRoot: string
|
|
93
|
+
readonly pkg: string
|
|
94
|
+
}): Effect.Effect<ProjitectPackageMetadata | null> =>
|
|
95
|
+
Effect.promise(async () => {
|
|
96
|
+
const pkgJsonPath = path.join(params.projectRoot, "node_modules", params.pkg, "package.json")
|
|
97
|
+
try {
|
|
98
|
+
const parsed: unknown = JSON.parse(await fs.readFile(pkgJsonPath, "utf8"))
|
|
99
|
+
if (!PredicateX.unsafeIsRecord(parsed)) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
const meta = parsed["projitect"]
|
|
103
|
+
if (!PredicateX.unsafeIsRecord(meta)) {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
const type = meta["type"]
|
|
107
|
+
const importStmt = meta["import"]
|
|
108
|
+
if (type !== "blueprint" && type !== "blueprint-set") {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
if (!Predicate.isString(importStmt)) {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
const sections = meta["sections"]
|
|
115
|
+
return {
|
|
116
|
+
type,
|
|
117
|
+
import: importStmt,
|
|
118
|
+
...StructX.defined("call", Predicate.isString(meta["call"]) ? meta["call"] : undefined),
|
|
119
|
+
...StructX.defined(
|
|
120
|
+
"callTemplate",
|
|
121
|
+
Predicate.isString(meta["callTemplate"]) ? meta["callTemplate"] : undefined,
|
|
122
|
+
),
|
|
123
|
+
...StructX.defined(
|
|
124
|
+
"sections",
|
|
125
|
+
Array.isArray(sections) ? Array.filter(sections, Predicate.isString) : undefined,
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
})
|
|
@@ -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
|
+
}
|