@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,68 @@
1
+ import { Schema } from "effect"
2
+ import { PjtLock } from "@projitect/core"
3
+
4
+ /**
5
+ * Versioned URL for the published `pjt inspect --json` contract. Doubles as the `$id` of the
6
+ * generated JSON Schema document and the `$schema` value every `--json` output carries. Bumps to
7
+ * `inspect.v2.json` only on a breaking change to the output shape, shipped in a major release.
8
+ */
9
+ export const InspectJsonSchemaUrl = "https://projitect.dev/schemas/inspect.v1.json"
10
+
11
+ /**
12
+ * Effect Schema for the `pjt inspect --json` output. `renderInspectJson` encodes through this so
13
+ * the TypeScript types it's built from (`FileDiff`, `UpgradeRecord`, `PjtLock.LockOperation`) are
14
+ * checked against it at compile time, and the published JSON Schema file is generated from this
15
+ * same definition — it can't drift from the code that produces the output.
16
+ */
17
+ export const InspectJson = Schema.Struct({
18
+ $schema: Schema.Literal(InspectJsonSchemaUrl).annotate({
19
+ description: "Identifies the published JSON Schema this output conforms to.",
20
+ }),
21
+ hasDrift: Schema.Boolean.annotate({
22
+ description: "Whether the project has drifted from its blueprints or lockfile.",
23
+ }),
24
+ files: Schema.Array(
25
+ Schema.Struct({
26
+ path: Schema.String,
27
+ status: Schema.Literals(["create", "modify", "ok"]).annotate({
28
+ description:
29
+ '"create" — file absent, will be created; "modify" — exists but content drifts; "ok" — in sync.',
30
+ }),
31
+ summary: Schema.String,
32
+ }),
33
+ ).annotate({ description: "Per-file drift status, one entry per managed file." }),
34
+ removals: Schema.Array(PjtLock.LockOperation).annotate({
35
+ description: "Operations recorded in the lockfile whose owning blueprint has left the tree.",
36
+ }),
37
+ upgrades: Schema.Array(
38
+ Schema.Struct({
39
+ blueprintId: Schema.String,
40
+ from: Schema.String,
41
+ to: Schema.String,
42
+ }),
43
+ ).annotate({
44
+ description: "Blueprints whose locked version differs from the tree's current version.",
45
+ }),
46
+ }).annotate({
47
+ title: "pjt inspect --json output",
48
+ description:
49
+ "Structured drift report for `pjt inspect --json`. New keys may be added within this version; removing or renaming a key requires a new schema version.",
50
+ })
51
+
52
+ export type InspectJson = typeof InspectJson.Type
53
+
54
+ /**
55
+ * The published schema file's contents — generated from {@link InspectJson}, never hand-edited.
56
+ * `additionalProperties: true` keeps the documented promise that new keys may be added within a
57
+ * version without breaking consumers.
58
+ */
59
+ export const inspectJsonSchemaDocument = (): Record<string, unknown> => {
60
+ const doc = Schema.toJsonSchemaDocument(InspectJson, { additionalProperties: true })
61
+ const hasDefinitions = Object.keys(doc.definitions).length > 0
62
+ return {
63
+ $schema: "https://json-schema.org/draft/2020-12/schema",
64
+ $id: InspectJsonSchemaUrl,
65
+ ...doc.schema,
66
+ ...(hasDefinitions ? { $defs: doc.definitions } : {}),
67
+ }
68
+ }
@@ -0,0 +1,106 @@
1
+ import { Array, Effect, Schema } from "effect"
2
+ import { Errors } from "@projitect/core"
3
+ import type { PjtLock, ProjitectConfig } from "@projitect/core"
4
+ import { loadBlueprintFile, isEffectTree } from "../loader.js"
5
+ import type { BlueprintTree } from "../loader.js"
6
+ import { buildPlan, diffLockfile } from "../plan.js"
7
+ import type { UpgradeRecord } from "../plan.js"
8
+ import { diffPlan, renderInspectReport } from "../differ.js"
9
+ import type { FileDiff } from "../differ.js"
10
+ import { makeProjectRootLayer, makeRealLayer } from "../filesystem-impl.js"
11
+ import { readLockfile } from "../lockfile.js"
12
+ import { InspectJson, InspectJsonSchemaUrl } from "./inspect-json.js"
13
+
14
+ /**
15
+ * Result of `pjt inspect`. `output` is the human-readable rendering; the structured fields
16
+ * (`files`, `removals`, `upgrades`) are what `--json` mode emits. Both representations are
17
+ * always computed — switching modes is just a renderer choice.
18
+ */
19
+ export interface InspectResult {
20
+ readonly hasDrift: boolean
21
+ readonly output: string
22
+ readonly files: readonly FileDiff[]
23
+ readonly removals: readonly PjtLock.LockOperation[]
24
+ readonly upgrades: readonly UpgradeRecord[]
25
+ }
26
+
27
+ /**
28
+ * `pjt inspect` — load the blueprint file, build the plan, diff against disk + lockfile, and
29
+ * return both a human-readable summary and the structured pieces a `--json` consumer needs.
30
+ *
31
+ * The dispatcher decides which representation to print; the command keeps the `hasDrift`
32
+ * boolean separate so the bin shim can set exit code 1 regardless of output mode (`pjt inspect
33
+ * --json` still exits nonzero on drift, which CI relies on for the "in spec / out of spec"
34
+ * promise).
35
+ */
36
+ export const inspect = (params: {
37
+ readonly config: ProjitectConfig.ProjitectConfig
38
+ }): Effect.Effect<InspectResult, Errors.ProjitectError> =>
39
+ Effect.gen(function* () {
40
+ const file = yield* loadBlueprintFile({
41
+ blueprintFile: params.config.blueprintFile,
42
+ projectRoot: params.config.projectRoot,
43
+ })
44
+ const tree: BlueprintTree = isEffectTree(file.blueprints)
45
+ ? yield* file.blueprints.pipe(
46
+ Effect.provide(
47
+ makeRealLayer({
48
+ blueprintId: "pjt:config-loader",
49
+ permissions: [{ kind: "read", glob: "**" }],
50
+ projectRoot: params.config.projectRoot,
51
+ }),
52
+ ),
53
+ )
54
+ : file.blueprints
55
+ const { plan, byBlueprint } = yield* buildPlan({
56
+ tree,
57
+ projectRoot: params.config.projectRoot,
58
+ })
59
+ // Fail closed: commands cannot be diffed yet, so a clean report would be a false one.
60
+ if (Array.isReadonlyArrayNonEmpty(plan.commands)) {
61
+ return yield* new Errors.OperationUnsupportedOnInspect({
62
+ id: "pjt.inspect.operation-unsupported",
63
+ operation: "Exec",
64
+ message: `\`pjt inspect\` cannot check post-apply commands, and ${plan.commands.length === 1 ? "1 is" : `${plan.commands.length} are`} declared (first: \`${plan.commands[0].command}\`).`,
65
+ })
66
+ }
67
+ return yield* Effect.gen(function* () {
68
+ const previous = yield* readLockfile
69
+ const { removals, upgrades } = diffLockfile({ previous, current: byBlueprint })
70
+ const diff = yield* diffPlan({ plan })
71
+ const hasDrift = diff.hasDrift || removals.length > 0
72
+ return {
73
+ hasDrift,
74
+ output: withNextStep(renderInspectReport({ diff, removals, upgrades }), hasDrift),
75
+ files: diff.files,
76
+ removals,
77
+ upgrades,
78
+ }
79
+ }).pipe(Effect.provide(makeProjectRootLayer(params.config.projectRoot)))
80
+ })
81
+
82
+ const withNextStep = (report: string, hasDrift: boolean): string =>
83
+ hasDrift ? `${report}\n\nDrift detected. Run \`pjt remodel\` to apply the blueprints.` : report
84
+
85
+ /**
86
+ * Render the inspect result as a stable JSON object for CI / scripting, conforming to the
87
+ * published JSON Schema at {@link InspectJsonSchemaUrl} (also published at
88
+ * `apps/website/public/schemas/inspect.v1.json`). The `$schema` key names that contract.
89
+ *
90
+ * Stable means: keys won't be renamed or removed without a major bump (and a new schema
91
+ * version). New keys may be added at either level within a version; consumers should ignore
92
+ * unknown keys. Encoding through {@link InspectJson} ties this output to the same definition the
93
+ * schema is generated from, so the two can't drift apart.
94
+ */
95
+ export const renderInspectJson = (result: InspectResult): string =>
96
+ `${JSON.stringify(
97
+ Schema.encodeSync(InspectJson)({
98
+ $schema: InspectJsonSchemaUrl,
99
+ hasDrift: result.hasDrift,
100
+ files: result.files,
101
+ removals: result.removals,
102
+ upgrades: result.upgrades,
103
+ }),
104
+ null,
105
+ 2,
106
+ )}\n`
@@ -0,0 +1,72 @@
1
+ import { Effect } from "effect"
2
+ import { StructX } from "@nunofyobiz/effect-extras"
3
+ import type { Errors, PjtLock, ProjitectConfig } from "@projitect/core"
4
+ import { loadBlueprintFile, isEffectTree } from "../loader.js"
5
+ import type { BlueprintTree } from "../loader.js"
6
+ import { buildPlan, diffLockfile } from "../plan.js"
7
+ import { applyPlan } from "../applier.js"
8
+ import { applyRemovals } from "../remover.js"
9
+ import { makeProjectRootLayer, makeRealLayer } from "../filesystem-impl.js"
10
+ import { readLockfile, writeLockfile } from "../lockfile.js"
11
+ import { runPostApply } from "../post-apply.js"
12
+ import type { ProcessSpawner } from "../platform/process-runner.js"
13
+
14
+ export interface RemodelResult {
15
+ readonly written: readonly string[]
16
+ readonly removed: readonly string[]
17
+ /** Post-apply commands that ran, deduplicated, in the order they ran. */
18
+ readonly ran: readonly string[]
19
+ }
20
+
21
+ /**
22
+ * `pjt remodel` — load blueprints, build the plan, apply changes to disk, rewrite `.pjt.lock`,
23
+ * then run the blueprints' post-apply commands.
24
+ * Non-destructive at the file-tree level: only touches files the plan covers (additions) and
25
+ * files where a blueprint that previously had ownership left the tree (removals). Commands run
26
+ * only after every file operation and the lockfile write have succeeded. `spawner` replaces the
27
+ * real process spawner in tests.
28
+ */
29
+ export const remodel = (params: {
30
+ readonly config: ProjitectConfig.ProjitectConfig
31
+ readonly spawner?: ProcessSpawner
32
+ }): Effect.Effect<RemodelResult, Errors.ProjitectError> =>
33
+ Effect.gen(function* () {
34
+ const file = yield* loadBlueprintFile({
35
+ blueprintFile: params.config.blueprintFile,
36
+ projectRoot: params.config.projectRoot,
37
+ })
38
+ const tree: BlueprintTree = isEffectTree(file.blueprints)
39
+ ? yield* file.blueprints.pipe(
40
+ Effect.provide(
41
+ makeRealLayer({
42
+ blueprintId: "pjt:config-loader",
43
+ permissions: [{ kind: "read", glob: "**" }],
44
+ projectRoot: params.config.projectRoot,
45
+ }),
46
+ ),
47
+ )
48
+ : file.blueprints
49
+
50
+ const { plan, byBlueprint } = yield* buildPlan({
51
+ tree,
52
+ projectRoot: params.config.projectRoot,
53
+ })
54
+ return yield* Effect.gen(function* () {
55
+ const previous = yield* readLockfile
56
+ const { removals } = diffLockfile({ previous, current: byBlueprint })
57
+
58
+ const written = yield* applyPlan({ plan })
59
+ const removed = yield* applyRemovals({ removals })
60
+
61
+ const nextLock: PjtLock.PjtLock = { version: 1, blueprints: byBlueprint }
62
+ yield* writeLockfile({ lock: nextLock })
63
+
64
+ const ran = yield* runPostApply({
65
+ commands: plan.commands,
66
+ projectRoot: params.config.projectRoot,
67
+ ...StructX.defined("spawner", params.spawner),
68
+ })
69
+
70
+ return { written, removed, ran }
71
+ }).pipe(Effect.provide(makeProjectRootLayer(params.config.projectRoot)))
72
+ })
@@ -0,0 +1,60 @@
1
+ import { StructX } from "@nunofyobiz/effect-extras"
2
+ import { ProjitectConfig } from "@projitect/core"
3
+
4
+ /** Partial config with mutable properties — for building up an override layer locally. */
5
+ export type WritablePartialConfig = {
6
+ -readonly [K in keyof ProjitectConfig.ProjitectConfig]?: ProjitectConfig.ProjitectConfig[K]
7
+ }
8
+
9
+ /**
10
+ * Resolve the four config layers into a single `ProjitectConfig`. Later layers win.
11
+ *
12
+ * Layers, in priority order (low to high):
13
+ * 1. `ProjitectConfig.defaults`
14
+ * 2. `env`: parsed `PJT_*` environment variables
15
+ * 3. `blueprintFile`: the `config:` field of the user's `.pjt.ts`
16
+ * 4. `cliArgs`: command-line overrides
17
+ */
18
+ export const resolveConfig = (layers: {
19
+ readonly env?: WritablePartialConfig
20
+ readonly blueprintFile?: WritablePartialConfig
21
+ readonly cliArgs?: WritablePartialConfig
22
+ }): ProjitectConfig.ProjitectConfig =>
23
+ // Absent layers map to `{}` — the cascade reducer's identity, so they fold as a no-op.
24
+ ProjitectConfig.resolve(layers.env ?? {}, layers.blueprintFile ?? {}, layers.cliArgs ?? {})
25
+
26
+ /**
27
+ * Parse the relevant `PJT_*` environment variables into a partial config. Unknown vars are
28
+ * ignored. Boolean vars accept `1`/`0`/`true`/`false`; numeric vars are parsed with `Number`.
29
+ */
30
+ export const parseEnv = (
31
+ env: Readonly<Record<string, string | undefined>>,
32
+ ): WritablePartialConfig =>
33
+ StructX.filterDefined({
34
+ projectRoot: env["PJT_PROJECT_ROOT"],
35
+ blueprintFile: env["PJT_BLUEPRINT_FILE"],
36
+ requireCleanGit: parseBool(env["PJT_REQUIRE_CLEAN_GIT"]),
37
+ jsonOutput: parseBool(env["PJT_JSON_OUTPUT"]),
38
+ verbosity: parseNumber(env["PJT_VERBOSITY"]),
39
+ })
40
+
41
+ const parseBool = (v: string | undefined): boolean | undefined => {
42
+ if (v === undefined) {
43
+ return undefined
44
+ }
45
+ if (v === "1" || v.toLowerCase() === "true") {
46
+ return true
47
+ }
48
+ if (v === "0" || v.toLowerCase() === "false") {
49
+ return false
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ const parseNumber = (v: string | undefined): number | undefined => {
55
+ if (v === undefined) {
56
+ return undefined
57
+ }
58
+ const n = Number(v)
59
+ return Number.isFinite(n) ? n : undefined
60
+ }
package/src/differ.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { Array, Effect, Match } from "effect"
2
+ import type { BlueprintFileSystem, PjtLock, Errors } from "@projitect/core"
3
+ import { RecordX } from "@nunofyobiz/effect-extras"
4
+ import { readIfExists } from "./project-file.js"
5
+ import { findRegion } from "./region.js"
6
+ import type {
7
+ FilePlan,
8
+ ProjectPlan,
9
+ RegionPlanFile,
10
+ MergePlanFile,
11
+ OwnedPlanFile,
12
+ SeedPlanFile,
13
+ UpgradeRecord,
14
+ } from "./plan.js"
15
+
16
+ export interface FileDiff {
17
+ readonly path: string
18
+ /** "create" — file absent, will be created; "modify" — exists but content drifts; "ok" — in sync. */
19
+ readonly status: "create" | "modify" | "ok"
20
+ readonly summary: string
21
+ }
22
+
23
+ export interface PlanDiff {
24
+ readonly files: readonly FileDiff[]
25
+ readonly hasDrift: boolean
26
+ }
27
+
28
+ /**
29
+ * Compare each file in the plan against the project filesystem. Returns a structured diff suitable for either
30
+ * human rendering (`renderPlanDiff`) or machine consumption (`--json`).
31
+ */
32
+ export const diffPlan = (params: {
33
+ readonly plan: ProjectPlan
34
+ }): Effect.Effect<
35
+ PlanDiff,
36
+ Errors.RegionMissingEnd | Errors.RegionDuplicate,
37
+ BlueprintFileSystem
38
+ > => {
39
+ const { plan } = params
40
+ return Effect.gen(function* () {
41
+ const files = yield* Effect.forEach(plan.files, (file) =>
42
+ readIfExists(file.path).pipe(Effect.flatMap((current) => diffFile({ file, current }))),
43
+ )
44
+ const hasDrift = Array.some(files, (file) => file.status !== "ok")
45
+ return { files, hasDrift }
46
+ })
47
+ }
48
+
49
+ const diffFile = (params: {
50
+ readonly file: FilePlan
51
+ readonly current: string | null
52
+ }): Effect.Effect<FileDiff, Errors.RegionMissingEnd | Errors.RegionDuplicate> =>
53
+ Match.valueTags(params.file, {
54
+ Region: (file) => diffRegion(file, params.current),
55
+ Merge: (file) => Effect.succeed(diffMerge(file, params.current)),
56
+ Owned: (file) => Effect.succeed(diffOwned(file, params.current)),
57
+ Seed: (file) => Effect.succeed(diffSeed(file, params.current)),
58
+ })
59
+
60
+ const diffRegion = (
61
+ file: RegionPlanFile,
62
+ current: string | null,
63
+ ): Effect.Effect<FileDiff, Errors.RegionMissingEnd | Errors.RegionDuplicate> =>
64
+ Effect.gen(function* () {
65
+ if (current === null) {
66
+ return {
67
+ path: file.path,
68
+ status: "create" as const,
69
+ summary: `+ create ${file.path} (${file.regions.length} region${file.regions.length === 1 ? "" : "s"})`,
70
+ }
71
+ }
72
+ const drifted: string[] = []
73
+ for (const region of file.regions) {
74
+ const found = yield* findRegion({
75
+ fileContent: current,
76
+ ownerId: region.ownerId,
77
+ commentPrefix: file.commentPrefix,
78
+ commentSuffix: file.commentSuffix,
79
+ path: file.path,
80
+ })
81
+ const expected = region.content.trimEnd()
82
+ if (found._tag === "Absent") {
83
+ drifted.push(`missing region ${region.ownerId}`)
84
+ } else if (found.content.trimEnd() !== expected) {
85
+ drifted.push(`region ${region.ownerId} content drift`)
86
+ }
87
+ }
88
+ if (drifted.length === 0) {
89
+ return { path: file.path, status: "ok" as const, summary: ` ok ${file.path}` }
90
+ }
91
+ return {
92
+ path: file.path,
93
+ status: "modify" as const,
94
+ summary: `~ modify ${file.path} — ${drifted.join(", ")}`,
95
+ }
96
+ })
97
+
98
+ const diffMerge = (file: MergePlanFile, current: string | null): FileDiff => {
99
+ if (current === null) {
100
+ return {
101
+ path: file.path,
102
+ status: "create",
103
+ summary: `+ create ${file.path}`,
104
+ }
105
+ }
106
+ try {
107
+ const parsed = JSON.parse(current) as unknown
108
+ const target = RecordX.deepMerge(parsed, file.value)
109
+ if (canonicalJson(parsed) === canonicalJson(target)) {
110
+ return { path: file.path, status: "ok", summary: ` ok ${file.path}` }
111
+ }
112
+ return {
113
+ path: file.path,
114
+ status: "modify",
115
+ summary: `~ modify ${file.path} (JSON merge)`,
116
+ }
117
+ } catch {
118
+ return {
119
+ path: file.path,
120
+ status: "modify",
121
+ summary: `~ modify ${file.path} (unparseable JSON)`,
122
+ }
123
+ }
124
+ }
125
+
126
+ const diffOwned = (file: OwnedPlanFile, current: string | null): FileDiff => {
127
+ if (current === null) {
128
+ return { path: file.path, status: "create", summary: `+ create ${file.path}` }
129
+ }
130
+ if (current === file.content) {
131
+ return { path: file.path, status: "ok", summary: ` ok ${file.path}` }
132
+ }
133
+ return { path: file.path, status: "modify", summary: `~ modify ${file.path}` }
134
+ }
135
+
136
+ const diffSeed = (file: SeedPlanFile, current: string | null): FileDiff => {
137
+ if (current === null) {
138
+ return { path: file.path, status: "create", summary: `+ create ${file.path} (seed)` }
139
+ }
140
+ return { path: file.path, status: "ok", summary: ` ok ${file.path} (seed, never enforced)` }
141
+ }
142
+
143
+ // Stable JSON string for structural comparison: deep key-sort, then stringify.
144
+ const canonicalJson = (v: unknown): string => JSON.stringify(RecordX.canonicalize(v))
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Render a diff for the terminal
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export const renderPlanDiff = (diff: PlanDiff): string => {
151
+ if (!diff.hasDrift) {
152
+ return "Project is in sync with blueprints. No changes needed."
153
+ }
154
+ const lines = diff.files.map((f) => f.summary)
155
+ return lines.join("\n")
156
+ }
157
+
158
+ /**
159
+ * Render the full inspect output: file-diff lines + lockfile-driven removals + version upgrades.
160
+ * Used by `pjt inspect` and `pjt remodel --dry-run` (future) to give a complete picture.
161
+ */
162
+ export const renderInspectReport = (params: {
163
+ readonly diff: PlanDiff
164
+ readonly removals: readonly PjtLock.LockOperation[]
165
+ readonly upgrades: readonly UpgradeRecord[]
166
+ }): string => {
167
+ const { diff, removals, upgrades } = params
168
+ const lines = Array.flatten([
169
+ Array.map(upgrades, (u) => `↑ upgrade ${u.blueprintId} ${u.from} → ${u.to}`),
170
+ Array.map(removals, removalSummary),
171
+ Array.map(diff.files, (f) => f.summary),
172
+ ])
173
+ if (lines.length === 0) {
174
+ return "Project is in sync with blueprints. No changes needed."
175
+ }
176
+ return lines.join("\n")
177
+ }
178
+
179
+ const removalSummary = (op: PjtLock.LockOperation): string =>
180
+ Match.valueTags(op, {
181
+ Region: (region) =>
182
+ `- remove ${region.ownerId} region from ${region.path} (blueprint left .pjt.ts)`,
183
+ Merge: (merge) =>
184
+ `- remove merge keys ${merge.ownedKeys.join(", ")} from ${merge.path} (blueprint left .pjt.ts)`,
185
+ Owned: (owned) => `- delete ${owned.path} (blueprint left .pjt.ts)`,
186
+ Seed: (seed) =>
187
+ ` (seed ${seed.ownerId} for ${seed.path} retained; blueprint left but seed mode is write-once)`,
188
+ })
@@ -0,0 +1,56 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import { Effect } from "effect"
4
+ import { Errors } from "@projitect/core"
5
+ import { spliceBlueprintFile } from "@projitect/blueprint"
6
+
7
+ export interface SpliceParams {
8
+ readonly projectRoot: string
9
+ readonly blueprintFile: string
10
+ /** Import statement to splice (one line, no trailing newline). Deduped. */
11
+ readonly importLine: string
12
+ /** Blueprint call lines (one entry per line, no leading indent, no trailing newline). */
13
+ readonly callLines: readonly string[]
14
+ }
15
+
16
+ /**
17
+ * Read `.pjt.ts`, splice an import and call lines in via the shared `spliceBlueprintFile`, and
18
+ * write it back. The marker convention and splice behavior live in `@projitect/blueprint`.
19
+ *
20
+ * Fails with `pjt.add.markers-missing` if either marker pair is missing — the user has
21
+ * hand-edited the file and we won't guess. They restore the markers, then re-run `pjt add`.
22
+ */
23
+ export const splice = (
24
+ params: SpliceParams,
25
+ ): Effect.Effect<void, Errors.AddMarkersMissing | Errors.FsReadFailed | Errors.FsWriteFailed> =>
26
+ Effect.gen(function* () {
27
+ const full = path.join(params.projectRoot, params.blueprintFile)
28
+ const raw = yield* Effect.tryPromise({
29
+ try: () => fs.readFile(full, "utf8"),
30
+ catch: (e) =>
31
+ new Errors.FsReadFailed({
32
+ id: "pjt.fs.read-failed",
33
+ path: params.blueprintFile,
34
+ cause: e instanceof Error ? e.message : String(e),
35
+ message: `Could not read ${params.blueprintFile}`,
36
+ }),
37
+ })
38
+
39
+ const next = yield* spliceBlueprintFile({
40
+ content: raw,
41
+ blueprintFile: params.blueprintFile,
42
+ importLine: params.importLine,
43
+ callLines: params.callLines,
44
+ })
45
+
46
+ yield* Effect.tryPromise({
47
+ try: () => fs.writeFile(full, next, "utf8"),
48
+ catch: (e) =>
49
+ new Errors.FsWriteFailed({
50
+ id: "pjt.fs.write-failed",
51
+ path: params.blueprintFile,
52
+ cause: e instanceof Error ? e.message : String(e),
53
+ message: `Could not write ${params.blueprintFile}`,
54
+ }),
55
+ })
56
+ })