@powerhousedao/ph-clint-cli 0.1.0-dev.34 → 0.1.0-dev.36

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.
@@ -38,5 +38,5 @@ export interface PostGenActionResult {
38
38
  reason?: string;
39
39
  durationMs: number;
40
40
  }
41
- export declare function collectPostGenActions(result: GenerateProjectResult, spec: ClintProjectSpec): PostGenAction[];
41
+ export declare function collectPostGenActions(result: GenerateProjectResult, spec: ClintProjectSpec): Promise<PostGenAction[]>;
42
42
  export declare function runPostGenActions(actions: PostGenAction[], ctx: PostGenActionContext): Promise<PostGenActionResult[]>;
@@ -7,6 +7,7 @@
7
7
  * `runPostGenActions()` executes them sequentially with proper ordering,
8
8
  * failure cascading, and log output.
9
9
  */
10
+ import fs from 'node:fs/promises';
10
11
  import path from 'node:path';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Fixed execution order — index defines precedence
@@ -45,9 +46,21 @@ function dependsOn(downstream, upstream) {
45
46
  return di > ui;
46
47
  }
47
48
  // ---------------------------------------------------------------------------
48
- // collectPostGenActions — pure function
49
+ // Helpers
49
50
  // ---------------------------------------------------------------------------
50
- export function collectPostGenActions(result, spec) {
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) {
51
64
  const split = result.appDir !== null;
52
65
  const relPaths = new Set(result.files.map((f) => f.relativePath));
53
66
  // Determine the earliest triggered action in the chain
@@ -55,13 +68,21 @@ export function collectPostGenActions(result, spec) {
55
68
  if (split) {
56
69
  const appFolder = result.appDir ? path.basename(result.appDir) : '';
57
70
  const cliFolder = path.basename(result.cliDir);
58
- // ph-init needed: app dir exists but no package.json (new scaffold)
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.
59
74
  const appPkgJsonChanged = relPaths.has(path.join(appFolder, 'package.json'));
60
- // Check for the .gitkeep — signals a new app dir that needs ph-init
61
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.
62
79
  if (appGitkeepWritten && !appPkgJsonChanged) {
63
- // .gitkeep written but no package.json — app needs ph init
64
- earliest = 'ph-init';
80
+ const appAlreadyInitialized = result.appDir
81
+ ? await fileExists(path.join(result.appDir, 'package.json'))
82
+ : false;
83
+ if (!appAlreadyInitialized) {
84
+ earliest = 'ph-init';
85
+ }
65
86
  }
66
87
  // Migration always triggers full rebuild
67
88
  if (result.migrated) {
@@ -87,11 +108,11 @@ export function collectPostGenActions(result, spec) {
87
108
  earliest = 'cli-install';
88
109
  }
89
110
  }
90
- // CLI source regenerated (.ts files)
111
+ // CLI source or prompts regenerated (.ts files, agent profile .md files)
91
112
  if (!earliest) {
92
113
  for (const rel of relPaths) {
93
114
  if (rel.startsWith(cliFolder + '/') &&
94
- rel.endsWith('.ts')) {
115
+ (rel.endsWith('.ts') || rel.endsWith('.md'))) {
95
116
  earliest = 'cli-build';
96
117
  break;
97
118
  }
@@ -285,6 +306,15 @@ async function executeAction(action, ctx) {
285
306
  ctx.log(` ${result.reason ?? 'ph init did not run'}`);
286
307
  return false;
287
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
+ }
288
318
  return result.exitCode === 0;
289
319
  }
290
320
  case 'app-install':
@@ -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
+ }
@@ -37,6 +37,8 @@ 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
39
  import { collectPostGenActions, } from './actions.js';
40
+ import { readGeneratedState, writeGeneratedState, generatedStateFromSpec, } from './generated.js';
41
+ import { patchAppPackageName } from './scaffold.js';
40
42
  /**
41
43
  * Generate / reconcile a project at `targetDir` from the given spec.
42
44
  */
@@ -156,6 +158,82 @@ function buildAppReadmeContent(spec) {
156
158
  '',
157
159
  ].join('\n');
158
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
+ }
159
237
  /** Create mode — empty target dir, write everything fresh. */
160
238
  async function runCreate(options, _warn) {
161
239
  const { targetDir, spec } = options;
@@ -179,6 +257,8 @@ async function runCreate(options, _warn) {
179
257
  relativePath: path.relative(targetDir, getSpecPath(targetDir)),
180
258
  });
181
259
  await writeHashes(targetDir, hashes);
260
+ // App is not yet initialized on create — ph-init runs as a post-gen action.
261
+ await writeGeneratedState(targetDir, generatedStateFromSpec(spec, false));
182
262
  const result = {
183
263
  mode: 'create',
184
264
  files,
@@ -189,13 +269,16 @@ async function runCreate(options, _warn) {
189
269
  appDir,
190
270
  pendingActions: [],
191
271
  };
192
- result.pendingActions = collectPostGenActions(result, spec);
272
+ result.pendingActions = await collectPostGenActions(result, spec);
193
273
  return result;
194
274
  }
195
275
  /** Update mode — reconcile against an existing project. */
196
276
  async function runUpdate(options, warn, migrated) {
197
277
  const { targetDir, spec } = options;
198
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);
199
282
  const { planned, cliDir, appDir } = planFiles(spec, targetDir);
200
283
  const plannedByKey = new Map(planned.map((p) => [p.relativePath, p]));
201
284
  const previous = await readHashes(targetDir);
@@ -299,6 +382,26 @@ async function runUpdate(options, warn, migrated) {
299
382
  }
300
383
  await writeProjectSpec(targetDir, spec);
301
384
  await writeHashes(targetDir, next);
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));
302
405
  const result = {
303
406
  mode: 'update',
304
407
  files,
@@ -309,6 +412,6 @@ async function runUpdate(options, warn, migrated) {
309
412
  appDir,
310
413
  pendingActions: [],
311
414
  };
312
- result.pendingActions = collectPostGenActions(result, spec);
415
+ result.pendingActions = await collectPostGenActions(result, spec);
313
416
  return result;
314
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';
@@ -13,14 +13,12 @@
13
13
  */
14
14
  import path from 'node:path';
15
15
  import { defineCommand } from '@powerhousedao/ph-clint';
16
- import { importSpec } from '@powerhousedao/ph-clint-app/document-models/ph-clint-project';
17
16
  import { z } from 'zod';
18
17
  import { clintProjectSpecSchema, } from '../spec/types.js';
19
18
  import { generateProject } from '../codegen/index.js';
20
19
  import { isDirEmptyEnough } from '../codegen/write.js';
21
20
  import { runPostGenActions } from '../codegen/actions.js';
22
- import { writeProjectSpec } from '../spec/file.js';
23
- import { specToImportInput } from '../triggers/spec-change.js';
21
+ import { ensureSpecDocument } from '../spec/ensure-document.js';
24
22
  const inputSchema = z.object({
25
23
  dir: z
26
24
  .string()
@@ -127,14 +125,9 @@ export const init = defineCommand({
127
125
  try {
128
126
  const reactor = await getReactor();
129
127
  if (reactor) {
130
- const newDoc = await reactor.client.createEmpty('powerhouse/ph-clint-project');
131
- const docId = newDoc.header.id;
132
- await folders.addDocument(docId, `specs/${spec.name}`, spec.name);
133
- const importInput = specToImportInput(spec);
134
- await reactor.client.execute(docId, 'main', [importSpec(importInput)]);
135
- spec.documentId = docId;
136
- spec.documentType = 'powerhouse/ph-clint-project';
137
- await writeProjectSpec(targetDir, spec);
128
+ const { docId } = await ensureSpecDocument({
129
+ spec, targetDir, reactor, folders,
130
+ });
138
131
  stdout(`Created spec document ${docId} in Clint Folders/specs/${spec.name}\n`);
139
132
  }
140
133
  }
@@ -12,6 +12,7 @@ import { z } from 'zod';
12
12
  import { readProjectSpec } from '../spec/file.js';
13
13
  import { generateProject } from '../codegen/index.js';
14
14
  import { runPostGenActions } from '../codegen/actions.js';
15
+ import { ensureSpecDocument } from '../spec/ensure-document.js';
15
16
  const inputSchema = z.object({
16
17
  dir: z
17
18
  .string()
@@ -26,7 +27,7 @@ export const regen = defineCommand({
26
27
  id: 'clint-project-regen',
27
28
  description: 'Regenerate project files from the persisted spec',
28
29
  inputSchema,
29
- execute: async (input, { workdir, stdout, log, runProcess }) => {
30
+ execute: async (input, { workdir, stdout, log, runProcess, reactor: getReactor, folders }) => {
30
31
  const targetDir = path.resolve(workdir, input.dir ?? '.');
31
32
  const spec = await readProjectSpec(targetDir);
32
33
  if (!spec) {
@@ -53,6 +54,23 @@ export const regen = defineCommand({
53
54
  else
54
55
  stdout('warning: ' + w + '\n');
55
56
  }
57
+ // Ensure spec document exists in drive (recover if deleted)
58
+ if (getReactor && folders) {
59
+ try {
60
+ const reactor = await getReactor();
61
+ if (reactor) {
62
+ const { docId, created } = await ensureSpecDocument({
63
+ spec, targetDir, reactor, folders,
64
+ });
65
+ if (created) {
66
+ lines.push(`Created spec document ${docId} in Clint Folders/specs/${spec.name}`);
67
+ }
68
+ }
69
+ }
70
+ catch (err) {
71
+ log?.warn(`Could not ensure spec document: ${err instanceof Error ? err.message : String(err)}`);
72
+ }
73
+ }
56
74
  stdout(lines.join('\n') + '\n');
57
75
  // Run post-generation actions (install, build, skills-sync).
58
76
  await runPostGenActions(result.pendingActions, {
@@ -0,0 +1,17 @@
1
+ import type { FolderOperations } from '@powerhousedao/ph-clint';
2
+ import type { ReactorContext, DocumentRegistry } from '@powerhousedao/ph-clint';
3
+ import type { ClintProjectSpec } from './types.js';
4
+ export interface EnsureSpecDocumentResult {
5
+ docId: string;
6
+ created: boolean;
7
+ }
8
+ /**
9
+ * Ensure the spec document exists in the personal drive. Creates one if
10
+ * missing or if the existing `documentId` no longer resolves to a drive node.
11
+ */
12
+ export declare function ensureSpecDocument<R extends DocumentRegistry = DocumentRegistry>(opts: {
13
+ spec: ClintProjectSpec;
14
+ targetDir: string;
15
+ reactor: ReactorContext<R>;
16
+ folders: FolderOperations;
17
+ }): Promise<EnsureSpecDocumentResult>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Ensure a ph-clint-project spec document exists in the personal drive.
3
+ *
4
+ * Single code path used by:
5
+ * - `clint-project-init` (after scaffolding)
6
+ * - `clint-project-regen` (recover deleted docs)
7
+ * - `spec-change` trigger (auto-link unlinked projects)
8
+ *
9
+ * If the spec already has a `documentId` that exists in the drive, this is
10
+ * a no-op. If the document was deleted (or never created), a new one is
11
+ * created, added to the drive at `specs/{name}`, and the spec JSON on disk
12
+ * is updated with the new `documentId`.
13
+ */
14
+ import { importSpec } from '@powerhousedao/ph-clint-app/document-models/ph-clint-project';
15
+ import { writeProjectSpec } from './file.js';
16
+ import { specToImportInput } from '../triggers/spec-change.js';
17
+ const DOCUMENT_TYPE = 'powerhouse/ph-clint-project';
18
+ /**
19
+ * Ensure the spec document exists in the personal drive. Creates one if
20
+ * missing or if the existing `documentId` no longer resolves to a drive node.
21
+ */
22
+ export async function ensureSpecDocument(opts) {
23
+ const { spec, targetDir, reactor, folders } = opts;
24
+ // Check if the document already exists in the drive's folder tree
25
+ if (spec.documentId) {
26
+ const entries = await folders.findByType(DOCUMENT_TYPE);
27
+ const inDrive = entries.some((e) => e.id === spec.documentId);
28
+ if (inDrive)
29
+ return { docId: spec.documentId, created: false };
30
+ }
31
+ // Reuse existing document if it's still in the reactor, otherwise create new
32
+ let docId;
33
+ if (spec.documentId) {
34
+ try {
35
+ const existing = await reactor.client.get(spec.documentId);
36
+ if (existing) {
37
+ docId = spec.documentId;
38
+ }
39
+ else {
40
+ const newDoc = await reactor.client.createEmpty(DOCUMENT_TYPE);
41
+ docId = newDoc.header.id;
42
+ }
43
+ }
44
+ catch {
45
+ const newDoc = await reactor.client.createEmpty(DOCUMENT_TYPE);
46
+ docId = newDoc.header.id;
47
+ }
48
+ }
49
+ else {
50
+ const newDoc = await reactor.client.createEmpty(DOCUMENT_TYPE);
51
+ docId = newDoc.header.id;
52
+ }
53
+ // Add to the drive's folder tree
54
+ await folders.addDocument(docId, `specs/${spec.name}`, spec.name);
55
+ const importInput = specToImportInput(spec);
56
+ await reactor.client.execute(docId, 'main', [importSpec(importInput)]);
57
+ // Persist the link back to disk
58
+ spec.documentId = docId;
59
+ spec.documentType = DOCUMENT_TYPE;
60
+ await writeProjectSpec(targetDir, spec);
61
+ return { docId, created: true };
62
+ }
@@ -17,12 +17,12 @@ import crypto from 'node:crypto';
17
17
  import fs from 'node:fs/promises';
18
18
  import path from 'node:path';
19
19
  import { scanProjects, getProjectMapping } from '@powerhousedao/ph-clint';
20
- import { importSpec } from '@powerhousedao/ph-clint-app/document-models/ph-clint-project';
21
20
  import { createDocumentChangeTrigger } from '../framework.js';
22
21
  import { generateProject } from '../codegen/index.js';
23
22
  import { runPostGenActions } from '../codegen/actions.js';
24
23
  import { specFromDocumentState } from '../spec/from-document.js';
25
24
  import { readProjectSpec, writeProjectSpec } from '../spec/file.js';
25
+ import { ensureSpecDocument } from '../spec/ensure-document.js';
26
26
  import { clintProject } from '../services/clint-project.js';
27
27
  const DOCUMENT_TYPE = 'powerhouse/ph-clint-project';
28
28
  const HASH_DIR = path.join('.ph', 'ph-clint-cli');
@@ -121,24 +121,28 @@ export const specChangeTrigger = createDocumentChangeTrigger({
121
121
  ? scanProjects(ctx.context.workdir, scanner)
122
122
  : [];
123
123
  const mapping = await getProjectMapping(scanResults, folders);
124
- // Auto-create spec documents for unlinked projects
124
+ // Auto-create spec documents for unlinked or orphaned projects
125
125
  const reactor = await ctx.reactor();
126
126
  if (reactor && folders) {
127
127
  for (const entry of mapping) {
128
- if (entry.path && !entry.documentId) {
129
- const existingSpec = await readProjectSpec(entry.path);
130
- if (!existingSpec)
131
- continue;
132
- log?.info(`${TAG} creating spec document for unlinked project ${entry.name}`);
133
- const newDoc = await reactor.client.createEmpty('powerhouse/ph-clint-project');
134
- const docId = newDoc.header.id;
135
- await folders.addDocument(docId, `specs/${entry.name}`, entry.name);
136
- const importInput = specToImportInput(existingSpec);
137
- await reactor.client.execute(docId, 'main', [importSpec(importInput)]);
138
- existingSpec.documentId = docId;
139
- existingSpec.documentType = 'powerhouse/ph-clint-project';
140
- await writeProjectSpec(entry.path, existingSpec);
141
- log?.info(`${TAG} linked project ${entry.name} to document ${docId}`);
128
+ if (!entry.path)
129
+ continue;
130
+ const existingSpec = await readProjectSpec(entry.path);
131
+ if (!existingSpec)
132
+ continue;
133
+ try {
134
+ const { docId, created } = await ensureSpecDocument({
135
+ spec: existingSpec,
136
+ targetDir: entry.path,
137
+ reactor,
138
+ folders,
139
+ });
140
+ if (created) {
141
+ log?.info(`${TAG} linked project ${entry.name} to document ${docId}`);
142
+ }
143
+ }
144
+ catch (err) {
145
+ log?.warn(`${TAG} could not ensure spec document for ${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
142
146
  }
143
147
  }
144
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powerhousedao/ph-clint-cli",
3
- "version": "0.1.0-dev.34",
3
+ "version": "0.1.0-dev.36",
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.34",
33
- "@powerhousedao/ph-clint-app": "^0.1.0-dev.34",
34
- "@powerhousedao/ph-clint-dev": "^0.1.0-dev.34",
32
+ "@powerhousedao/ph-clint": "^0.1.0-dev.36",
33
+ "@powerhousedao/ph-clint-app": "^0.1.0-dev.36",
34
+ "@powerhousedao/ph-clint-dev": "^0.1.0-dev.36",
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",