@dboio/cli 0.7.2 → 0.8.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,145 @@
1
+ import { readFile, writeFile, access } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import ignore from 'ignore';
4
+
5
+ const DBOIGNORE_FILE = '.dboignore';
6
+
7
+ /**
8
+ * Default .dboignore file content — shipped with `dbo init`.
9
+ * Gitignore-style syntax. Users can edit after creation.
10
+ */
11
+ const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
12
+ # (gitignore-style syntax — works like .gitignore)
13
+
14
+ # DBO internal
15
+ .dbo/
16
+ .dboignore
17
+ *.dboio.json
18
+ app.json
19
+ .app.json
20
+ dbo.deploy.json
21
+
22
+ # Editor / IDE
23
+ .idea/
24
+ .vscode/
25
+ *.codekit3
26
+
27
+ # Version control
28
+ .git/
29
+ .svn/
30
+ .hg/
31
+ .gitignore
32
+
33
+ # AI / tooling
34
+ .claude/
35
+ .mcp.json
36
+
37
+ # Node
38
+ node_modules/
39
+ package.json
40
+ package-lock.json
41
+
42
+ # Documentation (repo scaffolding)
43
+ SETUP.md
44
+ README.md
45
+ `;
46
+
47
+ // Session-level cache (one process = one command invocation)
48
+ let _cachedIg = null;
49
+ let _cachedRoot = null;
50
+
51
+ /**
52
+ * Return the full default .dboignore file content (with comments).
53
+ */
54
+ export function getDefaultFileContent() {
55
+ return DEFAULT_FILE_CONTENT;
56
+ }
57
+
58
+ /**
59
+ * Extract just the active pattern lines from DEFAULT_FILE_CONTENT.
60
+ */
61
+ function getDefaultPatternLines() {
62
+ return DEFAULT_FILE_CONTENT
63
+ .split('\n')
64
+ .filter(l => l && !l.startsWith('#'));
65
+ }
66
+
67
+ /**
68
+ * Load and return a cached `ignore` instance for the current project.
69
+ * Reads .dboignore if it exists; falls back to built-in defaults.
70
+ *
71
+ * @param {string} [cwd=process.cwd()] - Project root
72
+ * @returns {Promise<import('ignore').Ignore>}
73
+ */
74
+ export async function loadIgnore(cwd = process.cwd()) {
75
+ if (_cachedIg && _cachedRoot === cwd) return _cachedIg;
76
+
77
+ const ig = ignore();
78
+ const filePath = join(cwd, DBOIGNORE_FILE);
79
+
80
+ try {
81
+ const content = await readFile(filePath, 'utf8');
82
+ ig.add(content);
83
+ } catch {
84
+ // No .dboignore — use built-in defaults
85
+ ig.add(getDefaultPatternLines());
86
+ }
87
+
88
+ _cachedIg = ig;
89
+ _cachedRoot = cwd;
90
+ return ig;
91
+ }
92
+
93
+ /**
94
+ * Check whether a relative path should be ignored.
95
+ *
96
+ * @param {string} relativePath - Path relative to project root (forward slashes)
97
+ * @param {string} [cwd=process.cwd()]
98
+ * @returns {Promise<boolean>}
99
+ */
100
+ export async function isIgnored(relativePath, cwd = process.cwd()) {
101
+ const ig = await loadIgnore(cwd);
102
+ return ig.ignores(relativePath);
103
+ }
104
+
105
+ /**
106
+ * Filter an array of relative paths, returning only those NOT ignored.
107
+ *
108
+ * @param {string[]} paths - Relative paths
109
+ * @param {string} [cwd=process.cwd()]
110
+ * @returns {Promise<string[]>}
111
+ */
112
+ export async function filterIgnored(paths, cwd = process.cwd()) {
113
+ const ig = await loadIgnore(cwd);
114
+ return ig.filter(paths);
115
+ }
116
+
117
+ /**
118
+ * Create a .dboignore file with default patterns.
119
+ * Does nothing if the file already exists (unless force=true).
120
+ *
121
+ * @param {string} [cwd=process.cwd()]
122
+ * @param {{ force?: boolean }} [opts]
123
+ * @returns {Promise<boolean>} true if file was created/overwritten
124
+ */
125
+ export async function createDboignore(cwd = process.cwd(), { force = false } = {}) {
126
+ const filePath = join(cwd, DBOIGNORE_FILE);
127
+ if (!force) {
128
+ try {
129
+ await access(filePath);
130
+ return false; // already exists
131
+ } catch { /* doesn't exist — create it */ }
132
+ }
133
+ await writeFile(filePath, DEFAULT_FILE_CONTENT);
134
+ _cachedIg = null;
135
+ _cachedRoot = null;
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Reset the session cache. Call between tests that change cwd or .dboignore.
141
+ */
142
+ export function resetCache() {
143
+ _cachedIg = null;
144
+ _cachedRoot = null;
145
+ }
@@ -179,3 +179,117 @@ export function findBinByPath(dirPath, structure) {
179
179
  export function findChildBins(binId, structure) {
180
180
  return Object.values(structure).filter(e => e.parentBinID === binId);
181
181
  }
182
+
183
+ // ─── Extension Descriptor Sub-directory Support ───────────────────────────
184
+
185
+ /** Root for all extension descriptor-grouped sub-directories */
186
+ export const EXTENSION_DESCRIPTORS_DIR = 'Extensions';
187
+
188
+ /** Extensions that cannot be mapped go here (always created, even if empty) */
189
+ export const EXTENSION_UNSUPPORTED_DIR = 'Extensions/Unsupported';
190
+
191
+ /** Root-level documentation directory for alternate placement */
192
+ export const DOCUMENTATION_DIR = 'Documentation';
193
+
194
+ /**
195
+ * Build a descriptor→dirName mapping from a flat array of extension records.
196
+ * Scans records where Descriptor === "descriptor_definition".
197
+ * Maps String1 (key) → Name (directory name).
198
+ *
199
+ * Rules:
200
+ * - Null/empty String1: skip, push to warnings[]
201
+ * - Null/empty Name: use String1 as the directory name
202
+ * - Duplicate String1: last one wins, push to warnings[]
203
+ *
204
+ * @param {Object[]} extensionRecords
205
+ * @returns {{ mapping: Object<string,string>, warnings: string[] }}
206
+ */
207
+ export function buildDescriptorMapping(extensionRecords) {
208
+ const mapping = {};
209
+ const warnings = [];
210
+
211
+ for (const rec of extensionRecords) {
212
+ if (rec.Descriptor !== 'descriptor_definition') continue;
213
+
214
+ const rawKey = rec.String1;
215
+ // String1 may be a base64-encoded object from the server API
216
+ const key = resolveFieldValue(rawKey);
217
+ if (!key || String(key).trim() === '') {
218
+ warnings.push(`descriptor_definition UID=${rec.UID} has null/empty String1 — skipped`);
219
+ continue;
220
+ }
221
+ const keyStr = String(key).trim();
222
+
223
+ const rawName = rec.Name;
224
+ const nameStr = resolveFieldValue(rawName);
225
+ const dirName = (nameStr && String(nameStr).trim())
226
+ ? String(nameStr).trim()
227
+ : keyStr;
228
+
229
+ if (keyStr in mapping) {
230
+ warnings.push(`Duplicate descriptor_definition key "${keyStr}" — overwriting with UID=${rec.UID}`);
231
+ }
232
+ mapping[keyStr] = dirName;
233
+ }
234
+
235
+ return { mapping, warnings };
236
+ }
237
+
238
+ /**
239
+ * Resolve a field value that may be a base64-encoded object from the server API.
240
+ * Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
241
+ */
242
+ function resolveFieldValue(value) {
243
+ if (value && typeof value === 'object' && !Array.isArray(value)
244
+ && value.encoding === 'base64' && typeof value.value === 'string') {
245
+ return Buffer.from(value.value, 'base64').toString('utf8');
246
+ }
247
+ return value;
248
+ }
249
+
250
+ /**
251
+ * Persist descriptorMapping and extensionDescriptorDirs into .dbo/structure.json.
252
+ * Extends the existing structure object (already contains bin entries).
253
+ *
254
+ * @param {Object} structure - Current structure from loadStructureFile()
255
+ * @param {Object} mapping - descriptor key → dir name
256
+ */
257
+ export async function saveDescriptorMapping(structure, mapping) {
258
+ const descriptorDirs = [
259
+ EXTENSION_UNSUPPORTED_DIR,
260
+ ...Object.values(mapping).map(name => `${EXTENSION_DESCRIPTORS_DIR}/${name}`),
261
+ ];
262
+ const uniqueDirs = [...new Set(descriptorDirs)];
263
+
264
+ const extended = {
265
+ ...structure,
266
+ descriptorMapping: mapping,
267
+ extensionDescriptorDirs: uniqueDirs,
268
+ };
269
+ await saveStructureFile(extended);
270
+ }
271
+
272
+ /**
273
+ * Load descriptorMapping from .dbo/structure.json.
274
+ * Returns {} if not yet persisted.
275
+ */
276
+ export async function loadDescriptorMapping() {
277
+ const structure = await loadStructureFile();
278
+ return structure.descriptorMapping || {};
279
+ }
280
+
281
+ /**
282
+ * Resolve the sub-directory path for a single extension record.
283
+ * Returns "Extensions/<MappedName>" or "Extensions/Unsupported".
284
+ *
285
+ * @param {Object} record - Extension record with a .Descriptor field
286
+ * @param {Object} mapping - descriptor key → dir name (from buildDescriptorMapping)
287
+ * @returns {string}
288
+ */
289
+ export function resolveExtensionSubDir(record, mapping) {
290
+ const descriptor = record.Descriptor;
291
+ if (!descriptor || !mapping[descriptor]) {
292
+ return EXTENSION_UNSUPPORTED_DIR;
293
+ }
294
+ return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
295
+ }
@@ -145,7 +145,7 @@ export function buildTicketExpression(entity, rowId, ticketId) {
145
145
  *
146
146
  * @param {Object} options - Command options (checks options.ticket for flag override)
147
147
  */
148
- export async function checkStoredTicket(options) {
148
+ export async function checkStoredTicket(options, context = '') {
149
149
  // --ticket flag takes precedence; skip stored-ticket prompt
150
150
  if (options.ticket) {
151
151
  return { useTicket: false, clearTicket: false, cancel: false };
@@ -162,11 +162,12 @@ export async function checkStoredTicket(options) {
162
162
  return { useTicket: true, clearTicket: false, cancel: false };
163
163
  }
164
164
 
165
+ const suffix = context ? ` (${context})` : '';
165
166
  const inquirer = (await import('inquirer')).default;
166
167
  const { action } = await inquirer.prompt([{
167
168
  type: 'list',
168
169
  name: 'action',
169
- message: `Use stored Ticket ID "${data.ticket_id}" for this submission?`,
170
+ message: `Use stored Ticket ID "${data.ticket_id}" for this submission?${suffix}`,
170
171
  choices: [
171
172
  { name: `Yes, use "${data.ticket_id}"`, value: 'use' },
172
173
  { name: 'Use a different ticket for this submission only', value: 'alt_once' },
@@ -213,7 +214,7 @@ export async function checkStoredTicket(options) {
213
214
  * @param {string|null} [sessionOverride] - One-time ticket override from pre-flight prompt
214
215
  */
215
216
  export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, uid, options, sessionOverride = null) {
216
- if (options.ticket) return; // --ticket flag takes precedence
217
+ if (options.ticket) return null; // --ticket flag takes precedence
217
218
 
218
219
  const recordTicket = await getRecordTicket(uid);
219
220
  const globalTicket = await getGlobalTicket();
@@ -223,5 +224,7 @@ export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, ui
223
224
  const ticketExpr = buildTicketExpression(entity, rowId, ticketToUse);
224
225
  dataExprs.push(ticketExpr);
225
226
  log.dim(` Applying ticket: ${ticketToUse}`);
227
+ return ticketToUse;
226
228
  }
229
+ return null;
227
230
  }
@@ -20,20 +20,42 @@ export function parseServerDate(dateStr, serverTz) {
20
20
  if (!serverTz || serverTz === 'UTC') return asUtc;
21
21
 
22
22
  // Find offset: format this UTC instant in the server timezone,
23
- // then compute the difference to determine the actual UTC time
24
- const parts = new Intl.DateTimeFormat('en-US', {
23
+ // then compute the difference to determine the actual UTC time.
24
+ // Use hourCycle:'h23' (range 0-23) to avoid the hour12:false quirk where
25
+ // midnight is returned as "24" instead of "00" on some V8 versions.
26
+ const fmt = new Intl.DateTimeFormat('en-US', {
25
27
  timeZone: serverTz,
26
28
  year: 'numeric', month: '2-digit', day: '2-digit',
27
29
  hour: '2-digit', minute: '2-digit', second: '2-digit',
28
- hour12: false,
29
- }).formatToParts(asUtc);
30
+ hourCycle: 'h23',
31
+ });
30
32
 
31
- const p = {};
32
- for (const { type, value } of parts) p[type] = value;
33
- const tzLocal = new Date(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`);
34
- const offset = tzLocal - asUtc;
33
+ // Helper: compute the UTC offset (ms) for a given UTC instant in serverTz.
34
+ // Returns the offset such that: localTime (as UTC) = utcInstant + offset.
35
+ const getOffset = (utcInstant) => {
36
+ const parts = fmt.formatToParts(utcInstant);
37
+ const p = {};
38
+ for (const { type, value } of parts) p[type] = value;
39
+ const tzLocal = new Date(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`);
40
+ if (isNaN(tzLocal.getTime())) return null;
41
+ return tzLocal - utcInstant;
42
+ };
35
43
 
36
- return new Date(asUtc.getTime() - offset);
44
+ // Step 1: estimate the UTC equivalent by computing the offset at asUtc.
45
+ // asUtc is the server's local time naively parsed as UTC, so it may sit in
46
+ // a different DST regime than the true UTC. This gives a rough approximation.
47
+ const offset1 = getOffset(asUtc);
48
+ if (offset1 === null) return asUtc; // fallback: treat as UTC
49
+
50
+ const approx = new Date(asUtc.getTime() - offset1);
51
+
52
+ // Step 2: recompute the offset at the approximated true UTC.
53
+ // This corrects for DST boundary crossings where step 1 used the wrong
54
+ // summer/winter offset — one iteration is sufficient since DST shifts by 1h.
55
+ const offset2 = getOffset(approx);
56
+ if (offset2 === null) return approx;
57
+
58
+ return new Date(asUtc.getTime() - offset2);
37
59
  }
38
60
 
39
61
  /**