@dboio/cli 0.17.0 → 0.19.1

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.
Files changed (43) hide show
  1. package/README.md +111 -85
  2. package/package.json +1 -1
  3. package/plugins/claude/dbo/docs/dbo-cli-readme.md +111 -85
  4. package/src/commands/build.js +3 -3
  5. package/src/commands/clone.js +236 -97
  6. package/src/commands/deploy.js +3 -3
  7. package/src/commands/init.js +11 -11
  8. package/src/commands/install.js +3 -3
  9. package/src/commands/login.js +2 -2
  10. package/src/commands/mv.js +15 -15
  11. package/src/commands/pull.js +1 -1
  12. package/src/commands/push.js +193 -14
  13. package/src/commands/rm.js +2 -2
  14. package/src/commands/run.js +4 -4
  15. package/src/commands/status.js +1 -1
  16. package/src/commands/sync.js +2 -2
  17. package/src/lib/config.js +186 -135
  18. package/src/lib/delta.js +119 -17
  19. package/src/lib/dependencies.js +51 -24
  20. package/src/lib/deploy-config.js +4 -4
  21. package/src/lib/domain-guard.js +8 -9
  22. package/src/lib/filenames.js +12 -1
  23. package/src/lib/ignore.js +2 -3
  24. package/src/lib/insert.js +1 -1
  25. package/src/lib/metadata-schema.js +14 -20
  26. package/src/lib/metadata-templates.js +4 -4
  27. package/src/lib/migrations.js +1 -1
  28. package/src/lib/modify-key.js +1 -1
  29. package/src/lib/scaffold.js +5 -12
  30. package/src/lib/schema.js +67 -37
  31. package/src/lib/structure.js +6 -6
  32. package/src/lib/tagging.js +2 -2
  33. package/src/lib/ticketing.js +3 -7
  34. package/src/lib/toe-stepping.js +5 -5
  35. package/src/lib/transaction-key.js +1 -1
  36. package/src/migrations/004-rename-output-files.js +2 -2
  37. package/src/migrations/005-rename-output-metadata.js +2 -2
  38. package/src/migrations/006-remove-uid-companion-filenames.js +1 -1
  39. package/src/migrations/007-natural-entity-companion-filenames.js +1 -1
  40. package/src/migrations/008-metadata-uid-in-suffix.js +1 -1
  41. package/src/migrations/009-fix-media-collision-metadata-names.js +1 -1
  42. package/src/migrations/010-delete-paren-media-orphans.js +1 -1
  43. package/src/migrations/012-project-dir-restructure.js +211 -0
package/src/lib/schema.js CHANGED
@@ -1,53 +1,83 @@
1
- import { readFile, writeFile } from 'fs/promises';
2
- import { DboClient } from './client.js';
1
+ import { readFile } from 'fs/promises';
2
+ import { join, sep } from 'path';
3
3
 
4
- export const SCHEMA_FILE = 'schema.json';
5
- const SCHEMA_API_PATH = '/api/app/object/_system';
4
+ const SYSTEM_BASELINE_PATH = join('app_dependencies', '_system', '.app', '_system.json');
6
5
 
7
- /** Fetch schema from /api/app/object/_system. Returns parsed JSON or throws. */
8
- export async function fetchSchema(options = {}) {
9
- const client = new DboClient({ domain: options.domain, verbose: options.verbose });
10
- const result = await client.get(SCHEMA_API_PATH);
11
- if (!result.ok || !result.data) throw new Error(`Schema fetch failed: HTTP ${result.status}`);
12
- return result.data;
6
+ /**
7
+ * Kept for call-site compatibility with commands that pass systemSchemaPath.
8
+ * Points to the _system dependency baseline location.
9
+ */
10
+ export const SCHEMA_FILE = SYSTEM_BASELINE_PATH;
11
+
12
+ /**
13
+ * Load schema from the _system dependency baseline.
14
+ * When inside a dependency checkout (app_dependencies/<dep>/), traverses up
15
+ * to the parent project's _system baseline.
16
+ * Returns parsed object or null if the dependency hasn't been cloned yet.
17
+ */
18
+ export async function loadSchema() {
19
+ // Try local path first (works for top-level projects)
20
+ try {
21
+ return JSON.parse(await readFile(SYSTEM_BASELINE_PATH, 'utf8'));
22
+ } catch { /* not found locally */ }
23
+
24
+ // If inside a dependency checkout, try parent project's schema
25
+ const cwd = process.cwd();
26
+ const marker = `${sep}app_dependencies${sep}`;
27
+ const depIdx = cwd.indexOf(marker);
28
+ if (depIdx >= 0) {
29
+ const parentRoot = cwd.substring(0, depIdx);
30
+ const parentSchemaPath = join(parentRoot, SYSTEM_BASELINE_PATH);
31
+ try {
32
+ return JSON.parse(await readFile(parentSchemaPath, 'utf8'));
33
+ } catch { /* parent schema not available either */ }
34
+ }
35
+
36
+ return null;
13
37
  }
14
38
 
15
39
  /**
16
- * Fetch only the _LastUpdated timestamp from the schema endpoint.
17
- * Uses the column-filter syntax: /api/app/object/_system[_LastUpdated]
18
- * Returns ISO date string or null on failure.
40
+ * saveSchema() is now a no-op schema is managed by the _system dependency clone.
41
+ * Kept for call-site compatibility.
19
42
  */
20
- export async function fetchSchemaLastUpdated(options = {}) {
21
- const client = new DboClient({ domain: options.domain, verbose: options.verbose });
22
- const result = await client.get(`${SCHEMA_API_PATH}[_LastUpdated]`);
23
- if (!result.ok || !result.data) return null;
24
- return result.data._LastUpdated || null;
43
+ export async function saveSchema(_data) {
44
+ // no-op
25
45
  }
26
46
 
27
47
  /**
28
- * Check whether the server schema is newer than the local schema.json.
29
- * Returns true if the server _LastUpdated is more recent (or local is missing).
48
+ * Check whether the _system dependency needs refreshing.
49
+ * Delegates to checkDependencyStaleness('_system').
30
50
  */
31
51
  export async function isSchemaStale(options = {}) {
32
- const local = await loadSchema();
33
- if (!local || !local._LastUpdated) return true;
34
-
35
- const serverTs = await fetchSchemaLastUpdated(options);
36
- if (!serverTs) return false; // can't determine — assume not stale
37
-
38
- return new Date(serverTs) > new Date(local._LastUpdated);
52
+ const { checkDependencyStaleness } = await import('./dependencies.js');
53
+ return checkDependencyStaleness('_system', options);
39
54
  }
40
55
 
41
- /** Load schema.json from project root. Returns parsed object or null if missing. */
42
- export async function loadSchema() {
43
- try {
44
- return JSON.parse(await readFile(SCHEMA_FILE, 'utf8'));
45
- } catch {
46
- return null;
56
+ /**
57
+ * "Fetch" schema by forcing a re-clone of the _system dependency.
58
+ * When inside a dependency checkout, falls back to direct API fetch
59
+ * (syncDependencies would skip due to the self-detection guard).
60
+ * Returns the freshly loaded schema after sync completes.
61
+ */
62
+ export async function fetchSchema(options = {}) {
63
+ const cwd = process.cwd();
64
+ if (cwd.includes(`${sep}app_dependencies${sep}`)) {
65
+ // Inside a dependency checkout — can't sync _system here.
66
+ // Try parent's schema first (already cloned by parent).
67
+ const existing = await loadSchema();
68
+ if (existing) return existing;
69
+
70
+ // Fallback: fetch directly from API (old behavior)
71
+ const { DboClient } = await import('./client.js');
72
+ const { loadConfig } = await import('./config.js');
73
+ const config = await loadConfig();
74
+ const domain = options.domain || config.domain;
75
+ const client = new DboClient({ domain, verbose: options.verbose });
76
+ const resp = await client.get('/api/app/object/_system');
77
+ return resp?.data || resp;
47
78
  }
48
- }
49
79
 
50
- /** Write schema.json to project root. */
51
- export async function saveSchema(data) {
52
- await writeFile(SCHEMA_FILE, JSON.stringify(data, null, 2) + '\n');
80
+ const { syncDependencies } = await import('./dependencies.js');
81
+ await syncDependencies({ ...options, only: ['_system'], force: true });
82
+ return loadSchema();
53
83
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile, mkdir } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
 
4
- const STRUCTURE_FILE = '.dbo/structure.json';
4
+ const STRUCTURE_FILE = '.app/directories.json';
5
5
 
6
6
  /** All server-managed directories live under this subdirectory */
7
7
  export const LIB_DIR = 'lib';
@@ -271,15 +271,15 @@ export async function createDirectories(structure) {
271
271
  }
272
272
 
273
273
  /**
274
- * Save the bin structure to .dbo/structure.json.
274
+ * Save the bin structure to .app/directories.json.
275
275
  */
276
276
  export async function saveStructureFile(structure) {
277
- await mkdir('.dbo', { recursive: true });
277
+ await mkdir('.app', { recursive: true });
278
278
  await writeFile(STRUCTURE_FILE, JSON.stringify(structure, null, 2) + '\n');
279
279
  }
280
280
 
281
281
  /**
282
- * Load bin structure from .dbo/structure.json.
282
+ * Load bin structure from .app/directories.json.
283
283
  */
284
284
  export async function loadStructureFile() {
285
285
  try {
@@ -389,7 +389,7 @@ export function resolveFieldValue(value) {
389
389
  }
390
390
 
391
391
  /**
392
- * Persist descriptorMapping and extensionDescriptorDirs into .dbo/structure.json.
392
+ * Persist descriptorMapping and extensionDescriptorDirs into .app/directories.json.
393
393
  * Extends the existing structure object (already contains bin entries).
394
394
  *
395
395
  * @param {Object} structure - Current structure from loadStructureFile()
@@ -411,7 +411,7 @@ export async function saveDescriptorMapping(structure, mapping) {
411
411
  }
412
412
 
413
413
  /**
414
- * Load descriptorMapping from .dbo/structure.json.
414
+ * Load descriptorMapping from .app/directories.json.
415
415
  * Returns {} if not yet persisted.
416
416
  */
417
417
  export async function loadDescriptorMapping() {
@@ -149,13 +149,13 @@ async function _findUntrackedFiles(dir, ig, knownMetaPaths) {
149
149
  return all.filter(fp => !knownCompanions.has(fp));
150
150
  }
151
151
 
152
- // Recursively collect non-metadata, non-hidden, non-.dbo content files
152
+ // Recursively collect non-metadata, non-hidden, non-.app content files
153
153
  async function _collectContentFiles(dir, ig) {
154
154
  const results = [];
155
155
  let entries;
156
156
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
157
157
  for (const entry of entries) {
158
- if (entry.name.startsWith('.')) continue; // skip hidden and .dbo
158
+ if (entry.name.startsWith('.')) continue; // skip hidden and .app
159
159
  const fullPath = join(dir, entry.name);
160
160
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
161
161
  if (entry.isDirectory()) {
@@ -1,16 +1,12 @@
1
1
  import { readFile, writeFile, mkdir } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { log } from './logger.js';
4
+ import { projectDir } from './config.js';
4
5
 
5
- const DBO_DIR = '.dbo';
6
6
  const TICKETING_FILE = 'ticketing.local.json';
7
7
 
8
- function dboDir() {
9
- return join(process.cwd(), DBO_DIR);
10
- }
11
-
12
8
  function ticketingPath() {
13
- return join(dboDir(), TICKETING_FILE);
9
+ return join(projectDir(), TICKETING_FILE);
14
10
  }
15
11
 
16
12
  const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, ticket_confirmed: false, records: [] };
@@ -48,7 +44,7 @@ export async function loadTicketing() {
48
44
  * Save ticketing.local.json.
49
45
  */
50
46
  export async function saveTicketing(data) {
51
- await mkdir(dboDir(), { recursive: true });
47
+ await mkdir(projectDir(), { recursive: true });
52
48
  await writeFile(ticketingPath(), JSON.stringify(data, null, 2) + '\n');
53
49
  }
54
50
 
@@ -87,8 +87,8 @@ export async function fetchServerRecords(client, requests) {
87
87
  * makes a single HTTP request and the server only returns records modified
88
88
  * after the given date.
89
89
  *
90
- * The response is used ONLY for comparison — it must NOT replace app.json or
91
- * app_baseline.json.
90
+ * The response is used ONLY for comparison — it must NOT replace the metadata
91
+ * or baseline files.
92
92
  *
93
93
  * @param {DboClient} client
94
94
  * @param {string} appShortName - App short name for the /api/app/object/ endpoint
@@ -181,7 +181,7 @@ function decodeServerValue(value) {
181
181
  * a newer server edit, and _LastUpdatedUserID identifies who made the change.
182
182
  *
183
183
  * @param {Object} serverEntry - Record from per-record server fetch
184
- * @param {Object} baselineEntry - Record from .app_baseline.json
184
+ * @param {Object} baselineEntry - Record from the baseline file
185
185
  * @param {Object} localMeta - Local .metadata.json object
186
186
  * @param {string} metaDir - Absolute directory of the metadata file
187
187
  * @returns {Promise<Array<{ col: string, serverValue: string, localValue: string, baselineValue: string }>>}
@@ -278,12 +278,12 @@ function findOldestBaselineDate(records, baseline) {
278
278
  * unavailable or returns no data.
279
279
  *
280
280
  * The bulk response is used ONLY for comparison — it does NOT replace
281
- * app.json or app_baseline.json.
281
+ * the metadata or baseline files.
282
282
  *
283
283
  * @param {Array<{ meta: Object, metaPath: string }>} records
284
284
  * Records about to be pushed. Each must have meta.UID, meta._entity.
285
285
  * @param {DboClient} client
286
- * @param {Object} baseline - Loaded baseline from .dbo/.app_baseline.json
286
+ * @param {Object} baseline - Loaded baseline from .app/<shortName>.json (baseline)
287
287
  * @param {Object} options - Commander options (options.yes used for auto-accept)
288
288
  * @param {string} [appShortName] - App short name for bulk fetch (optional)
289
289
  * @param {string} [serverTz] - Server timezone from config (e.g. "America/Chicago")
@@ -41,6 +41,6 @@ export async function resolveTransactionKey(options = {}) {
41
41
  }]);
42
42
 
43
43
  await saveTransactionKeyPreset(preset);
44
- log.dim(` Saved TransactionKeyPreset: ${preset} to .dbo/config.json`);
44
+ log.dim(` Saved TransactionKeyPreset: ${preset} to .app/config.json`);
45
45
  return preset;
46
46
  }
@@ -73,10 +73,10 @@ async function rewriteReferences(filePath) {
73
73
 
74
74
  /**
75
75
  * Recursively find directories that contain _output~ files.
76
- * Skips .dbo/, node_modules/, trash/, .git/.
76
+ * Skips .app/, node_modules/, trash/, .git/.
77
77
  */
78
78
  async function findDirsWithOutputFiles(root) {
79
- const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
79
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
80
80
  const results = [];
81
81
 
82
82
  async function walk(dir) {
@@ -57,10 +57,10 @@ export default async function run(_options) {
57
57
 
58
58
  /**
59
59
  * Recursively find directories that contain output .json files (with ~ in name).
60
- * Skips .dbo/, node_modules/, trash/, .git/.
60
+ * Skips .app/, node_modules/, trash/, .git/.
61
61
  */
62
62
  async function findDirsWithOutputJsonFiles(root) {
63
- const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
63
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
64
64
  const results = [];
65
65
 
66
66
  async function walk(dir) {
@@ -161,7 +161,7 @@ export default async function run(_options) {
161
161
  }
162
162
  }
163
163
 
164
- const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
164
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
165
165
 
166
166
  async function findAllMetadataFiles(dir) {
167
167
  const results = [];
@@ -127,7 +127,7 @@ const ENTITY_DIRS = new Set([
127
127
  'data_source', 'group', 'site', 'redirect',
128
128
  ]);
129
129
 
130
- const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
130
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
131
131
 
132
132
  /**
133
133
  * Find all .metadata.json files inside entity directories (lib/<entity>/).
@@ -50,7 +50,7 @@ export default async function run(_options) {
50
50
  }
51
51
  }
52
52
 
53
- const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
53
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
54
54
 
55
55
  async function findAllLegacyMetadataFiles(dir) {
56
56
  const results = [];
@@ -141,7 +141,7 @@ export default async function run(_options) {
141
141
  }
142
142
  }
143
143
 
144
- const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
144
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
145
145
 
146
146
  async function findAllMetadataFiles(dir) {
147
147
  const results = [];
@@ -41,7 +41,7 @@ export default async function run(_options) {
41
41
  }
42
42
  }
43
43
 
44
- const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
44
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
45
45
 
46
46
  async function findParenMediaFiles(dir) {
47
47
  const results = [];
@@ -0,0 +1,211 @@
1
+ import { readFile, writeFile, rename, mkdir, rm, readdir, access } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ export const description = 'Rename .dbo/ → .app/; rename baseline/structure/metadata files; move dependencies → app_dependencies/; move config.local.json → ~/.dbo/settings.json; remove schema.json';
6
+
7
+ async function fileExists(p) {
8
+ try { await access(p); return true; } catch { return false; }
9
+ }
10
+
11
+ export default async function run() {
12
+ const cwd = process.cwd();
13
+ const log = (...args) => console.log(...args);
14
+
15
+ const dboDirPath = join(cwd, '.dbo');
16
+ const appDirPath = join(cwd, '.app');
17
+
18
+ // Guard: skip if already on new layout
19
+ if (!(await fileExists(dboDirPath)) || (await fileExists(appDirPath))) {
20
+ log(' .dbo/ not found or .app/ already exists — skipping');
21
+ return;
22
+ }
23
+
24
+ // Step 1: Read AppShortName from .dbo/config.json BEFORE rename
25
+ let appShortName = null;
26
+ try {
27
+ const cfg = JSON.parse(await readFile(join(dboDirPath, 'config.json'), 'utf8'));
28
+ appShortName = cfg.AppShortName || null;
29
+ } catch { /* no config — proceed without AppShortName */ }
30
+
31
+ const safeName = appShortName
32
+ ? String(appShortName).replace(/[/\\:*?"<>|]/g, '-')
33
+ : null;
34
+
35
+ // Step 2: Rename .dbo/ → .app/
36
+ try {
37
+ await rename(dboDirPath, appDirPath);
38
+ log(` Renamed .dbo/ → .app/`);
39
+ } catch (err) {
40
+ log(` (skip) Could not rename .dbo/ → .app/: ${err.message}`);
41
+ return;
42
+ }
43
+
44
+ // Step 3: Rename .app/.app_baseline.json → .app/<shortName>.json
45
+ if (safeName) {
46
+ const oldBaseline = join(appDirPath, '.app_baseline.json');
47
+ const newBaseline = join(appDirPath, `${safeName}.json`);
48
+ if ((await fileExists(oldBaseline)) && !(await fileExists(newBaseline))) {
49
+ try {
50
+ await rename(oldBaseline, newBaseline);
51
+ log(` Renamed .app/.app_baseline.json → .app/${safeName}.json`);
52
+ } catch (e) { log(` (skip) baseline rename: ${e.message}`); }
53
+ }
54
+ }
55
+
56
+ // Step 4: Rename .app/structure.json → .app/directories.json
57
+ const oldStructure = join(appDirPath, 'structure.json');
58
+ const newStructure = join(appDirPath, 'directories.json');
59
+ if ((await fileExists(oldStructure)) && !(await fileExists(newStructure))) {
60
+ try {
61
+ await rename(oldStructure, newStructure);
62
+ log(` Renamed .app/structure.json → .app/directories.json`);
63
+ } catch (e) { log(` (skip) structure rename: ${e.message}`); }
64
+ }
65
+
66
+ // Step 4b: Rename .app/metadata_schema.json → .app/<shortName>.metadata_schema.json
67
+ if (safeName) {
68
+ const oldSchema = join(appDirPath, 'metadata_schema.json');
69
+ const newSchema = join(appDirPath, `${safeName}.metadata_schema.json`);
70
+ if ((await fileExists(oldSchema)) && !(await fileExists(newSchema))) {
71
+ try {
72
+ await rename(oldSchema, newSchema);
73
+ log(` Renamed .app/metadata_schema.json → .app/${safeName}.metadata_schema.json`);
74
+ } catch (e) { log(` (skip) metadata_schema rename: ${e.message}`); }
75
+ }
76
+ }
77
+
78
+ // Step 5: Move .app/dependencies/ → app_dependencies/
79
+ const oldDepsDir = join(appDirPath, 'dependencies');
80
+ const newDepsRoot = join(cwd, 'app_dependencies');
81
+ if (await fileExists(oldDepsDir)) {
82
+ try {
83
+ await mkdir(newDepsRoot, { recursive: true });
84
+ const depNames = await readdir(oldDepsDir);
85
+ for (const name of depNames) {
86
+ const src = join(oldDepsDir, name);
87
+ const dest = join(newDepsRoot, name);
88
+ if (!(await fileExists(dest))) {
89
+ await rename(src, dest);
90
+ log(` Moved app_dependencies/${name}`);
91
+ }
92
+ }
93
+ try { await rm(oldDepsDir, { recursive: true }); } catch { /* non-empty */ }
94
+ } catch (e) { log(` (skip) dependencies move: ${e.message}`); }
95
+ }
96
+
97
+ // Step 6: Move root app.json → .app/<shortName>.metadata.json
98
+ if (safeName) {
99
+ const rootAppJson = join(cwd, 'app.json');
100
+ const newMetaPath = join(appDirPath, `${safeName}.metadata.json`);
101
+ if ((await fileExists(rootAppJson)) && !(await fileExists(newMetaPath))) {
102
+ try {
103
+ await rename(rootAppJson, newMetaPath);
104
+ log(` Moved app.json → .app/${safeName}.metadata.json`);
105
+ } catch (e) { log(` (skip) app.json move: ${e.message}`); }
106
+ }
107
+ }
108
+
109
+ // Step 7: Remove root schema.json
110
+ const rootSchema = join(cwd, 'schema.json');
111
+ if (await fileExists(rootSchema)) {
112
+ try {
113
+ await rm(rootSchema);
114
+ log(` Removed schema.json (schema now sourced from app_dependencies/_system/.app/_system.json)`);
115
+ } catch (e) { log(` (skip) schema.json removal: ${e.message}`); }
116
+ }
117
+
118
+ // Step 8: Move .app/config.local.json → ~/.dbo/settings.json
119
+ const oldLocal = join(appDirPath, 'config.local.json');
120
+ const globalSettingsDir = join(homedir(), '.dbo');
121
+ const globalSettings = join(globalSettingsDir, 'settings.json');
122
+ if (await fileExists(oldLocal)) {
123
+ try {
124
+ await mkdir(globalSettingsDir, { recursive: true });
125
+ let oldData = {};
126
+ try { oldData = JSON.parse(await readFile(oldLocal, 'utf8')); } catch { /* ignore */ }
127
+
128
+ let newData = {};
129
+ try { newData = JSON.parse(await readFile(globalSettings, 'utf8')); } catch { /* new file */ }
130
+
131
+ if (oldData.plugins) {
132
+ if (!newData.plugins) newData.plugins = {};
133
+ Object.assign(newData.plugins, oldData.plugins);
134
+ }
135
+
136
+ if (Array.isArray(oldData._completedMigrations) && oldData._completedMigrations.length > 0) {
137
+ if (!newData._completedMigrations) newData._completedMigrations = {};
138
+ const existing = new Set(Array.isArray(newData._completedMigrations[cwd]) ? newData._completedMigrations[cwd] : []);
139
+ for (const id of oldData._completedMigrations) existing.add(id);
140
+ newData._completedMigrations[cwd] = [...existing].sort();
141
+ }
142
+
143
+ await writeFile(globalSettings, JSON.stringify(newData, null, 2) + '\n');
144
+ try { await rm(oldLocal); } catch { /* leave it */ }
145
+ log(` Merged .app/config.local.json → ~/.dbo/settings.json`);
146
+ } catch (e) { log(` (skip) config.local.json merge: ${e.message}`); }
147
+ }
148
+
149
+ // Step 9: Update .gitignore
150
+ const gitignorePath = join(cwd, '.gitignore');
151
+ try {
152
+ let content = '';
153
+ try { content = await readFile(gitignorePath, 'utf8'); } catch { /* no .gitignore */ }
154
+
155
+ const replacements = [
156
+ ['.dbo/credentials.json', '.app/credentials.json'],
157
+ ['.dbo/cookies.txt', '.app/cookies.txt'],
158
+ ['.dbo/config.local.json', null],
159
+ ['.dbo/.app_baseline.json', null],
160
+ ['.dbo/ticketing.local.json', '.app/ticketing.local.json'],
161
+ ['.dbo/scripts.local.json', '.app/scripts.local.json'],
162
+ ['.dbo/errors.log', '.app/errors.log'],
163
+ ['.dbo/dependencies/', null],
164
+ ['schema.json', null],
165
+ ];
166
+
167
+ let changed = false;
168
+ let lines = content.split('\n');
169
+
170
+ for (const [oldPat, newPat] of replacements) {
171
+ const idx = lines.findIndex(l => l.trim() === oldPat);
172
+ if (idx >= 0) {
173
+ if (newPat) { lines[idx] = newPat; }
174
+ else lines.splice(idx, 1);
175
+ changed = true;
176
+ } else if (newPat && !content.includes(newPat)) {
177
+ lines.push(newPat);
178
+ changed = true;
179
+ }
180
+ }
181
+
182
+ if (!content.includes('app_dependencies/')) {
183
+ lines.push('app_dependencies/');
184
+ changed = true;
185
+ }
186
+
187
+ if (changed) {
188
+ await writeFile(gitignorePath, lines.join('\n'));
189
+ log(` Updated .gitignore with new .app/ paths`);
190
+ }
191
+ } catch (e) { log(` (skip) .gitignore update: ${e.message}`); }
192
+
193
+ // Step 10: Update .dboignore
194
+ const dboignorePath = join(cwd, '.dboignore');
195
+ try {
196
+ let content = await readFile(dboignorePath, 'utf8');
197
+ const updated = content
198
+ .replace(/^\.dbo\/$/m, '.app/')
199
+ .replace(/^\.dbo\/dependencies\/$/m, '')
200
+ .replace(/^app\.json$/m, '');
201
+ if (!updated.includes('app_dependencies/')) {
202
+ const finalContent = updated.replace(/^\.app\/$/m, '.app/\napp_dependencies/');
203
+ await writeFile(dboignorePath, finalContent);
204
+ } else {
205
+ await writeFile(dboignorePath, updated);
206
+ }
207
+ log(` Updated .dboignore`);
208
+ } catch { /* no .dboignore */ }
209
+
210
+ log(` Migration 012 complete: .dbo/ → .app/`);
211
+ }