@dboio/cli 0.4.2 → 0.6.0
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 +294 -71
- package/bin/dbo.js +12 -3
- package/bin/postinstall.js +88 -0
- package/package.json +10 -3
- package/src/commands/clone.js +597 -19
- package/src/commands/diff.js +246 -0
- package/src/commands/init.js +30 -22
- package/src/commands/install.js +517 -69
- package/src/commands/mv.js +869 -0
- package/src/commands/pull.js +6 -0
- package/src/commands/push.js +289 -33
- package/src/commands/rm.js +337 -0
- package/src/commands/status.js +28 -1
- package/src/lib/config.js +265 -0
- package/src/lib/delta.js +204 -0
- package/src/lib/dependencies.js +131 -0
- package/src/lib/diff.js +740 -0
- package/src/lib/save-to-disk.js +71 -4
- package/src/lib/structure.js +36 -0
- package/src/plugins/claudecommands/dbo.md +37 -6
- package/src/commands/update.js +0 -168
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFile, unlink, stat, rm as fsRm } from 'fs/promises';
|
|
3
|
+
import { join, dirname, basename, extname } from 'path';
|
|
4
|
+
import { log } from '../lib/logger.js';
|
|
5
|
+
import { formatError } from '../lib/formatter.js';
|
|
6
|
+
import { addDeleteEntry, removeAppJsonReference } from '../lib/config.js';
|
|
7
|
+
import { findMetadataFiles } from '../lib/diff.js';
|
|
8
|
+
import { loadStructureFile, findBinByPath, findChildBins, BINS_DIR } from '../lib/structure.js';
|
|
9
|
+
|
|
10
|
+
export const rmCommand = new Command('rm')
|
|
11
|
+
.description('Remove a file or directory locally and stage server deletions for the next dbo push')
|
|
12
|
+
.argument('<path>', 'File, metadata.json, or directory to remove')
|
|
13
|
+
.option('-f, --force', 'Skip confirmation prompts')
|
|
14
|
+
.option('--keep-local', 'Only stage server deletion, do not delete local files')
|
|
15
|
+
.action(async (targetPath, options) => {
|
|
16
|
+
try {
|
|
17
|
+
const pathStat = await stat(targetPath).catch(() => null);
|
|
18
|
+
if (!pathStat) {
|
|
19
|
+
log.error(`Path not found: "${targetPath}"`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (pathStat.isDirectory()) {
|
|
24
|
+
await rmDirectory(targetPath, options);
|
|
25
|
+
} else {
|
|
26
|
+
await rmFile(targetPath, options);
|
|
27
|
+
}
|
|
28
|
+
} catch (err) {
|
|
29
|
+
formatError(err);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ── File removal (existing logic) ──────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a file path to its metadata.json path.
|
|
38
|
+
*/
|
|
39
|
+
function resolveMetaPath(filePath) {
|
|
40
|
+
if (filePath.endsWith('.metadata.json')) {
|
|
41
|
+
return filePath;
|
|
42
|
+
}
|
|
43
|
+
const dir = dirname(filePath);
|
|
44
|
+
const ext = extname(filePath);
|
|
45
|
+
const base = basename(filePath, ext);
|
|
46
|
+
return join(dir, `${base}.metadata.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the entity-specific row ID from metadata.
|
|
51
|
+
* Checks _id first (shorthand), then derives from entity name.
|
|
52
|
+
*/
|
|
53
|
+
function getRowId(meta) {
|
|
54
|
+
if (meta._id) return meta._id;
|
|
55
|
+
if (meta._entity) {
|
|
56
|
+
const entityIdKey = meta._entity.charAt(0).toUpperCase() + meta._entity.slice(1) + 'ID';
|
|
57
|
+
if (meta[entityIdKey]) return meta[entityIdKey];
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Remove a single file record: stage deletion, remove from app.json, delete local files.
|
|
64
|
+
* Returns true if removed, false if skipped.
|
|
65
|
+
*/
|
|
66
|
+
async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
|
|
67
|
+
let meta;
|
|
68
|
+
try {
|
|
69
|
+
meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
70
|
+
} catch {
|
|
71
|
+
log.warn(` Could not read metadata: ${metaPath}`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const entity = meta._entity;
|
|
76
|
+
const uid = meta.UID;
|
|
77
|
+
const rowId = getRowId(meta);
|
|
78
|
+
|
|
79
|
+
if (!entity || !rowId) {
|
|
80
|
+
log.warn(` Skipping "${metaPath}": missing _entity or row ID`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Collect local files
|
|
85
|
+
const metaDir = dirname(metaPath);
|
|
86
|
+
const localFiles = [metaPath];
|
|
87
|
+
|
|
88
|
+
for (const col of (meta._contentColumns || [])) {
|
|
89
|
+
const ref = meta[col];
|
|
90
|
+
if (ref && String(ref).startsWith('@')) {
|
|
91
|
+
localFiles.push(join(metaDir, String(ref).substring(1)));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
96
|
+
localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const displayName = basename(metaPath, '.metadata.json');
|
|
100
|
+
|
|
101
|
+
// Prompt if needed
|
|
102
|
+
if (!skipPrompt && !options.force) {
|
|
103
|
+
const inquirer = (await import('inquirer')).default;
|
|
104
|
+
const { confirm } = await inquirer.prompt([{
|
|
105
|
+
type: 'confirm',
|
|
106
|
+
name: 'confirm',
|
|
107
|
+
message: `Remove "${displayName}" (${entity}:${uid || rowId})?`,
|
|
108
|
+
default: true,
|
|
109
|
+
}]);
|
|
110
|
+
if (!confirm) {
|
|
111
|
+
log.dim(` Skipped ${displayName}`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Stage deletion
|
|
117
|
+
const expression = `RowID:del${rowId};entity:${entity}=true`;
|
|
118
|
+
await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
|
|
119
|
+
log.success(` Staged: ${displayName} → ${expression}`);
|
|
120
|
+
|
|
121
|
+
// Remove from app.json
|
|
122
|
+
await removeAppJsonReference(metaPath);
|
|
123
|
+
|
|
124
|
+
// Delete local files
|
|
125
|
+
if (!options.keepLocal) {
|
|
126
|
+
for (const f of localFiles) {
|
|
127
|
+
try {
|
|
128
|
+
await unlink(f);
|
|
129
|
+
log.dim(` Deleted ${f}`);
|
|
130
|
+
} catch { /* file may not exist */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Remove a single file (entry point for non-directory rm).
|
|
139
|
+
*/
|
|
140
|
+
async function rmFile(filePath, options) {
|
|
141
|
+
const metaPath = resolveMetaPath(filePath);
|
|
142
|
+
|
|
143
|
+
let meta;
|
|
144
|
+
try {
|
|
145
|
+
meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
146
|
+
} catch {
|
|
147
|
+
log.error(`No metadata found at "${metaPath}". Cannot determine record to delete.`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const entity = meta._entity;
|
|
152
|
+
const uid = meta.UID;
|
|
153
|
+
const rowId = getRowId(meta);
|
|
154
|
+
|
|
155
|
+
if (!entity) {
|
|
156
|
+
log.error(`No _entity found in "${metaPath}".`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
if (!rowId) {
|
|
160
|
+
log.error(`No row ID found in "${metaPath}". Cannot build delete expression.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Collect local files for display
|
|
165
|
+
const metaDir = dirname(metaPath);
|
|
166
|
+
const localFiles = [metaPath];
|
|
167
|
+
for (const col of (meta._contentColumns || [])) {
|
|
168
|
+
const ref = meta[col];
|
|
169
|
+
if (ref && String(ref).startsWith('@')) {
|
|
170
|
+
localFiles.push(join(metaDir, String(ref).substring(1)));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
174
|
+
localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const displayName = basename(metaPath, '.metadata.json');
|
|
178
|
+
log.info(`Removing "${displayName}" (${entity}:${uid || rowId})`);
|
|
179
|
+
for (const f of localFiles) {
|
|
180
|
+
log.dim(` ${f}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Confirmation
|
|
184
|
+
if (!options.force) {
|
|
185
|
+
const inquirer = (await import('inquirer')).default;
|
|
186
|
+
const { confirm } = await inquirer.prompt([{
|
|
187
|
+
type: 'confirm',
|
|
188
|
+
name: 'confirm',
|
|
189
|
+
message: 'Do you really want to remove this file and all of its nodes?\n The next `dbo push` will also remove the record from the server.',
|
|
190
|
+
default: false,
|
|
191
|
+
}]);
|
|
192
|
+
if (!confirm) {
|
|
193
|
+
log.dim('Cancelled.');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const expression = `RowID:del${rowId};entity:${entity}=true`;
|
|
199
|
+
await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
|
|
200
|
+
log.success(`Staged deletion: ${expression}`);
|
|
201
|
+
|
|
202
|
+
await removeAppJsonReference(metaPath);
|
|
203
|
+
|
|
204
|
+
if (!options.keepLocal) {
|
|
205
|
+
for (const f of localFiles) {
|
|
206
|
+
try {
|
|
207
|
+
await unlink(f);
|
|
208
|
+
log.dim(` Deleted ${f}`);
|
|
209
|
+
} catch {
|
|
210
|
+
log.warn(` Could not delete ${f} (may not exist)`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
log.info('Run `dbo push` to apply the deletion on the server.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Directory removal ──────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Collect bins in depth-first order (leaves first) for safe deletion.
|
|
222
|
+
*/
|
|
223
|
+
function collectBinsDepthFirst(binId, structure) {
|
|
224
|
+
const result = [];
|
|
225
|
+
const children = findChildBins(binId, structure);
|
|
226
|
+
for (const child of children) {
|
|
227
|
+
result.push(...collectBinsDepthFirst(child.binId, structure));
|
|
228
|
+
}
|
|
229
|
+
result.push(structure[binId]);
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove a directory: all files inside, sub-bins recursively, then the bin itself.
|
|
235
|
+
*/
|
|
236
|
+
async function rmDirectory(dirPath, options) {
|
|
237
|
+
const structure = await loadStructureFile();
|
|
238
|
+
const bin = findBinByPath(dirPath, structure);
|
|
239
|
+
|
|
240
|
+
if (!bin) {
|
|
241
|
+
log.error(`Directory "${dirPath}" is not a known bin in structure.json.`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Collect all bins depth-first (leaves first)
|
|
246
|
+
const binsToRemove = collectBinsDepthFirst(bin.binId, structure);
|
|
247
|
+
|
|
248
|
+
// Count total files across all directories
|
|
249
|
+
let totalFiles = 0;
|
|
250
|
+
const binFilesMap = new Map(); // binId → [metaFiles]
|
|
251
|
+
for (const b of binsToRemove) {
|
|
252
|
+
const binDir = `${BINS_DIR}/${b.fullPath}`;
|
|
253
|
+
try {
|
|
254
|
+
const metaFiles = await findMetadataFiles(binDir);
|
|
255
|
+
binFilesMap.set(b.binId, metaFiles);
|
|
256
|
+
totalFiles += metaFiles.length;
|
|
257
|
+
} catch {
|
|
258
|
+
binFilesMap.set(b.binId, []);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Summary
|
|
263
|
+
log.info(`Directory "${dirPath}" contains ${totalFiles} file(s) across ${binsToRemove.length} directory(ies)`);
|
|
264
|
+
for (const b of binsToRemove) {
|
|
265
|
+
const files = binFilesMap.get(b.binId) || [];
|
|
266
|
+
log.dim(` ${BINS_DIR}/${b.fullPath}/ (${files.length} files, bin:${b.binId})`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Prompt for approach
|
|
270
|
+
let bulkRemove = options.force;
|
|
271
|
+
if (!options.force && totalFiles > 0) {
|
|
272
|
+
const inquirer = (await import('inquirer')).default;
|
|
273
|
+
const { action } = await inquirer.prompt([{
|
|
274
|
+
type: 'list',
|
|
275
|
+
name: 'action',
|
|
276
|
+
message: `How do you want to proceed?`,
|
|
277
|
+
choices: [
|
|
278
|
+
{ name: 'Remove all files and directories', value: 'all' },
|
|
279
|
+
{ name: 'Prompt for each file', value: 'each' },
|
|
280
|
+
{ name: 'Cancel', value: 'cancel' },
|
|
281
|
+
],
|
|
282
|
+
}]);
|
|
283
|
+
|
|
284
|
+
if (action === 'cancel') {
|
|
285
|
+
log.dim('Cancelled.');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
bulkRemove = action === 'all';
|
|
289
|
+
} else if (!options.force && totalFiles === 0) {
|
|
290
|
+
// Empty directory — just confirm bin deletion
|
|
291
|
+
const inquirer = (await import('inquirer')).default;
|
|
292
|
+
const { confirm } = await inquirer.prompt([{
|
|
293
|
+
type: 'confirm',
|
|
294
|
+
name: 'confirm',
|
|
295
|
+
message: `Remove empty directory "${dirPath}" (bin:${bin.binId})?\n The next \`dbo push\` will also remove it from the server.`,
|
|
296
|
+
default: true,
|
|
297
|
+
}]);
|
|
298
|
+
if (!confirm) {
|
|
299
|
+
log.dim('Cancelled.');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
bulkRemove = true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Process each bin (depth-first: leaves first)
|
|
306
|
+
for (const b of binsToRemove) {
|
|
307
|
+
const metaFiles = binFilesMap.get(b.binId) || [];
|
|
308
|
+
|
|
309
|
+
// Remove files in this bin
|
|
310
|
+
for (const metaPath of metaFiles) {
|
|
311
|
+
await rmFileRecord(metaPath, options, { skipPrompt: bulkRemove });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Stage the bin itself for deletion
|
|
315
|
+
const binExpression = `RowID:del${b.binId};entity:bin=true`;
|
|
316
|
+
await addDeleteEntry({
|
|
317
|
+
UID: b.uid,
|
|
318
|
+
RowID: b.binId,
|
|
319
|
+
entity: 'bin',
|
|
320
|
+
name: b.name,
|
|
321
|
+
expression: binExpression,
|
|
322
|
+
});
|
|
323
|
+
log.success(` Staged bin: ${b.name} (${b.binId}) → ${binExpression}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Remove local directories
|
|
327
|
+
if (!options.keepLocal) {
|
|
328
|
+
try {
|
|
329
|
+
await fsRm(dirPath, { recursive: true });
|
|
330
|
+
log.dim(` Removed directory: ${dirPath}`);
|
|
331
|
+
} catch {
|
|
332
|
+
log.warn(` Could not remove directory: ${dirPath}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
log.info('Run `dbo push` to apply the deletions on the server.');
|
|
337
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { loadConfig, isInitialized, getActiveCookiesPath, loadUserInfo } from '../lib/config.js';
|
|
2
|
+
import { loadConfig, isInitialized, getActiveCookiesPath, loadUserInfo, getAllPluginScopes } from '../lib/config.js';
|
|
3
3
|
import { loadCookies } from '../lib/cookie-jar.js';
|
|
4
|
+
import { access } from 'fs/promises';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
4
7
|
import { log } from '../lib/logger.js';
|
|
5
8
|
|
|
6
9
|
export const statusCommand = new Command('status')
|
|
@@ -34,6 +37,30 @@ export const statusCommand = new Command('status')
|
|
|
34
37
|
} else {
|
|
35
38
|
log.label('Session', 'No active session. Run "dbo login".');
|
|
36
39
|
}
|
|
40
|
+
|
|
41
|
+
// Display plugin status
|
|
42
|
+
const scopes = await getAllPluginScopes();
|
|
43
|
+
const pluginNames = Object.keys(scopes);
|
|
44
|
+
|
|
45
|
+
if (pluginNames.length > 0) {
|
|
46
|
+
log.plain('');
|
|
47
|
+
log.info('Claude Code Plugins:');
|
|
48
|
+
for (const [name, scope] of Object.entries(scopes)) {
|
|
49
|
+
const resolvedScope = scope || 'project';
|
|
50
|
+
const fileName = `${name}.md`;
|
|
51
|
+
let location;
|
|
52
|
+
if (resolvedScope === 'global') {
|
|
53
|
+
location = join(homedir(), '.claude', 'commands', fileName);
|
|
54
|
+
} else {
|
|
55
|
+
location = join(process.cwd(), '.claude', 'commands', fileName);
|
|
56
|
+
}
|
|
57
|
+
let installed = false;
|
|
58
|
+
try { await access(location); installed = true; } catch {}
|
|
59
|
+
const icon = installed ? '\u2713' : '\u2717';
|
|
60
|
+
const scopeLabel = resolvedScope === 'global' ? 'global' : 'project';
|
|
61
|
+
log.label(` ${name}`, `${icon} ${scopeLabel} (${installed ? location : 'not found'})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
37
64
|
} catch (err) {
|
|
38
65
|
log.error(err.message);
|
|
39
66
|
process.exit(1);
|
package/src/lib/config.js
CHANGED
|
@@ -4,8 +4,11 @@ import { log } from './logger.js';
|
|
|
4
4
|
|
|
5
5
|
const DBO_DIR = '.dbo';
|
|
6
6
|
const CONFIG_FILE = 'config.json';
|
|
7
|
+
const CONFIG_LOCAL_FILE = 'config.local.json';
|
|
7
8
|
const CREDENTIALS_FILE = 'credentials.json';
|
|
8
9
|
const COOKIES_FILE = 'cookies.txt';
|
|
10
|
+
const SYNCHRONIZE_FILE = 'synchronize.json';
|
|
11
|
+
const BASELINE_FILE = '.app.json';
|
|
9
12
|
|
|
10
13
|
function dboDir() {
|
|
11
14
|
return join(process.cwd(), DBO_DIR);
|
|
@@ -23,6 +26,10 @@ function cookiesPath() {
|
|
|
23
26
|
return join(dboDir(), COOKIES_FILE);
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
function synchronizePath() {
|
|
30
|
+
return join(dboDir(), SYNCHRONIZE_FILE);
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
async function exists(path) {
|
|
27
34
|
try {
|
|
28
35
|
await access(path);
|
|
@@ -212,6 +219,73 @@ export async function loadClonePlacement() {
|
|
|
212
219
|
}
|
|
213
220
|
}
|
|
214
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Save entity-dir filename column preference to .dbo/config.json.
|
|
224
|
+
* Key format: "<EntityType>FilenameCol" (e.g., "ExtensionFilenameCol")
|
|
225
|
+
*/
|
|
226
|
+
export async function saveEntityDirPreference(entityKey, filenameCol) {
|
|
227
|
+
await mkdir(dboDir(), { recursive: true });
|
|
228
|
+
let existing = {};
|
|
229
|
+
try {
|
|
230
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
231
|
+
} catch { /* no existing config */ }
|
|
232
|
+
// Capitalize first letter for config key: extension → ExtensionFilenameCol
|
|
233
|
+
const configKey = entityKey.charAt(0).toUpperCase() + entityKey.slice(1) + 'FilenameCol';
|
|
234
|
+
existing[configKey] = filenameCol;
|
|
235
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Load entity-dir filename column preference from .dbo/config.json.
|
|
240
|
+
*/
|
|
241
|
+
export async function loadEntityDirPreference(entityKey) {
|
|
242
|
+
try {
|
|
243
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
244
|
+
const config = JSON.parse(raw);
|
|
245
|
+
const configKey = entityKey.charAt(0).toUpperCase() + entityKey.slice(1) + 'FilenameCol';
|
|
246
|
+
return config[configKey] || null;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Save content extraction preferences for an entity type.
|
|
254
|
+
* Stores which base64 columns should be extracted as companion files and their extensions.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} entityKey - Entity type (e.g., 'extension', 'site')
|
|
257
|
+
* @param {Object} extractions - Map of column names to extensions: { "String10": "css", "String7": false, ... }
|
|
258
|
+
* false means user explicitly chose not to extract that column
|
|
259
|
+
*/
|
|
260
|
+
export async function saveEntityContentExtractions(entityKey, extractions) {
|
|
261
|
+
await mkdir(dboDir(), { recursive: true });
|
|
262
|
+
let existing = {};
|
|
263
|
+
try {
|
|
264
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
265
|
+
} catch { /* no existing config */ }
|
|
266
|
+
// Capitalize first letter for config key: extension → ExtensionContentExtractions
|
|
267
|
+
const configKey = entityKey.charAt(0).toUpperCase() + entityKey.slice(1) + 'ContentExtractions';
|
|
268
|
+
existing[configKey] = extractions;
|
|
269
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Load content extraction preferences for an entity type from .dbo/config.json.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} entityKey - Entity type (e.g., 'extension', 'site')
|
|
276
|
+
* @returns {Object|null} - Map of column names to extensions, or null if not saved
|
|
277
|
+
*/
|
|
278
|
+
export async function loadEntityContentExtractions(entityKey) {
|
|
279
|
+
try {
|
|
280
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
281
|
+
const config = JSON.parse(raw);
|
|
282
|
+
const configKey = entityKey.charAt(0).toUpperCase() + entityKey.slice(1) + 'ContentExtractions';
|
|
283
|
+
return config[configKey] || null;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
215
289
|
/**
|
|
216
290
|
* Save user profile fields (FirstName, LastName, Email) into credentials.json.
|
|
217
291
|
*/
|
|
@@ -244,6 +318,165 @@ export async function loadUserProfile() {
|
|
|
244
318
|
}
|
|
245
319
|
}
|
|
246
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Load synchronize.json (staging file for pending server operations).
|
|
323
|
+
*/
|
|
324
|
+
export async function loadSynchronize() {
|
|
325
|
+
try {
|
|
326
|
+
const raw = await readFile(synchronizePath(), 'utf8');
|
|
327
|
+
return JSON.parse(raw);
|
|
328
|
+
} catch {
|
|
329
|
+
return { delete: [], edit: [], add: [] };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Save synchronize.json.
|
|
335
|
+
*/
|
|
336
|
+
export async function saveSynchronize(data) {
|
|
337
|
+
await mkdir(dboDir(), { recursive: true });
|
|
338
|
+
await writeFile(synchronizePath(), JSON.stringify(data, null, 2) + '\n');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Stage a delete entry in synchronize.json. Deduplicates by UID.
|
|
343
|
+
*/
|
|
344
|
+
export async function addDeleteEntry(entry) {
|
|
345
|
+
const data = await loadSynchronize();
|
|
346
|
+
const idx = data.delete.findIndex(e => e.UID === entry.UID);
|
|
347
|
+
if (idx >= 0) {
|
|
348
|
+
data.delete[idx] = entry;
|
|
349
|
+
} else {
|
|
350
|
+
data.delete.push(entry);
|
|
351
|
+
}
|
|
352
|
+
await saveSynchronize(data);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Remove a delete entry from synchronize.json by UID.
|
|
357
|
+
*/
|
|
358
|
+
export async function removeDeleteEntry(uid) {
|
|
359
|
+
const data = await loadSynchronize();
|
|
360
|
+
data.delete = data.delete.filter(e => e.UID !== uid);
|
|
361
|
+
await saveSynchronize(data);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Remove a @metaPath reference from app.json children arrays.
|
|
366
|
+
*/
|
|
367
|
+
export async function removeAppJsonReference(metaPath) {
|
|
368
|
+
const appJsonPath = join(process.cwd(), 'app.json');
|
|
369
|
+
let appJson;
|
|
370
|
+
try {
|
|
371
|
+
appJson = JSON.parse(await readFile(appJsonPath, 'utf8'));
|
|
372
|
+
} catch {
|
|
373
|
+
return; // no app.json
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!appJson.children) return;
|
|
377
|
+
|
|
378
|
+
const ref = `@${metaPath}`;
|
|
379
|
+
let changed = false;
|
|
380
|
+
|
|
381
|
+
for (const [key, arr] of Object.entries(appJson.children)) {
|
|
382
|
+
if (!Array.isArray(arr)) continue;
|
|
383
|
+
const filtered = arr.filter(entry => entry !== ref);
|
|
384
|
+
if (filtered.length !== arr.length) {
|
|
385
|
+
appJson.children[key] = filtered;
|
|
386
|
+
changed = true;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (changed) {
|
|
391
|
+
await writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── config.local.json (per-user, gitignored) ────────────────────────────
|
|
396
|
+
|
|
397
|
+
function configLocalPath() {
|
|
398
|
+
return join(dboDir(), CONFIG_LOCAL_FILE);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Load config.local.json (per-user settings including plugin scopes).
|
|
403
|
+
* Returns empty structure if file doesn't exist.
|
|
404
|
+
*/
|
|
405
|
+
export async function loadLocalConfig() {
|
|
406
|
+
try {
|
|
407
|
+
const raw = await readFile(configLocalPath(), 'utf8');
|
|
408
|
+
return JSON.parse(raw);
|
|
409
|
+
} catch {
|
|
410
|
+
return { plugins: {} };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Save config.local.json.
|
|
416
|
+
*/
|
|
417
|
+
export async function saveLocalConfig(data) {
|
|
418
|
+
await mkdir(dboDir(), { recursive: true });
|
|
419
|
+
await writeFile(configLocalPath(), JSON.stringify(data, null, 2) + '\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Get the stored scope for a plugin by name (without extension).
|
|
424
|
+
* @param {string} pluginName - Plugin name without extension
|
|
425
|
+
* @param {string} [category='claudecommands'] - Plugin category
|
|
426
|
+
* @returns {Promise<'project' | 'global' | null>}
|
|
427
|
+
*/
|
|
428
|
+
export async function getPluginScope(pluginName, category = 'claudecommands') {
|
|
429
|
+
const config = await loadLocalConfig();
|
|
430
|
+
return config.plugins?.[category]?.[pluginName] || null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Set the scope for a plugin by name.
|
|
435
|
+
* @param {string} pluginName - Plugin name without extension
|
|
436
|
+
* @param {'project' | 'global'} scope
|
|
437
|
+
* @param {string} [category='claudecommands'] - Plugin category
|
|
438
|
+
*/
|
|
439
|
+
export async function setPluginScope(pluginName, scope, category = 'claudecommands') {
|
|
440
|
+
const config = await loadLocalConfig();
|
|
441
|
+
if (!config.plugins) config.plugins = {};
|
|
442
|
+
if (!config.plugins[category]) config.plugins[category] = {};
|
|
443
|
+
config.plugins[category][pluginName] = scope;
|
|
444
|
+
await saveLocalConfig(config);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Get all stored plugin scopes for a category.
|
|
449
|
+
* Returns object mapping plugin names to their scope strings.
|
|
450
|
+
* @param {string} [category='claudecommands'] - Plugin category
|
|
451
|
+
*/
|
|
452
|
+
export async function getAllPluginScopes(category = 'claudecommands') {
|
|
453
|
+
const config = await loadLocalConfig();
|
|
454
|
+
return config.plugins?.[category] || {};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ─── Gitignore ────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Remove a specific pattern from .gitignore.
|
|
461
|
+
* Used when moving a plugin from project to global scope.
|
|
462
|
+
*/
|
|
463
|
+
export async function removeFromGitignore(pattern) {
|
|
464
|
+
const gitignorePath = join(process.cwd(), '.gitignore');
|
|
465
|
+
let content = '';
|
|
466
|
+
try {
|
|
467
|
+
content = await readFile(gitignorePath, 'utf8');
|
|
468
|
+
} catch {
|
|
469
|
+
return; // no .gitignore
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!content.includes(pattern)) return;
|
|
473
|
+
|
|
474
|
+
const lines = content.split('\n');
|
|
475
|
+
const filtered = lines.filter(line => line.trim() !== pattern.trim());
|
|
476
|
+
await writeFile(gitignorePath, filtered.join('\n'));
|
|
477
|
+
log.dim(` Removed ${pattern} from .gitignore`);
|
|
478
|
+
}
|
|
479
|
+
|
|
247
480
|
/**
|
|
248
481
|
* Ensure patterns are in .gitignore. Creates .gitignore if it doesn't exist.
|
|
249
482
|
*/
|
|
@@ -267,3 +500,35 @@ export async function ensureGitignore(patterns) {
|
|
|
267
500
|
await writeFile(gitignorePath, content + addition);
|
|
268
501
|
for (const p of toAdd) log.dim(` Added ${p} to .gitignore`);
|
|
269
502
|
}
|
|
503
|
+
|
|
504
|
+
// ─── Baseline (.app.json) ─────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
function baselinePath() {
|
|
507
|
+
return join(process.cwd(), BASELINE_FILE);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Check if baseline file (.app.json) exists.
|
|
512
|
+
*/
|
|
513
|
+
export async function hasBaseline() {
|
|
514
|
+
return exists(baselinePath());
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Load .app.json baseline file (tracks server state for delta detection).
|
|
519
|
+
*/
|
|
520
|
+
export async function loadAppJsonBaseline() {
|
|
521
|
+
try {
|
|
522
|
+
const raw = await readFile(baselinePath(), 'utf8');
|
|
523
|
+
return JSON.parse(raw);
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Save .app.json baseline file.
|
|
531
|
+
*/
|
|
532
|
+
export async function saveAppJsonBaseline(data) {
|
|
533
|
+
await writeFile(baselinePath(), JSON.stringify(data, null, 2) + '\n');
|
|
534
|
+
}
|