@dboio/cli 0.9.8 → 0.11.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.
Files changed (38) hide show
  1. package/README.md +172 -70
  2. package/bin/dbo.js +2 -0
  3. package/bin/postinstall.js +9 -1
  4. package/package.json +3 -3
  5. package/plugins/claude/dbo/commands/dbo.md +3 -3
  6. package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
  7. package/src/commands/add.js +50 -0
  8. package/src/commands/clone.js +720 -552
  9. package/src/commands/content.js +7 -3
  10. package/src/commands/deploy.js +22 -7
  11. package/src/commands/diff.js +41 -3
  12. package/src/commands/init.js +42 -79
  13. package/src/commands/input.js +5 -0
  14. package/src/commands/login.js +2 -2
  15. package/src/commands/mv.js +3 -0
  16. package/src/commands/output.js +8 -10
  17. package/src/commands/pull.js +268 -87
  18. package/src/commands/push.js +814 -94
  19. package/src/commands/rm.js +4 -1
  20. package/src/commands/status.js +12 -1
  21. package/src/commands/sync.js +71 -0
  22. package/src/lib/client.js +10 -0
  23. package/src/lib/config.js +80 -8
  24. package/src/lib/delta.js +178 -25
  25. package/src/lib/diff.js +150 -20
  26. package/src/lib/folder-icon.js +120 -0
  27. package/src/lib/ignore.js +2 -3
  28. package/src/lib/input-parser.js +37 -10
  29. package/src/lib/metadata-templates.js +21 -4
  30. package/src/lib/migrations.js +75 -0
  31. package/src/lib/save-to-disk.js +1 -1
  32. package/src/lib/scaffold.js +58 -3
  33. package/src/lib/structure.js +158 -21
  34. package/src/lib/toe-stepping.js +381 -0
  35. package/src/migrations/001-transaction-key-preset-scope.js +35 -0
  36. package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
  37. package/src/migrations/003-move-deploy-config.js +50 -0
  38. package/src/migrations/004-rename-output-files.js +101 -0
package/src/lib/diff.js CHANGED
@@ -3,9 +3,120 @@ import { readFile, writeFile, readdir, access, stat } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { loadIgnore } from './ignore.js';
5
5
  import { parseServerDate, setFileTimestamps } from './timestamps.js';
6
- import { loadConfig, loadUserInfo } from './config.js';
6
+ import { loadConfig, loadUserInfo, loadAppJsonBaseline } from './config.js';
7
+ import { findBaselineEntry } from './delta.js';
7
8
  import { log } from './logger.js';
8
9
 
10
+ // ─── Baseline Cache ─────────────────────────────────────────────────────────
11
+ // Cached baseline used by isServerNewer to compare raw _LastUpdated strings
12
+ // and avoid false-positive conflicts from timezone conversion drift.
13
+ let _cachedBaseline;
14
+
15
+ /**
16
+ * Pre-load the previous baseline into memory so that isServerNewer can
17
+ * do a fast string comparison. Call once at the start of clone/pull.
18
+ */
19
+ export async function loadBaselineForComparison() {
20
+ _cachedBaseline = await loadAppJsonBaseline();
21
+ return _cachedBaseline;
22
+ }
23
+
24
+ /**
25
+ * Clear the cached baseline.
26
+ * Call after saving a new baseline (e.g. after clone completes).
27
+ */
28
+ export function resetBaselineCache() {
29
+ _cachedBaseline = undefined;
30
+ }
31
+
32
+ /**
33
+ * Look up the baseline _LastUpdated for a given entity/uid pair.
34
+ * Requires loadBaselineForComparison() to have been called first.
35
+ */
36
+ function getBaselineLastUpdated(entity, uid) {
37
+ if (!_cachedBaseline) return undefined;
38
+ const entry = findBaselineEntry(_cachedBaseline, entity, uid);
39
+ return entry?._LastUpdated;
40
+ }
41
+
42
+ // ─── File Finding ───────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Recursively search the project directory for a file matching a bare filename.
46
+ * Respects .dboignore and skips known non-project dirs.
47
+ *
48
+ * @param {string} filename - Bare filename (e.g., "myfile.md", "myfile.metadata.json")
49
+ * @param {string} [dir=process.cwd()] - Starting directory
50
+ * @param {import('ignore').Ignore} [ig] - Ignore instance
51
+ * @returns {Promise<string[]>} - Array of matching absolute paths
52
+ */
53
+ export async function findFileInProject(filename, dir, ig) {
54
+ if (!dir) dir = process.cwd();
55
+ if (!ig) ig = await loadIgnore();
56
+
57
+ const results = [];
58
+ const entries = await readdir(dir, { withFileTypes: true });
59
+
60
+ for (const entry of entries) {
61
+ const fullPath = join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
64
+ if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
65
+ results.push(...await findFileInProject(filename, fullPath, ig));
66
+ } else if (entry.name === filename) {
67
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
68
+ if (!ig.ignores(relPath)) results.push(fullPath);
69
+ }
70
+ }
71
+
72
+ return results;
73
+ }
74
+
75
+ // ─── UID Lookup ─────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Find records by UID across all metadata files and structure.json bins.
79
+ *
80
+ * @param {string[]} uids - Array of UIDs to search for
81
+ * @returns {Promise<Array<{ uid: string, metaPath?: string, meta?: Object, binEntry?: Object }>>}
82
+ */
83
+ export async function findByUID(uids) {
84
+ const uidSet = new Set(uids);
85
+ const results = [];
86
+ const found = new Set();
87
+
88
+ // Search metadata files
89
+ const ig = await loadIgnore();
90
+ const metaFiles = await findMetadataFiles(process.cwd(), ig);
91
+
92
+ for (const metaPath of metaFiles) {
93
+ try {
94
+ const raw = await readFile(metaPath, 'utf8');
95
+ const meta = JSON.parse(raw);
96
+ if (meta.UID && uidSet.has(meta.UID)) {
97
+ results.push({ uid: meta.UID, metaPath, meta });
98
+ found.add(meta.UID);
99
+ }
100
+ } catch { /* skip unreadable files */ }
101
+ }
102
+
103
+ // Search structure.json bin entries
104
+ if (found.size < uidSet.size) {
105
+ try {
106
+ const { loadStructureFile } = await import('./structure.js');
107
+ const structure = await loadStructureFile();
108
+ for (const [binId, entry] of Object.entries(structure)) {
109
+ if (entry.uid && uidSet.has(entry.uid) && !found.has(entry.uid)) {
110
+ results.push({ uid: entry.uid, binEntry: { binId: Number(binId), ...entry } });
111
+ found.add(entry.uid);
112
+ }
113
+ }
114
+ } catch { /* no structure file */ }
115
+ }
116
+
117
+ return results;
118
+ }
119
+
9
120
  // ─── Content Value Resolution ───────────────────────────────────────────────
10
121
 
11
122
  /**
@@ -30,7 +141,7 @@ async function fileExists(path) {
30
141
 
31
142
  /**
32
143
  * Recursively find all metadata files in a directory.
33
- * Includes .metadata.json files and output hierarchy files (_output~*.json).
144
+ * Includes .metadata.json files and output hierarchy files (Name~UID.json).
34
145
  */
35
146
  export async function findMetadataFiles(dir, ig) {
36
147
  if (!ig) ig = await loadIgnore();
@@ -47,10 +158,15 @@ export async function findMetadataFiles(dir, ig) {
47
158
  } else if (entry.name.endsWith('.metadata.json')) {
48
159
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
49
160
  if (!ig.ignores(relPath)) results.push(fullPath);
50
- } else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
51
- // Output hierarchy files: _output~<name>~<uid>.json and nested entity files
52
- const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
53
- 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
+ // Output hierarchy root files: Name~UID.json (or legacy _output~Name~UID.json)
163
+ // Exclude old-format child output files — they contain a dot-prefixed
164
+ // child-type segment (.column~, .join~, .filter~) before .json.
165
+ const isChildFile = /\.(column|join|filter)~/.test(entry.name);
166
+ if (!isChildFile) {
167
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
168
+ if (!ig.ignores(relPath)) results.push(fullPath);
169
+ }
54
170
  }
55
171
  }
56
172
 
@@ -134,11 +250,30 @@ export async function hasLocalModifications(metaPath, config = {}) {
134
250
  /**
135
251
  * Compare local sync time against server's _LastUpdated.
136
252
  * Returns true if the server record is newer than local files.
253
+ *
254
+ * When entity + uid are provided AND a baseline has been pre-loaded via
255
+ * loadBaselineForComparison(), the raw _LastUpdated strings are compared
256
+ * first. If they match exactly, the record hasn't changed on the server
257
+ * and we skip the timezone conversion entirely — preventing false positives
258
+ * caused by DST-related rounding in the mtime ↔ parseServerDate round-trip.
259
+ *
260
+ * @param {Date} localSyncTime - File mtime (from getLocalSyncTime)
261
+ * @param {string} serverLastUpdated - Raw _LastUpdated from the fresh server fetch
262
+ * @param {Object} config - Must include ServerTimezone
263
+ * @param {string} [entity] - Entity name for baseline lookup
264
+ * @param {string} [uid] - Record UID for baseline lookup
137
265
  */
138
- export function isServerNewer(localSyncTime, serverLastUpdated, config) {
266
+ export function isServerNewer(localSyncTime, serverLastUpdated, config, entity, uid) {
139
267
  if (!serverLastUpdated) return false;
140
268
  if (!localSyncTime) return true;
141
269
 
270
+ // Fast path: compare raw server string against baseline.
271
+ // If identical, the record hasn't changed — no timezone math needed.
272
+ if (entity && uid) {
273
+ const baselineLastUpdated = getBaselineLastUpdated(entity, uid);
274
+ if (baselineLastUpdated && serverLastUpdated === baselineLastUpdated) return false;
275
+ }
276
+
142
277
  const serverTz = config.ServerTimezone;
143
278
  const serverDate = parseServerDate(serverLastUpdated, serverTz);
144
279
  if (!serverDate) return false;
@@ -149,17 +284,8 @@ export function isServerNewer(localSyncTime, serverLastUpdated, config) {
149
284
 
150
285
  // ─── Server Fetching ────────────────────────────────────────────────────────
151
286
 
152
- /**
153
- * Fetch a single record from the server by entity and UID.
154
- */
155
- export async function fetchServerRecord(client, entity, uid) {
156
- const result = await client.get(`/api/output/entity/${entity}/${uid}`, {
157
- '_format': 'json_raw',
158
- });
159
- const data = result.payload || result.data;
160
- const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || [data]);
161
- return rows.length > 0 ? rows[0] : null;
162
- }
287
+ // Per-record fetch removed — all server record fetching now goes through
288
+ // fetchServerRecordsBatch() in toe-stepping.js via /api/app/object/.
163
289
 
164
290
  // ─── Diff Algorithm ─────────────────────────────────────────────────────────
165
291
 
@@ -321,8 +447,12 @@ function buildHunk(entries, start, end) {
321
447
  /**
322
448
  * Compare a local record (metadata.json + content files) against the server.
323
449
  * Returns a DiffResult object.
450
+ *
451
+ * @param {string} metaPath - Absolute path to the .metadata.json file
452
+ * @param {Object} config
453
+ * @param {Map<string, Object>} serverRecordsMap - Pre-fetched server records (uid → record)
324
454
  */
325
- export async function compareRecord(metaPath, client, config) {
455
+ export async function compareRecord(metaPath, config, serverRecordsMap) {
326
456
  let localMeta;
327
457
  try {
328
458
  localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
@@ -337,7 +467,7 @@ export async function compareRecord(metaPath, client, config) {
337
467
  return { error: `Missing _entity or UID in ${metaPath}` };
338
468
  }
339
469
 
340
- const serverRecord = await fetchServerRecord(client, entity, uid);
470
+ const serverRecord = serverRecordsMap?.get(uid) || null;
341
471
  if (!serverRecord) {
342
472
  return { error: `Record not found on server: ${entity}/${uid}` };
343
473
  }
@@ -0,0 +1,120 @@
1
+ import { execFile } from 'child_process';
2
+ import { writeFile, stat, access } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { promisify } from 'util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Best-effort: apply the system trash/recycle-bin icon to a folder.
10
+ * Never throws — all errors are silently swallowed.
11
+ *
12
+ * @param {string} folderPath Absolute path to the trash directory
13
+ */
14
+ export async function applyTrashIcon(folderPath) {
15
+ try {
16
+ const s = await stat(folderPath);
17
+ if (!s.isDirectory()) return;
18
+
19
+ switch (process.platform) {
20
+ case 'darwin':
21
+ await applyMacIcon(folderPath);
22
+ break;
23
+ case 'win32':
24
+ await applyWindowsIcon(folderPath);
25
+ break;
26
+ case 'linux':
27
+ await applyLinuxIcon(folderPath);
28
+ break;
29
+ }
30
+ } catch {
31
+ // Best-effort — never propagate
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Re-apply the trash icon only if the icon marker file is missing.
37
+ * Much cheaper than applyTrashIcon — skips the osascript/attrib call
38
+ * when the icon is already in place.
39
+ *
40
+ * Call this after moving files into trash/ to self-heal the icon
41
+ * in case the user cleared the directory contents.
42
+ *
43
+ * @param {string} folderPath Absolute path to the trash directory
44
+ */
45
+ export async function ensureTrashIcon(folderPath) {
46
+ try {
47
+ switch (process.platform) {
48
+ case 'darwin': {
49
+ // macOS: Icon\r file exists when icon is applied
50
+ const iconFile = join(folderPath, 'Icon\r');
51
+ try { await access(iconFile); return; } catch { /* missing — re-apply */ }
52
+ await applyMacIcon(folderPath);
53
+ break;
54
+ }
55
+ case 'win32': {
56
+ const iniPath = join(folderPath, 'desktop.ini');
57
+ try { await access(iniPath); return; } catch { /* missing */ }
58
+ await applyWindowsIcon(folderPath);
59
+ break;
60
+ }
61
+ case 'linux': {
62
+ const dirFile = join(folderPath, '.directory');
63
+ try { await access(dirFile); return; } catch { /* missing */ }
64
+ await applyLinuxIcon(folderPath);
65
+ break;
66
+ }
67
+ }
68
+ } catch {
69
+ // Best-effort — never propagate
70
+ }
71
+ }
72
+
73
+ // ── macOS ──────────────────────────────────────────────────────────────
74
+
75
+ const MACOS_TRASH_ICON =
76
+ '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/TrashIcon.icns';
77
+
78
+ async function applyMacIcon(folderPath) {
79
+ await access(MACOS_TRASH_ICON);
80
+
81
+ const script =
82
+ 'use framework "AppKit"\n' +
83
+ `set iconImage to (current application's NSImage's alloc()'s initWithContentsOfFile:"${MACOS_TRASH_ICON}")\n` +
84
+ `(current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:"${folderPath}" options:0)`;
85
+
86
+ await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
87
+ }
88
+
89
+ // ── Windows ────────────────────────────────────────────────────────────
90
+
91
+ async function applyWindowsIcon(folderPath) {
92
+ const iniPath = join(folderPath, 'desktop.ini');
93
+ const iniContent =
94
+ '[.ShellClassInfo]\r\nIconResource=%SystemRoot%\\System32\\shell32.dll,31\r\n';
95
+
96
+ await writeFile(iniPath, iniContent);
97
+ await execFileAsync('attrib', ['+H', '+S', iniPath], { timeout: 5000 });
98
+ await execFileAsync('attrib', ['+S', folderPath], { timeout: 5000 });
99
+ }
100
+
101
+ // ── Linux ──────────────────────────────────────────────────────────────
102
+
103
+ async function applyLinuxIcon(folderPath) {
104
+ // KDE Dolphin — .directory file
105
+ await writeFile(
106
+ join(folderPath, '.directory'),
107
+ '[Desktop Entry]\nIcon=user-trash\n',
108
+ );
109
+
110
+ // GNOME Nautilus — gio metadata (may not be available on KDE-only systems)
111
+ try {
112
+ await execFileAsync(
113
+ 'gio',
114
+ ['set', folderPath, 'metadata::custom-icon-name', 'user-trash'],
115
+ { timeout: 5000 },
116
+ );
117
+ } catch {
118
+ // gio not available — .directory file is enough for KDE
119
+ }
120
+ }
package/src/lib/ignore.js CHANGED
@@ -16,12 +16,11 @@ const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
16
16
  .dboignore
17
17
  *.dboio.json
18
18
  app.json
19
- .app.json
20
- dbo.deploy.json
21
19
 
22
20
  # Editor / IDE / OS
23
21
  .DS_Store
24
22
  Thumbs.db
23
+ Icon\\r
25
24
  .idea/
26
25
  .vscode/
27
26
  *.codekit3
@@ -43,7 +42,7 @@ package-lock.json
43
42
 
44
43
  # Local development (not pushed to DBO server)
45
44
  src/
46
- tests/
45
+ test/
47
46
 
48
47
  # Documentation (repo scaffolding)
49
48
  SETUP.md
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { log } from './logger.js';
3
- import { loadUserInfo, loadTicketSuggestionOutput } from './config.js';
3
+ import { loadConfig, loadUserInfo, loadTicketSuggestionOutput } from './config.js';
4
4
  import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket, setTicketingRequired } from './ticketing.js';
5
5
  import { DboClient } from './client.js';
6
6
 
@@ -186,7 +186,6 @@ export async function checkSubmitErrors(result, context = {}) {
186
186
  if (needsUser && !userResolved) {
187
187
  log.warn(`This operation requires an authenticated user (${matchedUserPattern}).`);
188
188
  log.dim(' Your session may have expired, or you may not be logged in.');
189
- log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
190
189
 
191
190
  const stored = await loadUserInfo();
192
191
 
@@ -197,6 +196,7 @@ export async function checkSubmitErrors(result, context = {}) {
197
196
  name: 'userChoice',
198
197
  message: 'User ID Required:',
199
198
  choices: [
199
+ { name: 'Re-login now (recommended)', value: '_relogin' },
200
200
  { name: `Use session user (ID: ${stored.userId})`, value: stored.userId },
201
201
  { name: 'Enter a different User ID', value: '_custom' },
202
202
  ],
@@ -210,9 +210,19 @@ export async function checkSubmitErrors(result, context = {}) {
210
210
  });
211
211
  } else {
212
212
  prompts.push({
213
- type: 'input',
214
- name: 'userValue',
213
+ type: 'list',
214
+ name: 'userChoice',
215
215
  message: 'User ID Required:',
216
+ choices: [
217
+ { name: 'Re-login now', value: '_relogin' },
218
+ { name: 'Enter User ID manually', value: '_custom' },
219
+ ],
220
+ });
221
+ prompts.push({
222
+ type: 'input',
223
+ name: 'customUserValue',
224
+ message: 'User ID:',
225
+ when: (answers) => answers.userChoice === '_custom',
216
226
  validate: v => v.trim() ? true : 'User ID is required',
217
227
  });
218
228
  }
@@ -233,12 +243,29 @@ export async function checkSubmitErrors(result, context = {}) {
233
243
 
234
244
  if (answers.ticketId) retryParams['_OverrideTicketID'] = answers.ticketId.trim();
235
245
 
236
- const userValue = answers.userValue
237
- || (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
238
- if (userValue) {
239
- const resolved = userValue.trim();
240
- retryParams['_OverrideUserID'] = resolved;
241
- _sessionUserOverride = resolved; // cache for remaining records in this batch
246
+ if (answers.userChoice === '_relogin') {
247
+ // Run login flow inline, then use the stored user ID
248
+ try {
249
+ const config = await loadConfig();
250
+ const { performLogin } = await import('../commands/login.js');
251
+ await performLogin(config.domain);
252
+ const refreshed = await loadUserInfo();
253
+ if (refreshed.userId) {
254
+ retryParams['_OverrideUserID'] = refreshed.userId;
255
+ _sessionUserOverride = refreshed.userId;
256
+ }
257
+ } catch (err) {
258
+ log.error(`Re-login failed: ${err.message}`);
259
+ return null;
260
+ }
261
+ } else {
262
+ const userValue = answers.userValue
263
+ || (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
264
+ if (userValue) {
265
+ const resolved = userValue.trim();
266
+ retryParams['_OverrideUserID'] = resolved;
267
+ _sessionUserOverride = resolved;
268
+ }
242
269
  }
243
270
 
244
271
  return retryParams;
@@ -28,20 +28,37 @@ export function resolveDirective(filePath) {
28
28
  const parts = rel.split('/');
29
29
  const topDir = parts[0];
30
30
 
31
- // docs/ prefix → static mapping
31
+ // docs/ prefix at project root → static mapping (docs/ stays at root)
32
32
  if (topDir === 'docs') {
33
33
  return { ...STATIC_DIRECTIVE_MAP.docs };
34
34
  }
35
35
 
36
- // extension/<descriptor>/need at least 3 parts (extension/descriptor/file)
36
+ // lib/<subDir>/...all server-managed dirs are now under lib/
37
+ if (topDir === 'lib') {
38
+ const subDir = parts[1];
39
+ if (!subDir) return null;
40
+
41
+ // lib/extension/<descriptor>/<file> — need at least 4 parts
42
+ if (subDir === 'extension') {
43
+ if (parts.length < 4) return null;
44
+ const descriptorDir = parts[2];
45
+ if (descriptorDir.startsWith('.') || descriptorDir === '_unsupported') return null;
46
+ return { entity: 'extension', descriptor: descriptorDir };
47
+ }
48
+
49
+ // lib/<entityType>/<file> — other entity dirs
50
+ if (ENTITY_DIR_NAMES.has(subDir)) {
51
+ return { entity: subDir, descriptor: null };
52
+ }
53
+ }
54
+
55
+ // Legacy fallback: bare entity-dir paths (pre-migration projects)
37
56
  if (topDir === 'extension') {
38
57
  if (parts.length < 3) return null;
39
58
  const secondDir = parts[1];
40
59
  if (secondDir.startsWith('.')) return null;
41
60
  return { entity: 'extension', descriptor: secondDir };
42
61
  }
43
-
44
- // Other entity-dir types
45
62
  if (ENTITY_DIR_NAMES.has(topDir)) {
46
63
  return { entity: topDir, descriptor: null };
47
64
  }
@@ -0,0 +1,75 @@
1
+ import { readdir } from 'fs/promises';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { isInitialized, loadCompletedMigrations, saveCompletedMigration } from './config.js';
5
+ import { log } from './logger.js';
6
+
7
+ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../migrations');
8
+ const MIGRATION_FILE_RE = /^(\d{3})-[\w-]+\.js$/;
9
+
10
+ /**
11
+ * Run all pending migrations in sequence.
12
+ * Called at the start of every command handler unless options.migrate === false.
13
+ *
14
+ * @param {object} options - Command options object (checked for migrate flag)
15
+ */
16
+ export async function runPendingMigrations(options = {}) {
17
+ // --no-migrate suppresses for this invocation
18
+ if (options.migrate === false) return;
19
+
20
+ // No .dbo/ project in this directory — nothing to migrate
21
+ if (!(await isInitialized())) return;
22
+
23
+ // Discover migration files
24
+ let files;
25
+ try {
26
+ files = await readdir(MIGRATIONS_DIR);
27
+ } catch {
28
+ return; // migrations/ directory absent — safe no-op
29
+ }
30
+
31
+ const migrationFiles = files
32
+ .filter(f => MIGRATION_FILE_RE.test(f))
33
+ .sort(); // ascending by NNN prefix
34
+
35
+ if (migrationFiles.length === 0) return;
36
+
37
+ const completed = new Set(await loadCompletedMigrations());
38
+ const pending = migrationFiles.filter(f => {
39
+ const id = f.match(MIGRATION_FILE_RE)?.[1];
40
+ return id && !completed.has(id);
41
+ });
42
+
43
+ if (pending.length === 0) return;
44
+
45
+ for (const file of pending) {
46
+ const id = file.match(MIGRATION_FILE_RE)[1];
47
+ try {
48
+ const migrationPath = join(MIGRATIONS_DIR, file);
49
+ const { default: run, description } = await import(migrationPath);
50
+ await run(options);
51
+ await saveCompletedMigration(id);
52
+ log.success(`Migration ${id}: ${description}`);
53
+ } catch (err) {
54
+ log.warn(`Migration ${id} failed: ${err.message} — skipped`);
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Count pending (unrun) migrations without executing them.
61
+ * Used by `dbo status` to report pending count.
62
+ *
63
+ * @returns {Promise<number>}
64
+ */
65
+ export async function countPendingMigrations() {
66
+ if (!(await isInitialized())) return 0;
67
+ let files;
68
+ try { files = await readdir(MIGRATIONS_DIR); } catch { return 0; }
69
+ const migrationFiles = files.filter(f => MIGRATION_FILE_RE.test(f));
70
+ const completed = new Set(await loadCompletedMigrations());
71
+ return migrationFiles.filter(f => {
72
+ const id = f.match(MIGRATION_FILE_RE)?.[1];
73
+ return id && !completed.has(id);
74
+ }).length;
75
+ }
@@ -230,7 +230,7 @@ export async function saveToDisk(rows, columns, options = {}) {
230
230
 
231
231
  if (bulkAction !== 'overwrite_all') {
232
232
  const localSyncTime = await diffModule.getLocalSyncTime(metaPath);
233
- const serverIsNewer = diffModule.isServerNewer(localSyncTime, row._LastUpdated, options.config || {});
233
+ const serverIsNewer = diffModule.isServerNewer(localSyncTime, row._LastUpdated, options.config || {}, options.entity, row.UID);
234
234
 
235
235
  if (serverIsNewer) {
236
236
  const action = await diffModule.promptChangeDetection(finalName, row, options.config || {});
@@ -1,7 +1,8 @@
1
- import { mkdir, stat, writeFile, access } from 'fs/promises';
1
+ import { mkdir, stat, readFile, writeFile, access } from 'fs/promises';
2
2
  import { join } from 'path';
3
- import { DEFAULT_PROJECT_DIRS } from './structure.js';
3
+ import { SCAFFOLD_DIRS } from './structure.js';
4
4
  import { log } from './logger.js';
5
+ import { applyTrashIcon } from './folder-icon.js';
5
6
 
6
7
  /**
7
8
  * Scaffold the standard DBO project directory structure in cwd.
@@ -16,7 +17,7 @@ export async function scaffoldProjectDirs(cwd = process.cwd()) {
16
17
  const skipped = [];
17
18
  const warned = [];
18
19
 
19
- for (const dir of DEFAULT_PROJECT_DIRS) {
20
+ for (const dir of SCAFFOLD_DIRS) {
20
21
  const target = join(cwd, dir);
21
22
  try {
22
23
  const s = await stat(target);
@@ -33,6 +34,9 @@ export async function scaffoldProjectDirs(cwd = process.cwd()) {
33
34
  }
34
35
  }
35
36
 
37
+ // Best-effort: apply trash icon to the trash directory
38
+ await applyTrashIcon(join(cwd, 'trash'));
39
+
36
40
  // Create app.json if absent
37
41
  const appJsonPath = join(cwd, 'app.json');
38
42
  try {
@@ -42,6 +46,57 @@ export async function scaffoldProjectDirs(cwd = process.cwd()) {
42
46
  created.push('app.json');
43
47
  }
44
48
 
49
+ // Create manifest.json if absent
50
+ const manifestPath = join(cwd, 'manifest.json');
51
+ try {
52
+ await access(manifestPath);
53
+ } catch {
54
+ // Try to resolve values from local app.json; fall back to empty strings
55
+ let appName = '';
56
+ let shortName = '';
57
+ let description = '';
58
+ let bgColor = '#ffffff';
59
+ let domain = '';
60
+
61
+ try {
62
+ const appData = JSON.parse(await readFile(appJsonPath, 'utf8'));
63
+ appName = appData.Name || '';
64
+ shortName = appData.ShortName || '';
65
+ description = appData.Description || '';
66
+ domain = appData._domain || '';
67
+
68
+ // Find background_color from extension children (widget matching ShortName)
69
+ if (shortName && Array.isArray(appData.children?.extension)) {
70
+ for (const ext of appData.children.extension) {
71
+ if (ext.Descriptor === 'widget' && ext.String1 === shortName && ext.String4) {
72
+ bgColor = ext.String4;
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ } catch { /* app.json missing or unparseable — use empty defaults */ }
78
+
79
+ const manifest = {
80
+ name: `${appName} | ${domain}`,
81
+ short_name: shortName,
82
+ description,
83
+ orientation: 'portrait',
84
+ start_url: shortName ? `/app/${shortName}/ui/` : '',
85
+ lang: 'en',
86
+ scope: shortName ? `/app/${shortName}/ui/` : '',
87
+ display_override: ['window-control-overlay', 'minimal-ui'],
88
+ display: 'standalone',
89
+ background_color: bgColor,
90
+ theme_color: '#000000',
91
+ id: shortName,
92
+ screenshots: [],
93
+ ios: {},
94
+ icons: [],
95
+ };
96
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
97
+ created.push('manifest.json');
98
+ }
99
+
45
100
  return { created, skipped, warned };
46
101
  }
47
102