@dboio/cli 0.15.2 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,492 @@
1
+ import { readFile, writeFile, readdir, access } from 'fs/promises';
2
+ import { join, relative, basename, extname } from 'path';
3
+ import { EXTENSION_DESCRIPTORS_DIR, ENTITY_DIR_NAMES } from './structure.js';
4
+ import inquirer from 'inquirer';
5
+
6
+ export const METADATA_SCHEMA_FILE = '.dbo/metadata_schema.json';
7
+
8
+ const STATIC_DIRECTIVE_MAP = {
9
+ docs: { entity: 'extension', descriptor: 'documentation' },
10
+ };
11
+
12
+ /**
13
+ * Convert a name to snake_case.
14
+ */
15
+ export function toSnakeCase(name) {
16
+ return name
17
+ .toLowerCase()
18
+ .replace(/[\s-]+/g, '_')
19
+ .replace(/[^a-z0-9_]/g, '');
20
+ }
21
+
22
+ /**
23
+ * Resolve entity/descriptor directive from a file path.
24
+ * Returns { entity, descriptor } or null.
25
+ */
26
+ export function resolveDirective(filePath) {
27
+ const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
28
+ const parts = rel.split('/');
29
+ const topDir = parts[0];
30
+
31
+ // docs/ prefix at project root → static mapping (docs/ stays at root)
32
+ if (topDir === 'docs') {
33
+ return { ...STATIC_DIRECTIVE_MAP.docs };
34
+ }
35
+
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('.')) 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)
56
+ if (topDir === 'extension') {
57
+ if (parts.length < 3) return null;
58
+ const secondDir = parts[1];
59
+ if (secondDir.startsWith('.')) return null;
60
+ return { entity: 'extension', descriptor: secondDir };
61
+ }
62
+ if (ENTITY_DIR_NAMES.has(topDir)) {
63
+ return { entity: topDir, descriptor: null };
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Load metadata schema from .dbo/metadata_schema.json.
71
+ */
72
+ export async function loadMetadataSchema() {
73
+ try {
74
+ const raw = await readFile(METADATA_SCHEMA_FILE, 'utf8');
75
+ try {
76
+ return JSON.parse(raw);
77
+ } catch {
78
+ console.warn('Warning: .dbo/metadata_schema.json is malformed JSON — falling back to generic wizard');
79
+ return null;
80
+ }
81
+ } catch (err) {
82
+ if (err.code === 'ENOENT') return {};
83
+ return {};
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Save metadata schema to .dbo/metadata_schema.json.
89
+ */
90
+ export async function saveMetadataSchema(templates) {
91
+ await writeFile(METADATA_SCHEMA_FILE, JSON.stringify(templates, null, 2) + '\n');
92
+ }
93
+
94
+ /**
95
+ * Merge extension descriptor definitions from dependency metadata_schema.json files.
96
+ * For each dependency in .dbo/dependencies/<name>/, reads its .dbo/metadata_schema.json
97
+ * and copies any extension descriptor entries that don't already exist locally.
98
+ *
99
+ * @param {Object} localSchema - The current project's metadata_schema object (mutated in place)
100
+ * @returns {Promise<boolean>} - true if any entries were merged
101
+ */
102
+ export async function mergeDescriptorSchemaFromDependencies(localSchema) {
103
+ if (!localSchema) return false;
104
+
105
+ const depsRoot = join('.dbo', 'dependencies');
106
+ let depNames;
107
+ try {
108
+ depNames = await readdir(depsRoot);
109
+ } catch {
110
+ return false; // no dependencies directory
111
+ }
112
+
113
+ let merged = false;
114
+
115
+ for (const depName of depNames) {
116
+ const depSchemaPath = join(depsRoot, depName, '.dbo', 'metadata_schema.json');
117
+ let depSchema;
118
+ try {
119
+ depSchema = JSON.parse(await readFile(depSchemaPath, 'utf8'));
120
+ } catch {
121
+ continue; // no schema or unreadable
122
+ }
123
+
124
+ // Only merge extension descriptor entries (the dict values under "extension")
125
+ const depExtension = depSchema.extension;
126
+ if (!depExtension || typeof depExtension !== 'object' || Array.isArray(depExtension)) continue;
127
+
128
+ for (const [descriptor, cols] of Object.entries(depExtension)) {
129
+ // Skip if local already has this descriptor
130
+ const existing = getTemplateCols(localSchema, 'extension', descriptor);
131
+ if (existing) continue;
132
+
133
+ setTemplateCols(localSchema, 'extension', descriptor, cols);
134
+ merged = true;
135
+ }
136
+ }
137
+
138
+ return merged;
139
+ }
140
+
141
+ /**
142
+ * Get template cols for a given entity and optional descriptor.
143
+ * Returns string[] or null.
144
+ */
145
+ export function getTemplateCols(templates, entity, descriptor) {
146
+ if (!templates || !templates[entity]) return null;
147
+ const entry = templates[entity];
148
+
149
+ if (descriptor) {
150
+ // entry must be an object (not array)
151
+ if (Array.isArray(entry)) return null;
152
+ return entry[descriptor] ?? null;
153
+ }
154
+
155
+ // no descriptor — entry must be an array
156
+ if (!Array.isArray(entry)) return null;
157
+ return entry;
158
+ }
159
+
160
+ /**
161
+ * Set template cols for a given entity and optional descriptor.
162
+ * Mutates templates in place.
163
+ */
164
+ export function setTemplateCols(templates, entity, descriptor, cols) {
165
+ if (descriptor) {
166
+ if (!templates[entity] || Array.isArray(templates[entity])) {
167
+ templates[entity] = {};
168
+ }
169
+ templates[entity][descriptor] = cols;
170
+ } else {
171
+ templates[entity] = cols;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Build cols from a record's keys, excluding _ prefixed and array values.
177
+ */
178
+ function buildColsFromRecord(record) {
179
+ return Object.keys(record).filter(key => {
180
+ if (key.startsWith('_')) return false;
181
+ if (Array.isArray(record[key])) return false;
182
+ return true;
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Build baseline minimum viable cols for an entity type.
188
+ */
189
+ function buildBaselineCols(entity, descriptor) {
190
+ const cols = ['AppID', 'Name'];
191
+ if (entity === 'extension') {
192
+ cols.push('ShortName');
193
+ if (descriptor) cols.push(`Descriptor=${descriptor}`);
194
+ cols.push('Active');
195
+ }
196
+ return cols;
197
+ }
198
+
199
+ /**
200
+ * Resolve template cols via three-level lookup:
201
+ * 1. metadata_schema.json
202
+ * 2. appJson sample record
203
+ * 3. baseline defaults
204
+ */
205
+ export async function resolveTemplateCols(entity, descriptor, appConfig, appJson) {
206
+ const templates = await loadMetadataSchema();
207
+ if (templates === null) return null;
208
+
209
+ // Level 1: existing template
210
+ const existing = getTemplateCols(templates, entity, descriptor);
211
+ if (existing) return { cols: existing, templates, isNew: false };
212
+
213
+ // Level 2: derive from appJson
214
+ if (appJson) {
215
+ let records = null;
216
+
217
+ if (entity === 'extension' && descriptor) {
218
+ // appJson[entity] might be an object keyed by descriptor
219
+ const entityData = appJson[entity];
220
+ if (entityData) {
221
+ if (Array.isArray(entityData)) {
222
+ records = entityData;
223
+ } else if (typeof entityData === 'object' && entityData[descriptor]) {
224
+ const descData = entityData[descriptor];
225
+ if (Array.isArray(descData)) records = descData;
226
+ }
227
+ }
228
+ } else {
229
+ const entityData = appJson[entity];
230
+ if (Array.isArray(entityData)) records = entityData;
231
+ }
232
+
233
+ if (records && records.length > 0) {
234
+ const cols = buildColsFromRecord(records[0]);
235
+ setTemplateCols(templates, entity, descriptor, cols);
236
+ await saveMetadataSchema(templates);
237
+ return { cols, templates, isNew: true };
238
+ }
239
+ }
240
+
241
+ // Level 3: baseline defaults
242
+ const cols = buildBaselineCols(entity, descriptor);
243
+ setTemplateCols(templates, entity, descriptor, cols);
244
+ await saveMetadataSchema(templates);
245
+ return { cols, templates, isNew: true };
246
+ }
247
+
248
+ /**
249
+ * Assemble metadata object from cols and file info.
250
+ */
251
+ export function assembleMetadata(cols, filePath, entity, descriptor, appConfig) {
252
+ const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
253
+ const base = basename(filePath, extname(filePath));
254
+ const fileName = basename(filePath);
255
+ const isDocsFile = rel.startsWith('docs/');
256
+ const meta = { _entity: entity };
257
+ const contentColumns = [];
258
+
259
+ for (const col of cols) {
260
+ if (col.includes('=')) {
261
+ const eqIdx = col.indexOf('=');
262
+ const key = col.substring(0, eqIdx);
263
+ const val = col.substring(eqIdx + 1);
264
+
265
+ if (val === '@reference') {
266
+ const refPath = isDocsFile ? '@/' + rel : '@' + fileName;
267
+ meta[key] = refPath;
268
+ contentColumns.push(key);
269
+ } else {
270
+ meta[key] = val;
271
+ }
272
+ } else if (col === 'AppID') {
273
+ meta.AppID = appConfig?.AppID ?? '';
274
+ } else if (col === 'Name') {
275
+ meta.Name = base;
276
+ } else if (col === 'ShortName') {
277
+ meta.ShortName = toSnakeCase(base);
278
+ } else {
279
+ meta[col] = '';
280
+ }
281
+ }
282
+
283
+ let refColMissing = false;
284
+ if (contentColumns.length === 0) {
285
+ refColMissing = true;
286
+ } else {
287
+ meta._companionReferenceColumns = contentColumns;
288
+ }
289
+
290
+ return { meta, contentColumns, refColMissing };
291
+ }
292
+
293
+ /**
294
+ * Prompt user to select a reference column from available string cols.
295
+ */
296
+ export async function promptReferenceColumn(cols, entity, descriptor) {
297
+ const stringCols = cols.filter(c => !c.includes('=') && c !== 'AppID' && c !== 'Name' && c !== 'ShortName');
298
+ if (stringCols.length === 0) return null;
299
+
300
+ const { selected } = await inquirer.prompt([{
301
+ type: 'list',
302
+ name: 'selected',
303
+ message: `Select the content/reference column for ${entity}${descriptor ? '/' + descriptor : ''}:`,
304
+ choices: stringCols,
305
+ }]);
306
+
307
+ return selected;
308
+ }
309
+
310
+ /**
311
+ * Build a template cols array from a clone record.
312
+ */
313
+ export function buildTemplateFromCloneRecord(record, contentColsExtracted = []) {
314
+ const cols = [];
315
+ for (const key of Object.keys(record)) {
316
+ if (key.startsWith('_')) continue;
317
+ if (Array.isArray(record[key])) continue;
318
+
319
+ if (contentColsExtracted.includes(key)) {
320
+ cols.push(key + '=@reference');
321
+ } else if (key === 'Descriptor' && record[key]) {
322
+ cols.push('Descriptor=' + record[key]);
323
+ } else {
324
+ cols.push(key);
325
+ }
326
+ }
327
+ return cols;
328
+ }
329
+
330
+ /**
331
+ * Parse a column entry with extended @reference syntax.
332
+ * Examples:
333
+ * "Content=@reference:@Name[@Extension]"
334
+ * "CustomSQL=@reference[sql]"
335
+ * "String20=@reference:SQLFileName[SQL]"
336
+ * "Column=@reference:@Name[@Ext||html]"
337
+ * "Column=@reference:@Name||fallback[html]"
338
+ * Returns: { column, filenameCol, filenameFallback, extensionCol, extensionFallback }
339
+ * - filenameCol: null | '@ColName' | 'LiteralSuffix'
340
+ * - extensionCol: null | '@ColName' | 'fixedExt'
341
+ * - *Fallback: fallback value after || within name/ext part
342
+ * Returns null if colExpr doesn't contain '=@reference'.
343
+ */
344
+ export function parseReferenceExpression(colExpr) {
345
+ if (!colExpr || !colExpr.includes('=@reference')) return null;
346
+ const eqIdx = colExpr.indexOf('=@reference');
347
+ const column = colExpr.slice(0, eqIdx);
348
+ const rest = colExpr.slice(eqIdx + '=@reference'.length); // ':...' or '[...]' or ''
349
+
350
+ let filenameCol = null, filenameFallback = null;
351
+ let extensionCol = null, extensionFallback = null;
352
+
353
+ let extSource = rest;
354
+
355
+ if (rest.startsWith(':')) {
356
+ const afterColon = rest.slice(1);
357
+ const bracketIdx = afterColon.indexOf('[');
358
+ const namePart = bracketIdx === -1 ? afterColon : afterColon.slice(0, bracketIdx);
359
+
360
+ if (namePart.includes('||')) {
361
+ const [nameExpr, nameFall] = namePart.split('||');
362
+ filenameCol = nameExpr || null;
363
+ filenameFallback = nameFall || null;
364
+ } else {
365
+ filenameCol = namePart || null;
366
+ }
367
+
368
+ extSource = bracketIdx === -1 ? '' : afterColon.slice(bracketIdx);
369
+ }
370
+
371
+ // Parse [extPart] from extSource, then {title} from remainder
372
+ let titleSource = extSource;
373
+ if (extSource.startsWith('[') && extSource.includes(']')) {
374
+ const closeBracket = extSource.lastIndexOf(']');
375
+ const extPart = extSource.slice(1, closeBracket);
376
+ if (extPart.includes('||')) {
377
+ const [extExpr, extFall] = extPart.split('||');
378
+ extensionCol = extExpr || null;
379
+ extensionFallback = extFall || null;
380
+ } else {
381
+ extensionCol = extPart || null;
382
+ }
383
+ titleSource = extSource.slice(closeBracket + 1);
384
+ }
385
+
386
+ // Parse {title} — human-readable alias used as the companion filename segment
387
+ let title = null;
388
+ if (titleSource.startsWith('{') && titleSource.includes('}')) {
389
+ title = titleSource.slice(1, titleSource.lastIndexOf('}')) || null;
390
+ }
391
+
392
+ return { column, filenameCol, filenameFallback, extensionCol, extensionFallback, title };
393
+ }
394
+
395
+ /**
396
+ * Sanitize a string for use as a filename component.
397
+ * @param {string} s
398
+ * @returns {string}
399
+ */
400
+ function sanitizeFilenameComponent(s) {
401
+ return s.replace(/[/\\:*?"<>|]/g, '_').trim();
402
+ }
403
+
404
+ /**
405
+ * Resolve the companion filename for a @reference expression.
406
+ * @param {object} parsed — result of parseReferenceExpression()
407
+ * @param {object} record — the server record (column values)
408
+ * @param {string} recordName — slugified record name (base for default naming)
409
+ * @returns {string|null} resolved filename (with extension) or null if extension unresolvable
410
+ */
411
+ export function resolveCompanionFilename(parsed, record, recordName) {
412
+ const { column, filenameCol, filenameFallback, extensionCol, extensionFallback } = parsed;
413
+
414
+ // --- Resolve filename stem ---
415
+ let stem;
416
+ if (!filenameCol) {
417
+ // No :namePart → "<recordName>.<columnName>"
418
+ stem = `${recordName}.${column}`;
419
+ } else if (filenameCol.startsWith('@')) {
420
+ const val = record?.[filenameCol.slice(1)];
421
+ const resolved = val != null && String(val).trim() ? sanitizeFilenameComponent(String(val)) : null;
422
+ stem = resolved || filenameFallback || recordName;
423
+ } else {
424
+ // Static suffix → "<recordName>.<staticSuffix>"
425
+ stem = `${recordName}.${filenameCol}`;
426
+ }
427
+
428
+ // --- Resolve extension ---
429
+ if (!extensionCol) return stem; // bare @reference — no extension
430
+
431
+ let ext;
432
+ if (extensionCol.startsWith('@')) {
433
+ const val = record?.[extensionCol.slice(1)];
434
+ ext = val != null && String(val).trim() ? String(val).trim().toLowerCase() : extensionFallback;
435
+ } else {
436
+ ext = extensionCol.toLowerCase();
437
+ }
438
+
439
+ if (!ext) return null; // unresolvable — caller must prompt
440
+ return `${stem}.${ext}`;
441
+ }
442
+
443
+ // Built-in @reference rules for well-known entities.
444
+ const BUILT_IN_REFERENCE_RULES = {
445
+ content: ['Content=@reference:@Name[@Extension]'],
446
+ output: ['CustomSQL=@reference[sql]'],
447
+ };
448
+
449
+ /**
450
+ * Generate metadata_schema.json entries from schema.json.
451
+ * Preserves existing entries (never overwrites).
452
+ * Returns merged result.
453
+ * @param {object} schema — parsed schema.json
454
+ * @param {object} existing — existing metadata_schema.json contents
455
+ */
456
+ export function generateMetadataFromSchema(schema, existing = {}) {
457
+ const entities = schema?.children?.entity ?? [];
458
+ const result = { ...existing };
459
+
460
+ for (const entity of entities) {
461
+ const entityKey = entity.UID;
462
+ if (!entityKey || result[entityKey] !== undefined) continue; // skip known/existing
463
+
464
+ const cols = (entity.children?.entity_column ?? [])
465
+ .filter(col => !col.PhysicalName.startsWith('_') && col.ReadOnly !== 1)
466
+ .map(col => {
467
+ if (col.DefaultValue === "b'0'") return `${col.PhysicalName}=0`;
468
+ if (col.DefaultValue === "b'1'") return `${col.PhysicalName}=1`;
469
+ return col.PhysicalName;
470
+ });
471
+
472
+ // Apply built-in @reference rules
473
+ const builtIn = BUILT_IN_REFERENCE_RULES[entityKey] ?? [];
474
+ const merged = [...cols];
475
+ for (const rule of builtIn) {
476
+ const parsed = parseReferenceExpression(rule);
477
+ if (!parsed) continue;
478
+ const idx = merged.indexOf(parsed.column);
479
+ if (idx !== -1) merged[idx] = rule; // replace plain column with @reference version
480
+ else merged.push(rule); // add if column not in list
481
+ }
482
+
483
+ result[entityKey] = merged;
484
+ }
485
+
486
+ return result;
487
+ }
488
+
489
+ // Backward-compat re-exports
490
+ export const loadMetadataTemplates = loadMetadataSchema;
491
+ export const saveMetadataTemplates = saveMetadataSchema;
492
+ export const METADATA_TEMPLATES_FILE = METADATA_SCHEMA_FILE;
@@ -314,7 +314,7 @@ export async function saveToDisk(rows, columns, options = {}) {
314
314
  }
315
315
 
316
316
  if (contentColumnsList.length > 0) {
317
- meta._contentColumns = contentColumnsList;
317
+ meta._companionReferenceColumns = contentColumnsList;
318
318
  }
319
319
 
320
320
  // ── Save metadata ───────────────────────────────────────────────────
@@ -0,0 +1,53 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { DboClient } from './client.js';
3
+
4
+ export const SCHEMA_FILE = 'schema.json';
5
+ const SCHEMA_API_PATH = '/api/app/object/_system';
6
+
7
+ /** Fetch schema from /api/app/object/_system. Returns parsed JSON or throws. */
8
+ export async function fetchSchema(options = {}) {
9
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
10
+ const result = await client.get(SCHEMA_API_PATH);
11
+ if (!result.ok || !result.data) throw new Error(`Schema fetch failed: HTTP ${result.status}`);
12
+ return result.data;
13
+ }
14
+
15
+ /**
16
+ * Fetch only the _LastUpdated timestamp from the schema endpoint.
17
+ * Uses the column-filter syntax: /api/app/object/_system[_LastUpdated]
18
+ * Returns ISO date string or null on failure.
19
+ */
20
+ export async function fetchSchemaLastUpdated(options = {}) {
21
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
22
+ const result = await client.get(`${SCHEMA_API_PATH}[_LastUpdated]`);
23
+ if (!result.ok || !result.data) return null;
24
+ return result.data._LastUpdated || null;
25
+ }
26
+
27
+ /**
28
+ * Check whether the server schema is newer than the local schema.json.
29
+ * Returns true if the server _LastUpdated is more recent (or local is missing).
30
+ */
31
+ export async function isSchemaStale(options = {}) {
32
+ const local = await loadSchema();
33
+ if (!local || !local._LastUpdated) return true;
34
+
35
+ const serverTs = await fetchSchemaLastUpdated(options);
36
+ if (!serverTs) return false; // can't determine — assume not stale
37
+
38
+ return new Date(serverTs) > new Date(local._LastUpdated);
39
+ }
40
+
41
+ /** Load schema.json from project root. Returns parsed object or null if missing. */
42
+ export async function loadSchema() {
43
+ try {
44
+ return JSON.parse(await readFile(SCHEMA_FILE, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Write schema.json to project root. */
51
+ export async function saveSchema(data) {
52
+ await writeFile(SCHEMA_FILE, JSON.stringify(data, null, 2) + '\n');
53
+ }
@@ -326,7 +326,7 @@ export function findChildBins(binId, structure) {
326
326
  /** Root for all extension descriptor-grouped sub-directories */
327
327
  export const EXTENSION_DESCRIPTORS_DIR = 'lib/extension';
328
328
 
329
- /** Extensions that cannot be mapped go here */
329
+ /** @deprecated Used only by migration 011 to relocate existing files. */
330
330
  export const EXTENSION_UNSUPPORTED_DIR = 'lib/extension/_unsupported';
331
331
 
332
332
  /** Root-level documentation directory (remains at project root) */
@@ -380,7 +380,7 @@ export function buildDescriptorMapping(extensionRecords) {
380
380
  * Resolve a field value that may be a base64-encoded object from the server API.
381
381
  * Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
382
382
  */
383
- function resolveFieldValue(value) {
383
+ export function resolveFieldValue(value) {
384
384
  if (value && typeof value === 'object' && !Array.isArray(value)
385
385
  && value.encoding === 'base64' && typeof value.value === 'string') {
386
386
  return Buffer.from(value.value, 'base64').toString('utf8');
@@ -430,7 +430,7 @@ export async function loadDescriptorMapping() {
430
430
  export function resolveExtensionSubDir(record, mapping) {
431
431
  const descriptor = record.Descriptor;
432
432
  if (!descriptor || !mapping[descriptor]) {
433
- return EXTENSION_UNSUPPORTED_DIR;
433
+ return EXTENSION_DESCRIPTORS_DIR;
434
434
  }
435
435
  return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
436
436
  }
@@ -117,7 +117,7 @@ async function _getCompanionPaths(metaPath) {
117
117
  const meta = JSON.parse(await readFile(metaPath, 'utf8'));
118
118
  const dir = dirname(metaPath);
119
119
  const paths = [];
120
- for (const col of (meta._contentColumns || [])) {
120
+ for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
121
121
  const ref = meta[col];
122
122
  if (ref && String(ref).startsWith('@')) {
123
123
  const candidate = join(dir, String(ref).substring(1));
@@ -13,7 +13,7 @@ function ticketingPath() {
13
13
  return join(dboDir(), TICKETING_FILE);
14
14
  }
15
15
 
16
- const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, records: [] };
16
+ const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, ticket_confirmed: false, records: [] };
17
17
 
18
18
  /**
19
19
  * Load ticketing.local.json. Returns default structure if missing or corrupted.
@@ -31,6 +31,7 @@ export async function loadTicketing() {
31
31
  return {
32
32
  ticket_id: data.ticket_id || null,
33
33
  ticketing_required: !!data.ticketing_required,
34
+ ticket_confirmed: !!data.ticket_confirmed,
34
35
  records: Array.isArray(data.records) ? data.records : [],
35
36
  };
36
37
  } catch (err) {
@@ -74,6 +75,7 @@ export async function getRecordTicket(uid) {
74
75
  export async function setGlobalTicket(ticketId) {
75
76
  const data = await loadTicketing();
76
77
  data.ticket_id = ticketId;
78
+ data.ticket_confirmed = false; // reset confirmation when ticket changes
77
79
  await saveTicketing(data);
78
80
  }
79
81
 
@@ -123,6 +125,7 @@ export async function isTicketingRequired() {
123
125
  export async function clearGlobalTicket() {
124
126
  const data = await loadTicketing();
125
127
  data.ticket_id = null;
128
+ data.ticket_confirmed = false;
126
129
  await saveTicketing(data);
127
130
  }
128
131
 
@@ -231,6 +234,12 @@ export async function checkStoredTicket(options, context = '') {
231
234
  return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };
232
235
  }
233
236
 
237
+ // Stored ticket exists and confirmed — auto-use without prompting
238
+ if (data.ticket_confirmed) {
239
+ log.info(`Using confirmed ticket "${data.ticket_id}"`);
240
+ return { useTicket: true, clearTicket: false, cancel: false };
241
+ }
242
+
234
243
  // Stored ticket exists — prompt to use, change, or clear
235
244
  const { action } = await inquirer.prompt([{
236
245
  type: 'list',
@@ -238,6 +247,7 @@ export async function checkStoredTicket(options, context = '') {
238
247
  message: `Use stored Ticket ID "${data.ticket_id}" for this submission?${suffix}`,
239
248
  choices: [
240
249
  { name: `Yes, use "${data.ticket_id}"`, value: 'use' },
250
+ { name: `Yes, use "${data.ticket_id}" for all future submissions (don't ask again)`, value: 'use_confirmed' },
241
251
  { name: 'Use a different ticket for this submission only', value: 'alt_once' },
242
252
  { name: 'Use a different ticket for this and future submissions', value: 'alt_save' },
243
253
  { name: 'No, clear stored ticket', value: 'clear' },
@@ -245,6 +255,12 @@ export async function checkStoredTicket(options, context = '') {
245
255
  ],
246
256
  }]);
247
257
 
258
+ if (action === 'use_confirmed') {
259
+ await saveTicketing({ ...data, ticket_confirmed: true });
260
+ log.dim(` Ticket "${data.ticket_id}" confirmed — will auto-apply without prompting`);
261
+ return { useTicket: true, clearTicket: false, cancel: false };
262
+ }
263
+
248
264
  if (action === 'alt_once' || action === 'alt_save') {
249
265
  const { altTicket } = await inquirer.prompt([{
250
266
  type: 'input',
@@ -257,7 +273,7 @@ export async function checkStoredTicket(options, context = '') {
257
273
  return { useTicket: false, clearTicket: false, cancel: true };
258
274
  }
259
275
  if (action === 'alt_save') {
260
- await saveTicketing({ ...data, ticket_id: ticket });
276
+ await saveTicketing({ ...data, ticket_id: ticket, ticket_confirmed: false });
261
277
  log.dim(` Stored ticket updated to "${ticket}"`);
262
278
  }
263
279
  return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };