@dboio/cli 0.9.6 → 0.10.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 +38 -1
- package/bin/dbo.js +2 -0
- package/package.json +1 -1
- package/src/commands/add.js +46 -0
- package/src/commands/clone.js +560 -246
- package/src/commands/init.js +30 -32
- package/src/commands/pull.js +264 -87
- package/src/commands/push.js +502 -57
- package/src/commands/rm.js +1 -1
- package/src/commands/sync.js +68 -0
- package/src/lib/config.js +49 -8
- package/src/lib/delta.js +115 -28
- package/src/lib/diff.js +9 -3
- package/src/lib/folder-icon.js +120 -0
- package/src/lib/ignore.js +1 -1
- package/src/lib/input-parser.js +37 -10
- package/src/lib/scaffold.js +82 -2
- package/src/lib/structure.js +2 -0
package/src/commands/rm.js
CHANGED
|
@@ -172,7 +172,7 @@ async function rmFile(filePath, options) {
|
|
|
172
172
|
process.exit(1);
|
|
173
173
|
}
|
|
174
174
|
if (!rowId) {
|
|
175
|
-
log.error(`No row ID found in "${metaPath}". Cannot build delete expression.`);
|
|
175
|
+
log.error(`No row ID found in "${metaPath}". Cannot build delete expression.\n Metadata needs _id or ${entity.charAt(0).toUpperCase() + entity.slice(1)}ID. Run "dbo pull" to populate.`);
|
|
176
176
|
process.exit(1);
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { log } from '../lib/logger.js';
|
|
3
|
+
import { loadConfig, loadAppConfig, saveAppJsonBaseline } from '../lib/config.js';
|
|
4
|
+
import { DboClient } from '../lib/client.js';
|
|
5
|
+
import { decodeBase64Fields } from './clone.js';
|
|
6
|
+
|
|
7
|
+
export const syncCommand = new Command('sync')
|
|
8
|
+
.description('Synchronise local state with the server')
|
|
9
|
+
.option('--baseline', 'Re-fetch server state and update .dbo/.app_baseline.json (does not modify local files)')
|
|
10
|
+
.action(async (options) => {
|
|
11
|
+
if (!options.baseline) {
|
|
12
|
+
log.warn('No sync mode specified. Use --baseline to reset the baseline file.');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const config = await loadConfig();
|
|
17
|
+
if (!config.domain) {
|
|
18
|
+
log.error('No domain configured. Run "dbo init" first.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const appConfig = await loadAppConfig();
|
|
23
|
+
if (!appConfig.AppShortName) {
|
|
24
|
+
log.error('No AppShortName found. Run "dbo clone" first.');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ora = (await import('ora')).default;
|
|
29
|
+
const spinner = ora('Syncing baseline from server...').start();
|
|
30
|
+
|
|
31
|
+
const client = new DboClient({ domain: config.domain, verbose: options.verbose });
|
|
32
|
+
|
|
33
|
+
let result;
|
|
34
|
+
try {
|
|
35
|
+
result = await client.get(`/api/app/object/${appConfig.AppShortName}`);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
spinner.fail(`Failed to fetch app JSON: ${err.message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const data = result.payload || result.data;
|
|
42
|
+
|
|
43
|
+
let appRecord;
|
|
44
|
+
if (Array.isArray(data)) {
|
|
45
|
+
appRecord = data.length > 0 ? data[0] : null;
|
|
46
|
+
} else if (data?.Rows?.length > 0) {
|
|
47
|
+
appRecord = data.Rows[0];
|
|
48
|
+
} else if (data?.rows?.length > 0) {
|
|
49
|
+
appRecord = data.rows[0];
|
|
50
|
+
} else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
|
|
51
|
+
appRecord = data;
|
|
52
|
+
} else {
|
|
53
|
+
appRecord = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!appRecord) {
|
|
57
|
+
spinner.fail(`No app found with ShortName "${appConfig.AppShortName}"`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Deep clone and decode base64 fields (same logic as clone's saveBaselineFile)
|
|
62
|
+
const baseline = JSON.parse(JSON.stringify(appRecord));
|
|
63
|
+
decodeBase64Fields(baseline);
|
|
64
|
+
|
|
65
|
+
await saveAppJsonBaseline(baseline);
|
|
66
|
+
spinner.succeed('.dbo/.app_baseline.json updated from server');
|
|
67
|
+
log.dim(' Run "dbo push" to sync local changes against the new baseline');
|
|
68
|
+
});
|
package/src/lib/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
|
1
|
+
import { readFile, writeFile, mkdir, access, chmod, unlink } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { log } from './logger.js';
|
|
4
4
|
|
|
@@ -8,7 +8,7 @@ const CONFIG_LOCAL_FILE = 'config.local.json';
|
|
|
8
8
|
const CREDENTIALS_FILE = 'credentials.json';
|
|
9
9
|
const COOKIES_FILE = 'cookies.txt';
|
|
10
10
|
const SYNCHRONIZE_FILE = 'synchronize.json';
|
|
11
|
-
const BASELINE_FILE = '.
|
|
11
|
+
const BASELINE_FILE = '.app_baseline.json';
|
|
12
12
|
|
|
13
13
|
function dboDir() {
|
|
14
14
|
return join(process.cwd(), DBO_DIR);
|
|
@@ -693,10 +693,10 @@ export async function ensureGitignore(patterns) {
|
|
|
693
693
|
for (const p of toAdd) log.dim(` Added ${p} to .gitignore`);
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
-
// ─── Baseline (.
|
|
696
|
+
// ─── Baseline (.dbo/.app_baseline.json) ───────────────────────────────────
|
|
697
697
|
|
|
698
|
-
function baselinePath() {
|
|
699
|
-
return join(
|
|
698
|
+
export function baselinePath() {
|
|
699
|
+
return join(dboDir(), BASELINE_FILE);
|
|
700
700
|
}
|
|
701
701
|
|
|
702
702
|
/**
|
|
@@ -707,11 +707,38 @@ export async function hasBaseline() {
|
|
|
707
707
|
}
|
|
708
708
|
|
|
709
709
|
/**
|
|
710
|
-
* Load .
|
|
710
|
+
* Load .dbo/.app_baseline.json baseline file.
|
|
711
|
+
* Auto-migrates from legacy root .app.json if the new path does not exist.
|
|
711
712
|
*/
|
|
712
713
|
export async function loadAppJsonBaseline() {
|
|
714
|
+
const newPath = baselinePath();
|
|
715
|
+
|
|
716
|
+
// Legacy migration: root .app.json → .dbo/.app_baseline.json
|
|
717
|
+
if (!(await exists(newPath))) {
|
|
718
|
+
const legacyPath = join(process.cwd(), '.app.json');
|
|
719
|
+
if (await exists(legacyPath)) {
|
|
720
|
+
let parsed;
|
|
721
|
+
try {
|
|
722
|
+
parsed = JSON.parse(await readFile(legacyPath, 'utf8'));
|
|
723
|
+
} catch {
|
|
724
|
+
log.warn('Could not migrate .app.json — file is not valid JSON. Delete it manually and run "dbo clone" to recreate the baseline.');
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
await mkdir(dboDir(), { recursive: true });
|
|
728
|
+
await writeFile(newPath, JSON.stringify(parsed, null, 2) + '\n');
|
|
729
|
+
try { await chmod(newPath, 0o444); } catch { /* ignore */ }
|
|
730
|
+
try {
|
|
731
|
+
await unlink(legacyPath);
|
|
732
|
+
} catch {
|
|
733
|
+
log.warn('Migrated baseline but could not delete root .app.json — please remove it manually.');
|
|
734
|
+
}
|
|
735
|
+
log.dim('Migrated .app.json → .dbo/.app_baseline.json (system-managed baseline)');
|
|
736
|
+
return parsed;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
713
740
|
try {
|
|
714
|
-
const raw = await readFile(
|
|
741
|
+
const raw = await readFile(newPath, 'utf8');
|
|
715
742
|
return JSON.parse(raw);
|
|
716
743
|
} catch {
|
|
717
744
|
return null;
|
|
@@ -719,10 +746,24 @@ export async function loadAppJsonBaseline() {
|
|
|
719
746
|
}
|
|
720
747
|
|
|
721
748
|
/**
|
|
722
|
-
* Save .
|
|
749
|
+
* Save .dbo/.app_baseline.json baseline file.
|
|
750
|
+
* Temporarily widens permissions before writing (chmod 0o644),
|
|
751
|
+
* then restores read-only (chmod 0o444) after writing.
|
|
723
752
|
*/
|
|
724
753
|
export async function saveAppJsonBaseline(data) {
|
|
754
|
+
await mkdir(dboDir(), { recursive: true });
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
await chmod(baselinePath(), 0o644);
|
|
758
|
+
} catch { /* file doesn't exist yet — first write */ }
|
|
759
|
+
|
|
725
760
|
await writeFile(baselinePath(), JSON.stringify(data, null, 2) + '\n');
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
await chmod(baselinePath(), 0o444);
|
|
764
|
+
} catch {
|
|
765
|
+
log.warn('⚠ Could not set baseline file permissions — ensure .dbo/.app_baseline.json is not manually edited');
|
|
766
|
+
}
|
|
726
767
|
}
|
|
727
768
|
|
|
728
769
|
/**
|
package/src/lib/delta.js
CHANGED
|
@@ -1,37 +1,26 @@
|
|
|
1
1
|
import { readFile, stat } from 'fs/promises';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { log } from './logger.js';
|
|
4
|
+
import { loadAppJsonBaseline, saveAppJsonBaseline } from './config.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Load the baseline file
|
|
7
|
+
* Load the baseline file from disk.
|
|
8
|
+
* Delegates to loadAppJsonBaseline() in config.js for the canonical path.
|
|
7
9
|
*
|
|
8
|
-
* @param {string} cwd - Current working directory
|
|
9
10
|
* @returns {Promise<Object|null>} - Baseline JSON or null if not found
|
|
10
11
|
*/
|
|
11
|
-
export async function loadBaseline(
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
const raw = await readFile(baselinePath, 'utf8');
|
|
15
|
-
return JSON.parse(raw);
|
|
16
|
-
} catch (err) {
|
|
17
|
-
if (err.code === 'ENOENT') {
|
|
18
|
-
return null; // Baseline doesn't exist
|
|
19
|
-
}
|
|
20
|
-
log.warn(`Failed to parse .app.json: ${err.message}`);
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
12
|
+
export async function loadBaseline() {
|
|
13
|
+
return loadAppJsonBaseline();
|
|
23
14
|
}
|
|
24
15
|
|
|
25
16
|
/**
|
|
26
|
-
* Save baseline data to .
|
|
17
|
+
* Save baseline data to disk.
|
|
18
|
+
* Delegates to saveAppJsonBaseline() in config.js for the canonical path.
|
|
27
19
|
*
|
|
28
20
|
* @param {Object} data - Baseline JSON data
|
|
29
|
-
* @param {string} cwd - Current working directory
|
|
30
21
|
*/
|
|
31
|
-
export async function saveBaseline(data
|
|
32
|
-
|
|
33
|
-
const baselinePath = join(cwd, '.app.json');
|
|
34
|
-
await writeFile(baselinePath, JSON.stringify(data, null, 2), 'utf8');
|
|
22
|
+
export async function saveBaseline(data) {
|
|
23
|
+
return saveAppJsonBaseline(data);
|
|
35
24
|
}
|
|
36
25
|
|
|
37
26
|
/**
|
|
@@ -48,12 +37,31 @@ export function findBaselineEntry(baseline, entity, uid) {
|
|
|
48
37
|
return null;
|
|
49
38
|
}
|
|
50
39
|
|
|
40
|
+
// Check top-level array first
|
|
51
41
|
const entityArray = baseline.children[entity];
|
|
52
|
-
if (
|
|
53
|
-
|
|
42
|
+
if (Array.isArray(entityArray)) {
|
|
43
|
+
const found = entityArray.find(item => item.UID === uid);
|
|
44
|
+
if (found) return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For output hierarchy entities (output_value, output_value_filter,
|
|
48
|
+
// output_value_entity_column_rel), also search nested inside each
|
|
49
|
+
// output record's .children — server nests them per-output.
|
|
50
|
+
if (entity !== 'output') {
|
|
51
|
+
const outputs = baseline.children.output;
|
|
52
|
+
if (Array.isArray(outputs)) {
|
|
53
|
+
for (const o of outputs) {
|
|
54
|
+
if (!o.children) continue;
|
|
55
|
+
const nested = o.children[entity];
|
|
56
|
+
if (Array.isArray(nested)) {
|
|
57
|
+
const found = nested.find(item => item.UID === uid);
|
|
58
|
+
if (found) return found;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
return
|
|
64
|
+
return null;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/**
|
|
@@ -127,6 +135,13 @@ export async function detectChangedColumns(metaPath, baseline) {
|
|
|
127
135
|
const baselineValue = normalizeValue(baselineEntry[columnName]);
|
|
128
136
|
|
|
129
137
|
if (currentValue !== baselineValue) {
|
|
138
|
+
// Skip Extension column when baseline is null/empty — clone derives
|
|
139
|
+
// it from the filename for local use but it's not a user change.
|
|
140
|
+
if (columnName === 'Extension'
|
|
141
|
+
&& !baselineValue
|
|
142
|
+
&& (baselineEntry[columnName] === null || baselineEntry[columnName] === undefined)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
130
145
|
changedColumns.push(columnName);
|
|
131
146
|
}
|
|
132
147
|
}
|
|
@@ -154,7 +169,7 @@ export async function detectChangedColumns(metaPath, baseline) {
|
|
|
154
169
|
* @param {Object} metadata - Metadata object
|
|
155
170
|
* @returns {string[]} - Array of user column names
|
|
156
171
|
*/
|
|
157
|
-
function getAllUserColumns(metadata) {
|
|
172
|
+
export function getAllUserColumns(metadata) {
|
|
158
173
|
return Object.keys(metadata).filter(col => !shouldSkipColumn(col));
|
|
159
174
|
}
|
|
160
175
|
|
|
@@ -164,7 +179,7 @@ function getAllUserColumns(metadata) {
|
|
|
164
179
|
* @param {string} columnName - Column name
|
|
165
180
|
* @returns {boolean} - True if should skip
|
|
166
181
|
*/
|
|
167
|
-
function shouldSkipColumn(columnName) {
|
|
182
|
+
export function shouldSkipColumn(columnName) {
|
|
168
183
|
// Skip system columns starting with underscore, UID, and children
|
|
169
184
|
return columnName.startsWith('_') ||
|
|
170
185
|
columnName === 'UID' ||
|
|
@@ -177,7 +192,7 @@ function shouldSkipColumn(columnName) {
|
|
|
177
192
|
* @param {*} value - Value to check
|
|
178
193
|
* @returns {boolean} - True if reference
|
|
179
194
|
*/
|
|
180
|
-
function isReference(value) {
|
|
195
|
+
export function isReference(value) {
|
|
181
196
|
return typeof value === 'string' && value.startsWith('@');
|
|
182
197
|
}
|
|
183
198
|
|
|
@@ -189,7 +204,7 @@ function isReference(value) {
|
|
|
189
204
|
* @param {string} baseDir - Base directory containing metadata
|
|
190
205
|
* @returns {string} - Absolute file path
|
|
191
206
|
*/
|
|
192
|
-
function resolveReferencePath(reference, baseDir) {
|
|
207
|
+
export function resolveReferencePath(reference, baseDir) {
|
|
193
208
|
const refPath = reference.substring(1); // Strip leading @
|
|
194
209
|
if (refPath.startsWith('/')) {
|
|
195
210
|
return join(process.cwd(), refPath);
|
|
@@ -203,7 +218,7 @@ function resolveReferencePath(reference, baseDir) {
|
|
|
203
218
|
* @param {*} value - Value to normalize
|
|
204
219
|
* @returns {string} - Normalized string value
|
|
205
220
|
*/
|
|
206
|
-
function normalizeValue(value) {
|
|
221
|
+
export function normalizeValue(value) {
|
|
207
222
|
if (value === null || value === undefined) {
|
|
208
223
|
return '';
|
|
209
224
|
}
|
|
@@ -215,3 +230,75 @@ function normalizeValue(value) {
|
|
|
215
230
|
|
|
216
231
|
return String(value).trim();
|
|
217
232
|
}
|
|
233
|
+
|
|
234
|
+
// ─── Compound Output Delta Detection ────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const _OUTPUT_DOC_KEYS = ['column', 'join', 'filter'];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Detect changed columns across a compound output file (root + inline children).
|
|
240
|
+
*
|
|
241
|
+
* @param {string} metaPath - Path to the root output JSON file
|
|
242
|
+
* @param {Object} baseline - The baseline JSON
|
|
243
|
+
* @returns {Promise<{ root: string[], children: Object<string, string[]> }>}
|
|
244
|
+
* root: changed column names on the root output entity
|
|
245
|
+
* children: { uid: [changedCols] } for each inline child entity
|
|
246
|
+
*/
|
|
247
|
+
export async function detectOutputChanges(metaPath, baseline) {
|
|
248
|
+
const metaRaw = await readFile(metaPath, 'utf8');
|
|
249
|
+
const meta = JSON.parse(metaRaw);
|
|
250
|
+
const metaDir = dirname(metaPath);
|
|
251
|
+
|
|
252
|
+
// Detect root changes
|
|
253
|
+
const root = await _detectEntityChanges(meta, baseline, metaDir);
|
|
254
|
+
|
|
255
|
+
// Detect child changes recursively via children.{column,join,filter}
|
|
256
|
+
const children = {};
|
|
257
|
+
if (meta.children) {
|
|
258
|
+
await _walkChildrenForChanges(meta.children, baseline, metaDir, children);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { root, children };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function _detectEntityChanges(entity, baseline, metaDir) {
|
|
265
|
+
const { UID, _entity } = entity;
|
|
266
|
+
const baselineEntry = findBaselineEntry(baseline, _entity, UID);
|
|
267
|
+
if (!baselineEntry) {
|
|
268
|
+
return getAllUserColumns(entity);
|
|
269
|
+
}
|
|
270
|
+
const changed = [];
|
|
271
|
+
for (const [col, val] of Object.entries(entity)) {
|
|
272
|
+
if (shouldSkipColumn(col) || col === 'children') continue;
|
|
273
|
+
if (isReference(val)) {
|
|
274
|
+
const refPath = resolveReferencePath(val, metaDir);
|
|
275
|
+
if (await compareFileContent(refPath, baselineEntry[col])) changed.push(col);
|
|
276
|
+
} else {
|
|
277
|
+
if (normalizeValue(val) !== normalizeValue(baselineEntry[col])) {
|
|
278
|
+
// Skip Extension column when baseline is null/empty (same as detectChangedColumns)
|
|
279
|
+
if (col === 'Extension'
|
|
280
|
+
&& !normalizeValue(baselineEntry[col])
|
|
281
|
+
&& (baselineEntry[col] === null || baselineEntry[col] === undefined)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
changed.push(col);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return changed;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function _walkChildrenForChanges(childrenObj, baseline, metaDir, result) {
|
|
292
|
+
for (const docKey of _OUTPUT_DOC_KEYS) {
|
|
293
|
+
const entityArray = childrenObj[docKey];
|
|
294
|
+
if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
|
|
295
|
+
for (const child of entityArray) {
|
|
296
|
+
const changed = await _detectEntityChanges(child, baseline, metaDir);
|
|
297
|
+
result[child.UID] = changed;
|
|
298
|
+
// Recurse into child's own children (e.g. column[0].children.filter)
|
|
299
|
+
if (child.children) {
|
|
300
|
+
await _walkChildrenForChanges(child.children, baseline, metaDir, result);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/lib/diff.js
CHANGED
|
@@ -48,9 +48,15 @@ export async function findMetadataFiles(dir, ig) {
|
|
|
48
48
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
49
49
|
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
50
50
|
} else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
|
|
51
|
-
// Output hierarchy files: _output~<name>~<uid>.json
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Output hierarchy root files only: _output~<name>~<uid>.json
|
|
52
|
+
// Exclude old-format child output files — they contain a dot-prefixed
|
|
53
|
+
// child-type segment (.column~, .join~, .filter~) before .json.
|
|
54
|
+
// Root output files never have these segments, even when <name> contains dots.
|
|
55
|
+
const isChildFile = /\.(column|join|filter)~/.test(entry.name);
|
|
56
|
+
if (!isChildFile) {
|
|
57
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
58
|
+
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
59
|
+
}
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -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
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;
|