@powerhousedao/ph-clint-cli 0.1.0-dev.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.
Files changed (81) hide show
  1. package/README.md +84 -0
  2. package/dist/agents/clint-agent.d.ts +12 -0
  3. package/dist/agents/clint-agent.js +42 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +79 -0
  6. package/dist/codegen/builders/agent-base-md.d.ts +5 -0
  7. package/dist/codegen/builders/agent-base-md.js +18 -0
  8. package/dist/codegen/builders/agent-ts.d.ts +9 -0
  9. package/dist/codegen/builders/agent-ts.js +34 -0
  10. package/dist/codegen/builders/app-index-ts.d.ts +18 -0
  11. package/dist/codegen/builders/app-index-ts.js +33 -0
  12. package/dist/codegen/builders/build-skills-ts.d.ts +4 -0
  13. package/dist/codegen/builders/build-skills-ts.js +37 -0
  14. package/dist/codegen/builders/cli-package-json.d.ts +8 -0
  15. package/dist/codegen/builders/cli-package-json.js +69 -0
  16. package/dist/codegen/builders/cli-ts.d.ts +7 -0
  17. package/dist/codegen/builders/cli-ts.js +110 -0
  18. package/dist/codegen/builders/config-ts.d.ts +8 -0
  19. package/dist/codegen/builders/config-ts.js +21 -0
  20. package/dist/codegen/builders/eslint-config-js.d.ts +4 -0
  21. package/dist/codegen/builders/eslint-config-js.js +13 -0
  22. package/dist/codegen/builders/framework-gen-ts.d.ts +20 -0
  23. package/dist/codegen/builders/framework-gen-ts.js +62 -0
  24. package/dist/codegen/builders/framework-ts.d.ts +21 -0
  25. package/dist/codegen/builders/framework-ts.js +74 -0
  26. package/dist/codegen/builders/gitignore.d.ts +4 -0
  27. package/dist/codegen/builders/gitignore.js +18 -0
  28. package/dist/codegen/builders/index.d.ts +43 -0
  29. package/dist/codegen/builders/index.js +60 -0
  30. package/dist/codegen/builders/jest-config-js.d.ts +4 -0
  31. package/dist/codegen/builders/jest-config-js.js +19 -0
  32. package/dist/codegen/builders/main-ts.d.ts +5 -0
  33. package/dist/codegen/builders/main-ts.js +13 -0
  34. package/dist/codegen/builders/mastra-index-ts.d.ts +8 -0
  35. package/dist/codegen/builders/mastra-index-ts.js +30 -0
  36. package/dist/codegen/builders/readme-md.d.ts +5 -0
  37. package/dist/codegen/builders/readme-md.js +44 -0
  38. package/dist/codegen/builders/root-package-json.d.ts +7 -0
  39. package/dist/codegen/builders/root-package-json.js +27 -0
  40. package/dist/codegen/builders/tsconfig-json.d.ts +4 -0
  41. package/dist/codegen/builders/tsconfig-json.js +22 -0
  42. package/dist/codegen/exec.d.ts +20 -0
  43. package/dist/codegen/exec.js +41 -0
  44. package/dist/codegen/hashes.d.ts +6 -0
  45. package/dist/codegen/hashes.js +60 -0
  46. package/dist/codegen/index.d.ts +39 -0
  47. package/dist/codegen/index.js +292 -0
  48. package/dist/codegen/markers.d.ts +36 -0
  49. package/dist/codegen/markers.js +86 -0
  50. package/dist/codegen/migrate/flat-to-split.d.ts +15 -0
  51. package/dist/codegen/migrate/flat-to-split.js +119 -0
  52. package/dist/codegen/scaffold.d.ts +34 -0
  53. package/dist/codegen/scaffold.js +60 -0
  54. package/dist/codegen/write.d.ts +6 -0
  55. package/dist/codegen/write.js +32 -0
  56. package/dist/commands/init.d.ts +57 -0
  57. package/dist/commands/init.js +168 -0
  58. package/dist/commands/regen.d.ts +18 -0
  59. package/dist/commands/regen.js +68 -0
  60. package/dist/config.d.ts +5 -0
  61. package/dist/config.js +7 -0
  62. package/dist/framework.d.ts +239 -0
  63. package/dist/framework.gen.d.ts +37 -0
  64. package/dist/framework.gen.js +16 -0
  65. package/dist/framework.js +41 -0
  66. package/dist/main.d.ts +2 -0
  67. package/dist/main.js +3 -0
  68. package/dist/mastra/index.d.ts +10 -0
  69. package/dist/mastra/index.js +11 -0
  70. package/dist/spec/file.d.ts +4 -0
  71. package/dist/spec/file.js +34 -0
  72. package/dist/spec/from-document.d.ts +18 -0
  73. package/dist/spec/from-document.js +30 -0
  74. package/dist/spec/types.d.ts +62 -0
  75. package/dist/spec/types.js +112 -0
  76. package/dist/triggers/spec-change.d.ts +43 -0
  77. package/dist/triggers/spec-change.js +171 -0
  78. package/package.json +60 -0
  79. package/prompts/agent-profiles/AgentBase.md +13 -0
  80. package/prompts/skills-ext/.gitkeep +0 -0
  81. package/prompts/skills-tpl/.gitkeep +0 -0
@@ -0,0 +1,39 @@
1
+ import { type ClintProjectSpec } from '../spec/types.js';
2
+ export type GenerateMode = 'create' | 'update' | 'auto';
3
+ export interface GenerateProjectOptions {
4
+ /** Absolute path to the project root. */
5
+ targetDir: string;
6
+ spec: ClintProjectSpec;
7
+ /** Defaults to `'auto'` — create if no persisted spec, else update. */
8
+ mode?: GenerateMode;
9
+ /** Create mode: allow writing into a non-empty directory. */
10
+ allowNonEmpty?: boolean;
11
+ /**
12
+ * Update mode: overwrite user-edited files (hash-mismatch) anyway.
13
+ * Also relaxes the git-dirty guard on flat → split migration.
14
+ */
15
+ force?: boolean;
16
+ /** Optional warning sink (diagnostics, not errors). */
17
+ onWarn?: (msg: string) => void;
18
+ }
19
+ export interface GeneratedFile {
20
+ absolutePath: string;
21
+ relativePath: string;
22
+ }
23
+ export interface GenerateProjectResult {
24
+ mode: 'create' | 'update';
25
+ /** Files newly written or rewritten. */
26
+ files: GeneratedFile[];
27
+ /** Files skipped because the user had edited them. */
28
+ skipped: GeneratedFile[];
29
+ /** Files removed because the new spec no longer emits them. */
30
+ deleted: GeneratedFile[];
31
+ /** True if a flat → split migration ran. */
32
+ migrated: boolean;
33
+ cliDir: string;
34
+ appDir: string | null;
35
+ }
36
+ /**
37
+ * Generate / reconcile a project at `targetDir` from the given spec.
38
+ */
39
+ export declare function generateProject(options: GenerateProjectOptions): Promise<GenerateProjectResult>;
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Code generator entry point.
3
+ *
4
+ * Takes a `ClintProjectSpec` + target directory and reconciles the on-disk
5
+ * project tree with it.
6
+ *
7
+ * Two modes:
8
+ *
9
+ * - **create** — the target dir is empty (or only contains ignorable
10
+ * artefacts). Every templated file is written fresh. Refuses to
11
+ * overwrite existing non-empty state unless `allowNonEmpty`.
12
+ *
13
+ * - **update** — a previous run's spec exists at
14
+ * `.ph/ph-clint-cli/project-spec.json`. Re-runs the builders, then for
15
+ * each emitted file:
16
+ * - files containing `@clint:begin/@clint:end` markers (today: just
17
+ * `src/cli.ts`) have their marker regions spliced in-place, leaving
18
+ * user code outside markers untouched;
19
+ * - files without markers are overwritten iff their on-disk hash still
20
+ * matches the hash stored from the previous write (user-edited files
21
+ * are skipped and surfaced via `onWarn`);
22
+ * - files the new spec no longer emits are removed if their hash still
23
+ * matches.
24
+ *
25
+ * A spec flip of `features.powerhouse.enabled` from `false` to `true`
26
+ * triggers the flat → split migration before the update flow runs.
27
+ *
28
+ * Mode is auto-detected by the presence of the persisted spec.
29
+ */
30
+ import fs from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import { getAppFolderName, getCliFolderName, } from '../spec/types.js';
33
+ import { getSpecPath, readProjectSpec, writeProjectSpec, } from '../spec/file.js';
34
+ import { hashContent, hashFile, readHashes, writeHashes, } from './hashes.js';
35
+ import { hasMarkers, spliceMarkerRegions } from './markers.js';
36
+ import { writeFileEnsuringDir, isDirEmptyEnough } from './write.js';
37
+ import { migrateFlatToSplit } from './migrate/flat-to-split.js';
38
+ import { CLI_FILE_BUILDERS, buildReadme, buildRootPackageJson, buildAppIndexTs, } from './builders/index.js';
39
+ /**
40
+ * Generate / reconcile a project at `targetDir` from the given spec.
41
+ */
42
+ export async function generateProject(options) {
43
+ const { targetDir, spec } = options;
44
+ const warn = options.onWarn ?? (() => { });
45
+ const existingSpec = await readProjectSpec(targetDir);
46
+ const resolvedMode = resolveMode(options.mode ?? 'auto', existingSpec !== null);
47
+ // Flat → split migration, if needed. Runs BEFORE we compute planned files
48
+ // so the hash record and on-disk state are already in split-layout shape
49
+ // by the time the normal reconciliation runs.
50
+ let migrated = false;
51
+ if (resolvedMode === 'update' &&
52
+ existingSpec &&
53
+ !existingSpec.features.powerhouse.enabled &&
54
+ spec.features.powerhouse.enabled) {
55
+ await migrateFlatToSplit({
56
+ targetDir,
57
+ spec,
58
+ force: options.force,
59
+ onWarn: warn,
60
+ });
61
+ migrated = true;
62
+ }
63
+ // Dispatch to the mode-specific flow.
64
+ if (resolvedMode === 'create') {
65
+ return runCreate(options, warn);
66
+ }
67
+ return runUpdate(options, warn, migrated);
68
+ }
69
+ function resolveMode(requested, specExists) {
70
+ if (requested === 'create')
71
+ return 'create';
72
+ if (requested === 'update')
73
+ return 'update';
74
+ return specExists ? 'update' : 'create';
75
+ }
76
+ /** Build the list of files the new spec would emit, with their content. */
77
+ function planFiles(spec, targetDir) {
78
+ const split = spec.features.powerhouse.enabled;
79
+ const cliDir = split ? path.join(targetDir, getCliFolderName(spec)) : targetDir;
80
+ const appDir = split ? path.join(targetDir, getAppFolderName(spec)) : null;
81
+ const planned = [];
82
+ for (const builder of CLI_FILE_BUILDERS) {
83
+ const content = builder.build(spec);
84
+ if (content === null)
85
+ continue;
86
+ const abs = path.join(cliDir, builder.relativePath);
87
+ planned.push({
88
+ relativePath: path.relative(targetDir, abs),
89
+ absolutePath: abs,
90
+ content,
91
+ initOnly: builder.initOnly,
92
+ });
93
+ }
94
+ // Project-root README — always at the top level.
95
+ const readmeAbs = path.join(targetDir, 'README.md');
96
+ planned.push({
97
+ relativePath: 'README.md',
98
+ absolutePath: readmeAbs,
99
+ content: buildReadme(spec),
100
+ });
101
+ // Split-layout only: root package.json + app placeholder files.
102
+ if (split && appDir) {
103
+ const rootPkg = path.join(targetDir, 'package.json');
104
+ planned.push({
105
+ relativePath: 'package.json',
106
+ absolutePath: rootPkg,
107
+ content: buildRootPackageJson(spec),
108
+ });
109
+ const appGitkeep = path.join(appDir, '.gitkeep');
110
+ planned.push({
111
+ relativePath: path.relative(targetDir, appGitkeep),
112
+ absolutePath: appGitkeep,
113
+ content: '',
114
+ });
115
+ const appReadme = path.join(appDir, 'README.md');
116
+ planned.push({
117
+ relativePath: path.relative(targetDir, appReadme),
118
+ absolutePath: appReadme,
119
+ content: buildAppReadmeContent(spec),
120
+ });
121
+ // Top-level barrel for the reactor package — lets impl code import from
122
+ // `{name}-app` instead of drilling into `document-models/<slug>/v1/gen`.
123
+ const appIndex = buildAppIndexTs(spec);
124
+ if (appIndex !== null) {
125
+ const appIndexAbs = path.join(appDir, 'index.ts');
126
+ planned.push({
127
+ relativePath: path.relative(targetDir, appIndexAbs),
128
+ absolutePath: appIndexAbs,
129
+ content: appIndex,
130
+ });
131
+ }
132
+ }
133
+ return { planned, cliDir, appDir };
134
+ }
135
+ function buildAppReadmeContent(spec) {
136
+ return [
137
+ `# ${spec.name}-app`,
138
+ '',
139
+ 'Powerhouse reactor-package for this project. Scaffold it with:',
140
+ '',
141
+ '```sh',
142
+ `cd ${spec.name}-app`,
143
+ 'ph init',
144
+ '```',
145
+ '',
146
+ 'Then run `pnpm install` at the project root.',
147
+ '',
148
+ ].join('\n');
149
+ }
150
+ /** Create mode — empty target dir, write everything fresh. */
151
+ async function runCreate(options, _warn) {
152
+ const { targetDir, spec } = options;
153
+ if (!options.allowNonEmpty) {
154
+ const empty = await isDirEmptyEnough(targetDir);
155
+ if (!empty) {
156
+ throw new Error(`target directory ${targetDir} is not empty (pass allowNonEmpty or --force to override)`);
157
+ }
158
+ }
159
+ const { planned, cliDir, appDir } = planFiles(spec, targetDir);
160
+ const hashes = {};
161
+ const files = [];
162
+ for (const p of planned) {
163
+ await writeFileEnsuringDir(p.absolutePath, p.content);
164
+ hashes[p.relativePath] = hashContent(p.content);
165
+ files.push({ absolutePath: p.absolutePath, relativePath: p.relativePath });
166
+ }
167
+ await writeProjectSpec(targetDir, spec);
168
+ files.push({
169
+ absolutePath: getSpecPath(targetDir),
170
+ relativePath: path.relative(targetDir, getSpecPath(targetDir)),
171
+ });
172
+ await writeHashes(targetDir, hashes);
173
+ return {
174
+ mode: 'create',
175
+ files,
176
+ skipped: [],
177
+ deleted: [],
178
+ migrated: false,
179
+ cliDir,
180
+ appDir,
181
+ };
182
+ }
183
+ /** Update mode — reconcile against an existing project. */
184
+ async function runUpdate(options, warn, migrated) {
185
+ const { targetDir, spec } = options;
186
+ const force = !!options.force;
187
+ const { planned, cliDir, appDir } = planFiles(spec, targetDir);
188
+ const plannedByKey = new Map(planned.map((p) => [p.relativePath, p]));
189
+ const previous = await readHashes(targetDir);
190
+ const next = {};
191
+ const files = [];
192
+ const skipped = [];
193
+ const deleted = [];
194
+ // Step 1: write / patch / skip each planned file.
195
+ for (const p of planned) {
196
+ if (p.initOnly) {
197
+ // User-owned files (e.g. `src/framework.ts` with its `configSchema`)
198
+ // are emitted only on create. In update mode we never overwrite them
199
+ // and never re-create them if missing — the user may have deliberately
200
+ // deleted or inlined them. Keep whatever hash the previous run stored
201
+ // so the deletion pass (step 2) still considers them "ours".
202
+ const stored = previous[p.relativePath];
203
+ if (stored !== undefined)
204
+ next[p.relativePath] = stored;
205
+ continue;
206
+ }
207
+ const existing = await hashFile(p.absolutePath);
208
+ if (existing === null) {
209
+ // Not yet on disk — fresh write.
210
+ await writeFileEnsuringDir(p.absolutePath, p.content);
211
+ next[p.relativePath] = hashContent(p.content);
212
+ files.push({
213
+ absolutePath: p.absolutePath,
214
+ relativePath: p.relativePath,
215
+ });
216
+ continue;
217
+ }
218
+ // File exists. Decide how to update it.
219
+ if (hasMarkers(p.content)) {
220
+ const existingContent = await fs.readFile(p.absolutePath, 'utf8');
221
+ const patched = hasMarkers(existingContent)
222
+ ? spliceMarkerRegions(existingContent, p.content)
223
+ : p.content;
224
+ if (patched === existingContent) {
225
+ // No-op: regions already match.
226
+ next[p.relativePath] = hashContent(patched);
227
+ continue;
228
+ }
229
+ await writeFileEnsuringDir(p.absolutePath, patched);
230
+ next[p.relativePath] = hashContent(patched);
231
+ files.push({
232
+ absolutePath: p.absolutePath,
233
+ relativePath: p.relativePath,
234
+ });
235
+ continue;
236
+ }
237
+ // No markers — hash-protected overwrite.
238
+ const stored = previous[p.relativePath];
239
+ const pristine = stored !== undefined && stored === existing;
240
+ if (!pristine && !force) {
241
+ // User edited a managed file. Leave it alone.
242
+ warn(`skipped ${p.relativePath} — modified since last generation. Use --force to overwrite.`);
243
+ skipped.push({
244
+ absolutePath: p.absolutePath,
245
+ relativePath: p.relativePath,
246
+ });
247
+ // Preserve the previous hash so the file stays on our ledger.
248
+ if (stored !== undefined)
249
+ next[p.relativePath] = stored;
250
+ continue;
251
+ }
252
+ if (existing === hashContent(p.content)) {
253
+ // Already up to date.
254
+ next[p.relativePath] = existing;
255
+ continue;
256
+ }
257
+ await writeFileEnsuringDir(p.absolutePath, p.content);
258
+ next[p.relativePath] = hashContent(p.content);
259
+ files.push({
260
+ absolutePath: p.absolutePath,
261
+ relativePath: p.relativePath,
262
+ });
263
+ }
264
+ // Step 2: delete files that the new spec no longer emits, IF we still
265
+ // recognise their content (hash match). Otherwise warn and keep.
266
+ for (const [relPath, storedHash] of Object.entries(previous)) {
267
+ if (plannedByKey.has(relPath))
268
+ continue;
269
+ const abs = path.join(targetDir, relPath);
270
+ const onDisk = await hashFile(abs);
271
+ if (onDisk === null)
272
+ continue; // already gone
273
+ if (onDisk !== storedHash && !force) {
274
+ warn(`kept ${relPath} — modified since last generation; new spec no longer emits it.`);
275
+ next[relPath] = storedHash;
276
+ continue;
277
+ }
278
+ await fs.rm(abs, { force: true });
279
+ deleted.push({ absolutePath: abs, relativePath: relPath });
280
+ }
281
+ await writeProjectSpec(targetDir, spec);
282
+ await writeHashes(targetDir, next);
283
+ return {
284
+ mode: 'update',
285
+ files,
286
+ skipped,
287
+ deleted,
288
+ migrated,
289
+ cliDir,
290
+ appDir,
291
+ };
292
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Marker-region parsing for `@clint:begin {name}` / `@clint:end {name}`
3
+ * comment blocks. Used in delta mode to splice freshly-generated regions
4
+ * into an existing, user-edited file while preserving everything outside
5
+ * the markers.
6
+ *
7
+ * The only syntactic requirement is that the markers are on their own lines,
8
+ * as ordinary line comments (`//`). Indentation is preserved for output.
9
+ */
10
+ export interface MarkerRegion {
11
+ /** Region identifier (the text after `@clint:begin`). */
12
+ name: string;
13
+ /** Lines *between* the begin and end markers (exclusive). */
14
+ body: string[];
15
+ /** Indentation of the begin line — used when rewriting. */
16
+ indent: string;
17
+ /** Line index of the begin marker in the source. */
18
+ beginLine: number;
19
+ /** Line index of the end marker in the source. */
20
+ endLine: number;
21
+ }
22
+ /**
23
+ * Parse every `@clint:begin {name}` … `@clint:end {name}` block in `source`.
24
+ * Throws if markers are mismatched or unclosed.
25
+ */
26
+ export declare function parseMarkerRegions(source: string): MarkerRegion[];
27
+ /**
28
+ * Replace every region in `target` that also exists in `source` with the
29
+ * `source` region's body. Leaves regions present in `target` but not `source`
30
+ * untouched. Ignores regions in `source` missing from `target`.
31
+ *
32
+ * Returns the rewritten file content.
33
+ */
34
+ export declare function spliceMarkerRegions(target: string, source: string): string;
35
+ /** Convenience — true if `content` contains at least one `@clint:begin`. */
36
+ export declare function hasMarkers(content: string): boolean;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Marker-region parsing for `@clint:begin {name}` / `@clint:end {name}`
3
+ * comment blocks. Used in delta mode to splice freshly-generated regions
4
+ * into an existing, user-edited file while preserving everything outside
5
+ * the markers.
6
+ *
7
+ * The only syntactic requirement is that the markers are on their own lines,
8
+ * as ordinary line comments (`//`). Indentation is preserved for output.
9
+ */
10
+ const BEGIN_RE = /^([ \t]*)\/\/\s*@clint:begin\s+(\S+)\s*$/;
11
+ const END_RE = /^([ \t]*)\/\/\s*@clint:end\s+(\S+)\s*$/;
12
+ /**
13
+ * Parse every `@clint:begin {name}` … `@clint:end {name}` block in `source`.
14
+ * Throws if markers are mismatched or unclosed.
15
+ */
16
+ export function parseMarkerRegions(source) {
17
+ const lines = source.split('\n');
18
+ const regions = [];
19
+ let open = null;
20
+ let bodyStart = 0;
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i];
23
+ const begin = BEGIN_RE.exec(line);
24
+ if (begin) {
25
+ if (open) {
26
+ throw new Error(`nested @clint:begin ${begin[2]} at line ${i + 1} while ${open.name} is still open`);
27
+ }
28
+ open = { name: begin[2], indent: begin[1], beginLine: i };
29
+ bodyStart = i + 1;
30
+ continue;
31
+ }
32
+ const end = END_RE.exec(line);
33
+ if (end) {
34
+ if (!open) {
35
+ throw new Error(`@clint:end ${end[2]} at line ${i + 1} without matching begin`);
36
+ }
37
+ if (open.name !== end[2]) {
38
+ throw new Error(`@clint:end ${end[2]} at line ${i + 1} does not match open @clint:begin ${open.name}`);
39
+ }
40
+ regions.push({
41
+ name: open.name,
42
+ body: lines.slice(bodyStart, i),
43
+ indent: open.indent,
44
+ beginLine: open.beginLine,
45
+ endLine: i,
46
+ });
47
+ open = null;
48
+ }
49
+ }
50
+ if (open) {
51
+ throw new Error(`unterminated @clint:begin ${open.name}`);
52
+ }
53
+ return regions;
54
+ }
55
+ /**
56
+ * Replace every region in `target` that also exists in `source` with the
57
+ * `source` region's body. Leaves regions present in `target` but not `source`
58
+ * untouched. Ignores regions in `source` missing from `target`.
59
+ *
60
+ * Returns the rewritten file content.
61
+ */
62
+ export function spliceMarkerRegions(target, source) {
63
+ const sourceRegions = parseMarkerRegions(source);
64
+ const sourceByName = new Map();
65
+ for (const r of sourceRegions)
66
+ sourceByName.set(r.name, r);
67
+ const targetRegions = parseMarkerRegions(target);
68
+ if (targetRegions.length === 0)
69
+ return target;
70
+ const lines = target.split('\n');
71
+ // Apply replacements from last to first so line indices stay valid.
72
+ const sorted = [...targetRegions].sort((a, b) => b.beginLine - a.beginLine);
73
+ for (const region of sorted) {
74
+ const fresh = sourceByName.get(region.name);
75
+ if (!fresh)
76
+ continue;
77
+ // Replace the body lines (exclusive of the begin/end marker lines).
78
+ const bodyLen = region.endLine - region.beginLine - 1;
79
+ lines.splice(region.beginLine + 1, bodyLen, ...fresh.body);
80
+ }
81
+ return lines.join('\n');
82
+ }
83
+ /** Convenience — true if `content` contains at least one `@clint:begin`. */
84
+ export function hasMarkers(content) {
85
+ return /^[ \t]*\/\/\s*@clint:begin\s+\S+\s*$/m.test(content);
86
+ }
@@ -0,0 +1,15 @@
1
+ import { type ClintProjectSpec } from '../../spec/types.js';
2
+ export interface FlatToSplitOptions {
3
+ targetDir: string;
4
+ spec: ClintProjectSpec;
5
+ /** Bypass the git-dirty guard. */
6
+ force?: boolean;
7
+ /** Optional warn sink for non-fatal messages. */
8
+ onWarn?: (msg: string) => void;
9
+ }
10
+ export interface FlatToSplitResult {
11
+ movedEntries: string[];
12
+ cliDir: string;
13
+ appDir: string;
14
+ }
15
+ export declare function migrateFlatToSplit(options: FlatToSplitOptions): Promise<FlatToSplitResult>;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * One-shot migration that runs when a project's spec flips
3
+ * `features.powerhouse.enabled` from `false` to `true`. It reshapes the
4
+ * on-disk layout from flat (everything at the project root) to split
5
+ * (`{name}-cli/` + `{name}-app/` siblings).
6
+ *
7
+ * The follow-up update-mode regeneration then fills in the correct content
8
+ * for each file in its new location — so this migration only moves bytes
9
+ * around. Content updates belong to the generator.
10
+ *
11
+ * `node_modules` is deliberately NOT moved — pnpm `file:` protocol uses
12
+ * symlinks that do not survive a cross-directory rename cleanly. Callers
13
+ * should re-run `pnpm install` after the migration.
14
+ */
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+ import { getAppFolderName, getCliFolderName, } from '../../spec/types.js';
19
+ import { readHashes, writeHashes } from '../hashes.js';
20
+ /** Entries we never move during a flat → split migration. */
21
+ function isUnmovable(entry, cliFolder, appFolder) {
22
+ if (entry === '.git')
23
+ return true;
24
+ if (entry === '.ph')
25
+ return true;
26
+ if (entry === 'README.md')
27
+ return true;
28
+ if (entry === 'node_modules')
29
+ return true;
30
+ if (entry === cliFolder)
31
+ return true;
32
+ if (entry === appFolder)
33
+ return true;
34
+ return false;
35
+ }
36
+ /**
37
+ * Guard: if the target is a git repo with a dirty working tree, refuse to
38
+ * migrate unless `force`. We never want to discard uncommitted edits.
39
+ * Resolves to `null` when the dir is not a git repo.
40
+ */
41
+ async function gitDirty(dir) {
42
+ return new Promise((resolve) => {
43
+ const child = spawn('git', ['status', '--porcelain'], {
44
+ cwd: dir,
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ });
47
+ let out = '';
48
+ child.stdout?.on('data', (d) => {
49
+ out += d.toString();
50
+ });
51
+ child.once('error', () => resolve(null));
52
+ child.once('close', (code) => {
53
+ if (code !== 0)
54
+ resolve(null);
55
+ else
56
+ resolve(out.trim() || null);
57
+ });
58
+ });
59
+ }
60
+ export async function migrateFlatToSplit(options) {
61
+ const { targetDir, spec } = options;
62
+ const cliFolder = getCliFolderName(spec);
63
+ const appFolder = getAppFolderName(spec);
64
+ const cliDir = path.join(targetDir, cliFolder);
65
+ const appDir = path.join(targetDir, appFolder);
66
+ if (!options.force) {
67
+ const dirty = await gitDirty(targetDir);
68
+ if (dirty) {
69
+ throw new Error(`refusing to migrate flat → split: ${targetDir} has uncommitted changes.\n` +
70
+ 'Commit or stash first, or re-run with --force.');
71
+ }
72
+ }
73
+ // Create the CLI destination. If it already exists and is non-empty we
74
+ // treat that as a malformed state and bail — a previous migration probably
75
+ // got interrupted.
76
+ await fs.mkdir(cliDir, { recursive: true });
77
+ const cliExisting = await fs.readdir(cliDir);
78
+ if (cliExisting.length > 0) {
79
+ throw new Error(`cannot migrate: ${cliDir} already exists and is non-empty. ` +
80
+ 'Resolve manually before re-running.');
81
+ }
82
+ const entries = await fs.readdir(targetDir);
83
+ const moved = [];
84
+ for (const entry of entries) {
85
+ if (isUnmovable(entry, cliFolder, appFolder)) {
86
+ if (entry === 'node_modules' && options.onWarn) {
87
+ options.onWarn('skipped node_modules during flat → split migration; re-run `pnpm install`.');
88
+ }
89
+ continue;
90
+ }
91
+ const src = path.join(targetDir, entry);
92
+ const dst = path.join(cliDir, entry);
93
+ await fs.rename(src, dst);
94
+ moved.push(entry);
95
+ }
96
+ // Rekey stored hashes so they still line up with the moved paths.
97
+ await rekeyHashes(targetDir, moved, cliFolder);
98
+ // Create the app folder with a placeholder so tooling (git, `ph init`) can
99
+ // find it. The generator and/or `ph init` fills in the real contents.
100
+ await fs.mkdir(appDir, { recursive: true });
101
+ return { movedEntries: moved, cliDir, appDir };
102
+ }
103
+ async function rekeyHashes(targetDir, movedEntries, cliFolder) {
104
+ const before = await readHashes(targetDir);
105
+ if (Object.keys(before).length === 0)
106
+ return;
107
+ const movedSet = new Set(movedEntries);
108
+ const after = {};
109
+ for (const [key, hash] of Object.entries(before)) {
110
+ const top = key.split(/[\\/]/)[0];
111
+ if (movedSet.has(top)) {
112
+ after[path.posix.join(cliFolder, key)] = hash;
113
+ }
114
+ else {
115
+ after[key] = hash;
116
+ }
117
+ }
118
+ await writeHashes(targetDir, after);
119
+ }
@@ -0,0 +1,34 @@
1
+ import type { ClintProjectSpec } from '../spec/types.js';
2
+ export interface PhInitOptions {
3
+ /** Absolute path to the project root. */
4
+ targetDir: string;
5
+ /** Absolute path to `{targetDir}/{name}-app` (exists with our placeholder). */
6
+ appDir: string;
7
+ spec: ClintProjectSpec;
8
+ /** Logger; defaults to noop. */
9
+ log?: (msg: string) => void;
10
+ /** Override `ph` binary name (for tests). */
11
+ binName?: string;
12
+ /** Stdio passthrough (tests pass 'ignore'). */
13
+ stdio?: 'inherit' | 'ignore';
14
+ }
15
+ export interface PhInitResult {
16
+ ran: boolean;
17
+ exitCode?: number;
18
+ reason?: string;
19
+ }
20
+ export declare function runPhInit(options: PhInitOptions): Promise<PhInitResult>;
21
+ export interface PnpmInstallOptions {
22
+ /** Directories to run `pnpm install` in, in order. */
23
+ dirs: string[];
24
+ log?: (msg: string) => void;
25
+ /** Override `pnpm` binary name (for tests). */
26
+ binName?: string;
27
+ stdio?: 'inherit' | 'ignore';
28
+ }
29
+ export interface PnpmInstallResult {
30
+ ran: string[];
31
+ skipped: string[];
32
+ reason?: string;
33
+ }
34
+ export declare function runPnpmInstall(options: PnpmInstallOptions): Promise<PnpmInstallResult>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Post-generation side-effects: invoke `ph init` to fill in the reactor
3
+ * package skeleton, and run `pnpm install` in the relevant sub-projects.
4
+ *
5
+ * Both steps are opt-out (flags on the init command) and both tolerate
6
+ * missing binaries — they never fail the init flow, only emit warnings.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { hasCommandOnPath, runCommand } from './exec.js';
11
+ export async function runPhInit(options) {
12
+ const log = options.log ?? (() => { });
13
+ const binName = options.binName ?? 'ph';
14
+ const appFolder = path.basename(options.appDir);
15
+ if (!(await hasCommandOnPath(binName))) {
16
+ log(`Skipping \`${binName} init\` — \`${binName}\` is not on PATH. ` +
17
+ `Install @powerhousedao/ph-cli and re-run manually inside ${appFolder}/.`);
18
+ return { ran: false, reason: 'ph-not-on-path' };
19
+ }
20
+ // `ph init {name}` creates a fresh directory beside the cwd. Our generator
21
+ // placed a `.gitkeep` + README.md placeholder there; clear it out so
22
+ // ph init has a blank slate to work with.
23
+ await fs.rm(options.appDir, { recursive: true, force: true });
24
+ log(`Running \`${binName} init ${appFolder}\` in ${options.targetDir} …`);
25
+ const result = await runCommand(binName, [
26
+ 'init',
27
+ appFolder,
28
+ '--pnpm',
29
+ '--dev',
30
+ ], { cwd: options.targetDir, stdio: options.stdio ?? 'inherit' });
31
+ if (result.exitCode !== 0) {
32
+ log(`\`${binName} init\` exited with code ${result.exitCode}. ` +
33
+ 'You can re-run it manually after resolving the issue.');
34
+ }
35
+ return { ran: true, exitCode: result.exitCode };
36
+ }
37
+ export async function runPnpmInstall(options) {
38
+ const log = options.log ?? (() => { });
39
+ const binName = options.binName ?? 'pnpm';
40
+ if (!(await hasCommandOnPath(binName))) {
41
+ log(`Skipping \`${binName} install\` — \`${binName}\` is not on PATH. ` +
42
+ 'Install pnpm and re-run manually.');
43
+ return { ran: [], skipped: options.dirs, reason: 'pnpm-not-on-path' };
44
+ }
45
+ const ran = [];
46
+ for (const dir of options.dirs) {
47
+ log(`Running \`${binName} install\` in ${dir} …`);
48
+ const result = await runCommand(binName, ['install'], {
49
+ cwd: dir,
50
+ stdio: options.stdio ?? 'inherit',
51
+ });
52
+ if (result.exitCode !== 0) {
53
+ log(`\`${binName} install\` exited with code ${result.exitCode} in ${dir}. ` +
54
+ 'Retry manually once the underlying issue is resolved.');
55
+ // Don't abort the whole chain — subsequent dirs may still work.
56
+ }
57
+ ran.push(dir);
58
+ }
59
+ return { ran, skipped: [] };
60
+ }