@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.
- package/README.md +153 -11
- package/package.json +3 -2
- package/src/commands/add.js +64 -11
- package/src/commands/clone.js +749 -63
- package/src/commands/init.js +28 -4
- package/src/commands/install.js +10 -1
- package/src/commands/login.js +69 -0
- package/src/commands/push.js +102 -18
- package/src/lib/config.js +101 -0
- package/src/lib/delta.js +14 -1
- package/src/lib/diff.js +71 -15
- package/src/lib/ignore.js +145 -0
- package/src/lib/structure.js +114 -0
- package/src/lib/ticketing.js +6 -3
- package/src/lib/timestamps.js +31 -9
|
@@ -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
|
+
}
|
package/src/lib/structure.js
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/ticketing.js
CHANGED
|
@@ -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
|
}
|
package/src/lib/timestamps.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
29
|
-
})
|
|
30
|
+
hourCycle: 'h23',
|
|
31
|
+
});
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
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
|
-
|
|
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
|
/**
|