@dboio/cli 0.8.2 → 0.9.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.
@@ -1,9 +1,23 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { log } from './logger.js';
3
3
  import { loadUserInfo, loadTicketSuggestionOutput } from './config.js';
4
- import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket } from './ticketing.js';
4
+ import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket, setTicketingRequired } from './ticketing.js';
5
5
  import { DboClient } from './client.js';
6
6
 
7
+ // Session-level cache: once the user resolves a UserID prompt, reuse it for all
8
+ // subsequent records in the same CLI invocation (one process = one push batch).
9
+ let _sessionUserOverride = null;
10
+
11
+ /** Get cached UserID override (set after first interactive prompt). */
12
+ export function getSessionUserOverride() {
13
+ return _sessionUserOverride;
14
+ }
15
+
16
+ /** Reset session caches (for tests). */
17
+ export function resetSessionCache() {
18
+ _sessionUserOverride = null;
19
+ }
20
+
7
21
  /**
8
22
  * Parse DBO input syntax and build form data.
9
23
  *
@@ -160,9 +174,16 @@ export async function checkSubmitErrors(result, context = {}) {
160
174
  return Object.keys(retryParams).length > 0 ? retryParams : null;
161
175
  }
162
176
 
177
+ // Session cache: reuse UserID from a previous prompt in this batch
178
+ const userResolved = needsUser && _sessionUserOverride;
179
+ if (userResolved) {
180
+ retryParams['_OverrideUserID'] = _sessionUserOverride;
181
+ log.dim(` Using cached user ID: ${_sessionUserOverride}`);
182
+ }
183
+
163
184
  const prompts = [];
164
185
 
165
- if (needsUser) {
186
+ if (needsUser && !userResolved) {
166
187
  log.warn(`This operation requires an authenticated user (${matchedUserPattern}).`);
167
188
  log.dim(' Your session may have expired, or you may not be logged in.');
168
189
  log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
@@ -215,7 +236,9 @@ export async function checkSubmitErrors(result, context = {}) {
215
236
  const userValue = answers.userValue
216
237
  || (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
217
238
  if (userValue) {
218
- retryParams['_OverrideUserID'] = userValue.trim();
239
+ const resolved = userValue.trim();
240
+ retryParams['_OverrideUserID'] = resolved;
241
+ _sessionUserOverride = resolved; // cache for remaining records in this batch
219
242
  }
220
243
 
221
244
  return retryParams;
@@ -226,6 +249,9 @@ export async function checkSubmitErrors(result, context = {}) {
226
249
  * Prompts the user with 4 recovery options.
227
250
  */
228
251
  async function handleTicketError(allText, context = {}) {
252
+ // Mark this app as requiring ticketing (detected on first ticket_error)
253
+ await setTicketingRequired();
254
+
229
255
  // Try to extract record details from error text
230
256
  const entityMatch = allText.match(/entity:(\w+)/);
231
257
  const rowIdMatch = allText.match(/RowID:(\d+)/);
@@ -312,8 +338,8 @@ async function handleTicketError(allText, context = {}) {
312
338
  name: 'ticketAction',
313
339
  message: 'Record update requires a Ticket ID:',
314
340
  choices: [
315
- { name: 'Apply a Ticket ID to this record and resubmit', value: 'apply_one' },
316
341
  { name: 'Apply a Ticket ID to all updates in this transaction, and update my current Ticket ID reference', value: 'apply_all' },
342
+ { name: 'Apply a Ticket ID to this record only', value: 'apply_one' },
317
343
  { name: 'Skip this record update', value: 'skip_one' },
318
344
  { name: 'Skip all updates that require a Ticket ID', value: 'skip_all' },
319
345
  ],
@@ -0,0 +1,264 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { relative, basename, extname } from 'path';
3
+ import { EXTENSION_DESCRIPTORS_DIR, ENTITY_DIR_NAMES } from './structure.js';
4
+ import inquirer from 'inquirer';
5
+
6
+ const METADATA_TEMPLATES_FILE = '.dbo/metadata_templates.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 → static mapping
32
+ if (topDir === 'docs') {
33
+ return { ...STATIC_DIRECTIVE_MAP.docs };
34
+ }
35
+
36
+ // extension/<descriptor>/ — need at least 3 parts (extension/descriptor/file)
37
+ if (topDir === 'extension') {
38
+ if (parts.length < 3) return null;
39
+ const secondDir = parts[1];
40
+ if (secondDir.startsWith('.')) return null;
41
+ return { entity: 'extension', descriptor: secondDir };
42
+ }
43
+
44
+ // Other entity-dir types
45
+ if (ENTITY_DIR_NAMES.has(topDir)) {
46
+ return { entity: topDir, descriptor: null };
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Load metadata templates from .dbo/metadata_templates.json.
54
+ */
55
+ export async function loadMetadataTemplates() {
56
+ try {
57
+ const raw = await readFile(METADATA_TEMPLATES_FILE, 'utf8');
58
+ try {
59
+ return JSON.parse(raw);
60
+ } catch {
61
+ console.warn('Warning: .dbo/metadata_templates.json is malformed JSON — falling back to generic wizard');
62
+ return null;
63
+ }
64
+ } catch (err) {
65
+ if (err.code === 'ENOENT') return {};
66
+ return {};
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Save metadata templates to .dbo/metadata_templates.json.
72
+ */
73
+ export async function saveMetadataTemplates(templates) {
74
+ await writeFile(METADATA_TEMPLATES_FILE, JSON.stringify(templates, null, 2) + '\n');
75
+ }
76
+
77
+ /**
78
+ * Get template cols for a given entity and optional descriptor.
79
+ * Returns string[] or null.
80
+ */
81
+ export function getTemplateCols(templates, entity, descriptor) {
82
+ if (!templates || !templates[entity]) return null;
83
+ const entry = templates[entity];
84
+
85
+ if (descriptor) {
86
+ // entry must be an object (not array)
87
+ if (Array.isArray(entry)) return null;
88
+ return entry[descriptor] ?? null;
89
+ }
90
+
91
+ // no descriptor — entry must be an array
92
+ if (!Array.isArray(entry)) return null;
93
+ return entry;
94
+ }
95
+
96
+ /**
97
+ * Set template cols for a given entity and optional descriptor.
98
+ * Mutates templates in place.
99
+ */
100
+ export function setTemplateCols(templates, entity, descriptor, cols) {
101
+ if (descriptor) {
102
+ if (!templates[entity] || Array.isArray(templates[entity])) {
103
+ templates[entity] = {};
104
+ }
105
+ templates[entity][descriptor] = cols;
106
+ } else {
107
+ templates[entity] = cols;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Build cols from a record's keys, excluding _ prefixed and array values.
113
+ */
114
+ function buildColsFromRecord(record) {
115
+ return Object.keys(record).filter(key => {
116
+ if (key.startsWith('_')) return false;
117
+ if (Array.isArray(record[key])) return false;
118
+ return true;
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Build baseline minimum viable cols for an entity type.
124
+ */
125
+ function buildBaselineCols(entity, descriptor) {
126
+ const cols = ['AppID', 'Name'];
127
+ if (entity === 'extension') {
128
+ cols.push('ShortName');
129
+ if (descriptor) cols.push(`Descriptor=${descriptor}`);
130
+ cols.push('Active');
131
+ }
132
+ return cols;
133
+ }
134
+
135
+ /**
136
+ * Resolve template cols via three-level lookup:
137
+ * 1. metadata_templates.json
138
+ * 2. appJson sample record
139
+ * 3. baseline defaults
140
+ */
141
+ export async function resolveTemplateCols(entity, descriptor, appConfig, appJson) {
142
+ const templates = await loadMetadataTemplates();
143
+ if (templates === null) return null;
144
+
145
+ // Level 1: existing template
146
+ const existing = getTemplateCols(templates, entity, descriptor);
147
+ if (existing) return { cols: existing, templates, isNew: false };
148
+
149
+ // Level 2: derive from appJson
150
+ if (appJson) {
151
+ let records = null;
152
+
153
+ if (entity === 'extension' && descriptor) {
154
+ // appJson[entity] might be an object keyed by descriptor
155
+ const entityData = appJson[entity];
156
+ if (entityData) {
157
+ if (Array.isArray(entityData)) {
158
+ records = entityData;
159
+ } else if (typeof entityData === 'object' && entityData[descriptor]) {
160
+ const descData = entityData[descriptor];
161
+ if (Array.isArray(descData)) records = descData;
162
+ }
163
+ }
164
+ } else {
165
+ const entityData = appJson[entity];
166
+ if (Array.isArray(entityData)) records = entityData;
167
+ }
168
+
169
+ if (records && records.length > 0) {
170
+ const cols = buildColsFromRecord(records[0]);
171
+ setTemplateCols(templates, entity, descriptor, cols);
172
+ await saveMetadataTemplates(templates);
173
+ return { cols, templates, isNew: true };
174
+ }
175
+ }
176
+
177
+ // Level 3: baseline defaults
178
+ const cols = buildBaselineCols(entity, descriptor);
179
+ setTemplateCols(templates, entity, descriptor, cols);
180
+ await saveMetadataTemplates(templates);
181
+ return { cols, templates, isNew: true };
182
+ }
183
+
184
+ /**
185
+ * Assemble metadata object from cols and file info.
186
+ */
187
+ export function assembleMetadata(cols, filePath, entity, descriptor, appConfig) {
188
+ const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
189
+ const base = basename(filePath, extname(filePath));
190
+ const fileName = basename(filePath);
191
+ const isDocsFile = rel.startsWith('docs/');
192
+ const meta = { _entity: entity };
193
+ const contentColumns = [];
194
+
195
+ for (const col of cols) {
196
+ if (col.includes('=')) {
197
+ const eqIdx = col.indexOf('=');
198
+ const key = col.substring(0, eqIdx);
199
+ const val = col.substring(eqIdx + 1);
200
+
201
+ if (val === '@reference') {
202
+ const refPath = isDocsFile ? '@/' + rel : '@' + fileName;
203
+ meta[key] = refPath;
204
+ contentColumns.push(key);
205
+ } else {
206
+ meta[key] = val;
207
+ }
208
+ } else if (col === 'AppID') {
209
+ meta.AppID = appConfig?.AppID ?? '';
210
+ } else if (col === 'Name') {
211
+ meta.Name = base;
212
+ } else if (col === 'ShortName') {
213
+ meta.ShortName = toSnakeCase(base);
214
+ } else {
215
+ meta[col] = '';
216
+ }
217
+ }
218
+
219
+ let refColMissing = false;
220
+ if (contentColumns.length === 0) {
221
+ refColMissing = true;
222
+ } else {
223
+ meta._contentColumns = contentColumns;
224
+ }
225
+
226
+ return { meta, contentColumns, refColMissing };
227
+ }
228
+
229
+ /**
230
+ * Prompt user to select a reference column from available string cols.
231
+ */
232
+ export async function promptReferenceColumn(cols, entity, descriptor) {
233
+ const stringCols = cols.filter(c => !c.includes('=') && c !== 'AppID' && c !== 'Name' && c !== 'ShortName');
234
+ if (stringCols.length === 0) return null;
235
+
236
+ const { selected } = await inquirer.prompt([{
237
+ type: 'list',
238
+ name: 'selected',
239
+ message: `Select the content/reference column for ${entity}${descriptor ? '/' + descriptor : ''}:`,
240
+ choices: stringCols,
241
+ }]);
242
+
243
+ return selected;
244
+ }
245
+
246
+ /**
247
+ * Build a template cols array from a clone record.
248
+ */
249
+ export function buildTemplateFromCloneRecord(record, contentColsExtracted = []) {
250
+ const cols = [];
251
+ for (const key of Object.keys(record)) {
252
+ if (key.startsWith('_')) continue;
253
+ if (Array.isArray(record[key])) continue;
254
+
255
+ if (contentColsExtracted.includes(key)) {
256
+ cols.push(key + '=@reference');
257
+ } else if (key === 'Descriptor' && record[key]) {
258
+ cols.push('Descriptor=' + record[key]);
259
+ } else {
260
+ cols.push(key);
261
+ }
262
+ }
263
+ return cols;
264
+ }
@@ -4,19 +4,22 @@ import { join } from 'path';
4
4
  const STRUCTURE_FILE = '.dbo/structure.json';
5
5
 
6
6
  /** All bin-placed files go under this directory at project root */
7
- export const BINS_DIR = 'Bins';
7
+ export const BINS_DIR = 'bins';
8
8
 
9
9
  /** Default top-level directories created at project root during clone */
10
10
  export const DEFAULT_PROJECT_DIRS = [
11
- BINS_DIR,
12
- 'Automations',
13
- 'App Versions',
14
- 'Documentation',
15
- 'Sites',
16
- 'Extensions',
17
- 'Data Sources',
18
- 'Groups',
19
- 'Integrations',
11
+ 'bins',
12
+ 'automation',
13
+ 'app_version',
14
+ 'docs',
15
+ 'site',
16
+ 'extension',
17
+ 'data_source',
18
+ 'group',
19
+ 'integration',
20
+ 'src',
21
+ 'tests',
22
+ 'trash',
20
23
  ];
21
24
 
22
25
  /** Map from physical output table names → documentation/display names */
@@ -35,16 +38,16 @@ export const OUTPUT_HIERARCHY_ENTITIES = [
35
38
  'output_value_entity_column_rel',
36
39
  ];
37
40
 
38
- /** Map from server entity key local project directory name */
39
- export const ENTITY_DIR_MAP = {
40
- extension: 'Extensions',
41
- app_version: 'App Versions',
42
- data_source: 'Data Sources',
43
- site: 'Sites',
44
- group: 'Groups',
45
- integration: 'Integrations',
46
- automation: 'Automations',
47
- };
41
+ /** Entity keys that correspond to project directories (key IS the dir name) */
42
+ export const ENTITY_DIR_NAMES = new Set([
43
+ 'extension',
44
+ 'app_version',
45
+ 'data_source',
46
+ 'site',
47
+ 'group',
48
+ 'integration',
49
+ 'automation',
50
+ ]);
48
51
 
49
52
  /**
50
53
  * Build a bin hierarchy from an array of bin objects.
@@ -160,11 +163,12 @@ export function getBinName(binId, structure) {
160
163
 
161
164
  /**
162
165
  * Reverse-lookup: find a bin entry by its directory path.
163
- * Accepts paths with or without the "Bins/" prefix.
166
+ * Accepts paths with or without the "bins/" prefix.
164
167
  * Returns { binId, name, path, segment, parentBinID, uid, fullPath } or null.
165
168
  */
166
169
  export function findBinByPath(dirPath, structure) {
167
- const normalized = dirPath.replace(/^Bins\//, '').replace(/^\/+|\/+$/g, '');
170
+ const binsPrefix = BINS_DIR + '/';
171
+ const normalized = dirPath.replace(new RegExp('^' + binsPrefix), '').replace(/^\/+|\/+$/g, '');
168
172
  for (const [binId, entry] of Object.entries(structure)) {
169
173
  if (entry.fullPath === normalized) {
170
174
  return { binId: Number(binId), ...entry };
@@ -183,13 +187,13 @@ export function findChildBins(binId, structure) {
183
187
  // ─── Extension Descriptor Sub-directory Support ───────────────────────────
184
188
 
185
189
  /** Root for all extension descriptor-grouped sub-directories */
186
- export const EXTENSION_DESCRIPTORS_DIR = 'Extensions';
190
+ export const EXTENSION_DESCRIPTORS_DIR = 'extension';
187
191
 
188
192
  /** Extensions that cannot be mapped go here (always created, even if empty) */
189
- export const EXTENSION_UNSUPPORTED_DIR = 'Extensions/Unsupported';
193
+ export const EXTENSION_UNSUPPORTED_DIR = 'extension/_unsupported';
190
194
 
191
195
  /** Root-level documentation directory for alternate placement */
192
- export const DOCUMENTATION_DIR = 'Documentation';
196
+ export const DOCUMENTATION_DIR = 'docs';
193
197
 
194
198
  /**
195
199
  * Build a descriptor→dirName mapping from a flat array of extension records.
@@ -280,7 +284,7 @@ export async function loadDescriptorMapping() {
280
284
 
281
285
  /**
282
286
  * Resolve the sub-directory path for a single extension record.
283
- * Returns "Extensions/<MappedName>" or "Extensions/Unsupported".
287
+ * Returns "extension/<MappedName>" or "extension/_unsupported".
284
288
  *
285
289
  * @param {Object} record - Extension record with a .Descriptor field
286
290
  * @param {Object} mapping - descriptor key → dir name (from buildDescriptorMapping)
@@ -13,7 +13,7 @@ function ticketingPath() {
13
13
  return join(dboDir(), TICKETING_FILE);
14
14
  }
15
15
 
16
- const DEFAULT_TICKETING = { ticket_id: null, records: [] };
16
+ const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, records: [] };
17
17
 
18
18
  /**
19
19
  * Load ticketing.local.json. Returns default structure if missing or corrupted.
@@ -30,6 +30,7 @@ export async function loadTicketing() {
30
30
  const data = JSON.parse(trimmed);
31
31
  return {
32
32
  ticket_id: data.ticket_id || null,
33
+ ticketing_required: !!data.ticketing_required,
33
34
  records: Array.isArray(data.records) ? data.records : [],
34
35
  };
35
36
  } catch (err) {
@@ -97,6 +98,25 @@ export async function setRecordTicket(uid, rowId, entity, ticketId) {
97
98
  await saveTicketing(data);
98
99
  }
99
100
 
101
+ /**
102
+ * Mark ticketing as required for this app (detected after first ticket_error).
103
+ */
104
+ export async function setTicketingRequired() {
105
+ const data = await loadTicketing();
106
+ if (!data.ticketing_required) {
107
+ data.ticketing_required = true;
108
+ await saveTicketing(data);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Check if ticketing is required for this app.
114
+ */
115
+ export async function isTicketingRequired() {
116
+ const data = await loadTicketing();
117
+ return !!data.ticketing_required;
118
+ }
119
+
100
120
  /**
101
121
  * Clear the global ticket_id (preserves records).
102
122
  */
@@ -152,18 +172,66 @@ export async function checkStoredTicket(options, context = '') {
152
172
  }
153
173
 
154
174
  const data = await loadTicketing();
155
- if (!data.ticket_id) {
175
+
176
+ // No stored ticket and ticketing not known to be required — skip prompt
177
+ if (!data.ticket_id && !data.ticketing_required) {
156
178
  return { useTicket: false, clearTicket: false, cancel: false };
157
179
  }
158
180
 
159
- // Non-interactive mode: auto-use stored ticket
181
+ // Non-interactive mode: auto-use stored ticket if available
160
182
  if (!process.stdin.isTTY) {
161
- log.info(`Using stored ticket "${data.ticket_id}" (non-interactive mode)`);
162
- return { useTicket: true, clearTicket: false, cancel: false };
183
+ if (data.ticket_id) {
184
+ log.info(`Using stored ticket "${data.ticket_id}" (non-interactive mode)`);
185
+ return { useTicket: true, clearTicket: false, cancel: false };
186
+ }
187
+ // ticketing_required but no stored ticket — can't prompt in non-interactive
188
+ log.warn('This app requires a Ticket ID but none is stored.');
189
+ log.dim(' Use --ticket <id> or run interactively first to set a ticket.');
190
+ return { useTicket: false, clearTicket: false, cancel: false };
163
191
  }
164
192
 
165
193
  const suffix = context ? ` (${context})` : '';
166
194
  const inquirer = (await import('inquirer')).default;
195
+
196
+ // Ticketing required but no stored ticket — prompt for one
197
+ if (!data.ticket_id && data.ticketing_required) {
198
+ const { action } = await inquirer.prompt([{
199
+ type: 'list',
200
+ name: 'action',
201
+ message: `This app requires a Ticket ID. Enter one for this submission?${suffix}`,
202
+ choices: [
203
+ { name: 'Enter a Ticket ID for this and future submissions', value: 'enter_save' },
204
+ { name: 'Enter a Ticket ID for this submission only', value: 'enter_once' },
205
+ { name: 'Continue without a ticket', value: 'skip' },
206
+ { name: 'Cancel submission', value: 'cancel' },
207
+ ],
208
+ }]);
209
+
210
+ if (action === 'cancel') {
211
+ return { useTicket: false, clearTicket: false, cancel: true };
212
+ }
213
+ if (action === 'skip') {
214
+ return { useTicket: false, clearTicket: false, cancel: false };
215
+ }
216
+
217
+ const { ticketInput } = await inquirer.prompt([{
218
+ type: 'input',
219
+ name: 'ticketInput',
220
+ message: 'Ticket ID:',
221
+ }]);
222
+ const ticket = ticketInput.trim();
223
+ if (!ticket) {
224
+ log.error(' No Ticket ID entered. Submission cancelled.');
225
+ return { useTicket: false, clearTicket: false, cancel: true };
226
+ }
227
+ if (action === 'enter_save') {
228
+ await saveTicketing({ ...data, ticket_id: ticket });
229
+ log.dim(` Stored ticket set to "${ticket}"`);
230
+ }
231
+ return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };
232
+ }
233
+
234
+ // Stored ticket exists — prompt to use, change, or clear
167
235
  const { action } = await inquirer.prompt([{
168
236
  type: 'list',
169
237
  name: 'action',