@dboio/cli 0.6.12 → 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
@@ -157,6 +157,7 @@ All configuration is **directory-scoped**. Each project folder maintains its own
157
157
  | `AppName` | string | App display name |
158
158
  | `AppShortName` | string | App short name (used for `dbo clone --app`) |
159
159
  | `AppModifyKey` | string | ModifyKey for locked/production apps (set by `dbo clone`, used for submission guards) |
160
+ | `TransactionKeyPreset` | `RowUID` \| `RowID` | Row key type for auto-assembled expressions (set during `dbo init`/`dbo clone`, default `RowUID`) |
160
161
  | `ContentPlacement` | `bin` \| `path` \| `ask` | Where to place content files during clone |
161
162
  | `MediaPlacement` | `bin` \| `fullpath` \| `ask` | Where to place media files during clone |
162
163
  | `<Entity>FilenameCol` | column name | Filename column for entity-dir records (e.g., `ExtensionFilenameCol`) |
@@ -168,6 +169,10 @@ All configuration is **directory-scoped**. Each project folder maintains its own
168
169
 
169
170
  These are set interactively on first clone and saved for future use. Pre-set them in `config.json` to skip the prompt entirely.
170
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
+
171
176
  ### Legacy migration
172
177
 
173
178
  If your project uses the older `.domain`, `.username`, `.password`, `.cookies` files, `dbo init` will detect them and offer to migrate automatically.
@@ -195,17 +200,20 @@ dbo init --domain my-domain.com --username me@co.io # with credentials
195
200
  dbo init --force # overwrite existing config
196
201
  dbo init --domain my-domain.com --app myapp --clone # init + clone an app
197
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
198
205
  ```
199
206
 
200
207
  | Flag | Description |
201
208
  |------|-------------|
202
209
  | `--domain <host>` | DBO instance domain |
203
210
  | `--username <user>` | DBO username (stored for login default) |
204
- | `--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 |
205
212
  | `--app <shortName>` | App short name (triggers clone after init) |
206
213
  | `--clone` | Clone the app after initialization |
207
214
  | `-g, --global` | Install Claude commands globally (`~/.claude/commands/`) |
208
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`) |
209
217
  | `-y, --yes` | Skip all interactive prompts (legacy migration, Claude Code setup) |
210
218
  | `--non-interactive` | Alias for `--yes` |
211
219
 
@@ -233,10 +241,16 @@ dbo init --domain my-domain.com --app myapp --clone
233
241
  |------|-------------|
234
242
  | `<source>` | Local JSON file path (optional positional argument) |
235
243
  | `--app <name>` | App short name to fetch from server |
236
- | `--domain <host>` | Override domain |
244
+ | `--domain <host>` | Override domain. Triggers a domain-change confirmation prompt when it differs from the project reference domain |
237
245
  | `-y, --yes` | Auto-accept all prompts |
238
246
  | `-v, --verbose` | Show HTTP request details |
239
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
+
240
254
  #### What clone does
241
255
 
242
256
  1. **Loads app JSON** — from a local file, server API, or interactive prompt
@@ -437,7 +451,6 @@ Output:
437
451
  Domain: my-domain.com
438
452
  Username: user@example.com
439
453
  User ID: 10296
440
- User UID: albain3dwkofbhnd1qtd1q
441
454
  Directory: /Users/me/projects/operator
442
455
  Session: Active (expires: 2026-03-15T10:30:00.000Z)
443
456
  Cookies: /Users/me/projects/operator/.dbo/cookies.txt
@@ -446,7 +459,7 @@ Output:
446
459
  dbo: ✓ global (~/.claude/commands/dbo.md)
447
460
  ```
448
461
 
449
- 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.
450
463
 
451
464
  Plugin scopes (project or global) are displayed when plugins have been installed. Scopes are stored in `.dbo/config.local.json`.
452
465
 
@@ -489,6 +502,7 @@ dbo input -d 'RowUID:abc;column:content.Content@file.css' -v
489
502
  | `-C, --confirm <true\|false>` | Commit changes (default: `true`). Use `false` for validation only |
490
503
  | `--ticket <id>` | Override ticket ID (`_OverrideTicketID`) |
491
504
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
505
+ | `--row-key <type>` | Row key type (`RowUID` or `RowID`) — no-op for `-d` passthrough, available for consistency |
492
506
  | `--login` | Auto-login user created by this submission |
493
507
  | `--transactional` | Use transactional processing |
494
508
  | `--json` | Output raw JSON response |
@@ -634,6 +648,7 @@ dbo input -d 'RowUID:albain3dwkofbhnd1qtd1q;column:content.Content@assets/css/co
634
648
  | `--ticket <id>` | Override ticket ID |
635
649
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
636
650
  | `--multipart` | Use multipart/form-data upload (for binary files) |
651
+ | `--row-key <type>` | Override row key type for this invocation (`RowUID` or `RowID`) |
637
652
 
638
653
  #### `dbo content pull`
639
654
 
@@ -945,6 +960,7 @@ The `@colors.css` reference tells push to read the content from that file. All o
945
960
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
946
961
  | `--ticket <id>` | Override ticket ID |
947
962
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
963
+ | `--row-key <type>` | Override row key type for this invocation (`RowUID` or `RowID`) |
948
964
  | `--meta-only` | Only push metadata, skip file content |
949
965
  | `--content-only` | Only push file content, skip metadata |
950
966
  | `-y, --yes` | Auto-accept all prompts |
@@ -1108,6 +1124,7 @@ The `@colors.css` reference tells the CLI to read the file content from `colors.
1108
1124
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1109
1125
  | `--ticket <id>` | Override ticket ID |
1110
1126
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1127
+ | `--row-key <type>` | Row key type (`RowUID` or `RowID`) — `add` always uses `RowID:add1` for new records regardless |
1111
1128
  | `-y, --yes` | Auto-accept all prompts |
1112
1129
  | `--json` | Output raw JSON |
1113
1130
  | `--jq <expr>` | Filter JSON response |
@@ -1328,6 +1345,9 @@ dbo deploy js:app --ticket abc123
1328
1345
 
1329
1346
  # Validate without deploying
1330
1347
  dbo deploy css:colors --confirm false
1348
+
1349
+ # Deploy a multipart (binary) entry from manifest
1350
+ dbo deploy img:logo
1331
1351
  ```
1332
1352
 
1333
1353
  | Flag | Description |
@@ -1337,6 +1357,7 @@ dbo deploy css:colors --confirm false
1337
1357
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1338
1358
  | `--ticket <id>` | Override ticket ID |
1339
1359
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1360
+ | `--row-key <type>` | Override row key type for this invocation (`RowUID` or `RowID`) |
1340
1361
  | `--json` | Output raw JSON |
1341
1362
  | `-v, --verbose` | Show HTTP request details |
1342
1363
  | `--domain <host>` | Override domain |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,7 @@ export const addCommand = new Command('add')
19
19
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
20
20
  .option('--ticket <id>', 'Override ticket ID')
21
21
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
22
+ .option('--row-key <type>', 'Row key type (RowUID or RowID) — add uses RowID:add1 for new records regardless')
22
23
  .option('-y, --yes', 'Auto-accept all prompts')
23
24
  .option('--json', 'Output raw JSON')
24
25
  .option('--jq <expr>', 'Filter JSON response')
@@ -2,11 +2,12 @@ import { Command } from 'commander';
2
2
  import { readFile, writeFile, mkdir, access } from 'fs/promises';
3
3
  import { join, basename, extname } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey } from '../lib/config.js';
5
+ import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset } from '../lib/config.js';
6
6
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
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
@@ -511,6 +522,28 @@ export async function performClone(source, options = {}) {
511
522
  log.warn('');
512
523
  }
513
524
 
525
+ // Prompt for TransactionKeyPreset if not already set
526
+ const existingPreset = await loadTransactionKeyPreset();
527
+ if (!existingPreset) {
528
+ if (options.yes || !process.stdin.isTTY) {
529
+ await saveTransactionKeyPreset('RowUID');
530
+ log.dim(' TransactionKeyPreset: RowUID (default)');
531
+ } else {
532
+ const inquirer = (await import('inquirer')).default;
533
+ const { preset } = await inquirer.prompt([{
534
+ type: 'list',
535
+ name: 'preset',
536
+ message: 'Which row key should the CLI use when building input expressions?',
537
+ choices: [
538
+ { name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
539
+ { name: 'RowID (numeric IDs)', value: 'RowID' },
540
+ ],
541
+ }]);
542
+ await saveTransactionKeyPreset(preset);
543
+ log.dim(` TransactionKeyPreset: ${preset}`);
544
+ }
545
+ }
546
+
514
547
  // Step 3: Update package.json
515
548
  await updatePackageJson(appJson, config);
516
549
 
@@ -593,7 +626,7 @@ export async function performClone(source, options = {}) {
593
626
  }
594
627
 
595
628
  // Step 7: Save app.json with references
596
- await saveAppJson(appJson, contentRefs, otherRefs);
629
+ await saveAppJson(appJson, contentRefs, otherRefs, effectiveDomain);
597
630
 
598
631
  // Step 8: Create .app.json baseline for delta tracking
599
632
  await saveBaselineFile(appJson);
@@ -1670,8 +1703,9 @@ export function guessExtensionForColumn(columnName) {
1670
1703
  /**
1671
1704
  * Save app.json to project root with @ references replacing processed entries.
1672
1705
  */
1673
- async function saveAppJson(appJson, contentRefs, otherRefs) {
1706
+ async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
1674
1707
  const output = { ...appJson };
1708
+ if (domain) output._domain = domain;
1675
1709
  output.children = { ...appJson.children };
1676
1710
 
1677
1711
  // Replace content array with references
@@ -7,6 +7,7 @@ import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { saveToDisk } from '../lib/save-to-disk.js';
8
8
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
9
9
  import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
10
+ import { resolveTransactionKey } from '../lib/transaction-key.js';
10
11
  import { log } from '../lib/logger.js';
11
12
 
12
13
  function collect(value, previous) {
@@ -32,6 +33,7 @@ const deployCmd = new Command('deploy')
32
33
  .option('--ticket <id>', 'Override ticket ID')
33
34
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
34
35
  .option('--multipart', 'Use multipart/form-data upload (for binary files)')
36
+ .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
35
37
  .option('--json', 'Output raw JSON')
36
38
  .option('-v, --verbose', 'Show HTTP request details')
37
39
  .option('--domain <host>', 'Override domain')
@@ -63,12 +65,14 @@ const deployCmd = new Command('deploy')
63
65
  }
64
66
  }
65
67
 
68
+ const transactionKey = await resolveTransactionKey(options);
66
69
  const extraParams = { '_confirm': options.confirm };
67
70
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
68
71
  if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
69
72
 
70
73
  const isMultipart = options.multipart === true;
71
- const dataExprs = isMultipart ? [] : [`RowUID:${uid};column:content.Content@${filepath}`];
74
+ const rowKeyExpr = transactionKey === 'RowID' ? `RowID:${uid}` : `RowUID:${uid}`;
75
+ const dataExprs = isMultipart ? [] : [`${rowKeyExpr};column:content.Content@${filepath}`];
72
76
 
73
77
  // Apply stored ticket if no --ticket flag
74
78
  if (!options.ticket) {
@@ -90,7 +94,7 @@ const deployCmd = new Command('deploy')
90
94
  }
91
95
  }
92
96
  const files = [{
93
- fieldName: `RowUID:${uid};column:content.Content`,
97
+ fieldName: `${rowKeyExpr};column:content.Content`,
94
98
  filePath: filepath,
95
99
  fileName: filepath.split('/').pop(),
96
100
  }];
@@ -5,6 +5,7 @@ import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
5
5
  import { formatResponse, formatError } from '../lib/formatter.js';
6
6
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
7
7
  import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
8
+ import { resolveTransactionKey } from '../lib/transaction-key.js';
8
9
  import { log } from '../lib/logger.js';
9
10
 
10
11
  const MANIFEST_FILE = 'dbo.deploy.json';
@@ -16,6 +17,7 @@ export const deployCommand = new Command('deploy')
16
17
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
17
18
  .option('--ticket <id>', 'Override ticket ID')
18
19
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
20
+ .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
19
21
  .option('--json', 'Output raw JSON')
20
22
  .option('-v, --verbose', 'Show HTTP request details')
21
23
  .option('--domain <host>', 'Override domain')
@@ -54,6 +56,9 @@ export const deployCommand = new Command('deploy')
54
56
  process.exit(1);
55
57
  }
56
58
 
59
+ // Resolve transaction key preset once before the entry loop
60
+ const transactionKey = await resolveTransactionKey(options);
61
+
57
62
  // ModifyKey guard — check once before any submissions
58
63
  const modifyKeyResult = await checkModifyKey(options);
59
64
  if (modifyKeyResult.cancel) {
@@ -103,9 +108,10 @@ export const deployCommand = new Command('deploy')
103
108
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
104
109
  if (activeModifyKey) extraParams['_modify_key'] = activeModifyKey;
105
110
 
111
+ const rowKeyExpr = transactionKey === 'RowID' ? `RowID:${uid}` : `RowUID:${uid}`;
106
112
  const dataExprs = isMultipart
107
- ? [`RowUID:${uid};column:${entity}.Filename=${filename}`]
108
- : [`RowUID:${uid};column:${entity}.${column}@${file}`];
113
+ ? [`${rowKeyExpr};column:${entity}.Filename=${filename}`]
114
+ : [`${rowKeyExpr};column:${entity}.${column}@${file}`];
109
115
 
110
116
  // Apply stored ticket if no --ticket flag
111
117
  if (!options.ticket) {
@@ -127,7 +133,7 @@ export const deployCommand = new Command('deploy')
127
133
  }
128
134
  }
129
135
  const files = [{
130
- fieldName: `RowUID:${uid};column:${entity}.${column}`,
136
+ fieldName: `${rowKeyExpr};column:${entity}.${column}`,
131
137
  filePath: file,
132
138
  fileName: file.split('/').pop(),
133
139
  }];
@@ -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
- import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore } from '../lib/config.js';
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,24 +57,79 @@ 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
 
67
89
  log.success(`Initialized .dbo/ for ${domain}`);
68
90
  log.dim(' Run "dbo login" to authenticate.');
69
91
 
92
+ // Prompt for TransactionKeyPreset
93
+ if (!options.nonInteractive) {
94
+ const inquirer = (await import('inquirer')).default;
95
+ const { preset } = await inquirer.prompt([{
96
+ type: 'list',
97
+ name: 'preset',
98
+ message: 'Which row key should the CLI use when building input expressions?',
99
+ choices: [
100
+ { name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
101
+ { name: 'RowID (numeric IDs)', value: 'RowID' },
102
+ ],
103
+ }]);
104
+ await saveTransactionKeyPreset(preset);
105
+ log.dim(` TransactionKeyPreset: ${preset}`);
106
+ } else {
107
+ await saveTransactionKeyPreset('RowUID');
108
+ }
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
+
70
133
  // Clone if requested
71
134
  if (options.clone || options.app) {
72
135
  let appShortName = options.app;
@@ -18,6 +18,7 @@ export const inputCommand = new Command('input')
18
18
  .option('-C, --confirm <value>', 'Commit changes: true (default) or false for validation only', 'true')
19
19
  .option('--ticket <id>', 'Override ticket ID (_OverrideTicketID)')
20
20
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
21
+ .option('--row-key <type>', 'Row key type (RowUID or RowID) — no-op for -d passthrough, available for consistency')
21
22
  .option('--login', 'Auto-login user created by this submission')
22
23
  .option('--transactional', 'Use transactional processing')
23
24
  .option('--json', 'Output raw JSON response')
@@ -9,6 +9,7 @@ import { shouldSkipColumn } from '../lib/columns.js';
9
9
  import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
10
10
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
11
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
12
+ import { resolveTransactionKey } from '../lib/transaction-key.js';
12
13
  import { setFileTimestamps } from '../lib/timestamps.js';
13
14
  import { findMetadataFiles } from '../lib/diff.js';
14
15
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
@@ -23,6 +24,7 @@ export const pushCommand = new Command('push')
23
24
  .option('--meta-only', 'Only push metadata changes, skip file content')
24
25
  .option('--content-only', 'Only push file content, skip metadata columns')
25
26
  .option('-y, --yes', 'Auto-accept all prompts (path refactoring, etc.)')
27
+ .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
26
28
  .option('--json', 'Output raw JSON')
27
29
  .option('--jq <expr>', 'Filter JSON response')
28
30
  .option('-v, --verbose', 'Show HTTP request details')
@@ -39,15 +41,18 @@ export const pushCommand = new Command('push')
39
41
  }
40
42
  const modifyKey = modifyKeyResult.modifyKey;
41
43
 
44
+ // Resolve transaction key preset
45
+ const transactionKey = await resolveTransactionKey(options);
46
+
42
47
  // Process pending deletions from synchronize.json
43
- await processPendingDeletes(client, options, modifyKey);
48
+ await processPendingDeletes(client, options, modifyKey, transactionKey);
44
49
 
45
50
  const pathStat = await stat(targetPath);
46
51
 
47
52
  if (pathStat.isDirectory()) {
48
- await pushDirectory(targetPath, client, options, modifyKey);
53
+ await pushDirectory(targetPath, client, options, modifyKey, transactionKey);
49
54
  } else {
50
- await pushSingleFile(targetPath, client, options, modifyKey);
55
+ await pushSingleFile(targetPath, client, options, modifyKey, transactionKey);
51
56
  }
52
57
  } catch (err) {
53
58
  formatError(err);
@@ -58,7 +63,7 @@ export const pushCommand = new Command('push')
58
63
  /**
59
64
  * Process pending delete entries from .dbo/synchronize.json
60
65
  */
61
- async function processPendingDeletes(client, options, modifyKey = null) {
66
+ async function processPendingDeletes(client, options, modifyKey = null, transactionKey = 'RowUID') {
62
67
  const sync = await loadSynchronize();
63
68
  if (!sync.delete || sync.delete.length === 0) return;
64
69
 
@@ -140,7 +145,7 @@ async function processPendingDeletes(client, options, modifyKey = null) {
140
145
  /**
141
146
  * Push a single file using its companion .metadata.json
142
147
  */
143
- async function pushSingleFile(filePath, client, options, modifyKey = null) {
148
+ async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
144
149
  // Find the metadata file
145
150
  const dir = dirname(filePath);
146
151
  const base = basename(filePath, extname(filePath));
@@ -154,13 +159,13 @@ async function pushSingleFile(filePath, client, options, modifyKey = null) {
154
159
  process.exit(1);
155
160
  }
156
161
 
157
- await pushFromMetadata(meta, metaPath, client, options, null, modifyKey);
162
+ await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
158
163
  }
159
164
 
160
165
  /**
161
166
  * Push all records found in a directory (recursive)
162
167
  */
163
- async function pushDirectory(dirPath, client, options, modifyKey = null) {
168
+ async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
164
169
  const metaFiles = await findMetadataFiles(dirPath);
165
170
 
166
171
  if (metaFiles.length === 0) {
@@ -272,7 +277,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null) {
272
277
 
273
278
  for (const item of toPush) {
274
279
  try {
275
- const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey);
280
+ const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
276
281
  if (success) {
277
282
  succeeded++;
278
283
  successfulPushes.push(item);
@@ -306,14 +311,37 @@ async function pushDirectory(dirPath, client, options, modifyKey = null) {
306
311
  * @param {string[]|null} changedColumns - Optional array of changed column names (for delta sync)
307
312
  * @returns {Promise<boolean>} - True if push succeeded
308
313
  */
309
- async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null, modifyKey = null) {
310
- const uid = meta.UID || meta._id;
314
+ async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null, modifyKey = null, transactionKey = 'RowUID') {
315
+ const uid = meta.UID;
316
+ const id = meta._id;
311
317
  const entity = meta._entity;
312
318
  const contentCols = new Set(meta._contentColumns || []);
313
319
  const metaDir = dirname(metaPath);
314
320
 
315
- if (!uid) {
316
- throw new Error(`No UID found in ${metaPath}`);
321
+ // Determine the row key prefix and value based on the preset
322
+ let rowKeyPrefix, rowKeyValue;
323
+ if (transactionKey === 'RowID') {
324
+ if (id) {
325
+ rowKeyPrefix = 'RowID';
326
+ rowKeyValue = id;
327
+ } else {
328
+ log.warn(` ⚠ Preset is RowID but no _id found in ${basename(metaPath)} — falling back to RowUID`);
329
+ rowKeyPrefix = 'RowUID';
330
+ rowKeyValue = uid;
331
+ }
332
+ } else {
333
+ if (uid) {
334
+ rowKeyPrefix = 'RowUID';
335
+ rowKeyValue = uid;
336
+ } else if (id) {
337
+ log.warn(` ⚠ Preset is RowUID but no UID found in ${basename(metaPath)} — falling back to RowID`);
338
+ rowKeyPrefix = 'RowID';
339
+ rowKeyValue = id;
340
+ }
341
+ }
342
+
343
+ if (!rowKeyValue) {
344
+ throw new Error(`No UID or _id found in ${metaPath}`);
317
345
  }
318
346
  if (!entity) {
319
347
  throw new Error(`No _entity found in ${metaPath}`);
@@ -351,9 +379,9 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
351
379
  // @filename reference — resolve to actual file path
352
380
  const refFile = strValue.substring(1);
353
381
  const refPath = join(metaDir, refFile);
354
- dataExprs.push(`RowUID:${uid};column:${entity}.${key}@${refPath}`);
382
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}@${refPath}`);
355
383
  } else {
356
- dataExprs.push(`RowUID:${uid};column:${entity}.${key}=${strValue}`);
384
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}=${strValue}`);
357
385
  }
358
386
  }
359
387
 
@@ -363,10 +391,10 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
363
391
  }
364
392
 
365
393
  const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
366
- log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) — ${fieldLabel}`);
394
+ log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
367
395
 
368
396
  // Apply stored ticket if no --ticket flag
369
- await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options);
397
+ await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
370
398
 
371
399
  const extraParams = { '_confirm': options.confirm };
372
400
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
@@ -422,7 +450,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
422
450
  }
423
451
 
424
452
  // Clean up per-record ticket on success
425
- await clearRecordTicket(uid);
453
+ await clearRecordTicket(uid || id);
426
454
 
427
455
  // Update file timestamps from server response
428
456
  try {
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { loadConfig, isInitialized, getActiveCookiesPath, loadUserInfo, getAllPluginScopes } from '../lib/config.js';
2
+ import { loadConfig, isInitialized, getActiveCookiesPath, loadUserInfo, getAllPluginScopes, loadTransactionKeyPreset } from '../lib/config.js';
3
3
  import { loadCookies } from '../lib/cookie-jar.js';
4
4
  import { access } from 'fs/promises';
5
5
  import { join } from 'path';
@@ -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();
@@ -38,6 +37,9 @@ export const statusCommand = new Command('status')
38
37
  log.label('Session', 'No active session. Run "dbo login".');
39
38
  }
40
39
 
40
+ const transactionKeyPreset = await loadTransactionKeyPreset();
41
+ log.label('Transaction Key', transactionKeyPreset || '(not set — defaults to RowUID)');
42
+
41
43
  // Display plugin status
42
44
  const scopes = await getAllPluginScopes();
43
45
  const pluginNames = Object.keys(scopes);
package/src/lib/config.js CHANGED
@@ -555,6 +555,31 @@ export async function loadAppModifyKey() {
555
555
  } catch { return null; }
556
556
  }
557
557
 
558
+ // ─── TransactionKeyPreset ─────────────────────────────────────────────────
559
+
560
+ /**
561
+ * Save TransactionKeyPreset to .dbo/config.json.
562
+ * @param {'RowUID'|'RowID'} preset
563
+ */
564
+ export async function saveTransactionKeyPreset(preset) {
565
+ await mkdir(dboDir(), { recursive: true });
566
+ let existing = {};
567
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
568
+ existing.TransactionKeyPreset = preset;
569
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
570
+ }
571
+
572
+ /**
573
+ * Load TransactionKeyPreset from .dbo/config.json.
574
+ * Returns 'RowUID', 'RowID', or null if not set.
575
+ */
576
+ export async function loadTransactionKeyPreset() {
577
+ try {
578
+ const raw = await readFile(configPath(), 'utf8');
579
+ return JSON.parse(raw).TransactionKeyPreset || null;
580
+ } catch { return null; }
581
+ }
582
+
558
583
  // ─── Gitignore ────────────────────────────────────────────────────────────
559
584
 
560
585
  /**
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ import { loadTransactionKeyPreset, saveTransactionKeyPreset } from './config.js';
2
+ import { log } from './logger.js';
3
+
4
+ /**
5
+ * Resolve the active transaction key preset.
6
+ * Priority: --row-key flag > config > lazy prompt > default RowUID.
7
+ *
8
+ * @param {Object} options - Command options (may have .rowKey)
9
+ * @returns {Promise<'RowUID'|'RowID'>}
10
+ */
11
+ export async function resolveTransactionKey(options = {}) {
12
+ // 1. --row-key flag takes precedence (no config write)
13
+ if (options.rowKey) return options.rowKey;
14
+
15
+ // 2. Check config
16
+ const stored = await loadTransactionKeyPreset();
17
+ if (stored) return stored;
18
+
19
+ // 3. Non-interactive or -y: default to RowUID, save to config
20
+ if (options.yes || options.nonInteractive || !process.stdin.isTTY) {
21
+ await saveTransactionKeyPreset('RowUID');
22
+ return 'RowUID';
23
+ }
24
+
25
+ // 4. Lazy prompt (first submission without preset configured)
26
+ log.plain('');
27
+ log.info('TransactionKeyPreset is not configured.');
28
+ log.dim(' RowUID — stable across domains (recommended)');
29
+ log.dim(' RowID — uses numeric IDs (may differ across domains)');
30
+ log.plain('');
31
+
32
+ const inquirer = (await import('inquirer')).default;
33
+ const { preset } = await inquirer.prompt([{
34
+ type: 'list',
35
+ name: 'preset',
36
+ message: 'Which row key should the CLI use when building input expressions?',
37
+ choices: [
38
+ { name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
39
+ { name: 'RowID (numeric IDs)', value: 'RowID' },
40
+ ],
41
+ }]);
42
+
43
+ await saveTransactionKeyPreset(preset);
44
+ log.dim(` Saved TransactionKeyPreset: ${preset} to .dbo/config.json`);
45
+ return preset;
46
+ }