@dboio/cli 0.11.4 → 0.15.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 (57) hide show
  1. package/README.md +183 -3
  2. package/bin/dbo.js +6 -0
  3. package/package.json +1 -1
  4. package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
  5. package/plugins/claude/dbo/commands/dbo.md +66 -243
  6. package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
  7. package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
  8. package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
  9. package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
  10. package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
  11. package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
  12. package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
  13. package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
  14. package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
  15. package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
  16. package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
  17. package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
  18. package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
  19. package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
  20. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
  21. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
  22. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
  23. package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
  24. package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
  25. package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
  26. package/plugins/claude/dbo/docs/dbo-cli-readme.md +2279 -0
  27. package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
  28. package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
  29. package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
  30. package/plugins/claude/dbo/skills/cli/SKILL.md +63 -246
  31. package/src/commands/add.js +373 -64
  32. package/src/commands/build.js +102 -0
  33. package/src/commands/clone.js +719 -212
  34. package/src/commands/deploy.js +9 -2
  35. package/src/commands/diff.js +7 -3
  36. package/src/commands/init.js +16 -2
  37. package/src/commands/input.js +3 -1
  38. package/src/commands/login.js +30 -4
  39. package/src/commands/mv.js +28 -7
  40. package/src/commands/push.js +298 -78
  41. package/src/commands/rm.js +21 -6
  42. package/src/commands/run.js +81 -0
  43. package/src/commands/tag.js +65 -0
  44. package/src/lib/config.js +67 -0
  45. package/src/lib/delta.js +7 -1
  46. package/src/lib/deploy-config.js +137 -0
  47. package/src/lib/diff.js +28 -5
  48. package/src/lib/filenames.js +198 -54
  49. package/src/lib/ignore.js +6 -0
  50. package/src/lib/input-parser.js +13 -4
  51. package/src/lib/scaffold.js +1 -1
  52. package/src/lib/scripts.js +232 -0
  53. package/src/lib/tagging.js +380 -0
  54. package/src/lib/toe-stepping.js +2 -1
  55. package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
  56. package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
  57. package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Filename convention helpers for the UID tilde convention.
3
- * All entity files use <basename>~<uid>.<ext> as the local filename.
2
+ * Filename convention helpers for the metadata~uid convention.
3
+ * Metadata files: name.metadata~uid.json
4
+ * Companion files: natural names, no UID embedded.
4
5
  */
5
6
 
6
- import { rename, writeFile } from 'fs/promises';
7
+ import { rename, unlink, writeFile, readFile, readdir } from 'fs/promises';
7
8
  import { join, dirname, basename, extname } from 'path';
8
9
 
9
10
  /**
@@ -20,6 +21,65 @@ export function buildUidFilename(name, uid, ext = '') {
20
21
  return ext ? `${base}.${ext}` : base;
21
22
  }
22
23
 
24
+ /**
25
+ * Build the natural companion filename for a content record.
26
+ * No ~UID suffix is embedded. Implements Name/Path/Extension priority.
27
+ *
28
+ * Priority:
29
+ * 1. Name is null → last segment of Path (includes ext if present)
30
+ * 2. Name present + Extension present → name.ext (lowercased ext, double-ext stripped)
31
+ * 3. Name present + Extension null + Path last segment matches Name with ext → path segment
32
+ * 4. Name present + Extension null + no usable Path → just Name (no ext)
33
+ * 5. No Name and no Path → fallback (typically UID)
34
+ *
35
+ * @param {Object} record - Server record with Name, Path, Extension fields
36
+ * @param {string} fallback - Value when nothing else is available (e.g. record UID)
37
+ * @returns {string}
38
+ */
39
+ export function buildContentFileName(record, fallback = 'untitled') {
40
+ const { Name, Path, Extension } = record;
41
+
42
+ // Priority 1: Name is null → use last segment of Path
43
+ if (!Name) {
44
+ if (Path) {
45
+ const segs = String(Path).replace(/\\/g, '/').split('/').filter(Boolean);
46
+ const last = segs[segs.length - 1];
47
+ if (last) return last;
48
+ }
49
+ return fallback;
50
+ }
51
+
52
+ const name = String(Name);
53
+
54
+ // Priority 2: Extension present → name.ext
55
+ if (Extension) {
56
+ const ext = String(Extension).toLowerCase();
57
+ const dotExt = `.${ext}`;
58
+ // Strip double extension (Name="colors.css", Extension="css" → "colors.css")
59
+ const nameClean = name.toLowerCase().endsWith(dotExt)
60
+ ? name.slice(0, -dotExt.length)
61
+ : name;
62
+ return `${nameClean}.${ext}`;
63
+ }
64
+
65
+ // Priority 3: Extension null → check if Path last segment provides the extension
66
+ if (Path) {
67
+ const segs = String(Path).replace(/\\/g, '/').split('/').filter(Boolean);
68
+ const last = segs[segs.length - 1];
69
+ if (last) {
70
+ const lastExt = extname(last);
71
+ if (lastExt) {
72
+ const lastBase = basename(last, lastExt);
73
+ // If Path's last segment base matches Name, treat it as the canonical filename+ext
74
+ if (lastBase === name || last === name) return last;
75
+ }
76
+ }
77
+ }
78
+
79
+ // Priority 4: No extension available → just name
80
+ return name;
81
+ }
82
+
23
83
  /**
24
84
  * Strip the ~<uid> portion from a local filename.
25
85
  * Used when the local file is "logo~def456.png" but the upload should send "logo.png".
@@ -58,6 +118,117 @@ export function detectLegacyDotUid(filename) {
58
118
  return match ? { name: match[1], uid: match[2] } : null;
59
119
  }
60
120
 
121
+ /**
122
+ * Build the new-convention metadata filename.
123
+ * Format: <naturalBase>.metadata~<uid>.json
124
+ *
125
+ * For content records: naturalBase = "colors" → "colors.metadata~abc123.json"
126
+ * For media records: naturalBase = "logo.png" → "logo.png.metadata~abc123.json"
127
+ * For output records: naturalBase = "Sales" → "Sales.metadata~abc123.json"
128
+ *
129
+ * @param {string} naturalBase - Name without any ~uid (may include media extension)
130
+ * @param {string} uid - Server-assigned UID
131
+ * @returns {string}
132
+ */
133
+ export function buildMetaFilename(naturalBase, uid) {
134
+ return `${naturalBase}.metadata~${uid}.json`;
135
+ }
136
+
137
+ /**
138
+ * Test whether a filename is a metadata file (new or legacy format).
139
+ *
140
+ * New format: name.metadata~uid.json
141
+ * Legacy format: name~uid.metadata.json (also accepted during migration)
142
+ *
143
+ * @param {string} filename
144
+ * @returns {boolean}
145
+ */
146
+ export function isMetadataFile(filename) {
147
+ return /\.metadata~[a-z0-9]+\.json$/i.test(filename) // new: name.metadata~uid.json
148
+ || filename.endsWith('.metadata.json') // legacy + pre-UID temp files
149
+ || /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename); // legacy dot-separated: name.uid.metadata.json
150
+ }
151
+
152
+ /**
153
+ * Parse a new-format metadata filename into its components.
154
+ * Returns null for legacy-format filenames (use detectLegacyTildeMetadata for those).
155
+ *
156
+ * @param {string} filename - e.g. "colors.metadata~abc123.json"
157
+ * @returns {{ naturalBase: string, uid: string } | null}
158
+ */
159
+ export function parseMetaFilename(filename) {
160
+ const m = filename.match(/^(.+)\.metadata~([a-z0-9]+)\.json$/i);
161
+ return m ? { naturalBase: m[1], uid: m[2] } : null;
162
+ }
163
+
164
+ /**
165
+ * Detect whether a filename uses the legacy tilde convention:
166
+ * <name>~<uid>.metadata.json OR <name>~<uid>.<ext>.metadata.json
167
+ * where uid is ≥10 lowercase alphanumeric characters.
168
+ *
169
+ * Returns { naturalBase, uid } or null.
170
+ */
171
+ export function detectLegacyTildeMetadata(filename) {
172
+ // Case 1: name~uid.metadata.json (content/entity metadata)
173
+ const m1 = filename.match(/^(.+)~([a-z0-9]{10,})\.metadata\.json$/);
174
+ if (m1) return { naturalBase: m1[1], uid: m1[2] };
175
+
176
+ // Case 2: name~uid.ext.metadata.json (media metadata)
177
+ const m2 = filename.match(/^(.+)~([a-z0-9]{10,})\.([a-z0-9]+)\.metadata\.json$/);
178
+ if (m2) return { naturalBase: `${m2[1]}.${m2[3]}`, uid: m2[2] };
179
+
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Find the metadata file that references a given companion file via @reference.
185
+ *
186
+ * Lookup order:
187
+ * 1. Direct: base.metadata.json (natural name match)
188
+ * 2. Scan: all *.metadata.json in the same directory, check @reference values
189
+ *
190
+ * @param {string} companionPath - Absolute path to the companion file
191
+ * @returns {Promise<string|null>} - Absolute path to the metadata file, or null
192
+ */
193
+ export async function findMetadataForCompanion(companionPath) {
194
+ const dir = dirname(companionPath);
195
+ const companionName = basename(companionPath);
196
+ const ext = extname(companionName);
197
+ const base = basename(companionName, ext);
198
+
199
+ let entries;
200
+ try { entries = await readdir(dir); } catch { return null; }
201
+
202
+ // 1. Fast path: match naturalBase of new-format metadata files
203
+ for (const entry of entries) {
204
+ const parsed = parseMetaFilename(entry);
205
+ if (parsed && (parsed.naturalBase === base || parsed.naturalBase === companionName)) {
206
+ return join(dir, entry);
207
+ }
208
+ }
209
+
210
+ // 2. Scan all metadata files (new + legacy) for @reference match
211
+ for (const entry of entries) {
212
+ if (!isMetadataFile(entry)) continue;
213
+ const metaPath = join(dir, entry);
214
+ try {
215
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
216
+ const cols = [...(meta._contentColumns || [])];
217
+ if (meta._mediaFile) cols.push('_mediaFile');
218
+ for (const col of cols) {
219
+ const ref = meta[col];
220
+ if (!ref || !String(ref).startsWith('@')) continue;
221
+ const refName = String(ref).substring(1);
222
+ if (refName === companionName || refName === `/${companionName}`) {
223
+ return metaPath;
224
+ }
225
+ }
226
+ } catch { /* skip unreadable */ }
227
+ }
228
+
229
+ return null;
230
+ }
231
+
61
232
  /**
62
233
  * Rename a file pair (content + metadata) to the ~uid convention after server assigns a UID.
63
234
  * Updates @reference values inside the metadata file.
@@ -72,71 +243,45 @@ export function detectLegacyDotUid(filename) {
72
243
  */
73
244
  export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
74
245
  const metaDir = dirname(metaPath);
75
- const metaBase = basename(metaPath, '.metadata.json'); // e.g. "colors" or "logo.png"
246
+ const metaFilename = basename(metaPath);
76
247
 
77
- if (hasUidInFilename(metaBase, uid)) {
78
- return { newMetaPath: metaPath, newFilePath: null }; // already renamed
248
+ // Already in new format — nothing to do
249
+ if (parseMetaFilename(metaFilename)?.uid === uid) {
250
+ return { newMetaPath: metaPath, newFilePath: null, updatedMeta: meta };
79
251
  }
80
252
 
81
- // For media files: metaBase = "logo.png", newBase = "logo~uid.png"
82
- // For content files: metaBase = "colors", newBase = "colors~uid"
83
- const metaBaseExt = extname(metaBase); // ".png" for media metadata, "" for content metadata
84
- let newMetaBase;
85
- if (metaBaseExt) {
86
- // Media: "logo.png" → "logo~uid.png"
87
- const nameWithoutExt = metaBase.slice(0, -metaBaseExt.length);
88
- newMetaBase = `${buildUidFilename(nameWithoutExt, uid)}${metaBaseExt}`;
253
+ // Determine naturalBase from the temp/old metadata filename
254
+ // Temp format from add.js: "colors.metadata.json" naturalBase = "colors"
255
+ // Old tilde format: "colors~uid.metadata.json" naturalBase = "colors"
256
+ let naturalBase;
257
+ const legacyParsed = detectLegacyTildeMetadata(metaFilename);
258
+ if (legacyParsed) {
259
+ naturalBase = legacyParsed.naturalBase;
260
+ } else if (metaFilename.endsWith('.metadata.json')) {
261
+ naturalBase = metaFilename.slice(0, -'.metadata.json'.length);
89
262
  } else {
90
- // Content/entity-dir: "colors" → "colors~uid"
91
- newMetaBase = buildUidFilename(metaBase, uid);
263
+ naturalBase = basename(metaFilename, '.json');
92
264
  }
93
265
 
94
- const newMetaPath = join(metaDir, `${newMetaBase}.metadata.json`);
95
-
96
- // Update metadata in memory
97
- meta.UID = uid;
98
- const updatedMeta = { ...meta };
99
-
100
- // Rename content files referenced in _contentColumns and _mediaFile
101
- const contentCols = [...(meta._contentColumns || [])];
102
- if (meta._mediaFile) contentCols.push('_mediaFile');
103
-
104
- let newFilePath = null;
266
+ const newMetaPath = join(metaDir, buildMetaFilename(naturalBase, uid));
105
267
 
106
- for (const col of contentCols) {
107
- const ref = meta[col];
108
- if (!ref || !String(ref).startsWith('@')) continue;
109
-
110
- const oldRefFile = String(ref).substring(1);
111
- const oldFilePath = join(metaDir, oldRefFile);
112
-
113
- // Compute new filename: insert uid
114
- const oldExt = extname(oldRefFile); // ".css", ".png"
115
- const oldBase = basename(oldRefFile, oldExt); // "colors" or "logo"
116
- const newRefBase = buildUidFilename(oldBase, uid);
117
- const newRefFile = oldExt ? `${newRefBase}${oldExt}` : newRefBase;
118
- const newRefPath = join(metaDir, newRefFile);
119
-
120
- try {
121
- await rename(oldFilePath, newRefPath);
122
- updatedMeta[col] = `@${newRefFile}`;
123
- if (!newFilePath) newFilePath = newRefPath;
124
- } catch {
125
- // File may already be renamed or may not exist
126
- }
127
- }
268
+ // Update only the UID field; @reference values are UNCHANGED (companions keep natural names)
269
+ const updatedMeta = { ...meta, UID: uid };
128
270
 
129
271
  // Write updated metadata to new path
130
272
  await writeFile(newMetaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
131
273
 
132
- // Remove old metadata file if it's different from newMetaPath
274
+ // Remove old metadata file (new content already written to newMetaPath above)
133
275
  if (metaPath !== newMetaPath) {
134
- try { await rename(metaPath, newMetaPath); } catch { /* already written above */ }
276
+ try { await unlink(metaPath); } catch { /* old file already gone */ }
135
277
  }
136
278
 
137
- // Restore timestamps
279
+ // Restore timestamps for metadata file and companions (companions are not renamed)
138
280
  if (serverTz && lastUpdated) {
139
281
  const { setFileTimestamps } = await import('./timestamps.js');
282
+ try { await setFileTimestamps(newMetaPath, lastUpdated, lastUpdated, serverTz); } catch {}
283
+ const contentCols = [...(meta._contentColumns || [])];
284
+ if (meta._mediaFile) contentCols.push('_mediaFile');
140
285
  for (const col of contentCols) {
141
286
  const ref = updatedMeta[col];
142
287
  if (ref && String(ref).startsWith('@')) {
@@ -144,8 +289,7 @@ export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, se
144
289
  try { await setFileTimestamps(fp, lastUpdated, lastUpdated, serverTz); } catch {}
145
290
  }
146
291
  }
147
- try { await setFileTimestamps(newMetaPath, lastUpdated, lastUpdated, serverTz); } catch {}
148
292
  }
149
293
 
150
- return { newMetaPath, newFilePath, updatedMeta };
294
+ return { newMetaPath, newFilePath: null, updatedMeta };
151
295
  }
package/src/lib/ignore.js CHANGED
@@ -44,6 +44,12 @@ package-lock.json
44
44
  src/
45
45
  test/
46
46
 
47
+ # Scaffold directories (managed by server, not user content)
48
+ trash/
49
+
50
+ # Build artifacts
51
+ *.map
52
+
47
53
  # Documentation (repo scaffolding)
48
54
  SETUP.md
49
55
  README.md
@@ -48,6 +48,15 @@ export async function buildInputBody(dataExpressions, extraParams = {}) {
48
48
  return parts.join('&');
49
49
  }
50
50
 
51
+ /**
52
+ * Encode a DBO input key for URL-encoded form data.
53
+ * Preserves : and ; which are structural separators in DBO syntax
54
+ * (e.g. "RowID:del123;entity:content"), but encodes everything else.
55
+ */
56
+ function encodeDboKey(key) {
57
+ return encodeURIComponent(key).replace(/%3A/gi, ':').replace(/%3B/gi, ';');
58
+ }
59
+
51
60
  async function encodeInputExpression(expr) {
52
61
  // Check if value part contains @filepath
53
62
  // Format: key=value or key@filepath
@@ -68,15 +77,15 @@ async function encodeInputExpression(expr) {
68
77
  const key = expr.substring(0, atIdx);
69
78
  const filePath = expr.substring(atIdx + 1);
70
79
  const content = await readFile(filePath, 'utf8');
71
- return `${encodeURIComponent(key)}=${encodeURIComponent(content)}`;
80
+ return `${encodeDboKey(key)}=${encodeURIComponent(content)}`;
72
81
  } else if (eqIdx !== -1) {
73
82
  // =value syntax: direct value
74
83
  const key = expr.substring(0, eqIdx);
75
84
  const value = expr.substring(eqIdx + 1);
76
- return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
85
+ return `${encodeDboKey(key)}=${encodeURIComponent(value)}`;
77
86
  } else {
78
- // No value separator, encode the whole thing
79
- return encodeURIComponent(expr);
87
+ // No value separator, encode the whole thing (preserve DBO separators)
88
+ return encodeDboKey(expr);
80
89
  }
81
90
  }
82
91
 
@@ -2,7 +2,7 @@ import { mkdir, stat, readFile, writeFile, access } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { SCAFFOLD_DIRS } from './structure.js';
4
4
  import { log } from './logger.js';
5
- import { applyTrashIcon } from './folder-icon.js';
5
+ import { applyTrashIcon } from './tagging.js';
6
6
 
7
7
  /**
8
8
  * Scaffold the standard DBO project directory structure in cwd.
@@ -0,0 +1,232 @@
1
+ // src/lib/scripts.js
2
+ import { spawn } from 'child_process';
3
+
4
+ const HOOK_NAMES = ['prebuild', 'build', 'postbuild', 'prepush', 'push', 'postpush'];
5
+
6
+ /**
7
+ * Merge scripts.json with scripts.local.json.
8
+ * scripts.local.json keys take precedence over scripts.json at each scope level.
9
+ *
10
+ * @param {object|null} base - Parsed scripts.json (or null)
11
+ * @param {object|null} local - Parsed scripts.local.json (or null)
12
+ * @returns {object} - Merged config with { scripts, targets, entities } keys
13
+ */
14
+ export function mergeScriptsConfig(base, local) {
15
+ const merged = {
16
+ scripts: Object.assign({}, base?.scripts, local?.scripts),
17
+ targets: {},
18
+ entities: {},
19
+ };
20
+ // Merge target entries: normalize path separators, local overrides base per-hook
21
+ const allTargetKeys = new Set([
22
+ ...Object.keys(base?.targets || {}),
23
+ ...Object.keys(local?.targets || {}),
24
+ ]);
25
+ for (const key of allTargetKeys) {
26
+ const normalKey = key.replace(/\\/g, '/');
27
+ merged.targets[normalKey] = Object.assign(
28
+ {},
29
+ base?.targets?.[key],
30
+ local?.targets?.[key]
31
+ );
32
+ }
33
+ // Merge entity entries: local overrides base per-hook
34
+ const allEntityKeys = new Set([
35
+ ...Object.keys(base?.entities || {}),
36
+ ...Object.keys(local?.entities || {}),
37
+ ]);
38
+ for (const key of allEntityKeys) {
39
+ merged.entities[key] = Object.assign(
40
+ {},
41
+ base?.entities?.[key],
42
+ local?.entities?.[key]
43
+ );
44
+ }
45
+ return merged;
46
+ }
47
+
48
+ /**
49
+ * Resolve hooks for a given file path and entity type.
50
+ * Resolution order (highest priority first): target → entity → global scripts.
51
+ * Resolution is per-hook — a target can override 'build' while global 'prepush' still applies.
52
+ *
53
+ * @param {string} filePath - Relative file path from project root (forward slashes)
54
+ * @param {string} entityType - Entity type string (e.g., 'content', 'extension.control')
55
+ * @param {object} config - Merged scripts config from mergeScriptsConfig()
56
+ * @returns {object} - Resolved hook map: { prebuild, build, postbuild, prepush, push, postpush }
57
+ * Each value is the resolved hook (string|string[]|false|true|undefined)
58
+ */
59
+ export function resolveHooks(filePath, entityType, config) {
60
+ const normalPath = filePath.replace(/\\/g, '/');
61
+ const resolved = {};
62
+
63
+ for (const hook of HOOK_NAMES) {
64
+ let value;
65
+
66
+ // 1. Global scripts (lowest priority)
67
+ if (config.scripts && hook in config.scripts) {
68
+ value = config.scripts[hook];
69
+ }
70
+
71
+ // 2. Entity-level: check both 'extension' (base) and 'extension.control' (specific)
72
+ // More-specific descriptor match overrides base entity match
73
+ const [baseEntity] = entityType.split('.');
74
+ if (baseEntity !== entityType && config.entities[baseEntity] && hook in config.entities[baseEntity]) {
75
+ value = config.entities[baseEntity][hook];
76
+ }
77
+ if (config.entities[entityType] && hook in config.entities[entityType]) {
78
+ value = config.entities[entityType][hook];
79
+ }
80
+
81
+ // 3. Target-level (highest priority): exact path match OR directory prefix match
82
+ for (const [targetKey, targetHooks] of Object.entries(config.targets)) {
83
+ const isDir = targetKey.endsWith('/');
84
+ const matches = isDir
85
+ ? normalPath.startsWith(targetKey) || normalPath.startsWith(targetKey.slice(0, -1) + '/')
86
+ : normalPath === targetKey;
87
+ if (matches && targetHooks && hook in targetHooks) {
88
+ value = targetHooks[hook];
89
+ }
90
+ }
91
+
92
+ if (value !== undefined) resolved[hook] = value;
93
+ }
94
+
95
+ return resolved;
96
+ }
97
+
98
+ /**
99
+ * Execute a single hook value.
100
+ * - string: run as shell command
101
+ * - string[]: run sequentially, fail-fast on first non-zero exit
102
+ * - false: no-op (returns { skipped: false })
103
+ * - true/undefined: no-op (use default behavior)
104
+ *
105
+ * @param {string|string[]|boolean|undefined} hookValue
106
+ * @param {object} env - Environment variables to inject (merged with process.env)
107
+ * @param {object} [opts]
108
+ * @param {string} [opts.cwd] - Working directory (default: process.cwd())
109
+ * @returns {Promise<{ skipped: boolean, exitCode: number|null }>}
110
+ */
111
+ export async function runHook(hookValue, env, { cwd = process.cwd() } = {}) {
112
+ if (hookValue === false) return { skipped: false, exitCode: null };
113
+ if (hookValue === true || hookValue === undefined) return { skipped: true, exitCode: null };
114
+
115
+ const commands = Array.isArray(hookValue) ? hookValue : [hookValue];
116
+ const mergedEnv = { ...process.env, ...env };
117
+
118
+ for (const cmd of commands) {
119
+ const exitCode = await spawnShell(cmd, mergedEnv, cwd);
120
+ if (exitCode !== 0) {
121
+ return { skipped: false, exitCode };
122
+ }
123
+ }
124
+ return { skipped: false, exitCode: 0 };
125
+ }
126
+
127
+ /**
128
+ * Spawn a shell command and wait for it to exit.
129
+ * Uses /bin/sh -c on Unix, cmd.exe /c on Windows.
130
+ * Inherits stdio so output streams directly to terminal.
131
+ *
132
+ * @param {string} cmd
133
+ * @param {object} env
134
+ * @param {string} cwd
135
+ * @returns {Promise<number>} exit code
136
+ */
137
+ function spawnShell(cmd, env, cwd) {
138
+ return new Promise((resolve) => {
139
+ const isWin = process.platform === 'win32';
140
+ const shell = isWin ? 'cmd.exe' : '/bin/sh';
141
+ const args = isWin ? ['/c', cmd] : ['-c', cmd];
142
+ const proc = spawn(shell, args, { env, cwd, stdio: 'inherit' });
143
+ proc.on('close', (code) => resolve(code ?? 1));
144
+ proc.on('error', () => resolve(1));
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Build env vars to inject into hook processes for a given target file.
150
+ *
151
+ * @param {string} filePath - Relative file path from project root
152
+ * @param {string} entityType - Entity type string
153
+ * @param {object} appConfig - From loadAppConfig(): { domain, AppShortName, AppUID }
154
+ * @returns {object} env vars
155
+ */
156
+ export function buildHookEnv(filePath, entityType, appConfig) {
157
+ return {
158
+ DBO_TARGET: filePath,
159
+ DBO_ENTITY: entityType,
160
+ DBO_DOMAIN: appConfig?.domain || '',
161
+ DBO_APP: appConfig?.AppShortName || '',
162
+ DBO_APP_UID: appConfig?.AppUID || '',
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Run the full build lifecycle for a target: prebuild → build → postbuild.
168
+ * Returns false if any hook fails (non-zero exit), true if all succeed or are skipped.
169
+ *
170
+ * @param {object} hooks - Resolved hooks from resolveHooks()
171
+ * @param {object} env - Env vars from buildHookEnv()
172
+ * @param {string} [cwd] - Working directory
173
+ * @returns {Promise<boolean>} true = success/skipped, false = failure
174
+ */
175
+ export async function runBuildLifecycle(hooks, env, cwd) {
176
+ for (const name of ['prebuild', 'build', 'postbuild']) {
177
+ if (!(name in hooks)) continue;
178
+ const result = await runHook(hooks[name], env, { cwd });
179
+ if (!result.skipped && result.exitCode !== 0 && result.exitCode !== null) {
180
+ return false;
181
+ }
182
+ }
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * Run the push lifecycle for a target: prepush → (push or default) → postpush.
188
+ * Returns:
189
+ * { runDefault: true } — caller should run the default push
190
+ * { runDefault: false } — push was handled by custom hook or push: false
191
+ * { failed: true } — a hook failed; abort
192
+ *
193
+ * @param {object} hooks - Resolved hooks
194
+ * @param {object} env - Env vars
195
+ * @param {string} [cwd] - Working directory
196
+ */
197
+ export async function runPushLifecycle(hooks, env, cwd) {
198
+ // prepush
199
+ if ('prepush' in hooks) {
200
+ const result = await runHook(hooks['prepush'], env, { cwd });
201
+ if (!result.skipped && result.exitCode !== 0 && result.exitCode !== null) {
202
+ return { failed: true };
203
+ }
204
+ }
205
+
206
+ // push
207
+ let runDefault = true;
208
+ if ('push' in hooks) {
209
+ const pushVal = hooks['push'];
210
+ if (pushVal === false) {
211
+ runDefault = false;
212
+ } else if (pushVal !== true && pushVal !== undefined) {
213
+ // Custom push command overrides default HTTP submit
214
+ const result = await runHook(pushVal, env, { cwd });
215
+ if (!result.skipped && result.exitCode !== 0 && result.exitCode !== null) {
216
+ return { failed: true };
217
+ }
218
+ runDefault = false;
219
+ }
220
+ // push: true or undefined → keep runDefault = true
221
+ }
222
+
223
+ // postpush (runs regardless of whether push was default or custom)
224
+ if ('postpush' in hooks) {
225
+ const result = await runHook(hooks['postpush'], env, { cwd });
226
+ if (!result.skipped && result.exitCode !== 0 && result.exitCode !== null) {
227
+ return { failed: true };
228
+ }
229
+ }
230
+
231
+ return { runDefault, failed: false };
232
+ }