@dboio/cli 0.10.1 → 0.11.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.
@@ -9,6 +9,7 @@ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/m
9
9
  import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
10
10
  import { resolveTransactionKey } from '../lib/transaction-key.js';
11
11
  import { log } from '../lib/logger.js';
12
+ import { runPendingMigrations } from '../lib/migrations.js';
12
13
 
13
14
  function collect(value, previous) {
14
15
  return previous.concat([value]);
@@ -18,7 +19,7 @@ export const contentCommand = new Command('content')
18
19
  .description('Get, deploy, or pull content from DBO.io')
19
20
  .argument('[uid]', 'Content UID')
20
21
  .option('-o, --output <path>', 'Save content to local file')
21
- .option('--format <type>', 'Output format')
22
+ .option('--template <value>', 'Output template (e.g., json_raw, html, txt, or a content UID)')
22
23
  .option('--no-minify', 'Disable minification')
23
24
  .option('--json', 'Output raw JSON')
24
25
  .option('-v, --verbose', 'Show HTTP request details')
@@ -37,8 +38,10 @@ const deployCmd = new Command('deploy')
37
38
  .option('--json', 'Output raw JSON')
38
39
  .option('-v, --verbose', 'Show HTTP request details')
39
40
  .option('--domain <host>', 'Override domain')
41
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
40
42
  .action(async (uid, filepath, options) => {
41
43
  try {
44
+ await runPendingMigrations(options);
42
45
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
43
46
 
44
47
  // ModifyKey guard
@@ -130,6 +133,7 @@ const deployCmd = new Command('deploy')
130
133
  result = await submit();
131
134
  }
132
135
 
136
+ if (result.successful) await client.voidCache();
133
137
  formatResponse(result, { json: options.json });
134
138
  if (!result.successful) process.exit(1);
135
139
  } catch (err) {
@@ -150,7 +154,7 @@ const pullCmd = new Command('pull')
150
154
  .action(async (uid, options) => {
151
155
  try {
152
156
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
153
- const params = { '_format': 'json_raw' };
157
+ const params = { '_template': 'json_raw' };
154
158
  if (options.maxrows) params['_maxrows'] = options.maxrows;
155
159
 
156
160
  for (const f of options.filter) {
@@ -216,7 +220,7 @@ contentCommand.action(async (uid, options) => {
216
220
  }
217
221
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
218
222
  const params = {};
219
- if (options.format) params['_format'] = options.format;
223
+ if (options.template) params['_template'] = options.template;
220
224
  if (options.noMinify) params['_no_minify'] = 'true';
221
225
 
222
226
  const result = await client.get(`/api/content/${uid}`, params);
@@ -7,12 +7,14 @@ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/m
7
7
  import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
8
8
  import { resolveTransactionKey } from '../lib/transaction-key.js';
9
9
  import { log } from '../lib/logger.js';
10
+ import { runPendingMigrations } from '../lib/migrations.js';
10
11
 
11
- const MANIFEST_FILE = 'dbo.deploy.json';
12
+ const MANIFEST_FILE = '.dbo/deploy_config.json';
13
+ const LEGACY_MANIFEST_FILE = 'dbo.deploy.json';
12
14
 
13
15
  export const deployCommand = new Command('deploy')
14
16
  .description('Deploy files to DBO.io using a manifest or direct arguments')
15
- .argument('[name]', 'Deployment name from dbo.deploy.json (e.g., css:colors)')
17
+ .argument('[name]', 'Deployment name from .dbo/deploy_config.json (e.g., css:colors)')
16
18
  .option('--all', 'Deploy all entries in the manifest')
17
19
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
18
20
  .option('--ticket <id>', 'Override ticket ID')
@@ -21,20 +23,32 @@ export const deployCommand = new Command('deploy')
21
23
  .option('--json', 'Output raw JSON')
22
24
  .option('-v, --verbose', 'Show HTTP request details')
23
25
  .option('--domain <host>', 'Override domain')
26
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
24
27
  .action(async (name, options) => {
25
28
  try {
29
+ await runPendingMigrations(options);
26
30
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
27
31
 
28
- // Load manifest
32
+ // Load manifest — try .dbo/deploy_config.json first, fall back to legacy dbo.deploy.json
29
33
  let manifest;
34
+ let manifestSource;
30
35
  try {
31
36
  const raw = await readFile(MANIFEST_FILE, 'utf8');
32
37
  manifest = JSON.parse(raw);
38
+ manifestSource = MANIFEST_FILE;
33
39
  } catch {
34
- if (!name) {
35
- log.error(`No ${MANIFEST_FILE} found and no deployment name specified.`);
36
- log.dim(' Create a dbo.deploy.json or use: dbo content deploy <uid> <filepath>');
37
- process.exit(1);
40
+ // Fall back to legacy location
41
+ try {
42
+ const raw = await readFile(LEGACY_MANIFEST_FILE, 'utf8');
43
+ manifest = JSON.parse(raw);
44
+ manifestSource = LEGACY_MANIFEST_FILE;
45
+ log.dim(` Using legacy ${LEGACY_MANIFEST_FILE} — run \`dbo\` to migrate it to ${MANIFEST_FILE}`);
46
+ } catch {
47
+ if (!name) {
48
+ log.error(`No ${MANIFEST_FILE} found and no deployment name specified.`);
49
+ log.dim(` Create ${MANIFEST_FILE} or use: dbo content deploy <uid> <filepath>`);
50
+ process.exit(1);
51
+ }
38
52
  }
39
53
  }
40
54
 
@@ -175,6 +189,7 @@ export const deployCommand = new Command('deploy')
175
189
  }
176
190
 
177
191
  if (result.successful) {
192
+ await client.voidCache();
178
193
  log.success(`${entryName} deployed`);
179
194
  } else {
180
195
  log.error(`${entryName} failed`);
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
- import { stat } from 'fs/promises';
2
+ import { readFile, stat } from 'fs/promises';
3
3
  import { join, dirname, basename, extname } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import { DboClient } from '../lib/client.js';
6
- import { loadConfig } from '../lib/config.js';
6
+ import { loadConfig, loadAppConfig, loadAppJsonBaseline } from '../lib/config.js';
7
7
  import { formatError } from '../lib/formatter.js';
8
8
  import { log } from '../lib/logger.js';
9
9
  import {
@@ -12,6 +12,9 @@ import {
12
12
  formatDiff,
13
13
  applyServerChanges,
14
14
  } from '../lib/diff.js';
15
+ import { fetchServerRecordsBatch } from '../lib/toe-stepping.js';
16
+ import { findBaselineEntry } from '../lib/delta.js';
17
+ import { runPendingMigrations } from '../lib/migrations.js';
15
18
 
16
19
  export const diffCommand = new Command('diff')
17
20
  .description('Compare local files with server versions and selectively merge changes')
@@ -20,8 +23,10 @@ export const diffCommand = new Command('diff')
20
23
  .option('-y, --yes', 'Accept all server changes without prompting')
21
24
  .option('-v, --verbose', 'Show HTTP request details')
22
25
  .option('--domain <host>', 'Override domain')
26
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
23
27
  .action(async (targetPath, options) => {
24
28
  try {
29
+ await runPendingMigrations(options);
25
30
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
26
31
  const config = await loadConfig();
27
32
 
@@ -36,6 +41,39 @@ export const diffCommand = new Command('diff')
36
41
 
37
42
  log.info(`Comparing ${metaFiles.length} record(s) against server...`);
38
43
 
44
+ // Batch-fetch server records via /api/app/object/ with UpdatedAfter
45
+ const ora = (await import('ora')).default;
46
+ let serverRecordsMap = new Map();
47
+ const appConfig = await loadAppConfig();
48
+ if (appConfig?.AppShortName) {
49
+ // Find oldest baseline _LastUpdated across all files to diff
50
+ const baseline = await loadAppJsonBaseline();
51
+ let oldestDate = null;
52
+ for (const metaPath of metaFiles) {
53
+ try {
54
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
55
+ const uid = meta.UID || meta._id;
56
+ const entity = meta._entity;
57
+ if (!uid || !entity || !baseline) continue;
58
+ const entry = findBaselineEntry(baseline, entity, uid);
59
+ if (!entry?._LastUpdated) continue;
60
+ const d = new Date(entry._LastUpdated);
61
+ if (!isNaN(d) && (!oldestDate || d < oldestDate)) oldestDate = d;
62
+ } catch { /* skip unreadable */ }
63
+ }
64
+
65
+ if (oldestDate) {
66
+ const updatedAfter = oldestDate.toISOString();
67
+ const spinner = ora('Fetching server state for comparison...').start();
68
+ serverRecordsMap = await fetchServerRecordsBatch(client, appConfig.AppShortName, updatedAfter);
69
+ if (serverRecordsMap.size > 0) {
70
+ spinner.succeed(`Fetched ${serverRecordsMap.size} record(s) from server`);
71
+ } else {
72
+ spinner.warn('No server records returned');
73
+ }
74
+ }
75
+ }
76
+
39
77
  let updated = 0;
40
78
  let skipped = 0;
41
79
  let unchanged = 0;
@@ -45,7 +83,7 @@ export const diffCommand = new Command('diff')
45
83
  for (const metaPath of metaFiles) {
46
84
  const metaBase = basename(metaPath, '.metadata.json');
47
85
 
48
- const result = await compareRecord(metaPath, client, config);
86
+ const result = await compareRecord(metaPath, config, serverRecordsMap);
49
87
 
50
88
  if (result.error) {
51
89
  log.warn(`${metaBase}: ${result.error}`);
@@ -1,13 +1,14 @@
1
1
  import { Command } from 'commander';
2
- import { readdir, mkdir } from 'fs/promises';
2
+ import { mkdir } from 'fs/promises';
3
3
  import { join } from 'path';
4
- import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput, loadConfig } from '../lib/config.js';
4
+ import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
5
5
  import { installOrUpdateClaudeCommands } from './install.js';
6
6
  import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
7
- import { createDboignore, loadIgnore } from '../lib/ignore.js';
7
+ import { createDboignore } from '../lib/ignore.js';
8
8
  import { log } from '../lib/logger.js';
9
9
  import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
10
10
  import { performLogin } from './login.js';
11
+ import { runPendingMigrations } from '../lib/migrations.js';
11
12
 
12
13
  export const initCommand = new Command('init')
13
14
  .description('Initialize DBO CLI configuration for the current directory')
@@ -23,10 +24,12 @@ export const initCommand = new Command('init')
23
24
  .option('--scaffold', 'Create standard project directories (app_version, automation, bins, …)')
24
25
  .option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
25
26
  .option('--media-placement <placement>', 'Set media placement when cloning: fullpath or binpath (default: bin)')
27
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
26
28
  .action(async (options) => {
27
29
  // Merge --yes into nonInteractive
28
30
  if (options.yes) options.nonInteractive = true;
29
31
  try {
32
+ await runPendingMigrations(options);
30
33
  // --dboignore: standalone operation, works regardless of init state
31
34
  if (options.dboignore) {
32
35
  const created = await createDboignore(process.cwd(), { force: options.force });
@@ -40,13 +43,7 @@ export const initCommand = new Command('init')
40
43
 
41
44
  if (await isInitialized() && !options.force) {
42
45
  if (options.scaffold) {
43
- // Try to pass app short name for media sub-directory creation
44
- let shortName;
45
- try {
46
- const cfg = await loadConfig();
47
- shortName = cfg.AppShortName;
48
- } catch { /* no config yet */ }
49
- const result = await scaffoldProjectDirs(process.cwd(), { appShortName: shortName });
46
+ const result = await scaffoldProjectDirs();
50
47
  logScaffoldResult(result);
51
48
  return;
52
49
  }
@@ -105,7 +102,7 @@ export const initCommand = new Command('init')
105
102
  }
106
103
 
107
104
  // Ensure sensitive files are gitignored
108
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', 'trash/', 'Icon\\r']);
105
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r']);
109
106
 
110
107
  const createdIgnore = await createDboignore();
111
108
  if (createdIgnore) log.dim(' Created .dboignore');
@@ -124,59 +121,27 @@ export const initCommand = new Command('init')
124
121
  await performLogin(domain, username);
125
122
  }
126
123
 
127
- // Prompt for TransactionKeyPreset
128
- if (!options.nonInteractive) {
129
- const inquirer = (await import('inquirer')).default;
130
- const { preset } = await inquirer.prompt([{
131
- type: 'list',
132
- name: 'preset',
133
- message: 'Which row key should the CLI use when building input expressions?',
134
- choices: [
135
- { name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
136
- { name: 'RowID (numeric IDs)', value: 'RowID' },
137
- ],
138
- }]);
139
- await saveTransactionKeyPreset(preset);
140
- log.dim(` TransactionKeyPreset: ${preset}`);
141
- } else {
142
- await saveTransactionKeyPreset('RowUID');
143
- }
124
+ // TransactionKeyPreset always RowUID (stable across domains)
125
+ await saveTransactionKeyPreset('RowUID');
126
+ log.dim(' TransactionKeyPreset: RowUID');
144
127
 
145
- // Prompt for TicketSuggestionOutput
128
+ // TicketSuggestionOutput auto-set default
146
129
  const DEFAULT_TICKET_SUGGESTION_OUTPUT = 'ojaie9t3o0kfvliahnuuda';
147
- if (!options.nonInteractive) {
148
- const inquirer = (await import('inquirer')).default;
149
- const { ticketSuggestionOutput } = await inquirer.prompt([{
150
- type: 'input',
151
- name: 'ticketSuggestionOutput',
152
- message: 'Output UID for ticket suggestions (leave blank to skip):',
153
- default: DEFAULT_TICKET_SUGGESTION_OUTPUT,
154
- }]);
155
- const tsVal = ticketSuggestionOutput.trim();
156
- if (tsVal) {
157
- await saveTicketSuggestionOutput(tsVal);
158
- log.dim(` TicketSuggestionOutput: ${tsVal}`);
159
- }
160
- } else {
161
- await saveTicketSuggestionOutput(DEFAULT_TICKET_SUGGESTION_OUTPUT);
162
- }
130
+ await saveTicketSuggestionOutput(DEFAULT_TICKET_SUGGESTION_OUTPUT);
131
+ log.dim(` TicketSuggestionOutput: ${DEFAULT_TICKET_SUGGESTION_OUTPUT}`);
163
132
 
164
133
  // ─── Scaffold ─────────────────────────────────────────────────────────────
165
- let shouldScaffold = options.scaffold;
166
-
167
- if (!shouldScaffold && !options.nonInteractive) {
168
- const entries = await readdir(process.cwd(), { withFileTypes: true });
169
- const ig = await loadIgnore();
170
- const isEmpty = entries.every(e => ig.ignores(e.isDirectory() ? e.name + '/' : e.name));
171
-
172
- const inquirer = (await import('inquirer')).default;
173
- const { doScaffold } = await inquirer.prompt([{
174
- type: 'confirm',
175
- name: 'doScaffold',
176
- message: 'Would you like to scaffold the standard project directory structure?',
177
- default: isEmpty,
178
- }]);
179
- shouldScaffold = doScaffold;
134
+ // Auto-scaffold if standard directories don't exist yet
135
+ let shouldScaffold = true;
136
+
137
+ if (!options.scaffold && !options.nonInteractive) {
138
+ // Check if already scaffolded (bins/ exists = already done)
139
+ try {
140
+ const s = await (await import('fs/promises')).stat(join(process.cwd(), 'lib/bins'));
141
+ if (s.isDirectory()) shouldScaffold = false; // already scaffolded
142
+ } catch {
143
+ // bins/ doesn't exist — scaffold needed
144
+ }
180
145
  }
181
146
 
182
147
  if (shouldScaffold) {
@@ -6,6 +6,7 @@ import { loadAppConfig } from '../lib/config.js';
6
6
  import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
7
7
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
8
8
  import { log } from '../lib/logger.js';
9
+ import { runPendingMigrations } from '../lib/migrations.js';
9
10
 
10
11
  function collect(value, previous) {
11
12
  return previous.concat([value]);
@@ -25,8 +26,10 @@ export const inputCommand = new Command('input')
25
26
  .option('--jq <expr>', 'Filter JSON response with jq-like expression (implies --json)')
26
27
  .option('-v, --verbose', 'Show HTTP request details')
27
28
  .option('--domain <host>', 'Override domain')
29
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
28
30
  .action(async (options) => {
29
31
  try {
32
+ await runPendingMigrations(options);
30
33
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
31
34
 
32
35
  const extraParams = {};
@@ -124,6 +127,7 @@ export const inputCommand = new Command('input')
124
127
  result = await client.postMultipart('/api/input/submit', fields, files);
125
128
  }
126
129
 
130
+ if (result.successful) await client.voidCache();
127
131
  formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
128
132
  if (!result.successful) process.exit(1);
129
133
  } else {
@@ -153,6 +157,7 @@ export const inputCommand = new Command('input')
153
157
  result = await client.postUrlEncoded('/api/input/submit', body);
154
158
  }
155
159
 
160
+ if (result.successful) await client.voidCache();
156
161
  formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
157
162
  if (!result.successful) process.exit(1);
158
163
  }
@@ -42,7 +42,7 @@ export async function performLogin(domain, knownUsername) {
42
42
 
43
43
  // Fetch and store user info (non-critical)
44
44
  try {
45
- const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_format': 'json_raw' });
45
+ const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_template': 'json_raw' });
46
46
  const userData = userResult.payload || userResult.data;
47
47
  const rows = Array.isArray(userData) ? userData : (userData?.Rows || userData?.rows || []);
48
48
  if (rows.length > 0) {
@@ -121,7 +121,7 @@ export const loginCommand = new Command('login')
121
121
 
122
122
  // Fetch current user info to store ID for future submissions
123
123
  try {
124
- const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_format': 'json_raw' });
124
+ const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_template': 'json_raw' });
125
125
  const userData = userResult.payload || userResult.data;
126
126
  const rows = Array.isArray(userData) ? userData : (userData?.Rows || userData?.rows || []);
127
127
  if (rows.length > 0) {
@@ -13,6 +13,7 @@ import {
13
13
  BINS_DIR
14
14
  } from '../lib/structure.js';
15
15
  import { findMetadataFiles } from '../lib/diff.js';
16
+ import { runPendingMigrations } from '../lib/migrations.js';
16
17
 
17
18
  export const mvCommand = new Command('mv')
18
19
  .description('Move files or bins to a new location and update metadata')
@@ -22,8 +23,10 @@ export const mvCommand = new Command('mv')
22
23
  .option('-v, --verbose', 'Show detailed operations')
23
24
  .option('--dry-run', 'Preview changes without executing')
24
25
  .option('-C, --confirm <value>', 'Commit changes (true|false)', 'true')
26
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
25
27
  .action(async (source, destination, options) => {
26
28
  try {
29
+ await runPendingMigrations(options);
27
30
  // Only items inside Bins/ are moveable
28
31
  const relSource = isAbsolute(source) ? relative(process.cwd(), source) : source;
29
32
  const normalized = relSource.replace(/\/+$/, '');
@@ -15,14 +15,13 @@ export const outputCommand = new Command('output')
15
15
  .option('-e, --entity <uid>', 'Query by entity UID')
16
16
  .option('--row <id>', 'Specific row ID')
17
17
  .option('--filter <expr>', 'Filter expression (repeatable)', collect, [])
18
- .option('--format <type>', 'Output format: json, html, csv, xml, txt, pdf')
18
+ .option('--template <value>', 'Output template: json_raw (default), json_indented, json, html, csv, xml, txt, pdf, or a content UID')
19
19
  .option('--sort <expr>', 'Sort expression (repeatable)', collect, [])
20
20
  .option('--search <expr>', 'Full-text search')
21
21
  .option('--page <n>', 'Page number')
22
22
  .option('--rows-per-page <n>', 'Rows per page')
23
23
  .option('--maxrows <n>', 'Maximum rows')
24
24
  .option('--rows <range>', 'Row range (e.g., 1-10)')
25
- .option('--template <value>', 'Custom template')
26
25
  .option('--debug', 'Include debug info')
27
26
  .option('--debug-sql', 'Include SQL debug info')
28
27
  .option('--debug-verbose', 'Verbose debug output')
@@ -30,7 +29,7 @@ export const outputCommand = new Command('output')
30
29
  .option('--limit <n>', 'Maximum rows to return (_limit)')
31
30
  .option('--rowcount <bool>', 'Include row count: true (default) or false for performance')
32
31
  .option('--display <expr>', 'Show/hide template tags (repeatable), e.g., "sidebar=hide"', collect, [])
33
- .option('--format-values', 'Enable value formatting in json_raw output')
32
+ .option('--format-values', 'Enable value formatting with json_raw template')
34
33
  .option('--empty-response-code <code>', 'HTTP status code when output returns no results')
35
34
  .option('--fallback-content <expr>', 'Fallback content UID for error codes, e.g., "404=contentUID"')
36
35
  .option('--escape-html <bool>', 'Control HTML escaping: true or false')
@@ -75,13 +74,12 @@ export const outputCommand = new Command('output')
75
74
  }
76
75
 
77
76
  // Build query params — default to json_raw for normalized data
78
- const params = { '_format': options.format || 'json_raw' };
77
+ const params = { '_template': options.template || 'json_raw' };
79
78
  if (options.search) params['_search'] = options.search;
80
79
  if (options.page) params['_page'] = options.page;
81
80
  if (options.rowsPerPage) params['_RowsPerPage'] = options.rowsPerPage;
82
81
  if (options.maxrows) params['_maxrows'] = options.maxrows;
83
82
  if (options.rows) params['_rows'] = options.rows;
84
- if (options.template) params['_template'] = options.template;
85
83
  if (options.limit) params['_limit'] = options.limit;
86
84
  if (options.rowcount !== undefined) params['_rowcount'] = options.rowcount;
87
85
  if (options.debug) params['_debug'] = 'true';
@@ -142,9 +140,9 @@ export const outputCommand = new Command('output')
142
140
  params['_sort'] = options.sort;
143
141
  }
144
142
 
145
- // For save mode or jq mode, force json_raw if user didn't set a custom format
146
- if ((options.save || options.saveFilename || options.jq) && !options.format) {
147
- params['_format'] = 'json_raw';
143
+ // For save mode or jq mode, force json_raw if user didn't set a custom template
144
+ if ((options.save || options.saveFilename || options.jq) && !options.template) {
145
+ params['_template'] = 'json_raw';
148
146
  }
149
147
 
150
148
  const result = await client.get(path, params);
@@ -171,8 +169,8 @@ export const outputCommand = new Command('output')
171
169
  return;
172
170
  }
173
171
 
174
- // Default to colorized JSON when using json_raw (no explicit --format, --json, or --jq)
175
- const jq = options.jq || (!options.format && !options.json ? '.' : undefined);
172
+ // Default to colorized JSON when using json_raw (no explicit --template, --json, or --jq)
173
+ const jq = options.jq || (!options.template && !options.json ? '.' : undefined);
176
174
  formatResponse(result, { json: options.json, jq, columns: options.columns });
177
175
  if (!result.successful && !result.ok) process.exit(1);
178
176
  } catch (err) {
@@ -4,11 +4,12 @@ import { join } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import { performClone, resolveRecordPaths, resolveMediaPaths, resolveEntityDirPaths, resolveEntityFilter, buildOutputFilename } from './clone.js';
6
6
  import { loadConfig, loadClonePlacement } from '../lib/config.js';
7
- import { loadStructureFile, resolveBinPath, BINS_DIR, ENTITY_DIR_NAMES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR } from '../lib/structure.js';
7
+ import { loadStructureFile, resolveBinPath, BINS_DIR, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, resolveEntityDirPath } from '../lib/structure.js';
8
8
  import { log } from '../lib/logger.js';
9
9
  import { formatError } from '../lib/formatter.js';
10
- import { getLocalSyncTime, isServerNewer } from '../lib/diff.js';
10
+ import { getLocalSyncTime, isServerNewer, loadBaselineForComparison } from '../lib/diff.js';
11
11
  import { DboClient } from '../lib/client.js';
12
+ import { runPendingMigrations } from '../lib/migrations.js';
12
13
 
13
14
  async function fileExists(path) {
14
15
  try { await access(path); return true; } catch { return false; }
@@ -117,10 +118,12 @@ async function dryRunScan(appJson, options) {
117
118
  const serverTz = config.ServerTimezone || 'America/Los_Angeles';
118
119
  const configWithTz = { ...config, ServerTimezone: serverTz };
119
120
 
121
+ // Pre-load baseline for fast _LastUpdated string comparison in isServerNewer
122
+ await loadBaselineForComparison();
123
+
120
124
  const structure = await loadStructureFile();
121
125
  const saved = await loadClonePlacement();
122
126
  const contentPlacement = saved.contentPlacement || 'bin';
123
- const mediaPlacement = saved.mediaPlacement || 'bin';
124
127
 
125
128
  const entityFilter = resolveEntityFilter(options.entity);
126
129
 
@@ -138,7 +141,7 @@ async function dryRunScan(appJson, options) {
138
141
  // Scan media records
139
142
  if (!entityFilter || entityFilter.has('media')) {
140
143
  for (const record of (appJson.children.media || [])) {
141
- const { metaPath } = resolveMediaPaths(record, structure, mediaPlacement);
144
+ const { metaPath } = resolveMediaPaths(record, structure);
142
145
  await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, 'media');
143
146
  }
144
147
  }
@@ -156,13 +159,12 @@ async function dryRunScan(appJson, options) {
156
159
  const { metaPath } = resolveEntityDirPaths(entityName, record, dirName);
157
160
  await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, entityName);
158
161
  }
159
- } else if (ENTITY_DIR_NAMES.has(entityName)) {
162
+ } else {
160
163
  for (const record of entries) {
161
- const { metaPath } = resolveEntityDirPaths(entityName, record, entityName);
164
+ const { metaPath } = resolveEntityDirPaths(entityName, record, resolveEntityDirPath(entityName));
162
165
  await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, entityName);
163
166
  }
164
167
  }
165
- // Generic entities in bins — skip for simplicity in dry-run (they use content placement)
166
168
  }
167
169
 
168
170
  // Scan output hierarchy (compound single-file format)
@@ -229,7 +231,7 @@ async function scanRecord(record, metaPath, _serverTz, configWithTz, counts, det
229
231
  }
230
232
 
231
233
  const localSyncTime = await getLocalSyncTime(metaPath);
232
- if (record._LastUpdated && isServerNewer(localSyncTime, record._LastUpdated, configWithTz)) {
234
+ if (record._LastUpdated && isServerNewer(localSyncTime, record._LastUpdated, configWithTz, entityName, record.UID)) {
233
235
  counts.changed++;
234
236
  details.changed.push({ entity: entityName, name });
235
237
  } else {
@@ -258,8 +260,10 @@ export const pullCommand = new Command('pull')
258
260
  .option('--force', 'Force re-processing, skip change detection')
259
261
  .option('--descriptor-types <bool>', 'Sort extensions into descriptor sub-directories (default: true)', 'true')
260
262
  .option('--documentation-only', 'When used with -e extension, pull only documentation extensions')
263
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
261
264
  .action(async (options) => {
262
265
  try {
266
+ await runPendingMigrations(options);
263
267
  const config = await loadConfig();
264
268
 
265
269
  if (!config.AppShortName) {