@powerhousedao/ph-clint-cli 0.1.0-dev.33 → 0.1.0-dev.35

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,42 @@
1
+ import type { GenerateProjectResult } from './index.js';
2
+ import type { ClintProjectSpec, ExternalSkill } from '../spec/types.js';
3
+ import type { ProcessRunOptions } from '@powerhousedao/ph-clint';
4
+ export type PostGenAction = {
5
+ kind: 'ph-init';
6
+ targetDir: string;
7
+ appDir: string;
8
+ spec: ClintProjectSpec;
9
+ } | {
10
+ kind: 'app-install';
11
+ dir: string;
12
+ } | {
13
+ kind: 'app-build';
14
+ dir: string;
15
+ } | {
16
+ kind: 'cli-install';
17
+ dir: string;
18
+ } | {
19
+ kind: 'cli-build';
20
+ dir: string;
21
+ } | {
22
+ kind: 'skills-sync';
23
+ targetDir: string;
24
+ desired: ExternalSkill[];
25
+ };
26
+ export type PostGenActionKind = PostGenAction['kind'];
27
+ export interface PostGenActionContext {
28
+ log: (msg: string) => void;
29
+ runProcess?: (command: string, opts?: Omit<ProcessRunOptions, 'onOutput'>) => Promise<{
30
+ success: boolean;
31
+ output: string;
32
+ }>;
33
+ skip?: Set<PostGenActionKind>;
34
+ }
35
+ export interface PostGenActionResult {
36
+ action: PostGenAction;
37
+ status: 'success' | 'failed' | 'skipped';
38
+ reason?: string;
39
+ durationMs: number;
40
+ }
41
+ export declare function collectPostGenActions(result: GenerateProjectResult, spec: ClintProjectSpec): Promise<PostGenAction[]>;
42
+ export declare function runPostGenActions(actions: PostGenAction[], ctx: PostGenActionContext): Promise<PostGenActionResult[]>;
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Post-generation actions — a typed pipeline that runs after codegen.
3
+ *
4
+ * `collectPostGenActions()` inspects a `GenerateProjectResult` to determine
5
+ * which side-effects (install, build, ph-init, skills-sync) are needed.
6
+ *
7
+ * `runPostGenActions()` executes them sequentially with proper ordering,
8
+ * failure cascading, and log output.
9
+ */
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ // ---------------------------------------------------------------------------
13
+ // Fixed execution order — index defines precedence
14
+ // ---------------------------------------------------------------------------
15
+ const ACTION_ORDER = [
16
+ 'ph-init',
17
+ 'app-install',
18
+ 'app-build',
19
+ 'cli-install',
20
+ 'cli-build',
21
+ 'skills-sync',
22
+ ];
23
+ function actionIndex(kind) {
24
+ return ACTION_ORDER.indexOf(kind);
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // Dependency map — which actions depend on which (for failure cascading)
28
+ // ---------------------------------------------------------------------------
29
+ /** Returns true if `downstream` depends on `upstream` succeeding. */
30
+ function dependsOn(downstream, upstream) {
31
+ // ph-init → app-install → app-build → cli-install → cli-build
32
+ // skills-sync is independent of the build chain
33
+ const chain = [
34
+ 'ph-init',
35
+ 'app-install',
36
+ 'app-build',
37
+ 'cli-install',
38
+ 'cli-build',
39
+ ];
40
+ if (downstream === 'skills-sync')
41
+ return false;
42
+ const di = chain.indexOf(downstream);
43
+ const ui = chain.indexOf(upstream);
44
+ if (di < 0 || ui < 0)
45
+ return false;
46
+ return di > ui;
47
+ }
48
+ // ---------------------------------------------------------------------------
49
+ // Helpers
50
+ // ---------------------------------------------------------------------------
51
+ async function fileExists(filePath) {
52
+ try {
53
+ await fs.access(filePath);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // collectPostGenActions
62
+ // ---------------------------------------------------------------------------
63
+ export async function collectPostGenActions(result, spec) {
64
+ const split = result.appDir !== null;
65
+ const relPaths = new Set(result.files.map((f) => f.relativePath));
66
+ // Determine the earliest triggered action in the chain
67
+ let earliest = null;
68
+ if (split) {
69
+ const appFolder = result.appDir ? path.basename(result.appDir) : '';
70
+ const cliFolder = path.basename(result.cliDir);
71
+ // ph-init needed: app dir placeholder written but app not yet initialized.
72
+ // Check the actual filesystem — if package.json already exists on disk,
73
+ // ph init has already run and must not run again.
74
+ const appPkgJsonChanged = relPaths.has(path.join(appFolder, 'package.json'));
75
+ const appGitkeepWritten = relPaths.has(path.join(appFolder, '.gitkeep'));
76
+ // ph-init needed: app dir placeholder written but app not yet initialized.
77
+ // Check the actual filesystem — if package.json already exists on disk,
78
+ // ph init has already run and must not run again.
79
+ if (appGitkeepWritten && !appPkgJsonChanged) {
80
+ const appAlreadyInitialized = result.appDir
81
+ ? await fileExists(path.join(result.appDir, 'package.json'))
82
+ : false;
83
+ if (!appAlreadyInitialized) {
84
+ earliest = 'ph-init';
85
+ }
86
+ }
87
+ // Migration always triggers full rebuild
88
+ if (result.migrated) {
89
+ earliest = earliest ?? 'app-install';
90
+ }
91
+ // App package.json changed
92
+ if (!earliest && appPkgJsonChanged) {
93
+ earliest = 'app-install';
94
+ }
95
+ // App source regenerated (framework.gen.ts, index.ts, etc.)
96
+ if (!earliest) {
97
+ for (const rel of relPaths) {
98
+ if (rel.startsWith(appFolder + '/') &&
99
+ rel.endsWith('.ts')) {
100
+ earliest = 'app-build';
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ // CLI package.json changed
106
+ if (!earliest) {
107
+ if (relPaths.has(path.join(cliFolder, 'package.json'))) {
108
+ earliest = 'cli-install';
109
+ }
110
+ }
111
+ // CLI source or prompts regenerated (.ts files, agent profile .md files)
112
+ if (!earliest) {
113
+ for (const rel of relPaths) {
114
+ if (rel.startsWith(cliFolder + '/') &&
115
+ (rel.endsWith('.ts') || rel.endsWith('.md'))) {
116
+ earliest = 'cli-build';
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ }
122
+ else {
123
+ // Flat layout
124
+ if (relPaths.has('package.json')) {
125
+ earliest = 'cli-install';
126
+ }
127
+ else {
128
+ for (const rel of relPaths) {
129
+ if (rel.endsWith('.ts')) {
130
+ earliest = 'cli-build';
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ // Build the action list from earliest onward
137
+ const actions = [];
138
+ if (earliest && split && result.appDir) {
139
+ const ei = actionIndex(earliest);
140
+ if (ei <= actionIndex('ph-init')) {
141
+ actions.push({
142
+ kind: 'ph-init',
143
+ targetDir: path.dirname(result.appDir),
144
+ appDir: result.appDir,
145
+ spec,
146
+ });
147
+ }
148
+ if (ei <= actionIndex('app-install')) {
149
+ actions.push({ kind: 'app-install', dir: result.appDir });
150
+ }
151
+ if (ei <= actionIndex('app-build')) {
152
+ actions.push({ kind: 'app-build', dir: result.appDir });
153
+ }
154
+ if (ei <= actionIndex('cli-install')) {
155
+ actions.push({ kind: 'cli-install', dir: result.cliDir });
156
+ }
157
+ if (ei <= actionIndex('cli-build')) {
158
+ actions.push({ kind: 'cli-build', dir: result.cliDir });
159
+ }
160
+ }
161
+ else if (earliest && !split) {
162
+ const ei = actionIndex(earliest);
163
+ if (ei <= actionIndex('cli-install')) {
164
+ actions.push({ kind: 'cli-install', dir: result.cliDir });
165
+ }
166
+ if (ei <= actionIndex('cli-build')) {
167
+ actions.push({ kind: 'cli-build', dir: result.cliDir });
168
+ }
169
+ }
170
+ // skills-sync is independent — append if needed
171
+ if (spec.externalSkills.length > 0) {
172
+ const targetDir = split ? result.cliDir : result.cliDir;
173
+ actions.push({
174
+ kind: 'skills-sync',
175
+ targetDir,
176
+ desired: spec.externalSkills,
177
+ });
178
+ }
179
+ return actions;
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Human-readable label for an action
183
+ // ---------------------------------------------------------------------------
184
+ function actionLabel(action) {
185
+ switch (action.kind) {
186
+ case 'ph-init':
187
+ return `Initialize reactor package (${path.basename(action.appDir)})`;
188
+ case 'app-install':
189
+ return `Install dependencies (${path.basename(action.dir)})`;
190
+ case 'app-build':
191
+ return `Build (${path.basename(action.dir)})`;
192
+ case 'cli-install':
193
+ return `Install dependencies (${path.basename(action.dir)})`;
194
+ case 'cli-build':
195
+ return `Build (${path.basename(action.dir)})`;
196
+ case 'skills-sync':
197
+ return `Sync external skills (${action.desired.length} skill${action.desired.length === 1 ? '' : 's'})`;
198
+ }
199
+ }
200
+ // ---------------------------------------------------------------------------
201
+ // runPostGenActions — effectful runner
202
+ // ---------------------------------------------------------------------------
203
+ export async function runPostGenActions(actions, ctx) {
204
+ if (actions.length === 0)
205
+ return [];
206
+ const skip = ctx.skip ?? new Set();
207
+ // Filter out explicitly skipped actions. Also skip builds if their
208
+ // corresponding install is skipped (building without install is pointless).
209
+ const effective = actions.filter((a) => {
210
+ if (skip.has(a.kind))
211
+ return false;
212
+ // If app-install is skipped, also skip app-build
213
+ if (a.kind === 'app-build' && skip.has('app-install'))
214
+ return false;
215
+ // If cli-install is skipped, also skip cli-build
216
+ if (a.kind === 'cli-build' && skip.has('cli-install'))
217
+ return false;
218
+ return true;
219
+ });
220
+ if (effective.length === 0)
221
+ return [];
222
+ // Print numbered summary
223
+ ctx.log(`\nPost-generation steps (${effective.length}):`);
224
+ effective.forEach((a, i) => {
225
+ ctx.log(` ${i + 1}. ${actionLabel(a)}`);
226
+ });
227
+ ctx.log('');
228
+ const results = [];
229
+ const failed = new Set();
230
+ for (let i = 0; i < effective.length; i++) {
231
+ const action = effective[i];
232
+ const label = actionLabel(action);
233
+ const prefix = `[${i + 1}/${effective.length}]`;
234
+ // Check if a dependency failed
235
+ const failedDep = [...failed].find((f) => dependsOn(action.kind, f));
236
+ if (failedDep) {
237
+ ctx.log(`${prefix} ${label}... skipped (dependency failed)`);
238
+ results.push({
239
+ action,
240
+ status: 'skipped',
241
+ reason: 'dependency failed',
242
+ durationMs: 0,
243
+ });
244
+ continue;
245
+ }
246
+ ctx.log(`${prefix} ${label}...`);
247
+ const t0 = Date.now();
248
+ try {
249
+ const success = await executeAction(action, ctx);
250
+ const elapsed = Date.now() - t0;
251
+ if (success) {
252
+ ctx.log(` done (${formatDuration(elapsed)})`);
253
+ results.push({ action, status: 'success', durationMs: elapsed });
254
+ }
255
+ else {
256
+ ctx.log(` FAILED`);
257
+ results.push({ action, status: 'failed', durationMs: elapsed });
258
+ failed.add(action.kind);
259
+ }
260
+ }
261
+ catch (err) {
262
+ const elapsed = Date.now() - t0;
263
+ const msg = err instanceof Error ? err.message : String(err);
264
+ ctx.log(` FAILED: ${msg}`);
265
+ results.push({
266
+ action,
267
+ status: 'failed',
268
+ reason: msg,
269
+ durationMs: elapsed,
270
+ });
271
+ failed.add(action.kind);
272
+ }
273
+ }
274
+ // Print summary
275
+ const succeeded = results.filter((r) => r.status === 'success').length;
276
+ const failedCount = results.filter((r) => r.status === 'failed').length;
277
+ const skippedCount = results.filter((r) => r.status === 'skipped').length;
278
+ ctx.log('');
279
+ if (failedCount === 0 && skippedCount === 0) {
280
+ ctx.log(`Post-generation: ${succeeded} step${succeeded === 1 ? '' : 's'} completed successfully.`);
281
+ ctx.log('Ready — run `pnpm dev` to start.');
282
+ }
283
+ else {
284
+ ctx.log(`Post-generation: ${succeeded} succeeded, ${failedCount} failed, ${skippedCount} skipped.`);
285
+ if (failedCount > 0) {
286
+ ctx.log('Fix the reported error(s) and re-run `ph-clint clint-project-regen`.');
287
+ }
288
+ }
289
+ return results;
290
+ }
291
+ // ---------------------------------------------------------------------------
292
+ // Action dispatch
293
+ // ---------------------------------------------------------------------------
294
+ async function executeAction(action, ctx) {
295
+ switch (action.kind) {
296
+ case 'ph-init': {
297
+ const { runPhInit } = await import('./scaffold.js');
298
+ const result = await runPhInit({
299
+ targetDir: action.targetDir,
300
+ appDir: action.appDir,
301
+ spec: action.spec,
302
+ log: (msg) => ctx.log(` ${msg}`),
303
+ runProcess: ctx.runProcess,
304
+ });
305
+ if (!result.ran) {
306
+ ctx.log(` ${result.reason ?? 'ph init did not run'}`);
307
+ return false;
308
+ }
309
+ if (result.exitCode === 0) {
310
+ // Mark app as initialized in generated state
311
+ const { readGeneratedState, writeGeneratedState } = await import('./generated.js');
312
+ const gen = await readGeneratedState(action.targetDir);
313
+ if (gen) {
314
+ gen.appInitialized = true;
315
+ await writeGeneratedState(action.targetDir, gen);
316
+ }
317
+ }
318
+ return result.exitCode === 0;
319
+ }
320
+ case 'app-install':
321
+ case 'cli-install': {
322
+ if (ctx.runProcess) {
323
+ const result = await ctx.runProcess('pnpm install', {
324
+ cwd: action.dir,
325
+ timeout: 300_000,
326
+ });
327
+ return result.success;
328
+ }
329
+ // Fallback: direct spawn
330
+ const { runCommand } = await import('./exec.js');
331
+ const result = await runCommand('pnpm', ['install'], {
332
+ cwd: action.dir,
333
+ stdio: 'inherit',
334
+ });
335
+ return result.exitCode === 0;
336
+ }
337
+ case 'app-build':
338
+ case 'cli-build': {
339
+ return runBuild(action.dir, ctx);
340
+ }
341
+ case 'skills-sync': {
342
+ const { syncExternalSkills } = await import('../skills/sync.js');
343
+ await syncExternalSkills({
344
+ targetDir: action.targetDir,
345
+ desired: action.desired,
346
+ log: (msg) => ctx.log(` ${msg}`),
347
+ });
348
+ return true;
349
+ }
350
+ }
351
+ }
352
+ async function runBuild(dir, ctx) {
353
+ if (ctx.runProcess) {
354
+ const result = await ctx.runProcess('pnpm build', {
355
+ cwd: dir,
356
+ timeout: 120_000,
357
+ });
358
+ return result.success;
359
+ }
360
+ // Fallback: direct spawn
361
+ const { runCommand } = await import('./exec.js');
362
+ const result = await runCommand('pnpm', ['build'], {
363
+ cwd: dir,
364
+ stdio: 'inherit',
365
+ });
366
+ return result.exitCode === 0;
367
+ }
368
+ function formatDuration(ms) {
369
+ if (ms < 1000)
370
+ return `${ms}ms`;
371
+ return `${(ms / 1000).toFixed(1)}s`;
372
+ }
@@ -36,6 +36,7 @@ export function buildRootPackageJson(spec) {
36
36
  name: getPackageName(spec),
37
37
  version: spec.version,
38
38
  private: true,
39
+ type: 'module',
39
40
  ...(spec.description ? { description: spec.description } : {}),
40
41
  scripts,
41
42
  };
@@ -0,0 +1,17 @@
1
+ import { type ClintProjectSpec } from '../spec/types.js';
2
+ export interface GeneratedState {
3
+ name: string;
4
+ scope: string | undefined;
5
+ cliFolderName: string;
6
+ appFolderName: string;
7
+ /** True once `ph init` has run successfully in the app dir. */
8
+ appInitialized: boolean;
9
+ }
10
+ export declare function getGeneratedPath(targetDir: string): string;
11
+ export declare function readGeneratedState(targetDir: string): Promise<GeneratedState | null>;
12
+ export declare function writeGeneratedState(targetDir: string, state: GeneratedState): Promise<void>;
13
+ /**
14
+ * Derive a `GeneratedState` from a spec. The `appInitialized` flag must be
15
+ * supplied by the caller (it depends on filesystem state, not the spec).
16
+ */
17
+ export declare function generatedStateFromSpec(spec: ClintProjectSpec, appInitialized: boolean): GeneratedState;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tracks the derived state from the last successful code generation.
3
+ *
4
+ * Stored at `{targetDir}/.ph/ph-clint-cli/generated.json`. Used to detect
5
+ * folder renames (name/scope changes) and re-apply patches that depend on
6
+ * the project identity.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { getAppFolderName, getCliFolderName, } from '../spec/types.js';
11
+ const GEN_DIR = path.join('.ph', 'ph-clint-cli');
12
+ const GEN_FILE = 'generated.json';
13
+ export function getGeneratedPath(targetDir) {
14
+ return path.join(targetDir, GEN_DIR, GEN_FILE);
15
+ }
16
+ export async function readGeneratedState(targetDir) {
17
+ try {
18
+ const raw = await fs.readFile(getGeneratedPath(targetDir), 'utf8');
19
+ return JSON.parse(raw);
20
+ }
21
+ catch (err) {
22
+ if (err &&
23
+ typeof err === 'object' &&
24
+ 'code' in err &&
25
+ err.code === 'ENOENT') {
26
+ return null;
27
+ }
28
+ throw err;
29
+ }
30
+ }
31
+ export async function writeGeneratedState(targetDir, state) {
32
+ const file = getGeneratedPath(targetDir);
33
+ await fs.mkdir(path.dirname(file), { recursive: true });
34
+ await fs.writeFile(file, JSON.stringify(state, null, 2) + '\n', 'utf8');
35
+ }
36
+ /**
37
+ * Derive a `GeneratedState` from a spec. The `appInitialized` flag must be
38
+ * supplied by the caller (it depends on filesystem state, not the spec).
39
+ */
40
+ export function generatedStateFromSpec(spec, appInitialized) {
41
+ const split = spec.features.powerhouse !== 'Disabled';
42
+ return {
43
+ name: spec.name,
44
+ scope: spec.scope,
45
+ cliFolderName: split ? getCliFolderName(spec) : '',
46
+ appFolderName: split ? getAppFolderName(spec) : '',
47
+ appInitialized,
48
+ };
49
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ClintProjectSpec } from '../spec/types.js';
2
+ import { type PostGenAction } from './actions.js';
2
3
  export type GenerateMode = 'create' | 'update' | 'auto';
3
4
  export interface GenerateProjectOptions {
4
5
  /** Absolute path to the project root. */
@@ -32,6 +33,8 @@ export interface GenerateProjectResult {
32
33
  migrated: boolean;
33
34
  cliDir: string;
34
35
  appDir: string | null;
36
+ /** Pending post-generation actions derived from what changed. */
37
+ pendingActions: PostGenAction[];
35
38
  }
36
39
  /**
37
40
  * Generate / reconcile a project at `targetDir` from the given spec.
@@ -36,6 +36,9 @@ import { hasMarkers, spliceMarkerRegions } from './markers.js';
36
36
  import { writeFileEnsuringDir, isDirEmptyEnough } from './write.js';
37
37
  import { migrateFlatToSplit } from './migrate/flat-to-split.js';
38
38
  import { CLI_FILE_BUILDERS, getProfileFileBuilders, buildReadme, buildRootPackageJson, buildPublishConfigJs, buildAppIndexTs, } from './builders/index.js';
39
+ import { collectPostGenActions, } from './actions.js';
40
+ import { readGeneratedState, writeGeneratedState, generatedStateFromSpec, } from './generated.js';
41
+ import { patchAppPackageName } from './scaffold.js';
39
42
  /**
40
43
  * Generate / reconcile a project at `targetDir` from the given spec.
41
44
  */
@@ -155,6 +158,82 @@ function buildAppReadmeContent(spec) {
155
158
  '',
156
159
  ].join('\n');
157
160
  }
161
+ // ---------------------------------------------------------------------------
162
+ // Folder rename handling
163
+ // ---------------------------------------------------------------------------
164
+ async function fileExists(filePath) {
165
+ try {
166
+ await fs.access(filePath);
167
+ return true;
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
173
+ /**
174
+ * Detect and execute folder renames when the project name changes.
175
+ *
176
+ * Reads the previous `GeneratedState` to find the old folder names, compares
177
+ * with the new spec's folder names. If they differ:
178
+ * 1. Renames the directories on disk.
179
+ * 2. Rewrites hash-record keys from old → new prefixes.
180
+ *
181
+ * Must run BEFORE `planFiles()` so the reconciliation loop sees the files
182
+ * in their new locations.
183
+ */
184
+ async function handleFolderRename(targetDir, spec, prev, warn) {
185
+ const split = spec.features.powerhouse !== 'Disabled';
186
+ if (!split || !prev || !prev.cliFolderName) {
187
+ return { renamed: false, appInitialized: prev?.appInitialized ?? false };
188
+ }
189
+ const newCliFolder = getCliFolderName(spec);
190
+ const newAppFolder = getAppFolderName(spec);
191
+ const oldCliFolder = prev.cliFolderName;
192
+ const oldAppFolder = prev.appFolderName;
193
+ if (oldCliFolder === newCliFolder && oldAppFolder === newAppFolder) {
194
+ return { renamed: false, appInitialized: prev.appInitialized };
195
+ }
196
+ // Rename CLI folder
197
+ if (oldCliFolder !== newCliFolder) {
198
+ const oldPath = path.join(targetDir, oldCliFolder);
199
+ const newPath = path.join(targetDir, newCliFolder);
200
+ if (await fileExists(oldPath)) {
201
+ if (await fileExists(newPath)) {
202
+ warn(`cannot rename ${oldCliFolder} → ${newCliFolder}: target already exists`);
203
+ return { renamed: false, appInitialized: prev.appInitialized };
204
+ }
205
+ await fs.rename(oldPath, newPath);
206
+ }
207
+ }
208
+ // Rename app folder
209
+ if (oldAppFolder !== newAppFolder) {
210
+ const oldPath = path.join(targetDir, oldAppFolder);
211
+ const newPath = path.join(targetDir, newAppFolder);
212
+ if (await fileExists(oldPath)) {
213
+ if (await fileExists(newPath)) {
214
+ warn(`cannot rename ${oldAppFolder} → ${newAppFolder}: target already exists`);
215
+ return { renamed: false, appInitialized: prev.appInitialized };
216
+ }
217
+ await fs.rename(oldPath, newPath);
218
+ }
219
+ }
220
+ // Rewrite hash keys from old folder prefixes to new ones
221
+ const hashes = await readHashes(targetDir);
222
+ const remapped = {};
223
+ for (const [key, value] of Object.entries(hashes)) {
224
+ let newKey = key;
225
+ if (oldCliFolder !== newCliFolder && key.startsWith(oldCliFolder + '/')) {
226
+ newKey = newCliFolder + key.slice(oldCliFolder.length);
227
+ }
228
+ else if (oldAppFolder !== newAppFolder &&
229
+ key.startsWith(oldAppFolder + '/')) {
230
+ newKey = newAppFolder + key.slice(oldAppFolder.length);
231
+ }
232
+ remapped[newKey] = value;
233
+ }
234
+ await writeHashes(targetDir, remapped);
235
+ return { renamed: true, appInitialized: prev.appInitialized };
236
+ }
158
237
  /** Create mode — empty target dir, write everything fresh. */
159
238
  async function runCreate(options, _warn) {
160
239
  const { targetDir, spec } = options;
@@ -178,7 +257,9 @@ async function runCreate(options, _warn) {
178
257
  relativePath: path.relative(targetDir, getSpecPath(targetDir)),
179
258
  });
180
259
  await writeHashes(targetDir, hashes);
181
- return {
260
+ // App is not yet initialized on create — ph-init runs as a post-gen action.
261
+ await writeGeneratedState(targetDir, generatedStateFromSpec(spec, false));
262
+ const result = {
182
263
  mode: 'create',
183
264
  files,
184
265
  skipped: [],
@@ -186,12 +267,18 @@ async function runCreate(options, _warn) {
186
267
  migrated: false,
187
268
  cliDir,
188
269
  appDir,
270
+ pendingActions: [],
189
271
  };
272
+ result.pendingActions = await collectPostGenActions(result, spec);
273
+ return result;
190
274
  }
191
275
  /** Update mode — reconcile against an existing project. */
192
276
  async function runUpdate(options, warn, migrated) {
193
277
  const { targetDir, spec } = options;
194
278
  const force = !!options.force;
279
+ // --- Folder rename detection ---
280
+ const prev = await readGeneratedState(targetDir);
281
+ const { renamed, appInitialized: appWasInitialized } = await handleFolderRename(targetDir, spec, prev, warn);
195
282
  const { planned, cliDir, appDir } = planFiles(spec, targetDir);
196
283
  const plannedByKey = new Map(planned.map((p) => [p.relativePath, p]));
197
284
  const previous = await readHashes(targetDir);
@@ -295,7 +382,27 @@ async function runUpdate(options, warn, migrated) {
295
382
  }
296
383
  await writeProjectSpec(targetDir, spec);
297
384
  await writeHashes(targetDir, next);
298
- return {
385
+ // Re-patch app package.json if name or scope changed and app is initialized.
386
+ // Add to `files` so collectPostGenActions sees the change and triggers
387
+ // app-install → app-build → cli-install → cli-build.
388
+ const appIsInitialized = appDir
389
+ ? await fileExists(path.join(appDir, 'package.json'))
390
+ : false;
391
+ const nameOrScopeChanged = prev
392
+ ? prev.name !== spec.name || prev.scope !== spec.scope
393
+ : false;
394
+ if (appDir && appIsInitialized && nameOrScopeChanged) {
395
+ const patched = await patchAppPackageName(appDir, spec, warn);
396
+ if (patched) {
397
+ const appFolder = path.basename(appDir);
398
+ files.push({
399
+ absolutePath: path.join(appDir, 'package.json'),
400
+ relativePath: path.join(appFolder, 'package.json'),
401
+ });
402
+ }
403
+ }
404
+ await writeGeneratedState(targetDir, generatedStateFromSpec(spec, appIsInitialized));
405
+ const result = {
299
406
  mode: 'update',
300
407
  files,
301
408
  skipped,
@@ -303,5 +410,8 @@ async function runUpdate(options, warn, migrated) {
303
410
  migrated,
304
411
  cliDir,
305
412
  appDir,
413
+ pendingActions: [],
306
414
  };
415
+ result.pendingActions = await collectPostGenActions(result, spec);
416
+ return result;
307
417
  }
@@ -34,6 +34,16 @@ export interface PhInitResult {
34
34
  */
35
35
  export declare function getPhVersion(binName: string): string | undefined;
36
36
  export declare function runPhInit(options: PhInitOptions): Promise<PhInitResult>;
37
+ /**
38
+ * Patch the app `package.json` name to the scoped form (`@scope/name-app`).
39
+ * Called after `ph init` and after name/scope renames.
40
+ *
41
+ * Returns `true` if the file was actually changed.
42
+ */
43
+ export declare function patchAppPackageName(appDir: string, spec: {
44
+ name: string;
45
+ scope?: string;
46
+ }, log?: (msg: string) => void): Promise<boolean>;
37
47
  export interface PnpmInstallOptions {
38
48
  /** Directories to run `pnpm install` in, in order. */
39
49
  dirs: string[];
@@ -31,6 +31,15 @@ export async function runPhInit(options) {
31
31
  const log = options.log ?? (() => { });
32
32
  const binName = options.binName ?? 'ph';
33
33
  const appFolder = path.basename(options.appDir);
34
+ // Guard: never re-init an already-initialized app directory.
35
+ try {
36
+ await fs.access(path.join(options.appDir, 'package.json'));
37
+ log(`App directory ${appFolder} already initialized — skipping ph init.`);
38
+ return { ran: false, reason: 'already-initialized' };
39
+ }
40
+ catch {
41
+ // No package.json — proceed with init.
42
+ }
34
43
  if (!(await hasCommandOnPath(binName))) {
35
44
  log(`Skipping \`${binName} init\` — \`${binName}\` is not on PATH. ` +
36
45
  `Install @powerhousedao/ph-cli and re-run manually inside ${appFolder}/.`);
@@ -76,25 +85,40 @@ export async function runPhInit(options) {
76
85
  await patchScopedName(options, appFolder, log);
77
86
  return { ran: true, exitCode: result.exitCode };
78
87
  }
79
- async function patchScopedName(options, appFolder, log) {
80
- if (!options.spec.scope)
81
- return;
82
- const appPkgJsonPath = path.join(options.appDir, 'package.json');
88
+ /**
89
+ * Patch the app `package.json` name to the scoped form (`@scope/name-app`).
90
+ * Called after `ph init` and after name/scope renames.
91
+ *
92
+ * Returns `true` if the file was actually changed.
93
+ */
94
+ export async function patchAppPackageName(appDir, spec, log) {
95
+ const appFolder = path.basename(appDir);
96
+ const scopedName = spec.scope
97
+ ? `@${spec.scope}/${appFolder}`
98
+ : appFolder;
99
+ const appPkgJsonPath = path.join(appDir, 'package.json');
83
100
  try {
84
101
  const raw = await fs.readFile(appPkgJsonPath, 'utf8');
85
102
  const pkg = JSON.parse(raw);
86
- const scopedName = `@${options.spec.scope}/${appFolder}`;
87
103
  if (pkg.name !== scopedName) {
88
104
  pkg.name = scopedName;
89
- pkg.publishConfig = { access: 'public' };
105
+ if (spec.scope) {
106
+ pkg.publishConfig = { access: 'public' };
107
+ }
90
108
  await fs.writeFile(appPkgJsonPath, JSON.stringify(pkg, null, 2) + '\n');
91
- log(`Patched app package name to ${scopedName}`);
109
+ log?.(`Patched app package name to ${scopedName}`);
110
+ return true;
92
111
  }
112
+ return false;
93
113
  }
94
114
  catch (err) {
95
- log(`Warning: could not patch app package name: ${err instanceof Error ? err.message : String(err)}`);
115
+ log?.(`Warning: could not patch app package name: ${err instanceof Error ? err.message : String(err)}`);
116
+ return false;
96
117
  }
97
118
  }
119
+ async function patchScopedName(options, _appFolder, log) {
120
+ await patchAppPackageName(options.appDir, options.spec, log);
121
+ }
98
122
  export async function runPnpmInstall(options) {
99
123
  const log = options.log ?? (() => { });
100
124
  const binName = options.binName ?? 'pnpm';
@@ -18,7 +18,7 @@ import { z } from 'zod';
18
18
  import { clintProjectSpecSchema, } from '../spec/types.js';
19
19
  import { generateProject } from '../codegen/index.js';
20
20
  import { isDirEmptyEnough } from '../codegen/write.js';
21
- import { runPhInit, runPnpmInstall } from '../codegen/scaffold.js';
21
+ import { runPostGenActions } from '../codegen/actions.js';
22
22
  import { writeProjectSpec } from '../spec/file.js';
23
23
  import { specToImportInput } from '../triggers/spec-change.js';
24
24
  const inputSchema = z.object({
@@ -143,37 +143,20 @@ export const init = defineCommand({
143
143
  stdout(`Note: could not create spec document — ${err instanceof Error ? err.message : String(err)}\n`);
144
144
  }
145
145
  }
146
- const split = spec.features.powerhouse !== 'Disabled';
147
- // Reactor package scaffolding (split layout only).
148
- if (split && result.appDir && !input.skipPhInit) {
149
- const phResult = await runPhInit({
150
- targetDir,
151
- appDir: result.appDir,
152
- spec,
153
- log: (msg) => stdout(msg + '\n'),
154
- runProcess,
155
- });
156
- if (!phResult.ran) {
157
- stdout(`Note: \`ph init\` did not run — ${phResult.reason ?? 'see above'}.\n`);
158
- }
159
- }
160
- // Dependency installation.
161
- if (!input.skipInstall) {
162
- const dirs = [];
163
- if (split) {
164
- if (result.appDir)
165
- dirs.push(result.appDir);
166
- dirs.push(result.cliDir);
167
- }
168
- else {
169
- dirs.push(targetDir);
170
- }
171
- await runPnpmInstall({
172
- dirs,
173
- log: (msg) => stdout(msg + '\n'),
174
- runProcess,
175
- });
146
+ // Run post-generation actions (ph-init, install, build, skills-sync).
147
+ const skip = new Set();
148
+ if (input.skipPhInit)
149
+ skip.add('ph-init');
150
+ if (input.skipInstall) {
151
+ skip.add('app-install');
152
+ skip.add('cli-install');
176
153
  }
154
+ await runPostGenActions(result.pendingActions, {
155
+ log: (msg) => stdout(msg + '\n'),
156
+ runProcess,
157
+ skip,
158
+ });
159
+ const split = spec.features.powerhouse !== 'Disabled';
177
160
  const next = [];
178
161
  next.push('');
179
162
  next.push('Next steps:');
@@ -11,6 +11,7 @@ import { defineCommand } from '@powerhousedao/ph-clint';
11
11
  import { z } from 'zod';
12
12
  import { readProjectSpec } from '../spec/file.js';
13
13
  import { generateProject } from '../codegen/index.js';
14
+ import { runPostGenActions } from '../codegen/actions.js';
14
15
  const inputSchema = z.object({
15
16
  dir: z
16
17
  .string()
@@ -25,7 +26,7 @@ export const regen = defineCommand({
25
26
  id: 'clint-project-regen',
26
27
  description: 'Regenerate project files from the persisted spec',
27
28
  inputSchema,
28
- execute: async (input, { workdir, stdout, log }) => {
29
+ execute: async (input, { workdir, stdout, log, runProcess }) => {
29
30
  const targetDir = path.resolve(workdir, input.dir ?? '.');
30
31
  const spec = await readProjectSpec(targetDir);
31
32
  if (!spec) {
@@ -53,6 +54,11 @@ export const regen = defineCommand({
53
54
  stdout('warning: ' + w + '\n');
54
55
  }
55
56
  stdout(lines.join('\n') + '\n');
57
+ // Run post-generation actions (install, build, skills-sync).
58
+ await runPostGenActions(result.pendingActions, {
59
+ log: (msg) => stdout(msg + '\n'),
60
+ runProcess,
61
+ });
56
62
  return {
57
63
  text: lines.join('\n'),
58
64
  data: {
@@ -20,10 +20,9 @@ import { scanProjects, getProjectMapping } from '@powerhousedao/ph-clint';
20
20
  import { importSpec } from '@powerhousedao/ph-clint-app/document-models/ph-clint-project';
21
21
  import { createDocumentChangeTrigger } from '../framework.js';
22
22
  import { generateProject } from '../codegen/index.js';
23
- import { runPhInit } from '../codegen/scaffold.js';
23
+ import { runPostGenActions } from '../codegen/actions.js';
24
24
  import { specFromDocumentState } from '../spec/from-document.js';
25
25
  import { readProjectSpec, writeProjectSpec } from '../spec/file.js';
26
- import { syncExternalSkills } from '../skills/sync.js';
27
26
  import { clintProject } from '../services/clint-project.js';
28
27
  const DOCUMENT_TYPE = 'powerhouse/ph-clint-project';
29
28
  const HASH_DIR = path.join('.ph', 'ph-clint-cli');
@@ -184,62 +183,11 @@ export const specChangeTrigger = createDocumentChangeTrigger({
184
183
  for (const w of warnings)
185
184
  log.warn(`${TAG} ${w}`);
186
185
  }
187
- // Scaffold the reactor package (app dir) if it hasn't been initialized yet.
188
- if (result.appDir) {
189
- const appPkgJson = path.join(result.appDir, 'package.json');
190
- let appExists = false;
191
- try {
192
- await fs.access(appPkgJson);
193
- appExists = true;
194
- }
195
- catch { /* noop */ }
196
- if (!appExists) {
197
- log?.info(`${TAG} initializing reactor package at ${result.appDir}`);
198
- const phResult = await runPhInit({
199
- targetDir,
200
- appDir: result.appDir,
201
- spec,
202
- log: (msg) => log?.info(`${TAG} ${msg}`),
203
- runProcess: ctx.context.runProcess,
204
- });
205
- if (!phResult.ran) {
206
- log?.warn(`${TAG} ph init skipped: ${phResult.reason ?? 'unknown'}`);
207
- }
208
- else if (phResult.exitCode !== 0) {
209
- log?.warn(`${TAG} ph init exited with code ${phResult.exitCode}`);
210
- }
211
- }
212
- // Always ensure the app package name matches the spec's scope,
213
- // even if ph init ran in a previous trigger cycle without scope.
214
- if (spec.scope) {
215
- try {
216
- const raw = await fs.readFile(appPkgJson, 'utf8');
217
- const pkg = JSON.parse(raw);
218
- const appFolder = path.basename(result.appDir);
219
- const scopedName = `@${spec.scope}/${appFolder}`;
220
- if (pkg.name !== scopedName) {
221
- pkg.name = scopedName;
222
- pkg.publishConfig = { access: 'public' };
223
- await fs.writeFile(appPkgJson, JSON.stringify(pkg, null, 2) + '\n');
224
- log?.info(`${TAG} patched app package name to ${scopedName}`);
225
- }
226
- }
227
- catch (err) {
228
- log?.warn(`${TAG} could not patch app package name: ${err instanceof Error ? err.message : String(err)}`);
229
- }
230
- }
231
- }
232
- // Sync external skills after codegen.
233
- if (spec.externalSkills.length > 0) {
234
- const skillsResult = await syncExternalSkills({
235
- targetDir,
236
- desired: spec.externalSkills,
237
- log: (msg) => log?.info(`${TAG} ${msg}`),
238
- });
239
- if (log && (skillsResult.added.length > 0 || skillsResult.removed.length > 0)) {
240
- log.info(`${TAG} skills sync: +${skillsResult.added.length} -${skillsResult.removed.length}`);
241
- }
242
- }
186
+ // Run post-generation actions (ph-init, install, build, skills-sync).
187
+ await runPostGenActions(result.pendingActions, {
188
+ log: (msg) => log?.info(`${TAG} ${msg}`),
189
+ runProcess: ctx.context.runProcess,
190
+ });
243
191
  }
244
192
  // Work done inline, no additional work item needed
245
193
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powerhousedao/ph-clint-cli",
3
- "version": "0.1.0-dev.33",
3
+ "version": "0.1.0-dev.35",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -29,9 +29,9 @@
29
29
  "@mastra/core": "^1.22.0",
30
30
  "@mastra/libsql": "^1.7.4",
31
31
  "@mastra/memory": "^1.13.1",
32
- "@powerhousedao/ph-clint": "^0.1.0-dev.33",
33
- "@powerhousedao/ph-clint-app": "^0.1.0-dev.33",
34
- "@powerhousedao/ph-clint-dev": "^0.1.0-dev.33",
32
+ "@powerhousedao/ph-clint": "^0.1.0-dev.35",
33
+ "@powerhousedao/ph-clint-app": "^0.1.0-dev.35",
34
+ "@powerhousedao/ph-clint-dev": "^0.1.0-dev.35",
35
35
  "@powerhousedao/reactor": "6.0.0-dev.170",
36
36
  "@powerhousedao/reactor-api": "6.0.0-dev.170",
37
37
  "@powerhousedao/shared": "6.0.0-dev.170",