@dboio/cli 0.13.2 → 0.15.1
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 +57 -0
- package/bin/dbo.js +2 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +76 -74
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +57 -0
- package/plugins/claude/dbo/skills/cli/SKILL.md +2 -1
- package/src/commands/add.js +12 -7
- package/src/commands/clone.js +138 -94
- package/src/commands/deploy.js +9 -2
- package/src/commands/diff.js +4 -4
- package/src/commands/login.js +30 -4
- package/src/commands/mv.js +17 -4
- package/src/commands/push.js +100 -103
- package/src/commands/rm.js +6 -4
- package/src/commands/tag.js +65 -0
- package/src/lib/config.js +28 -0
- package/src/lib/deploy-config.js +137 -0
- package/src/lib/diff.js +5 -4
- package/src/lib/filenames.js +89 -24
- package/src/lib/scaffold.js +1 -1
- package/src/lib/tagging.js +380 -0
- package/src/lib/toe-stepping.js +2 -1
- package/src/migrations/006-remove-uid-companion-filenames.js +3 -3
- package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
- package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join, relative, extname, basename } from 'path';
|
|
3
|
+
import { log } from './logger.js';
|
|
4
|
+
|
|
5
|
+
const DEPLOY_CONFIG_FILE = '.dbo/deploy_config.json';
|
|
6
|
+
|
|
7
|
+
function deployConfigPath() {
|
|
8
|
+
return join(process.cwd(), DEPLOY_CONFIG_FILE);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sortedKeys(obj) {
|
|
12
|
+
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load .dbo/deploy_config.json. Returns { deployments: {} } if missing.
|
|
17
|
+
* Throws on malformed JSON (do not silently recreate — would lose existing entries).
|
|
18
|
+
*/
|
|
19
|
+
export async function loadDeployConfig() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(deployConfigPath(), 'utf8');
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err.code === 'ENOENT') return { deployments: {} };
|
|
25
|
+
throw new Error(`Failed to parse ${DEPLOY_CONFIG_FILE}: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Write .dbo/deploy_config.json with alphabetically sorted deployment keys.
|
|
31
|
+
*/
|
|
32
|
+
export async function saveDeployConfig(config) {
|
|
33
|
+
await mkdir(join(process.cwd(), '.dbo'), { recursive: true });
|
|
34
|
+
const sorted = { ...config, deployments: sortedKeys(config.deployments || {}) };
|
|
35
|
+
await writeFile(deployConfigPath(), JSON.stringify(sorted, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive the <ext>:<basename> deploy key from a relative file path.
|
|
40
|
+
* Finds the shortest key not already occupied in existingDeployments.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} relPath - e.g. "lib/bins/app/assets/css/colors.css"
|
|
43
|
+
* @param {Object} existingDeployments - current deployments object (to avoid collisions)
|
|
44
|
+
* @returns {string} - e.g. "css:colors"
|
|
45
|
+
*/
|
|
46
|
+
export function buildDeployKey(relPath, existingDeployments = {}) {
|
|
47
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
48
|
+
const parts = normalized.split('/');
|
|
49
|
+
const filename = parts[parts.length - 1];
|
|
50
|
+
const ext = extname(filename).slice(1).toLowerCase(); // '' if no dot
|
|
51
|
+
const base = ext ? basename(filename, `.${ext}`) : filename;
|
|
52
|
+
const prefix = ext || 'file';
|
|
53
|
+
|
|
54
|
+
// Try simplest key first, then progressively add parent segments for disambiguation
|
|
55
|
+
const dirParts = parts.slice(0, -1);
|
|
56
|
+
const candidates = [`${prefix}:${base}`];
|
|
57
|
+
for (let depth = 1; depth <= dirParts.length; depth++) {
|
|
58
|
+
const suffix = dirParts.slice(-depth).join('/');
|
|
59
|
+
candidates.push(`${prefix}:${suffix}/${base}`);
|
|
60
|
+
}
|
|
61
|
+
candidates.push(`${prefix}:${normalized}`); // absolute fallback
|
|
62
|
+
|
|
63
|
+
for (const key of candidates) {
|
|
64
|
+
if (!existingDeployments[key]) return key;
|
|
65
|
+
}
|
|
66
|
+
return `${prefix}:${normalized}`; // always unique
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Insert or update a deploy config entry for a companion file.
|
|
71
|
+
*
|
|
72
|
+
* Behaviour:
|
|
73
|
+
* - If an entry with the same UID already exists → update file, entity, column in place
|
|
74
|
+
* (keeps the existing key, avoids duplicate keys on re-clone)
|
|
75
|
+
* - If a new key collision exists with a DIFFERENT UID → log a warning, skip
|
|
76
|
+
* - Otherwise → create new entry with the shortest unique key
|
|
77
|
+
*
|
|
78
|
+
* @param {string} companionPath - Absolute path to the companion file
|
|
79
|
+
* @param {string} uid - Record UID (from metadata)
|
|
80
|
+
* @param {string} entity - Entity type (e.g. 'content', 'extension', 'media')
|
|
81
|
+
* @param {string} [column] - Column name (e.g. 'Content', 'CSS')
|
|
82
|
+
*/
|
|
83
|
+
export async function upsertDeployEntry(companionPath, uid, entity, column) {
|
|
84
|
+
if (!uid || !companionPath) return;
|
|
85
|
+
const config = await loadDeployConfig();
|
|
86
|
+
const { deployments } = config;
|
|
87
|
+
|
|
88
|
+
const relPath = relative(process.cwd(), companionPath).replace(/\\/g, '/');
|
|
89
|
+
|
|
90
|
+
// Find if this UID is already tracked under any key
|
|
91
|
+
const existingKey = Object.keys(deployments).find(k => deployments[k].uid === uid);
|
|
92
|
+
|
|
93
|
+
if (existingKey) {
|
|
94
|
+
// Update path + metadata in place — keeps existing key, avoids duplicates on re-clone
|
|
95
|
+
deployments[existingKey] = {
|
|
96
|
+
...deployments[existingKey],
|
|
97
|
+
file: relPath,
|
|
98
|
+
entity,
|
|
99
|
+
...(column ? { column } : {}),
|
|
100
|
+
};
|
|
101
|
+
config.deployments = sortedKeys(deployments);
|
|
102
|
+
await saveDeployConfig(config);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// New entry — build unique key avoiding all occupied keys
|
|
107
|
+
const key = buildDeployKey(relPath, deployments);
|
|
108
|
+
|
|
109
|
+
if (deployments[key]) {
|
|
110
|
+
// buildDeployKey returned an occupied key (should not happen — means all path segments collide)
|
|
111
|
+
log.warn(`Deploy config: skipping auto-registration of "${relPath}" — key "${key}" already exists for a different record. Add manually to ${DEPLOY_CONFIG_FILE}.`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const entry = { uid, file: relPath, entity };
|
|
116
|
+
if (column) entry.column = column;
|
|
117
|
+
deployments[key] = entry;
|
|
118
|
+
config.deployments = sortedKeys(deployments);
|
|
119
|
+
await saveDeployConfig(config);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove the deploy config entry matching a given UID.
|
|
124
|
+
* No-op if no entry with that UID exists.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} uid - Record UID to remove
|
|
127
|
+
*/
|
|
128
|
+
export async function removeDeployEntry(uid) {
|
|
129
|
+
if (!uid) return;
|
|
130
|
+
const config = await loadDeployConfig();
|
|
131
|
+
const { deployments } = config;
|
|
132
|
+
const key = Object.keys(deployments).find(k => deployments[k].uid === uid);
|
|
133
|
+
if (!key) return;
|
|
134
|
+
delete deployments[key];
|
|
135
|
+
config.deployments = sortedKeys(deployments);
|
|
136
|
+
await saveDeployConfig(config);
|
|
137
|
+
}
|
package/src/lib/diff.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadIgnore } from './ignore.js';
|
|
|
5
5
|
import { parseServerDate, setFileTimestamps } from './timestamps.js';
|
|
6
6
|
import { loadConfig, loadUserInfo, loadAppJsonBaseline } from './config.js';
|
|
7
7
|
import { findBaselineEntry } from './delta.js';
|
|
8
|
+
import { isMetadataFile, parseMetaFilename } from './filenames.js';
|
|
8
9
|
import { log } from './logger.js';
|
|
9
10
|
|
|
10
11
|
// ─── Baseline Cache ─────────────────────────────────────────────────────────
|
|
@@ -155,10 +156,10 @@ export async function findMetadataFiles(dir, ig) {
|
|
|
155
156
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
156
157
|
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
157
158
|
results.push(...await findMetadataFiles(fullPath, ig));
|
|
158
|
-
} else if (entry.name
|
|
159
|
+
} else if (isMetadataFile(entry.name) && !entry.name.startsWith('__WILL_DELETE__')) {
|
|
159
160
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
160
161
|
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
161
|
-
} else if (entry.name.endsWith('.json') && !entry.name
|
|
162
|
+
} else if (entry.name.endsWith('.json') && !isMetadataFile(entry.name) && !entry.name.includes('.CustomSQL.') && entry.name.includes('~')) {
|
|
162
163
|
// Output hierarchy root files: Name~UID.json (or legacy _output~Name~UID.json)
|
|
163
164
|
// Exclude old-format child output files — they contain a dot-prefixed
|
|
164
165
|
// child-type segment (.column~, .join~, .filter~) before .json.
|
|
@@ -495,7 +496,7 @@ export async function compareRecord(metaPath, config, serverRecordsMap) {
|
|
|
495
496
|
}
|
|
496
497
|
|
|
497
498
|
const metaDir = dirname(metaPath);
|
|
498
|
-
const metaBase = basename(metaPath, '.metadata.json');
|
|
499
|
+
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
499
500
|
const contentCols = localMeta._contentColumns || [];
|
|
500
501
|
const fieldDiffs = [];
|
|
501
502
|
|
|
@@ -799,7 +800,7 @@ export async function inlineDiffAndMerge(serverRow, metaPath, config, options =
|
|
|
799
800
|
}
|
|
800
801
|
|
|
801
802
|
const metaDir = dirname(metaPath);
|
|
802
|
-
const metaBase = basename(metaPath, '.metadata.json');
|
|
803
|
+
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
803
804
|
const contentCols = localMeta._contentColumns || [];
|
|
804
805
|
const fieldDiffs = [];
|
|
805
806
|
|
package/src/lib/filenames.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
7
|
import { rename, unlink, writeFile, readFile, readdir } from 'fs/promises';
|
|
@@ -117,6 +118,68 @@ export function detectLegacyDotUid(filename) {
|
|
|
117
118
|
return match ? { name: match[1], uid: match[2] } : null;
|
|
118
119
|
}
|
|
119
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
|
+
|
|
120
183
|
/**
|
|
121
184
|
* Find the metadata file that references a given companion file via @reference.
|
|
122
185
|
*
|
|
@@ -133,19 +196,20 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
133
196
|
const ext = extname(companionName);
|
|
134
197
|
const base = basename(companionName, ext);
|
|
135
198
|
|
|
136
|
-
// 1. Direct match: base.metadata.json
|
|
137
|
-
const directMeta = join(dir, `${base}.metadata.json`);
|
|
138
|
-
try {
|
|
139
|
-
await readFile(directMeta, 'utf8');
|
|
140
|
-
return directMeta;
|
|
141
|
-
} catch { /* not found */ }
|
|
142
|
-
|
|
143
|
-
// 2. Scan sibling *.metadata.json files for @reference match
|
|
144
199
|
let entries;
|
|
145
200
|
try { entries = await readdir(dir); } catch { return null; }
|
|
146
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
|
|
147
211
|
for (const entry of entries) {
|
|
148
|
-
if (!entry
|
|
212
|
+
if (!isMetadataFile(entry)) continue;
|
|
149
213
|
const metaPath = join(dir, entry);
|
|
150
214
|
try {
|
|
151
215
|
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
@@ -155,7 +219,6 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
155
219
|
const ref = meta[col];
|
|
156
220
|
if (!ref || !String(ref).startsWith('@')) continue;
|
|
157
221
|
const refName = String(ref).substring(1);
|
|
158
|
-
// Match both relative and root-relative (@/) references
|
|
159
222
|
if (refName === companionName || refName === `/${companionName}`) {
|
|
160
223
|
return metaPath;
|
|
161
224
|
}
|
|
@@ -180,25 +243,27 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
180
243
|
*/
|
|
181
244
|
export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
|
|
182
245
|
const metaDir = dirname(metaPath);
|
|
183
|
-
const
|
|
246
|
+
const metaFilename = basename(metaPath);
|
|
184
247
|
|
|
185
|
-
|
|
248
|
+
// Already in new format — nothing to do
|
|
249
|
+
if (parseMetaFilename(metaFilename)?.uid === uid) {
|
|
186
250
|
return { newMetaPath: metaPath, newFilePath: null, updatedMeta: meta };
|
|
187
251
|
}
|
|
188
252
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
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);
|
|
197
262
|
} else {
|
|
198
|
-
|
|
263
|
+
naturalBase = basename(metaFilename, '.json');
|
|
199
264
|
}
|
|
200
265
|
|
|
201
|
-
const newMetaPath = join(metaDir,
|
|
266
|
+
const newMetaPath = join(metaDir, buildMetaFilename(naturalBase, uid));
|
|
202
267
|
|
|
203
268
|
// Update only the UID field; @reference values are UNCHANGED (companions keep natural names)
|
|
204
269
|
const updatedMeta = { ...meta, UID: uid };
|
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.
|