@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.
- 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 +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +153 -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 +36 -0
- package/dist/pm.d.ts.map +1 -0
- package/dist/pm.js +95 -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 +233 -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 +163 -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,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
|
+
})
|
package/src/edit-pjt.ts
ADDED
|
@@ -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
|
+
})
|