@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.
- package/README.md +172 -70
- package/bin/dbo.js +2 -0
- package/bin/postinstall.js +9 -1
- package/package.json +3 -3
- package/plugins/claude/dbo/commands/dbo.md +3 -3
- package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
- package/src/commands/add.js +50 -0
- package/src/commands/clone.js +720 -552
- package/src/commands/content.js +7 -3
- package/src/commands/deploy.js +22 -7
- package/src/commands/diff.js +41 -3
- package/src/commands/init.js +42 -79
- package/src/commands/input.js +5 -0
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +3 -0
- package/src/commands/output.js +8 -10
- package/src/commands/pull.js +268 -87
- package/src/commands/push.js +814 -94
- package/src/commands/rm.js +4 -1
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +71 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +80 -8
- package/src/lib/delta.js +178 -25
- package/src/lib/diff.js +150 -20
- package/src/lib/folder-icon.js +120 -0
- package/src/lib/ignore.js +2 -3
- package/src/lib/input-parser.js +37 -10
- package/src/lib/metadata-templates.js +21 -4
- package/src/lib/migrations.js +75 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/scaffold.js +58 -3
- package/src/lib/structure.js +158 -21
- package/src/lib/toe-stepping.js +381 -0
- package/src/migrations/001-transaction-key-preset-scope.js +35 -0
- package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
- package/src/migrations/003-move-deploy-config.js +50 -0
- 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 (
|
|
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.
|
|
51
|
-
// Output hierarchy files:
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
45
|
+
test/
|
|
47
46
|
|
|
48
47
|
# Documentation (repo scaffolding)
|
|
49
48
|
SETUP.md
|
package/src/lib/input-parser.js
CHANGED
|
@@ -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: '
|
|
214
|
-
name: '
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|
package/src/lib/save-to-disk.js
CHANGED
|
@@ -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 || {});
|
package/src/lib/scaffold.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|