@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 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 and UID are populated by `dbo login`. If they show "(not set)", run `dbo login` to fetch them.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -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, validate: v => v ? true : 'Domain is required' },
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;
@@ -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
+ }