@dboio/cli 0.6.13 → 0.6.14
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 +16 -4
- package/package.json +1 -1
- package/src/commands/clone.js +14 -2
- package/src/commands/init.js +47 -2
- package/src/commands/status.js +0 -1
- package/src/lib/domain-guard.js +95 -0
- package/src/lib/scaffold.js +62 -0
package/README.md
CHANGED
|
@@ -169,6 +169,10 @@ All configuration is **directory-scoped**. Each project folder maintains its own
|
|
|
169
169
|
|
|
170
170
|
These are set interactively on first clone and saved for future use. Pre-set them in `config.json` to skip the prompt entirely.
|
|
171
171
|
|
|
172
|
+
#### `app.json._domain`
|
|
173
|
+
|
|
174
|
+
The `_domain` field in `app.json` stores the project's reference domain (set during `dbo clone`). This is committed to git and used for domain-change detection when running `dbo init --force` or `dbo clone --domain`. It provides a stable cross-user baseline — all collaborators share the same reference domain.
|
|
175
|
+
|
|
172
176
|
### Legacy migration
|
|
173
177
|
|
|
174
178
|
If your project uses the older `.domain`, `.username`, `.password`, `.cookies` files, `dbo init` will detect them and offer to migrate automatically.
|
|
@@ -196,17 +200,20 @@ dbo init --domain my-domain.com --username me@co.io # with credentials
|
|
|
196
200
|
dbo init --force # overwrite existing config
|
|
197
201
|
dbo init --domain my-domain.com --app myapp --clone # init + clone an app
|
|
198
202
|
dbo init --domain my-domain.com -y # skip all prompts
|
|
203
|
+
dbo init --scaffold # scaffold dirs (prompts for domain)
|
|
204
|
+
dbo init --scaffold --yes # scaffold dirs non-interactively
|
|
199
205
|
```
|
|
200
206
|
|
|
201
207
|
| Flag | Description |
|
|
202
208
|
|------|-------------|
|
|
203
209
|
| `--domain <host>` | DBO instance domain |
|
|
204
210
|
| `--username <user>` | DBO username (stored for login default) |
|
|
205
|
-
| `--force` | Overwrite existing configuration |
|
|
211
|
+
| `--force` | Overwrite existing configuration. Triggers a domain-change confirmation prompt when the new domain differs from the project reference domain |
|
|
206
212
|
| `--app <shortName>` | App short name (triggers clone after init) |
|
|
207
213
|
| `--clone` | Clone the app after initialization |
|
|
208
214
|
| `-g, --global` | Install Claude commands globally (`~/.claude/commands/`) |
|
|
209
215
|
| `--local` | Install Claude commands to project (`.claude/commands/`) |
|
|
216
|
+
| `--scaffold` | Pre-create standard project directories (`App Versions`, `Automations`, `Bins`, `Data Sources`, `Documentation`, `Extensions`, `Groups`, `Integrations`, `Sites`) |
|
|
210
217
|
| `-y, --yes` | Skip all interactive prompts (legacy migration, Claude Code setup) |
|
|
211
218
|
| `--non-interactive` | Alias for `--yes` |
|
|
212
219
|
|
|
@@ -234,10 +241,16 @@ dbo init --domain my-domain.com --app myapp --clone
|
|
|
234
241
|
|------|-------------|
|
|
235
242
|
| `<source>` | Local JSON file path (optional positional argument) |
|
|
236
243
|
| `--app <name>` | App short name to fetch from server |
|
|
237
|
-
| `--domain <host>` | Override domain |
|
|
244
|
+
| `--domain <host>` | Override domain. Triggers a domain-change confirmation prompt when it differs from the project reference domain |
|
|
238
245
|
| `-y, --yes` | Auto-accept all prompts |
|
|
239
246
|
| `-v, --verbose` | Show HTTP request details |
|
|
240
247
|
|
|
248
|
+
#### Domain change detection
|
|
249
|
+
|
|
250
|
+
When cloning with a different domain than the project's reference domain (`app.json._domain` or `config.json.domain`), the CLI warns before proceeding. When `TransactionKeyPreset=RowID`, this escalates to a critical error because numeric IDs are not unique across domains — pushing to the wrong domain can corrupt records. In non-interactive mode (`-y`), RowUID domain changes proceed with a warning, but RowID domain changes throw a hard error.
|
|
251
|
+
|
|
252
|
+
The project's reference domain is stored in `app.json._domain` (committed to git) during clone, giving the CLI a stable cross-user baseline.
|
|
253
|
+
|
|
241
254
|
#### What clone does
|
|
242
255
|
|
|
243
256
|
1. **Loads app JSON** — from a local file, server API, or interactive prompt
|
|
@@ -438,7 +451,6 @@ Output:
|
|
|
438
451
|
Domain: my-domain.com
|
|
439
452
|
Username: user@example.com
|
|
440
453
|
User ID: 10296
|
|
441
|
-
User UID: albain3dwkofbhnd1qtd1q
|
|
442
454
|
Directory: /Users/me/projects/operator
|
|
443
455
|
Session: Active (expires: 2026-03-15T10:30:00.000Z)
|
|
444
456
|
Cookies: /Users/me/projects/operator/.dbo/cookies.txt
|
|
@@ -447,7 +459,7 @@ Output:
|
|
|
447
459
|
dbo: ✓ global (~/.claude/commands/dbo.md)
|
|
448
460
|
```
|
|
449
461
|
|
|
450
|
-
User ID
|
|
462
|
+
User ID is populated by `dbo login`. If it shows "(not set)", run `dbo login` to fetch it.
|
|
451
463
|
|
|
452
464
|
Plugin scopes (project or global) are displayed when plugins have been installed. Scopes are stored in `.dbo/config.local.json`.
|
|
453
465
|
|
package/package.json
CHANGED
package/src/commands/clone.js
CHANGED
|
@@ -7,6 +7,7 @@ import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile
|
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
8
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
9
9
|
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge } from '../lib/diff.js';
|
|
10
|
+
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Resolve a column value that may be base64-encoded.
|
|
@@ -437,6 +438,7 @@ export const cloneCommand = new Command('clone')
|
|
|
437
438
|
*/
|
|
438
439
|
export async function performClone(source, options = {}) {
|
|
439
440
|
const config = await loadConfig();
|
|
441
|
+
const effectiveDomain = options.domain || config.domain;
|
|
440
442
|
let appJson;
|
|
441
443
|
|
|
442
444
|
// Step 1: Load the app JSON
|
|
@@ -487,6 +489,15 @@ export async function performClone(source, options = {}) {
|
|
|
487
489
|
throw new Error('Invalid app JSON: missing UID or children');
|
|
488
490
|
}
|
|
489
491
|
|
|
492
|
+
// Domain change detection
|
|
493
|
+
if (effectiveDomain) {
|
|
494
|
+
const { changed, proceed } = await checkDomainChange(effectiveDomain, options);
|
|
495
|
+
if (changed && !proceed) {
|
|
496
|
+
log.info('Clone aborted: domain change denied.');
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
490
501
|
log.success(`Cloning "${appJson.Name}" (${appJson.ShortName})`);
|
|
491
502
|
|
|
492
503
|
// Ensure sensitive files are gitignored
|
|
@@ -615,7 +626,7 @@ export async function performClone(source, options = {}) {
|
|
|
615
626
|
}
|
|
616
627
|
|
|
617
628
|
// Step 7: Save app.json with references
|
|
618
|
-
await saveAppJson(appJson, contentRefs, otherRefs);
|
|
629
|
+
await saveAppJson(appJson, contentRefs, otherRefs, effectiveDomain);
|
|
619
630
|
|
|
620
631
|
// Step 8: Create .app.json baseline for delta tracking
|
|
621
632
|
await saveBaselineFile(appJson);
|
|
@@ -1692,8 +1703,9 @@ export function guessExtensionForColumn(columnName) {
|
|
|
1692
1703
|
/**
|
|
1693
1704
|
* Save app.json to project root with @ references replacing processed entries.
|
|
1694
1705
|
*/
|
|
1695
|
-
async function saveAppJson(appJson, contentRefs, otherRefs) {
|
|
1706
|
+
async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
|
|
1696
1707
|
const output = { ...appJson };
|
|
1708
|
+
if (domain) output._domain = domain;
|
|
1697
1709
|
output.children = { ...appJson.children };
|
|
1698
1710
|
|
|
1699
1711
|
// Replace content array with references
|
package/src/commands/init.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { access } from 'fs/promises';
|
|
2
|
+
import { access, readdir } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset } from '../lib/config.js';
|
|
5
5
|
import { installOrUpdateClaudeCommands } from './install.js';
|
|
6
|
+
import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
|
|
6
7
|
import { log } from '../lib/logger.js';
|
|
8
|
+
import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
|
|
7
9
|
|
|
8
10
|
export const initCommand = new Command('init')
|
|
9
11
|
.description('Initialize DBO CLI configuration for the current directory')
|
|
@@ -16,11 +18,17 @@ export const initCommand = new Command('init')
|
|
|
16
18
|
.option('--non-interactive', 'Skip all interactive prompts')
|
|
17
19
|
.option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
|
|
18
20
|
.option('--local', 'Install Claude commands to project directory (.claude/commands/)')
|
|
21
|
+
.option('--scaffold', 'Create standard project directories (App Versions, Automations, Bins, …)')
|
|
19
22
|
.action(async (options) => {
|
|
20
23
|
// Merge --yes into nonInteractive
|
|
21
24
|
if (options.yes) options.nonInteractive = true;
|
|
22
25
|
try {
|
|
23
26
|
if (await isInitialized() && !options.force) {
|
|
27
|
+
if (options.scaffold) {
|
|
28
|
+
const result = await scaffoldProjectDirs();
|
|
29
|
+
logScaffoldResult(result);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
24
32
|
log.warn('Already initialized. Use --force to overwrite.');
|
|
25
33
|
return;
|
|
26
34
|
}
|
|
@@ -49,18 +57,32 @@ export const initCommand = new Command('init')
|
|
|
49
57
|
if (!domain || !username) {
|
|
50
58
|
const inquirer = (await import('inquirer')).default;
|
|
51
59
|
const answers = await inquirer.prompt([
|
|
52
|
-
{ type: 'input', name: 'domain', message: 'Domain (e.g., myapp.dbo.io):', default: domain || undefined, when: !domain
|
|
60
|
+
{ type: 'input', name: 'domain', message: 'Domain (e.g., myapp.dbo.io):', default: domain || undefined, when: !domain },
|
|
53
61
|
{ type: 'input', name: 'username', message: 'Username (email):', when: !username },
|
|
54
62
|
]);
|
|
55
63
|
domain = domain || answers.domain;
|
|
56
64
|
username = username || answers.username;
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
let domainChanged = false;
|
|
68
|
+
if (options.force) {
|
|
69
|
+
const { changed, proceed } = await checkDomainChange(domain, options);
|
|
70
|
+
if (changed && !proceed) {
|
|
71
|
+
log.info('Init aborted: domain change denied.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
domainChanged = changed;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
await initConfig(domain);
|
|
60
78
|
if (username) {
|
|
61
79
|
await saveCredentials(username);
|
|
62
80
|
}
|
|
63
81
|
|
|
82
|
+
if (options.force && domainChanged) {
|
|
83
|
+
await writeAppJsonDomain(domain);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
// Ensure sensitive files are gitignored
|
|
65
87
|
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
|
|
66
88
|
|
|
@@ -85,6 +107,29 @@ export const initCommand = new Command('init')
|
|
|
85
107
|
await saveTransactionKeyPreset('RowUID');
|
|
86
108
|
}
|
|
87
109
|
|
|
110
|
+
// ─── Scaffold ─────────────────────────────────────────────────────────────
|
|
111
|
+
let shouldScaffold = options.scaffold;
|
|
112
|
+
|
|
113
|
+
if (!shouldScaffold && !options.nonInteractive) {
|
|
114
|
+
const entries = await readdir(process.cwd());
|
|
115
|
+
const IGNORED = new Set(['.dbo', '.claude', '.idea', '.vscode']);
|
|
116
|
+
const isEmpty = entries.every(e => IGNORED.has(e));
|
|
117
|
+
|
|
118
|
+
const inquirer = (await import('inquirer')).default;
|
|
119
|
+
const { doScaffold } = await inquirer.prompt([{
|
|
120
|
+
type: 'confirm',
|
|
121
|
+
name: 'doScaffold',
|
|
122
|
+
message: 'Would you like to scaffold the standard project directory structure?',
|
|
123
|
+
default: isEmpty,
|
|
124
|
+
}]);
|
|
125
|
+
shouldScaffold = doScaffold;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (shouldScaffold) {
|
|
129
|
+
const result = await scaffoldProjectDirs();
|
|
130
|
+
logScaffoldResult(result);
|
|
131
|
+
}
|
|
132
|
+
|
|
88
133
|
// Clone if requested
|
|
89
134
|
if (options.clone || options.app) {
|
|
90
135
|
let appShortName = options.app;
|
package/src/commands/status.js
CHANGED
|
@@ -18,7 +18,6 @@ export const statusCommand = new Command('status')
|
|
|
18
18
|
log.label('Username', config.username || '(not set)');
|
|
19
19
|
const userInfo = await loadUserInfo();
|
|
20
20
|
log.label('User ID', userInfo.userId || '(not set)');
|
|
21
|
-
log.label('User UID', userInfo.userUid || '(not set — run "dbo login")');
|
|
22
21
|
log.label('Directory', process.cwd());
|
|
23
22
|
|
|
24
23
|
const cookiesPath = await getActiveCookiesPath();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFile, writeFile, stat, utimes } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { loadConfig, loadTransactionKeyPreset } from './config.js';
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
|
|
6
|
+
const appJsonPath = () => join(process.cwd(), 'app.json');
|
|
7
|
+
|
|
8
|
+
/** Read _domain from app.json. Returns null if file missing or field absent. */
|
|
9
|
+
export async function readAppJsonDomain() {
|
|
10
|
+
try {
|
|
11
|
+
const obj = JSON.parse(await readFile(appJsonPath(), 'utf8'));
|
|
12
|
+
return obj._domain || null;
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Write _domain to app.json, preserving mtime so git doesn't see a spurious change.
|
|
18
|
+
* No-op if app.json does not exist.
|
|
19
|
+
*/
|
|
20
|
+
export async function writeAppJsonDomain(domain) {
|
|
21
|
+
const path = appJsonPath();
|
|
22
|
+
let originalStat;
|
|
23
|
+
try { originalStat = await stat(path); } catch { return; }
|
|
24
|
+
const obj = JSON.parse(await readFile(path, 'utf8'));
|
|
25
|
+
obj._domain = domain;
|
|
26
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n');
|
|
27
|
+
await utimes(path, originalStat.atime, originalStat.mtime); // restore mtime
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check whether newDomain differs from the project reference domain.
|
|
32
|
+
* Reference = app.json._domain (authoritative) → fallback: config.json.domain.
|
|
33
|
+
* If reference is absent entirely, returns { changed: false } (no warning).
|
|
34
|
+
*
|
|
35
|
+
* Returns { changed: false } or { changed: true, proceed: bool }.
|
|
36
|
+
* Throws in non-interactive mode when TransactionKeyPreset=RowID and domain changes.
|
|
37
|
+
*/
|
|
38
|
+
export async function checkDomainChange(newDomain, options = {}) {
|
|
39
|
+
let referenceDomain = await readAppJsonDomain();
|
|
40
|
+
if (!referenceDomain) {
|
|
41
|
+
const config = await loadConfig();
|
|
42
|
+
referenceDomain = config.domain || null;
|
|
43
|
+
}
|
|
44
|
+
if (!referenceDomain) return { changed: false };
|
|
45
|
+
|
|
46
|
+
const norm = d => d ? String(d).replace(/^https?:\/\//, '').replace(/\/$/, '').toLowerCase() : null;
|
|
47
|
+
if (norm(newDomain) === norm(referenceDomain)) return { changed: false };
|
|
48
|
+
|
|
49
|
+
const preset = await loadTransactionKeyPreset();
|
|
50
|
+
const isRowID = preset === 'RowID';
|
|
51
|
+
|
|
52
|
+
if (options.yes || !process.stdin.isTTY) {
|
|
53
|
+
if (isRowID) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Domain change detected: "${referenceDomain}" → "${newDomain}"\n` +
|
|
56
|
+
`Cannot proceed in non-interactive mode with TransactionKeyPreset=RowID. ` +
|
|
57
|
+
`Numeric IDs are not unique across domains — submitting to the wrong domain may overwrite or corrupt records.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
log.warn(`Domain change detected: "${referenceDomain}" → "${newDomain}". Proceeding (TransactionKeyPreset=RowUID).`);
|
|
61
|
+
return { changed: true, proceed: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const inquirer = (await import('inquirer')).default;
|
|
65
|
+
log.plain('');
|
|
66
|
+
|
|
67
|
+
if (isRowID) {
|
|
68
|
+
log.error('⛔ CRITICAL: Domain change detected');
|
|
69
|
+
log.warn(` Reference domain : ${referenceDomain}`);
|
|
70
|
+
log.warn(` New domain : ${newDomain}`);
|
|
71
|
+
log.plain('');
|
|
72
|
+
log.warn(' TransactionKeyPreset=RowID is active. Numeric IDs are NOT unique across');
|
|
73
|
+
log.warn(' domains. Pushing to the wrong domain may overwrite or corrupt records in');
|
|
74
|
+
log.warn(' a different app.');
|
|
75
|
+
log.plain('');
|
|
76
|
+
const { confirmText } = await inquirer.prompt([{
|
|
77
|
+
type: 'input',
|
|
78
|
+
name: 'confirmText',
|
|
79
|
+
message: `Type "yes, change domain" to confirm switching to "${newDomain}":`,
|
|
80
|
+
}]);
|
|
81
|
+
return { changed: true, proceed: confirmText.trim() === 'yes, change domain' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log.warn('⚠ Domain change detected');
|
|
85
|
+
log.dim(` Reference domain : ${referenceDomain}`);
|
|
86
|
+
log.dim(` New domain : ${newDomain}`);
|
|
87
|
+
log.plain('');
|
|
88
|
+
const { confirm } = await inquirer.prompt([{
|
|
89
|
+
type: 'confirm',
|
|
90
|
+
name: 'confirm',
|
|
91
|
+
message: `Switch domain from "${referenceDomain}" to "${newDomain}"?`,
|
|
92
|
+
default: false,
|
|
93
|
+
}]);
|
|
94
|
+
return { changed: true, proceed: confirm };
|
|
95
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile, access } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { DEFAULT_PROJECT_DIRS } from './structure.js';
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scaffold the standard DBO project directory structure in cwd.
|
|
8
|
+
* Creates missing directories, skips existing ones, warns on name conflicts.
|
|
9
|
+
* Also creates app.json with {} if absent.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} [cwd=process.cwd()]
|
|
12
|
+
* @returns {Promise<{ created: string[], skipped: string[], warned: string[] }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function scaffoldProjectDirs(cwd = process.cwd()) {
|
|
15
|
+
const created = [];
|
|
16
|
+
const skipped = [];
|
|
17
|
+
const warned = [];
|
|
18
|
+
|
|
19
|
+
for (const dir of DEFAULT_PROJECT_DIRS) {
|
|
20
|
+
const target = join(cwd, dir);
|
|
21
|
+
try {
|
|
22
|
+
const s = await stat(target);
|
|
23
|
+
if (s.isDirectory()) {
|
|
24
|
+
skipped.push(dir);
|
|
25
|
+
} else {
|
|
26
|
+
log.warn(` Skipping "${dir}" — a file with that name already exists`);
|
|
27
|
+
warned.push(dir);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Does not exist — create it
|
|
31
|
+
await mkdir(target, { recursive: true });
|
|
32
|
+
created.push(dir);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create app.json if absent
|
|
37
|
+
const appJsonPath = join(cwd, 'app.json');
|
|
38
|
+
try {
|
|
39
|
+
await access(appJsonPath);
|
|
40
|
+
} catch {
|
|
41
|
+
await writeFile(appJsonPath, '{}\n');
|
|
42
|
+
created.push('app.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { created, skipped, warned };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Print the scaffold summary to the console.
|
|
50
|
+
* @param {{ created: string[], skipped: string[], warned: string[] }} result
|
|
51
|
+
*/
|
|
52
|
+
export function logScaffoldResult({ created, skipped, warned }) {
|
|
53
|
+
if (created.length) {
|
|
54
|
+
log.success('Scaffolded directories:');
|
|
55
|
+
for (const d of created) log.dim(` + ${d}`);
|
|
56
|
+
}
|
|
57
|
+
if (skipped.length) {
|
|
58
|
+
log.dim('Skipped (already exist):');
|
|
59
|
+
for (const d of skipped) log.dim(` · ${d}`);
|
|
60
|
+
}
|
|
61
|
+
// warned items already printed inline during scaffold
|
|
62
|
+
}
|