@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.
- package/README.md +183 -3
- package/bin/dbo.js +6 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +66 -243
- package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
- package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
- package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
- package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
- package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
- package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
- package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
- package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
- package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
- package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
- package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
- package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
- package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
- package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +2279 -0
- package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
- package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
- package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
- package/plugins/claude/dbo/skills/cli/SKILL.md +63 -246
- package/src/commands/add.js +373 -64
- package/src/commands/build.js +102 -0
- package/src/commands/clone.js +719 -212
- package/src/commands/deploy.js +9 -2
- package/src/commands/diff.js +7 -3
- package/src/commands/init.js +16 -2
- package/src/commands/input.js +3 -1
- package/src/commands/login.js +30 -4
- package/src/commands/mv.js +28 -7
- package/src/commands/push.js +298 -78
- package/src/commands/rm.js +21 -6
- package/src/commands/run.js +81 -0
- package/src/commands/tag.js +65 -0
- package/src/lib/config.js +67 -0
- package/src/lib/delta.js +7 -1
- package/src/lib/deploy-config.js +137 -0
- package/src/lib/diff.js +28 -5
- package/src/lib/filenames.js +198 -54
- package/src/lib/ignore.js +6 -0
- package/src/lib/input-parser.js +13 -4
- package/src/lib/scaffold.js +1 -1
- package/src/lib/scripts.js +232 -0
- package/src/lib/tagging.js +380 -0
- package/src/lib/toe-stepping.js +2 -1
- package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
- package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
- package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
package/src/lib/filenames.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Filename convention helpers for the
|
|
3
|
-
*
|
|
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
|
|
246
|
+
const metaFilename = basename(metaPath);
|
|
76
247
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
let
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
newMetaBase = buildUidFilename(metaBase, uid);
|
|
263
|
+
naturalBase = basename(metaFilename, '.json');
|
|
92
264
|
}
|
|
93
265
|
|
|
94
|
-
const newMetaPath = join(metaDir,
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
274
|
+
// Remove old metadata file (new content already written to newMetaPath above)
|
|
133
275
|
if (metaPath !== newMetaPath) {
|
|
134
|
-
try { await
|
|
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
package/src/lib/input-parser.js
CHANGED
|
@@ -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 `${
|
|
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 `${
|
|
85
|
+
return `${encodeDboKey(key)}=${encodeURIComponent(value)}`;
|
|
77
86
|
} else {
|
|
78
|
-
// No value separator, encode the whole thing
|
|
79
|
-
return
|
|
87
|
+
// No value separator, encode the whole thing (preserve DBO separators)
|
|
88
|
+
return encodeDboKey(expr);
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
|
package/src/lib/scaffold.js
CHANGED
|
@@ -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 './
|
|
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
|
+
}
|