@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.
@@ -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.endsWith('.metadata.json') && !entry.name.startsWith('__WILL_DELETE__')) {
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.endsWith('.metadata.json') && !entry.name.includes('.CustomSQL.') && entry.name.includes('~')) {
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
 
@@ -1,6 +1,7 @@
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
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.endsWith('.metadata.json')) continue;
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 metaBase = basename(metaPath, '.metadata.json'); // e.g. "colors" or "logo.png"
246
+ const metaFilename = basename(metaPath);
184
247
 
185
- if (hasUidInFilename(metaBase, uid)) {
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
- // Compute new metadata filename with ~UID
190
- // For media files: metaBase = "logo.png", newBase = "logo~uid.png"
191
- // For content files: metaBase = "colors", newBase = "colors~uid"
192
- const metaBaseExt = extname(metaBase); // ".png" for media metadata, "" for content metadata
193
- let newMetaBase;
194
- if (metaBaseExt) {
195
- const nameWithoutExt = metaBase.slice(0, -metaBaseExt.length);
196
- 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);
197
262
  } else {
198
- newMetaBase = buildUidFilename(metaBase, uid);
263
+ naturalBase = basename(metaFilename, '.json');
199
264
  }
200
265
 
201
- const newMetaPath = join(metaDir, `${newMetaBase}.metadata.json`);
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 };
@@ -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.