@dboio/cli 0.11.4 → 0.13.2

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 (48) hide show
  1. package/README.md +126 -3
  2. package/bin/dbo.js +4 -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 +65 -244
  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 +2222 -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 +62 -246
  31. package/src/commands/add.js +366 -62
  32. package/src/commands/build.js +102 -0
  33. package/src/commands/clone.js +602 -139
  34. package/src/commands/diff.js +4 -0
  35. package/src/commands/init.js +16 -2
  36. package/src/commands/input.js +3 -1
  37. package/src/commands/mv.js +12 -4
  38. package/src/commands/push.js +265 -70
  39. package/src/commands/rm.js +16 -3
  40. package/src/commands/run.js +81 -0
  41. package/src/lib/config.js +39 -0
  42. package/src/lib/delta.js +7 -1
  43. package/src/lib/diff.js +24 -2
  44. package/src/lib/filenames.js +120 -41
  45. package/src/lib/ignore.js +6 -0
  46. package/src/lib/input-parser.js +13 -4
  47. package/src/lib/scripts.js +232 -0
  48. package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
@@ -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
+ }
@@ -0,0 +1,181 @@
1
+ import { readdir, readFile, writeFile, rename, access, mkdir } from 'fs/promises';
2
+ import { join, extname, basename, dirname } from 'path';
3
+ import { log } from '../lib/logger.js';
4
+ import { stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
5
+ import { ensureTrashIcon } from '../lib/folder-icon.js';
6
+
7
+ export const description = 'Rename legacy ~UID companion files to natural filenames';
8
+
9
+ /**
10
+ * Migration 006 — Rename companion files from name~uid.ext to name.ext.
11
+ *
12
+ * Handles two cases:
13
+ * A) @reference itself contains ~UID (e.g. "@colors~uid.css") — strip ~UID
14
+ * from both the filename on disk AND the @reference in metadata.
15
+ * B) @reference is already natural but file on disk has ~UID — just rename file.
16
+ *
17
+ * Metadata files retain their ~UID naming.
18
+ */
19
+ export default async function run(_options) {
20
+ const cwd = process.cwd();
21
+ let totalRenamed = 0;
22
+ let totalRefsUpdated = 0;
23
+
24
+ const metaFiles = await findAllMetadataFiles(cwd);
25
+
26
+ for (const metaPath of metaFiles) {
27
+ try {
28
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
29
+ const uid = meta.UID;
30
+ if (!uid) continue;
31
+
32
+ const metaDir = dirname(metaPath);
33
+ const contentCols = [...(meta._contentColumns || [])];
34
+ if (meta._mediaFile) contentCols.push('_mediaFile');
35
+ let metaChanged = false;
36
+
37
+ for (const col of contentCols) {
38
+ const ref = meta[col];
39
+ if (!ref || !String(ref).startsWith('@')) continue;
40
+
41
+ const refName = String(ref).substring(1);
42
+
43
+ // Case A: @reference itself contains ~UID — strip it
44
+ if (hasUidInFilename(refName, uid)) {
45
+ const naturalName = stripUidFromFilename(refName, uid);
46
+ const legacyPath = join(metaDir, refName);
47
+ const naturalPath = join(metaDir, naturalName);
48
+
49
+ // Rename or trash legacy file
50
+ let naturalExists = false;
51
+ let legacyExists = false;
52
+ try { await access(naturalPath); naturalExists = true; } catch { /* missing */ }
53
+ try { await access(legacyPath); legacyExists = true; } catch { /* missing */ }
54
+
55
+ if (legacyExists && !naturalExists) {
56
+ try {
57
+ await rename(legacyPath, naturalPath);
58
+ log.dim(` ${refName} → ${naturalName}`);
59
+ totalRenamed++;
60
+ } catch { /* rename failed */ }
61
+ } else if (legacyExists && naturalExists) {
62
+ // Both exist — move orphan to trash
63
+ try {
64
+ const trashDir = join(process.cwd(), 'trash');
65
+ await mkdir(trashDir, { recursive: true });
66
+ await rename(legacyPath, join(trashDir, basename(legacyPath)));
67
+ await ensureTrashIcon(trashDir);
68
+ log.dim(` Trashed orphan: ${refName}`);
69
+ totalRenamed++;
70
+ } catch { /* non-critical */ }
71
+ }
72
+
73
+ // Update @reference
74
+ meta[col] = `@${naturalName}`;
75
+ metaChanged = true;
76
+ continue;
77
+ }
78
+
79
+ // Case B: @reference is natural but file on disk might have ~UID
80
+ const naturalPath = join(metaDir, refName);
81
+ try { await access(naturalPath); continue; } catch { /* missing */ }
82
+
83
+ const ext = extname(refName);
84
+ const base = basename(refName, ext);
85
+ const legacyName = ext ? `${base}~${uid}${ext}` : `${base}~${uid}`;
86
+ const legacyPath = join(metaDir, legacyName);
87
+
88
+ try {
89
+ await access(legacyPath);
90
+ await rename(legacyPath, naturalPath);
91
+ log.dim(` ${legacyName} → ${refName}`);
92
+ totalRenamed++;
93
+ } catch { /* legacy not present — skip */ }
94
+ }
95
+
96
+ // Rewrite metadata if @references were updated
97
+ if (metaChanged) {
98
+ try {
99
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
100
+ totalRefsUpdated++;
101
+ } catch { /* non-critical */ }
102
+ }
103
+ } catch { /* skip unreadable metadata */ }
104
+ }
105
+
106
+ // Pass 2: Trash orphaned ~UID companion files not referenced by any metadata
107
+ const referencedFiles = new Set();
108
+ const tildeFiles = [];
109
+
110
+ for (const metaPath of metaFiles) {
111
+ try {
112
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
113
+ const metaDir = dirname(metaPath);
114
+ const cols = [...(meta._contentColumns || [])];
115
+ if (meta._mediaFile) cols.push('_mediaFile');
116
+ for (const col of cols) {
117
+ const ref = meta[col];
118
+ if (ref && String(ref).startsWith('@')) {
119
+ const refName = String(ref).substring(1);
120
+ referencedFiles.add(refName.startsWith('/') ? join(cwd, refName) : join(metaDir, refName));
121
+ }
122
+ }
123
+ } catch { /* skip */ }
124
+ }
125
+
126
+ // Scan for ~ files not referenced
127
+ async function scanForTildeFiles(dir) {
128
+ let entries;
129
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
130
+ for (const entry of entries) {
131
+ if (SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
132
+ const full = join(dir, entry.name);
133
+ if (entry.isDirectory()) { await scanForTildeFiles(full); continue; }
134
+ if (!entry.name.endsWith('.metadata.json') && entry.name.includes('~')) {
135
+ tildeFiles.push(full);
136
+ }
137
+ }
138
+ }
139
+ await scanForTildeFiles(cwd);
140
+
141
+ const orphans = tildeFiles.filter(f => !referencedFiles.has(f));
142
+ if (orphans.length > 0) {
143
+ const trashDir = join(cwd, 'trash');
144
+ await mkdir(trashDir, { recursive: true });
145
+ let trashed = 0;
146
+ for (const orphan of orphans) {
147
+ try {
148
+ await rename(orphan, join(trashDir, basename(orphan)));
149
+ trashed++;
150
+ } catch { /* non-critical */ }
151
+ }
152
+ if (trashed > 0) {
153
+ await ensureTrashIcon(trashDir);
154
+ totalRenamed += trashed;
155
+ log.dim(` Trashed ${trashed} orphaned legacy ~UID file(s)`);
156
+ }
157
+ }
158
+
159
+ if (totalRenamed > 0 || totalRefsUpdated > 0) {
160
+ log.dim(` Renamed ${totalRenamed} companion file(s), updated ${totalRefsUpdated} metadata @reference(s)`);
161
+ }
162
+ }
163
+
164
+ const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
165
+
166
+ async function findAllMetadataFiles(dir) {
167
+ const results = [];
168
+ try {
169
+ const entries = await readdir(dir, { withFileTypes: true });
170
+ for (const entry of entries) {
171
+ if (SKIP.has(entry.name)) continue;
172
+ const full = join(dir, entry.name);
173
+ if (entry.isDirectory()) {
174
+ results.push(...await findAllMetadataFiles(full));
175
+ } else if (entry.name.endsWith('.metadata.json')) {
176
+ results.push(full);
177
+ }
178
+ }
179
+ } catch { /* skip unreadable dirs */ }
180
+ return results;
181
+ }