@projitect/blueprint 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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Section helpers — thin wrappers around `regionFile` that bake in the right comment
3
+ * delimiters for two common file shapes:
4
+ *
5
+ * - **`markdownSection`** for HTML/MDX/XML files where comments need a closing `-->`.
6
+ * The fenced region renders invisible in markdown output — readers never see the markers.
7
+ * Useful for README.md, AGENTS.md, CONTRIBUTING.md, generated MDX docs, etc.
8
+ *
9
+ * - **`ignoreSection`** for `#`-comment "ignore"-style files. Same shape as the gitignore
10
+ * convention; the helper exists because the name documents intent better than a bare
11
+ * `regionFile({ commentPrefix: "#" })` does, and the surface guides authors toward the
12
+ * right primitive when they're scaffolding a `.eslintignore` / `.prettierignore` /
13
+ * `.dockerignore` / `.npmignore` blueprint.
14
+ *
15
+ * Both helpers re-emit the underlying `RegionFileSpec`-shaped result of `regionFile`, so
16
+ * everything that works for `regionFile` works for these (extra permissions, descriptions,
17
+ * etc.). They aren't shipped as separate npm packages because there's no opinionated content
18
+ * to bundle — the blueprint author always provides their own.
19
+ */
20
+ import { StructX } from "@nunofyobiz/effect-extras";
21
+ import { regionFile } from "./constructors.js";
22
+ /**
23
+ * Region-mode blueprint for HTML / MDX / XML files. The fence is rendered as two HTML
24
+ * comments on their own lines:
25
+ *
26
+ * <!-- pjt:<owner-id> start -->
27
+ * <body>
28
+ * <!-- pjt:<owner-id> end -->
29
+ *
30
+ * The markers don't render in HTML output (they're comments), so readers of the published
31
+ * file never see them. `pjt inspect` flags hand-edits between the markers; lines outside
32
+ * the fence belong to the user.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * markdownSection({
37
+ * id: "my-org:readme:badges",
38
+ * version: "1.0.0",
39
+ * path: "README.md",
40
+ * content: "![ci](https://example.com/badge.svg)\n",
41
+ * })
42
+ * ```
43
+ */
44
+ export const markdownSection = (spec) => regionFile({
45
+ id: spec.id,
46
+ version: spec.version,
47
+ ...StructX.defined("description", spec.description),
48
+ path: spec.path,
49
+ commentPrefix: "<!--",
50
+ commentSuffix: " -->",
51
+ content: spec.content,
52
+ ...StructX.defined("extraPermissions", spec.extraPermissions),
53
+ });
54
+ /**
55
+ * Region-mode blueprint for `#`-comment ignore-style files: `.gitignore`, `.eslintignore`,
56
+ * `.prettierignore`, `.dockerignore`, `.npmignore`, etc. The fence is two `#`-prefixed lines
57
+ * on either side of the section content:
58
+ *
59
+ * # pjt:<owner-id> start
60
+ * <body>
61
+ * # pjt:<owner-id> end
62
+ *
63
+ * Equivalent to `regionFile({ commentPrefix: "#", ... })`, but the name documents the
64
+ * intent and makes the right primitive easy to find.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * ignoreSection({
69
+ * id: "my-org:eslintignore:generated",
70
+ * version: "1.0.0",
71
+ * path: ".eslintignore",
72
+ * content: "dist/\nbuild/\ncoverage/\n",
73
+ * })
74
+ * ```
75
+ */
76
+ export const ignoreSection = (spec) => regionFile({
77
+ id: spec.id,
78
+ version: spec.version,
79
+ ...StructX.defined("description", spec.description),
80
+ path: spec.path,
81
+ commentPrefix: "#",
82
+ content: spec.content,
83
+ ...StructX.defined("extraPermissions", spec.extraPermissions),
84
+ });
85
+ //# sourceMappingURL=sections.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sections.js","sourceRoot":"","sources":["../src/sections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAEnD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAW9C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,IAAiB,EAAuB,EAAE,CACxE,UAAU,CAAC;IACT,EAAE,EAAE,IAAI,CAAC,EAAE;IACX,OAAO,EAAE,IAAI,CAAC,OAAO;IACrB,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,EAAE,IAAI,CAAC,WAAW,CAAC;IACnD,IAAI,EAAE,IAAI,CAAC,IAAI;IACf,aAAa,EAAE,MAAM;IACrB,aAAa,EAAE,MAAM;IACrB,OAAO,EAAE,IAAI,CAAC,OAAO;IACrB,GAAG,OAAO,CAAC,OAAO,CAAC,kBAAkB,EAAE,IAAI,CAAC,gBAAgB,CAAC;CAC9D,CAAC,CAAA;AAEJ;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,IAAiB,EAAuB,EAAE,CACtE,UAAU,CAAC;IACT,EAAE,EAAE,IAAI,CAAC,EAAE;IACX,OAAO,EAAE,IAAI,CAAC,OAAO;IACrB,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,EAAE,IAAI,CAAC,WAAW,CAAC;IACnD,IAAI,EAAE,IAAI,CAAC,IAAI;IACf,aAAa,EAAE,GAAG;IAClB,OAAO,EAAE,IAAI,CAAC,OAAO;IACrB,GAAG,OAAO,CAAC,OAAO,CAAC,kBAAkB,EAAE,IAAI,CAAC,gBAAgB,CAAC;CAC9D,CAAC,CAAA"}
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+ import { Errors } from "@projitect/core";
3
+ /**
4
+ * Splice an import line and one or more blueprint call lines into `.pjt.ts` text using the
5
+ * convention markers. The import goes between `// pjt:imports start/end` (deduped against
6
+ * existing content), the calls go between `// pjt:blueprints start/end` (appended, with 4-space
7
+ * indent to match the standard template). An empty `callLines` leaves the blueprints block alone.
8
+ *
9
+ * Pure string-to-string: the caller owns reading and writing the file. `blueprintFile` is only
10
+ * used to name the file in the error message.
11
+ *
12
+ * Fails with `pjt.add.markers-missing` if either marker pair is missing — the user has
13
+ * hand-edited the file and we won't guess.
14
+ */
15
+ export declare const spliceBlueprintFile: (params: {
16
+ readonly content: string;
17
+ readonly blueprintFile: string;
18
+ /** Import statement to splice (one line, no trailing newline). Deduped. */
19
+ readonly importLine: string;
20
+ /** Blueprint call lines (one entry per line, no leading indent, no trailing newline). */
21
+ readonly callLines: readonly string[];
22
+ }) => Effect.Effect<string, Errors.AddMarkersMissing>;
23
+ //# sourceMappingURL=splice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"splice.d.ts","sourceRoot":"","sources":["../src/splice.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAA;AAQxC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ;IAC1C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,yFAAyF;IACzF,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;CACtC,KAAG,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,iBAAiB,CAY9C,CAAA"}
package/dist/splice.js ADDED
@@ -0,0 +1,78 @@
1
+ import { Effect } from "effect";
2
+ import { Errors } from "@projitect/core";
3
+ import { StringX } from "@nunofyobiz/effect-extras";
4
+ const IMPORTS_START = "// pjt:imports start";
5
+ const IMPORTS_END = "// pjt:imports end";
6
+ const BLUEPRINTS_START = "// pjt:blueprints start";
7
+ const BLUEPRINTS_END = "// pjt:blueprints end";
8
+ /**
9
+ * Splice an import line and one or more blueprint call lines into `.pjt.ts` text using the
10
+ * convention markers. The import goes between `// pjt:imports start/end` (deduped against
11
+ * existing content), the calls go between `// pjt:blueprints start/end` (appended, with 4-space
12
+ * indent to match the standard template). An empty `callLines` leaves the blueprints block alone.
13
+ *
14
+ * Pure string-to-string: the caller owns reading and writing the file. `blueprintFile` is only
15
+ * used to name the file in the error message.
16
+ *
17
+ * Fails with `pjt.add.markers-missing` if either marker pair is missing — the user has
18
+ * hand-edited the file and we won't guess.
19
+ */
20
+ export const spliceBlueprintFile = (params) => Effect.gen(function* () {
21
+ const afterImport = yield* spliceImport({
22
+ content: params.content,
23
+ blueprintFile: params.blueprintFile,
24
+ importLine: params.importLine,
25
+ });
26
+ return yield* spliceCalls({
27
+ content: afterImport,
28
+ blueprintFile: params.blueprintFile,
29
+ callLines: params.callLines,
30
+ });
31
+ });
32
+ const spliceImport = (params) => {
33
+ const { content, blueprintFile, importLine } = params;
34
+ return Effect.gen(function* () {
35
+ const lines = content.split("\n");
36
+ const startIndex = lines.findIndex((l) => l.trim() === IMPORTS_START);
37
+ const endIndex = lines.findIndex((l) => l.trim() === IMPORTS_END);
38
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
39
+ return yield* new Errors.AddMarkersMissing({
40
+ id: "pjt.add.markers-missing",
41
+ blueprintFile,
42
+ missingMarker: `${IMPORTS_START} / ${IMPORTS_END}`,
43
+ message: `${blueprintFile} is missing the \`pjt:imports start\` / \`pjt:imports end\` markers. ` +
44
+ "Restore them (see `pjt init`'s starter template) and re-run `pjt add`.",
45
+ });
46
+ }
47
+ // Dedupe: skip if any line between the markers already equals the import
48
+ const between = lines.slice(startIndex + 1, endIndex).map((l) => l.trim());
49
+ if (between.includes(importLine.trim())) {
50
+ return content;
51
+ }
52
+ return StringX.insertBeforeLine(content, endIndex, [importLine]);
53
+ });
54
+ };
55
+ const spliceCalls = (params) => {
56
+ const { content, blueprintFile, callLines } = params;
57
+ return Effect.gen(function* () {
58
+ if (callLines.length === 0) {
59
+ return content;
60
+ }
61
+ const lines = content.split("\n");
62
+ const startIndex = lines.findIndex((l) => l.trim() === BLUEPRINTS_START);
63
+ const endIndex = lines.findIndex((l) => l.trim() === BLUEPRINTS_END);
64
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
65
+ return yield* new Errors.AddMarkersMissing({
66
+ id: "pjt.add.markers-missing",
67
+ blueprintFile,
68
+ missingMarker: `${BLUEPRINTS_START} / ${BLUEPRINTS_END}`,
69
+ message: `${blueprintFile} is missing the \`pjt:blueprints start\` / \`pjt:blueprints end\` markers. ` +
70
+ "Restore them and re-run `pjt add`.",
71
+ });
72
+ }
73
+ const indent = " "; // 4-space, matching the starter template
74
+ const newCalls = callLines.map((c) => `${indent}${c.replace(/^\s+/u, "")}`);
75
+ return StringX.insertBeforeLine(content, endIndex, newCalls);
76
+ });
77
+ };
78
+ //# sourceMappingURL=splice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"splice.js","sourceRoot":"","sources":["../src/splice.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@projitect/blueprint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.1",
4
+ "description": "Authoring SDK for projitect blueprints — BlueprintFileSystem service, region/merge/owned/seed constructors, directory scope, detectors.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/projitect/projitect.git",
8
+ "directory": "packages/blueprint"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!**/*.tsbuildinfo"
22
+ ],
23
+ "dependencies": {
24
+ "@nunofyobiz/effect-extras": "3.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "effect": "4.0.0-rc.111",
28
+ "typescript": "5.9.3",
29
+ "@projitect/core": "1.0.1",
30
+ "@projitect/test-kit": "1.0.1"
31
+ },
32
+ "peerDependencies": {
33
+ "effect": "4.0.0-rc.111",
34
+ "@projitect/core": "1.0.1"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.12.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc --build",
41
+ "build:clean": "tsc --build --clean",
42
+ "tc": "tsc --build"
43
+ }
6
44
  }
@@ -0,0 +1,140 @@
1
+ import { Array, Effect, Match } from "effect"
2
+ import { Prompt } from "effect/unstable/cli"
3
+ import type { Errors } from "@projitect/core"
4
+
5
+ /**
6
+ * The shape of the `"projitect"` field in a blueprint package's `package.json`. Defines how
7
+ * `pjt add <pkg>` should splice into the user's `.pjt.ts`.
8
+ */
9
+ export interface ProjitectPackageMetadata {
10
+ /**
11
+ * `"blueprint"` for single-call packages (e.g. one default export). `"blueprint-set"` for
12
+ * packages that expose multiple sections (e.g. `gitignores.macOs()`, `gitignores.node()`).
13
+ */
14
+ readonly type: "blueprint" | "blueprint-set"
15
+ /** The import statement to splice into `// pjt:imports`, e.g. `import { foo } from "foo"`. */
16
+ readonly import: string
17
+ /** For `type: "blueprint"` only — the call template, e.g. `myBlueprint()`. */
18
+ readonly call?: string
19
+ /** For `type: "blueprint-set"` only — substitute `{section}` with each chosen name. */
20
+ readonly callTemplate?: string
21
+ /** For `type: "blueprint-set"` only — the list of available section names. */
22
+ readonly sections?: readonly string[]
23
+ }
24
+
25
+ /**
26
+ * Strategy for picking which sections of a `blueprint-set` package to splice.
27
+ *
28
+ * - `"all"` — silent default; pick every section the package declares. Used when the caller
29
+ * wants the legacy "no --section means everything" behavior.
30
+ * - `{ kind: "explicit", sections }` — caller already knows. `--section macOs,node` lands here.
31
+ * - `{ kind: "ask", choose }` — interactive: defer to the supplied callback (typically
32
+ * `chooseSectionsInteractive`). The callback runs only when the installed package's metadata
33
+ * says `type: "blueprint-set"` and exposes sections.
34
+ *
35
+ * `R` parametrizes the callback's requirement channel so the caller can hand us an Effect
36
+ * that needs `Prompt.Environment` (FileSystem | Path | Terminal).
37
+ */
38
+ export type SectionStrategy<R = never> =
39
+ | { readonly _tag: "All" }
40
+ | { readonly _tag: "Explicit"; readonly sections: readonly string[] }
41
+ | {
42
+ readonly _tag: "Ask"
43
+ readonly choose: (
44
+ metadata: ProjitectPackageMetadata,
45
+ ) => Effect.Effect<readonly string[], Errors.ProjitectError, R>
46
+ }
47
+
48
+ /**
49
+ * What the user wants spliced from a package. Cancellation is its own case so it can never be
50
+ * mistaken for "all":
51
+ *
52
+ * - `All` — every declared section.
53
+ * - `Chosen` — exactly these sections (non-empty).
54
+ * - `Nothing` — the user picked no sections or quit the prompt; the host must not write.
55
+ */
56
+ export type SectionSelection =
57
+ | { readonly _tag: "All" }
58
+ | { readonly _tag: "Chosen"; readonly sections: readonly string[] }
59
+ | { readonly _tag: "Nothing" }
60
+
61
+ const chosenOr = (sections: readonly string[], whenEmpty: SectionSelection): SectionSelection =>
62
+ Array.isReadonlyArrayNonEmpty(sections) ? { _tag: "Chosen", sections } : whenEmpty
63
+
64
+ /** Resolve which sections the user wants, per `strategy`. */
65
+ export const resolveSections = <R>(params: {
66
+ readonly metadata: ProjitectPackageMetadata
67
+ readonly strategy: SectionStrategy<R>
68
+ }): Effect.Effect<SectionSelection, Errors.ProjitectError, R> => {
69
+ const { metadata, strategy } = params
70
+ // Section strategy only matters for blueprint-sets. For `type: "blueprint"` the selection is
71
+ // ignored — `computeSplice` emits a single non-templated call line.
72
+ if (metadata.type === "blueprint") {
73
+ return Effect.succeed({ _tag: "All" })
74
+ }
75
+ return Match.valueTags(strategy, {
76
+ All: () => Effect.succeed<SectionSelection>({ _tag: "All" }),
77
+ Explicit: (explicit) => Effect.succeed(chosenOr(explicit.sections, { _tag: "All" })),
78
+ Ask: (ask) =>
79
+ Effect.map(ask.choose(metadata), (picked) => chosenOr(picked, { _tag: "Nothing" })),
80
+ })
81
+ }
82
+
83
+ /**
84
+ * Compute the import line and call lines to splice for the selection. A `Nothing` selection
85
+ * yields no call lines and no sections; hosts check `selection._tag` and skip the write.
86
+ */
87
+ export const computeSplice = (params: {
88
+ readonly metadata: ProjitectPackageMetadata
89
+ readonly selection: SectionSelection
90
+ }): {
91
+ readonly importLine: string
92
+ readonly callLines: readonly string[]
93
+ readonly sectionsAdded: readonly string[]
94
+ } => {
95
+ const { metadata, selection } = params
96
+ if (metadata.type === "blueprint") {
97
+ const call =
98
+ metadata.call ?? "/* TODO: replace with the blueprint call from the package's README */"
99
+ return {
100
+ importLine: metadata.import,
101
+ callLines: [`${call},`],
102
+ sectionsAdded: [],
103
+ }
104
+ }
105
+ // Blueprint-set
106
+ const allSections = metadata.sections ?? []
107
+ const chosen = Match.value(selection).pipe(
108
+ Match.tag("All", () => allSections),
109
+ Match.tag("Chosen", (picked) => picked.sections.filter((s) => allSections.includes(s))),
110
+ Match.tag("Nothing", () => []),
111
+ Match.exhaustive,
112
+ )
113
+ const template = metadata.callTemplate ?? "{section}()"
114
+ return {
115
+ importLine: metadata.import,
116
+ callLines: chosen.map((s) => `${template.replace("{section}", s)},`),
117
+ sectionsAdded: chosen,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Interactive section picker for a `type: "blueprint-set"` package. Ctrl-C yields an empty
123
+ * selection rather than crashing; `resolveSections` maps an empty `Ask` result to
124
+ * `Nothing`, so quitting never splices anything.
125
+ *
126
+ * Returns an empty selection if no sections are declared (defensive default — shouldn't happen
127
+ * for well-formed metadata, but we don't want to crash on a malformed package).
128
+ */
129
+ export const chooseSectionsInteractive = (
130
+ metadata: ProjitectPackageMetadata,
131
+ ): Effect.Effect<readonly string[], never, Prompt.Environment> => {
132
+ const sections = metadata.sections ?? []
133
+ if (!Array.isReadonlyArrayNonEmpty(sections)) {
134
+ return Effect.succeed([])
135
+ }
136
+ return Prompt.multiSelect({
137
+ message: "Pick sections to add (space to toggle, enter to confirm):",
138
+ choices: sections.map((name) => ({ title: name, value: name })),
139
+ }).pipe(Effect.catchTag("QuitError", () => Effect.succeed<readonly string[]>([])))
140
+ }
@@ -0,0 +1,202 @@
1
+ import { Array, Effect } from "effect"
2
+ import { StructX } from "@nunofyobiz/effect-extras"
3
+ import type { Blueprint, Permission, Errors, BlueprintFileSystem } from "@projitect/core"
4
+ import { ChangeSet } from "@projitect/core"
5
+
6
+ /**
7
+ * Spec for a region-mode blueprint: replace or insert the marked section in a shared text file.
8
+ * Multiple blueprints can coexist in the same file as long as they use different `ownerId`s.
9
+ */
10
+ export interface RegionFileSpec {
11
+ readonly id: string
12
+ readonly version: string
13
+ readonly description?: string
14
+ readonly path: string
15
+ readonly commentPrefix?: string
16
+ /**
17
+ * Optional closing delimiter for prefix/suffix-pair comment styles. Set to `" -->"` for
18
+ * HTML/MDX. Leave undefined (default) for single-prefix styles like `#` and `//`.
19
+ */
20
+ readonly commentSuffix?: string
21
+ readonly content: string
22
+ readonly extraPermissions?: readonly Permission.Permission[]
23
+ }
24
+
25
+ /**
26
+ * Create a region-mode blueprint. The blueprint's `id` doubles as the region's `ownerId`.
27
+ * `commentPrefix` defaults to `#` (suitable for `.gitignore` / YAML / shell).
28
+ *
29
+ * For HTML / MDX / XML files, prefer the `markdownSection` helper — it sets the right
30
+ * prefix and suffix pair for you. For ignore-style files, prefer `ignoreSection` — same
31
+ * shape with a name that documents its intent.
32
+ */
33
+ export const regionFile = (spec: RegionFileSpec): Blueprint.Blueprint => ({
34
+ id: spec.id,
35
+ version: spec.version,
36
+ ...StructX.defined("description", spec.description),
37
+ permissions: [
38
+ { kind: "write", glob: spec.path },
39
+ { kind: "read", glob: spec.path },
40
+ ...(spec.extraPermissions ?? []),
41
+ ],
42
+ plan: Effect.succeed(
43
+ ChangeSet.of(
44
+ ChangeSet.RegionOp.make({
45
+ ownerId: spec.id,
46
+ path: spec.path,
47
+ commentPrefix: spec.commentPrefix ?? "#",
48
+ ...StructX.defined("commentSuffix", spec.commentSuffix),
49
+ content: spec.content,
50
+ }),
51
+ ),
52
+ ),
53
+ })
54
+
55
+ /**
56
+ * Spec for a merge-mode blueprint: deep-merge a partial value into a structured (JSON) file.
57
+ * The `ownedKeys` list declares which dotted-path keys this blueprint claims — two blueprints
58
+ * claiming the same key is a `pjt.plan.conflict-merge` error.
59
+ */
60
+ export interface JsonMergeSpec {
61
+ readonly id: string
62
+ readonly version: string
63
+ readonly description?: string
64
+ readonly path: string
65
+ readonly ownedKeys: readonly string[]
66
+ readonly value: unknown
67
+ readonly extraPermissions?: readonly Permission.Permission[]
68
+ }
69
+
70
+ /**
71
+ * Create a merge-mode blueprint for a JSON file (typically `package.json` / `tsconfig.json`).
72
+ */
73
+ export const jsonMerge = (spec: JsonMergeSpec): Blueprint.Blueprint => ({
74
+ id: spec.id,
75
+ version: spec.version,
76
+ ...StructX.defined("description", spec.description),
77
+ permissions: [
78
+ { kind: "write", glob: spec.path },
79
+ { kind: "read", glob: spec.path },
80
+ ...(spec.extraPermissions ?? []),
81
+ ],
82
+ plan: Effect.succeed(
83
+ ChangeSet.of(
84
+ ChangeSet.MergeOp.make({
85
+ ownerId: spec.id,
86
+ path: spec.path,
87
+ ownedKeys: spec.ownedKeys,
88
+ value: spec.value,
89
+ }),
90
+ ),
91
+ ),
92
+ })
93
+
94
+ /**
95
+ * Spec for an owned-mode blueprint: this blueprint owns the file's entire content. Any other
96
+ * blueprint touching the same path is a `pjt.plan.conflict-owned` error.
97
+ */
98
+ export interface OwnFileSpec {
99
+ readonly id: string
100
+ readonly version: string
101
+ readonly description?: string
102
+ readonly path: string
103
+ readonly content: string
104
+ /** Directory the file's owner manages; empty directories are pruned up to it on removal. */
105
+ readonly ownedDir?: string
106
+ readonly extraPermissions?: readonly Permission.Permission[]
107
+ }
108
+
109
+ export const ownFile = (spec: OwnFileSpec): Blueprint.Blueprint => ({
110
+ id: spec.id,
111
+ version: spec.version,
112
+ ...StructX.defined("description", spec.description),
113
+ permissions: [{ kind: "write", glob: spec.path }, ...(spec.extraPermissions ?? [])],
114
+ plan: Effect.succeed(
115
+ ChangeSet.of(
116
+ ChangeSet.OwnedOp.make({
117
+ ownerId: spec.id,
118
+ path: spec.path,
119
+ content: spec.content,
120
+ ...StructX.defined("ownedDir", spec.ownedDir),
121
+ }),
122
+ ),
123
+ ),
124
+ })
125
+
126
+ /**
127
+ * Spec for a seed-mode blueprint: write the file at first build, never enforce it again. Useful
128
+ * for templates the project maintainer is expected to customize (e.g. README, initial `.pjt.ts`).
129
+ */
130
+ export interface SeedFileSpec {
131
+ readonly id: string
132
+ readonly version: string
133
+ readonly description?: string
134
+ readonly path: string
135
+ readonly content: string
136
+ readonly extraPermissions?: readonly Permission.Permission[]
137
+ }
138
+
139
+ export const seedFile = (spec: SeedFileSpec): Blueprint.Blueprint => ({
140
+ id: spec.id,
141
+ version: spec.version,
142
+ ...StructX.defined("description", spec.description),
143
+ permissions: [{ kind: "write", glob: spec.path }, ...(spec.extraPermissions ?? [])],
144
+ plan: Effect.succeed(
145
+ ChangeSet.of(
146
+ ChangeSet.SeedOp.make({
147
+ ownerId: spec.id,
148
+ path: spec.path,
149
+ content: spec.content,
150
+ }),
151
+ ),
152
+ ),
153
+ })
154
+
155
+ /**
156
+ * Spec for a post-apply command: after every file operation has succeeded, `pjt build` and
157
+ * `pjt remodel` run `fix` from the project root. `check` is the command that reports whether the
158
+ * fix is needed. The blueprint declares an `exec` permission for each of the two command lines.
159
+ */
160
+ export interface ExecSpec {
161
+ readonly id: string
162
+ readonly version: string
163
+ readonly description?: string
164
+ readonly check: string
165
+ readonly fix: string
166
+ readonly extraPermissions?: readonly Permission.Permission[]
167
+ }
168
+
169
+ export const exec = (spec: ExecSpec): Blueprint.Blueprint => ({
170
+ id: spec.id,
171
+ version: spec.version,
172
+ ...StructX.defined("description", spec.description),
173
+ permissions: [
174
+ ...Array.dedupe([spec.fix, spec.check]).map(
175
+ (command): Permission.Permission => ({ kind: "exec", command }),
176
+ ),
177
+ ...(spec.extraPermissions ?? []),
178
+ ],
179
+ plan: Effect.succeed(
180
+ ChangeSet.of(ChangeSet.ExecOp.make({ ownerId: spec.id, command: spec.fix, check: spec.check })),
181
+ ),
182
+ })
183
+
184
+ /**
185
+ * Build a blueprint from a user-supplied plan Effect. Use this when your plan needs to read
186
+ * existing files, branch on detected state, or compose multiple operations.
187
+ */
188
+ export interface ComputedSpec {
189
+ readonly id: string
190
+ readonly version: string
191
+ readonly description?: string
192
+ readonly permissions: readonly Permission.Permission[]
193
+ readonly plan: Effect.Effect<ChangeSet.ChangeSet, Errors.BlueprintError, BlueprintFileSystem>
194
+ }
195
+
196
+ export const computed = (spec: ComputedSpec): Blueprint.Blueprint => ({
197
+ id: spec.id,
198
+ version: spec.version,
199
+ ...StructX.defined("description", spec.description),
200
+ permissions: spec.permissions,
201
+ plan: spec.plan,
202
+ })
package/src/detect.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { Effect } from "effect"
2
+ import { BlueprintFileSystem } from "@projitect/core"
3
+ import type { Errors } from "@projitect/core"
4
+
5
+ export type PackageManager = "pnpm" | "npm" | "yarn" | "bun"
6
+
7
+ /**
8
+ * Inspect lockfiles to determine which package manager the project uses. Reads (in order):
9
+ * `pnpm-lock.yaml`, `bun.lockb`, `yarn.lock`, `package-lock.json`. Falls back to `npm` if none
10
+ * are present.
11
+ *
12
+ * Returns the detected manager; the blueprint must declare `read:` permission on the relevant
13
+ * lockfile path globs (e.g. `pnpm-lock.yaml`).
14
+ */
15
+ export const detectPackageManager: Effect.Effect<
16
+ PackageManager,
17
+ Errors.FsPermissionDenied | Errors.FsReadFailed,
18
+ BlueprintFileSystem
19
+ > = Effect.gen(function* () {
20
+ const fs = yield* BlueprintFileSystem
21
+ if (yield* fs.exists("pnpm-lock.yaml")) {
22
+ return "pnpm" as const
23
+ }
24
+ if (yield* fs.exists("bun.lockb")) {
25
+ return "bun" as const
26
+ }
27
+ if (yield* fs.exists("yarn.lock")) {
28
+ return "yarn" as const
29
+ }
30
+ if (yield* fs.exists("package-lock.json")) {
31
+ return "npm" as const
32
+ }
33
+ return "npm" as const
34
+ })
35
+
36
+ /**
37
+ * Read and parse `package.json` from the project root. Returns the parsed object as `unknown` —
38
+ * blueprints should decode it with a `Schema` if they need typed access to fields.
39
+ */
40
+ export const readPackageJson: Effect.Effect<
41
+ unknown,
42
+ Errors.FsReadFailed | Errors.FsPermissionDenied,
43
+ BlueprintFileSystem
44
+ > = Effect.gen(function* () {
45
+ const fs = yield* BlueprintFileSystem
46
+ const raw = yield* fs.readFile("package.json")
47
+ return JSON.parse(raw) as unknown
48
+ })
@@ -0,0 +1,30 @@
1
+ import { Predicate } from "effect"
2
+ import type { Blueprint } from "@projitect/core"
3
+
4
+ /**
5
+ * Scope children to a subdirectory. Inside a `directory(name, [...])` group, every child
6
+ * blueprint's `path` is reinterpreted relative to `name/`, and write permissions outside the
7
+ * subtree are stripped from the children's declared `Permission`s.
8
+ *
9
+ * Implementation note: `directory` returns a `DirectoryBlueprint` — a tagged variant that the
10
+ * planner unpacks. Authors should treat the return value as opaque.
11
+ */
12
+ export interface DirectoryBlueprint {
13
+ readonly _tag: "Directory"
14
+ readonly name: string
15
+ readonly children: readonly (Blueprint.Blueprint | DirectoryBlueprint)[]
16
+ }
17
+
18
+ export const directory = (
19
+ name: string,
20
+ children: readonly (Blueprint.Blueprint | DirectoryBlueprint)[],
21
+ ): DirectoryBlueprint => ({
22
+ _tag: "Directory",
23
+ name,
24
+ children,
25
+ })
26
+
27
+ export const isDirectoryBlueprint = (
28
+ v: Blueprint.Blueprint | DirectoryBlueprint,
29
+ // `Directory` is the only member of the union carrying a `_tag`, so its presence is decisive.
30
+ ): v is DirectoryBlueprint => Predicate.hasProperty(v, "_tag")