@dboio/cli 0.8.2 → 0.9.3

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
@@ -116,6 +116,39 @@ dbo content deploy albain3dwkofbhnd1qtd1q assets/css/colors.css
116
116
 
117
117
  ---
118
118
 
119
+ ## Project Directory Structure
120
+
121
+ When you run `dbo init --scaffold` or `dbo clone`, the following standard directories are created in your project root:
122
+
123
+ ```
124
+ my-project/
125
+ ├── .dbo/ # Config, synchronization and session info for dbo cli commands
126
+ ├── .claude/ # Claude Code plugin config (commands, specs, plans, skills)
127
+ ├── node_modules/ # Automatically created; contains all external libraries
128
+ ├── src/ # Your actual source code (sass, typescript, etc.)
129
+ ├── tests/ # Project-level tests
130
+ ├── bins/ # Assets (contents, outputs, images, HTML, CSS)
131
+ ├── trash/ # Staged soft-deleted files from dbo rm
132
+ ├── automation/ # Automation entity records
133
+ ├── app_version/ # App version entity records
134
+ ├── data_source/ # Data source entity records
135
+ ├── docs/ # Project documentation and docs entities
136
+ ├── extension/ # Extension entity records
137
+ │ ├── <DescriptorType>/ # Sub-directory per descriptor type (e.g., component/, form/)
138
+ │ └── _unsupported/ # Extensions with unrecognized descriptor types
139
+ ├── group/ # Group entity records
140
+ ├── integration/ # Integration entity records
141
+ ├── site/ # Site entity records
142
+ ├── .gitignore # Tells Git to ignore files in the repo sync
143
+ ├── .dboignore # Tells dbo cli to ignore files in commands
144
+ ├── package.json # Metadata, scripts, and dependency list
145
+ └── package-lock.json # Records exact versions of dependencies installed
146
+ ```
147
+
148
+ > **Breaking change from pre-0.9.1**: Directory names have changed from mixed-case (`Extensions/`, `App Versions/`, `bins/`) to lowercase snake_case (`extension/`, `app_version/`, `bins/`). If you have an existing project, rename your directories manually before running `dbo clone` again.
149
+
150
+ ---
151
+
119
152
  ## Configuration
120
153
 
121
154
  All configuration is **directory-scoped**. Each project folder maintains its own `.dbo/` directory with its own domain and session. Switch environments by switching directories:
@@ -135,6 +168,7 @@ All configuration is **directory-scoped**. Each project folder maintains its own
135
168
  | `credentials.json` | Username, user ID, UID, name, email (no password) | Gitignored (per-user) |
136
169
  | `cookies.txt` | Session cookie (Netscape format) | Gitignored (per-user) |
137
170
  | `structure.json` | Bin directory mapping (created by `dbo clone`) | Committable (shared) |
171
+ | `metadata_templates.json` | Column templates per entity/descriptor (auto-generated by `dbo clone`) | Committable (shared) |
138
172
 
139
173
  `dbo init` automatically adds `.dbo/credentials.json`, `.dbo/cookies.txt`, `.dbo/config.local.json`, and `.dbo/ticketing.local.json` to `.gitignore` (creates the file if it doesn't exist).
140
174
 
@@ -197,13 +231,13 @@ A gitignore-style file in the project root that controls which files and directo
197
231
  **/*.CustomSQL.sql
198
232
 
199
233
  # Ignore a specific record (by metadata file path)
200
- Bins/app/my-draft-page.metadata.json
234
+ bins/app/my-draft-page.metadata.json
201
235
 
202
236
  # Ignore an entire directory
203
- Bins/staging/
237
+ bins/staging/
204
238
 
205
- # Ignore all content in Bins/ (still push entity-dir records like Extensions/)
206
- Bins/
239
+ # Ignore all content in bins/ (still push entity-dir records like extension/)
240
+ bins/
207
241
  ```
208
242
 
209
243
  **Syntax:** Same as `.gitignore` — glob patterns, `#` comments, blank lines, negation with `!`, directory-only patterns with trailing `/`.
@@ -272,8 +306,9 @@ dbo init --scaffold --yes # scaffold dirs non-intera
272
306
  | `--clone` | Clone the app after initialization |
273
307
  | `-g, --global` | Install Claude commands globally (`~/.claude/commands/`) |
274
308
  | `--local` | Install Claude commands to project (`.claude/commands/`) |
275
- | `--scaffold` | Pre-create standard project directories (`App Versions`, `Automations`, `Bins`, `Data Sources`, `Documentation`, `Extensions`, `Groups`, `Integrations`, `Sites`) |
309
+ | `--scaffold` | Pre-create standard project directories (`app_version`, `automation`, `bins`, `data_source`, `docs`, `extension`, `group`, `integration`, `site`, `src`, `tests`, `trash`) |
276
310
  | `--dboignore` | Create `.dboignore` with default patterns (use with `--force` to overwrite existing) |
311
+ | `--media-placement <placement>` | Set media placement when cloning: `fullpath` or `binpath` (default: `bin`) |
277
312
  | `-y, --yes` | Skip all interactive prompts (legacy migration, Claude Code setup) |
278
313
  | `--non-interactive` | Alias for `--yes` |
279
314
 
@@ -309,7 +344,8 @@ dbo clone -e extension --descriptor-types false # Clone extensions flat (no de
309
344
  | `--app <name>` | App short name to fetch from server |
310
345
  | `-e, --entity <type>` | Only clone a specific entity type (e.g. `output`, `content`, `media`, `extension`) |
311
346
  | `--documentation-only` | When used with `-e extension`, clone only documentation extensions |
312
- | `--descriptor-types <bool>` | Sort extensions into descriptor sub-directories (default: `true`). Set to `false` to use flat `Extensions/` layout |
347
+ | `--descriptor-types <bool>` | Sort extensions into descriptor sub-directories (default: `true`). Set to `false` to use flat `extension/` layout |
348
+ | `--media-placement <placement>` | Set media placement: `fullpath` or `binpath` (default: `bin`). Skips interactive media placement prompt |
313
349
  | `--domain <host>` | Override domain. Triggers a domain-change confirmation prompt when it differs from the project reference domain |
314
350
  | `--force` | Skip source mismatch confirmation and change detection; re-processes all files |
315
351
  | `-y, --yes` | Auto-accept all prompts (also skips source mismatch confirmation) |
@@ -330,7 +366,7 @@ The project's reference domain is stored in `app.json._domain` (committed to git
330
366
  5. **Saves `.dbo/structure.json`** — maps BinIDs to directory paths for file placement
331
367
  6. **Writes content files** — decodes base64 content, creates `*.metadata.json` + content files in the correct bin directory. When a record's `Extension` field is empty, prompts you to choose from `css`, `js`, `html`, `xml`, `txt`, `md`, `cs`, `json`, `sql` (or skip to keep no extension). The chosen extension is saved back into the `metadata.json` `Extension` field
332
368
  7. **Downloads media files** — fetches binary files (images, CSS, fonts) from the server via `/api/media/{uid}` and saves with metadata
333
- 8. **Processes entity-dir records** — entities matching project directories (`extension`, `app_version`, `data_source`, `site`, `group`, `integration`, `automation`) are saved as `.metadata.json` files in their corresponding directory (e.g., `Extensions/`, `Data Sources/`)
369
+ 8. **Processes entity-dir records** — entities matching project directories (`extension`, `app_version`, `data_source`, `site`, `group`, `integration`, `automation`) are saved as `.metadata.json` files in their corresponding directory (e.g., `extension/`, `data_source/`)
334
370
  9. **Processes other entities** — remaining entities with a `BinID` are placed in the corresponding bin directory
335
371
  10. **Saves `app.json`** — clone of the original JSON with processed entries replaced by `@path/to/*.metadata.json` references
336
372
 
@@ -367,7 +403,7 @@ When cloning an app that was already cloned locally, the CLI detects existing fi
367
403
  When multiple records would create files at the same path (e.g., a `content` record and a `media` record both named `colors.css`), the CLI detects the collision before writing any files and prompts you to choose which record to keep:
368
404
 
369
405
  ```
370
- ⚠ Collision: 2 records want to create "Bins/app/colors.css"
406
+ ⚠ Collision: 2 records want to create "bins/app/colors.css"
371
407
  ? Which record should create this file?
372
408
  ❯ [content] colors (UID: abc123)
373
409
  [media] colors.css (UID: def456)
@@ -405,13 +441,13 @@ Entity types that correspond to project directories (`extension`, `app_version`,
405
441
 
406
442
  | Entity Key | Directory |
407
443
  |------------|-----------|
408
- | `extension` | `Extensions/<Descriptor>/` (see below) |
409
- | `app_version` | `App Versions/` |
410
- | `data_source` | `Data Sources/` |
411
- | `site` | `Sites/` |
412
- | `group` | `Groups/` |
413
- | `integration` | `Integrations/` |
414
- | `automation` | `Automations/` |
444
+ | `extension` | `extension/<Descriptor>/` (see below) |
445
+ | `app_version` | `app_version/` |
446
+ | `data_source` | `data_source/` |
447
+ | `site` | `site/` |
448
+ | `group` | `group/` |
449
+ | `integration` | `integration/` |
450
+ | `automation` | `automation/` |
415
451
 
416
452
  For each entity type, the CLI prompts to choose which column becomes the filename (defaults to `Name`, fallback `UID`). The choice is saved per entity type in `config.json` (e.g., `ExtensionFilenameCol`).
417
453
 
@@ -421,13 +457,13 @@ Use `-y` to skip prompts (uses `Name` column, no content extraction).
421
457
 
422
458
  #### Extension descriptor sub-directories
423
459
 
424
- Extension records are organized into sub-directories under `Extensions/` based on their `Descriptor` column value. A special extension type called `descriptor_definition` (itself an extension with `Descriptor === "descriptor_definition"`) provides the mapping from descriptor key (`String1`) to display/directory name (`Name`).
460
+ Extension records are organized into sub-directories under `extension/` based on their `Descriptor` column value. A special extension type called `descriptor_definition` (itself an extension with `Descriptor === "descriptor_definition"`) provides the mapping from descriptor key (`String1`) to display/directory name (`Name`).
425
461
 
426
462
  During clone, the CLI:
427
463
 
428
464
  1. **Pre-scans** all extension records for `descriptor_definition` entries and builds a `String1 → Name` mapping
429
- 2. **Creates sub-directories** under `Extensions/` for each mapped descriptor (e.g., `Extensions/Documentation/`, `Extensions/Includes/`)
430
- 3. **Always creates** `Extensions/Unsupported/` — extensions with an unmapped or null `Descriptor` are placed here
465
+ 2. **Creates sub-directories** under `extension/` for each mapped descriptor (e.g., `extension/Documentation/`, `extension/Includes/`)
466
+ 3. **Always creates** `extension/_unsupported/` — extensions with an unmapped or null `Descriptor` are placed here
431
467
  4. **Prompts per descriptor** — filename column and content extraction prompts fire once per unique `Descriptor` value (not once for all extensions)
432
468
  5. **Persists the mapping** in `.dbo/structure.json` under `descriptorMapping`
433
469
 
@@ -441,10 +477,10 @@ During clone, the CLI:
441
477
 
442
478
  **Documentation alternate placement**: When extracting MD content from `documentation` descriptor extensions, the CLI offers a choice:
443
479
 
444
- - **Root placement** (`/Documentation/<filename>.md`) — recommended for easy access; creates `@/Documentation/<filename>.md` references in metadata
445
- - **Inline placement** (`Extensions/Documentation/<filename>.md`) — keeps files alongside metadata
480
+ - **Root placement** (`/docs/<filename>.md`) — recommended for easy access; creates `@/docs/<filename>.md` references in metadata
481
+ - **Inline placement** (`extension/Documentation/<filename>.md`) — keeps files alongside metadata
446
482
 
447
- Root-placed files use absolute-from-root `@/` references (e.g., `@/Documentation/my-doc.md`) so `dbo push` can locate them regardless of where the metadata lives.
483
+ Root-placed files use absolute-from-root `@/` references (e.g., `@/docs/my-doc.md`) so `dbo push` can locate them regardless of where the metadata lives.
448
484
 
449
485
  **Entity filter support**:
450
486
 
@@ -455,7 +491,7 @@ dbo clone -e extension # Clone all extensions (all
455
491
  dbo clone -e extension --descriptor-types false # Flat layout (no descriptor sorting)
456
492
  ```
457
493
 
458
- **`dbo add` auto-inference**: Running `dbo add Documentation/my-doc.md` when `ExtensionDocumentationMDPlacement` is `"root"` auto-creates a companion `.metadata.json` in `Extensions/Documentation/` with the correct `@/` reference.
494
+ **`dbo add` auto-inference**: Running `dbo add docs/my-doc.md` when `ExtensionDocumentationMDPlacement` is `"root"` auto-creates a companion `.metadata.json` in `extension/documentation/` with the correct `@/` reference.
459
495
 
460
496
  #### Output structure
461
497
 
@@ -467,7 +503,7 @@ project/
467
503
  .gitignore # credentials.json + cookies.txt added
468
504
  package.json # Updated with app info + deploy script
469
505
  app.json # Clone of original with @references
470
- Bins/ # ← root for all bin-placed files
506
+ bins/ # ← root for all bin-placed files
471
507
  app/ # ← directory from children.bin
472
508
  thomas-scratch.md
473
509
  thomas-scratch.metadata.json
@@ -475,67 +511,93 @@ project/
475
511
  CurrentTicketID.metadata.json
476
512
  ticket_test/ # ← another bin directory
477
513
  ...
478
- Extensions/ # ← extension records organized by descriptor
514
+ extension/ # ← extension records organized by descriptor
479
515
  Documentation/ # ← descriptor sub-directory (from descriptor_definition)
480
516
  MyDoc.uid1.metadata.json
481
517
  MyDoc.uid1.String10.md # ← extracted content column
482
518
  Includes/
483
519
  Header.uid2.metadata.json
484
- Unsupported/ # ← always created (unmapped/null descriptors)
485
- Documentation/ # ← root-placed documentation MD files (optional)
520
+ _unsupported/ # ← always created (unmapped/null descriptors)
521
+ docs/ # ← root-placed documentation MD files (optional)
486
522
  MyDoc.md
487
- Data Sources/
523
+ data_source/
488
524
  MySQL-Primary.metadata.json
489
- Sites/
525
+ site/
490
526
  MainSite.metadata.json
491
527
  media/operator/app/... # ← FullPath placement (when MediaPlacement=fullpath)
492
528
  ```
493
529
 
494
- #### Understanding the `Bins/` directory structure
530
+ #### Understanding the `bins/` directory structure
495
531
 
496
- The `Bins/` directory is the default location for all bin-placed content files during clone. It organizes files according to your app's bin hierarchy from DBO.io.
532
+ The `bins/` directory is the default location for all bin-placed content files during clone. It organizes files according to your app's bin hierarchy from DBO.io.
497
533
 
498
534
  **Directory Organization:**
499
535
 
500
- - **`Bins/`** — Root directory for all bin-placed files (local organizational directory only)
501
- - **`Bins/app/`** — Special subdirectory for the main app bin (typically the default bin)
502
- - **`Bins/custom_name/`** — Custom bin directories (e.g., `tpl/`, `ticket_test/`, etc.)
536
+ - **`bins/`** — Root directory for all bin-placed files (local organizational directory only)
537
+ - **`bins/app/`** — Special subdirectory for the main app bin (typically the default bin)
538
+ - **`bins/custom_name/`** — Custom bin directories (e.g., `tpl/`, `ticket_test/`, etc.)
503
539
 
504
- **Important: The `Bins/app/` special case**
540
+ **Important: The `bins/app/` special case**
505
541
 
506
- The `app/` subdirectory under `Bins/` is treated specially:
542
+ The `app/` subdirectory under `bins/` is treated specially:
507
543
 
508
- 1. **It's organizational only** — The `Bins/app/` prefix exists only for local file organization and is **not part of the server-side path**.
544
+ 1. **It's organizational only** — The `bins/app/` prefix exists only for local file organization and is **not part of the server-side path**.
509
545
 
510
- 2. **Path normalization** — When comparing paths (during `dbo push`), the CLI automatically strips both `Bins/` and `app/` from paths:
546
+ 2. **Path normalization** — When comparing paths (during `dbo push`), the CLI automatically strips both `bins/` and `app/` from paths:
511
547
  ```
512
- Local file: Bins/app/assets/css/operator.css
548
+ Local file: bins/app/assets/css/operator.css
513
549
  Server Path: assets/css/operator.css
514
550
  → These are considered the same path ✓
515
551
  ```
516
552
 
517
- 3. **Custom bins are preserved** — Other subdirectories like `Bins/tpl/` or `Bins/ticket_test/` represent actual bin hierarchies and their names are meaningful:
553
+ 3. **Custom bins are preserved** — Other subdirectories like `bins/tpl/` or `bins/ticket_test/` represent actual bin hierarchies and their names are meaningful:
518
554
  ```
519
- Local file: Bins/tpl/header.html
555
+ Local file: bins/tpl/header.html
520
556
  Server Path: tpl/header.html
521
557
  → The 'tpl/' directory is preserved ✓
522
558
  ```
523
559
 
524
560
  **Why this matters:**
525
561
 
526
- - When you `dbo push` files from `Bins/app/`, the CLI knows these paths should match the root-level paths in your metadata
527
- - If your metadata `Path` column contains `assets/css/colors.css`, it will correctly match files in `Bins/app/assets/css/colors.css`
528
- - Custom bin directories like `Bins/tpl/` serve from the `tpl/` directive and maintain their path structure
562
+ - When you `dbo push` files from `bins/app/`, the CLI knows these paths should match the root-level paths in your metadata
563
+ - If your metadata `Path` column contains `assets/css/colors.css`, it will correctly match files in `bins/app/assets/css/colors.css`
564
+ - Custom bin directories like `bins/tpl/` serve from the `tpl/` directive and maintain their path structure
529
565
 
530
566
  **Leading slash handling:**
531
567
 
532
568
  The CLI handles leading `/` in metadata paths flexibly:
533
- - `Path: assets/css/file.css` matches `Bins/app/assets/css/file.css` ✓
534
- - `Path: /assets/css/file.css` also matches `Bins/app/assets/css/file.css` ✓
535
- - `Path: /assets/css/file.css` matches `Bins/assets/css/file.css` ✓
569
+ - `Path: assets/css/file.css` matches `bins/app/assets/css/file.css` ✓
570
+ - `Path: /assets/css/file.css` also matches `bins/app/assets/css/file.css` ✓
571
+ - `Path: /assets/css/file.css` matches `bins/assets/css/file.css` ✓
536
572
 
537
573
  This ensures compatibility with various path formats from the server while maintaining correct local file organization.
538
574
 
575
+ #### Metadata Templates
576
+
577
+ During `dbo clone`, the CLI auto-generates `.dbo/metadata_templates.json` — a file that records which columns each entity/descriptor uses. This file is seeded from the first cloned record of each type and can be manually edited afterwards.
578
+
579
+ **File format:**
580
+
581
+ ```json
582
+ {
583
+ "site": ["AppID", "Name", "ShortName", "Active"],
584
+ "extension": {
585
+ "documentation": ["AppID", "Name", "Descriptor=documentation", "String10=@reference"],
586
+ "control": ["AppID", "Name", "ShortName", "Active"]
587
+ }
588
+ }
589
+ ```
590
+
591
+ **Column syntax:**
592
+
593
+ | Syntax | Meaning |
594
+ |--------|---------|
595
+ | `Name` | Plain column — populated from filename or left empty |
596
+ | `Descriptor=documentation` | Literal value — always set to `documentation` |
597
+ | `String10=@reference` | Content reference — links to the file being added |
598
+
599
+ `dbo add` uses these templates to auto-detect the entity type from a file's directory placement and generate metadata without prompting. For example, `dbo add extension/include/nav.html` resolves to entity `extension` / descriptor `include` and applies the matching template.
600
+
539
601
  ---
540
602
 
541
603
  ### `dbo login`
@@ -747,8 +809,20 @@ The save-to-disk feature fetches data and persists records as local files. It us
747
809
  4. **Extension** — use an entity column or specify manually
748
810
 
749
811
  Each record produces:
750
- - A content file: `[filename].[extension]`
751
- - A metadata file: `[filename].metadata.json` (all column values)
812
+ - A content file: `[filename]~[uid].[extension]` (e.g., `colors~abc123.css`)
813
+ - A metadata file: `[filename]~[uid].metadata.json` (all column values)
814
+
815
+ ### Tilde UID Convention
816
+
817
+ Every local file whose server record has a known UID embeds that UID in the filename, separated by a tilde (`~`):
818
+
819
+ - Content: `<basename>~<uid>.<ext>` / `<basename>~<uid>.metadata.json`
820
+ - Media: `<basename>~<uid>.<ext>` / `<basename>~<uid>.<ext>.metadata.json`
821
+ - Entity-dir: `<name>~<uid>.metadata.json`
822
+
823
+ Exception: if the chosen filename column value *is* the UID itself, the tilde suffix is omitted: `<uid>.<ext>`.
824
+
825
+ When pushing, the `~<uid>` portion is automatically stripped from filenames sent to the server (e.g., `logo~def456.png` is uploaded as `logo.png`).
752
826
 
753
827
  If the path column contains a filename (e.g., `assets/js/main.js`), the CLI detects it and asks if you want to use it.
754
828
 
@@ -1133,29 +1207,29 @@ Remove a file or directory locally and stage server deletions for the next `dbo
1133
1207
 
1134
1208
  ```bash
1135
1209
  # Remove a content file (finds companion .metadata.json automatically)
1136
- dbo rm Bins/app/some-file.txt
1210
+ dbo rm bins/app/some-file.txt
1137
1211
 
1138
1212
  # Remove by metadata file directly
1139
- dbo rm Bins/app/some-file.metadata.json
1213
+ dbo rm bins/app/some-file.metadata.json
1140
1214
 
1141
1215
  # Skip confirmation prompt
1142
- dbo rm -f Bins/app/some-file.txt
1216
+ dbo rm -f bins/app/some-file.txt
1143
1217
 
1144
1218
  # Stage server deletion without deleting local files
1145
- dbo rm --keep-local Bins/app/some-file.txt
1219
+ dbo rm --keep-local bins/app/some-file.txt
1146
1220
  ```
1147
1221
 
1148
1222
  #### Directory
1149
1223
 
1150
1224
  ```bash
1151
1225
  # Remove a directory and all its contents (prompts for approach)
1152
- dbo rm Bins/app/ui/
1226
+ dbo rm bins/app/ui/
1153
1227
 
1154
1228
  # Remove directory without prompts
1155
- dbo rm -f Bins/app/ui/
1229
+ dbo rm -f bins/app/ui/
1156
1230
 
1157
1231
  # Stage deletions without removing local files/directories
1158
- dbo rm --keep-local Bins/app/ui/
1232
+ dbo rm --keep-local bins/app/ui/
1159
1233
  ```
1160
1234
 
1161
1235
  When removing a directory, the CLI:
@@ -1170,6 +1244,7 @@ When removing a directory, the CLI:
1170
1244
  | `<path>` | File, `.metadata.json`, or directory to remove |
1171
1245
  | `-f, --force` | Skip all confirmation prompts |
1172
1246
  | `--keep-local` | Only stage server deletions, keep local files/directories |
1247
+ | `--hard` | Immediately delete local files (no `Trash/` move; legacy behavior) |
1173
1248
 
1174
1249
  #### What rm does (single file)
1175
1250
 
@@ -1178,7 +1253,10 @@ When removing a directory, the CLI:
1178
1253
  3. **Prompts for confirmation** — "Do you really want to remove this file and all of its nodes?"
1179
1254
  4. **Stages deletion** — adds a delete entry to `.dbo/synchronize.json`
1180
1255
  5. **Updates app.json** — removes the `@path/to/file.metadata.json` reference from children arrays
1181
- 6. **Deletes local files** — removes the metadata file and all referenced content/media files (unless `--keep-local`)
1256
+ 6. **Soft-deletes local files** — renames files with `__WILL_DELETE__` prefix (unless `--keep-local` or `--hard`)
1257
+ 7. **After `dbo push`** — successfully deleted records have their `__WILL_DELETE__` files moved to `Trash/`
1258
+
1259
+ Use `--hard` to immediately delete files without the `Trash/` safety net.
1182
1260
 
1183
1261
  #### synchronize.json
1184
1262
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.8.2",
3
+ "version": "0.9.3",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,15 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, readdir, stat, writeFile, mkdir } from 'fs/promises';
2
+ import { readFile, readdir, stat, writeFile, mkdir, rename } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
- import { loadAppConfig, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference } from '../lib/config.js';
9
+ import { loadAppConfig, loadAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from '../lib/config.js';
10
+ import { resolveDirective, resolveTemplateCols, assembleMetadata, promptReferenceColumn, setTemplateCols, saveMetadataTemplates, loadMetadataTemplates } from '../lib/metadata-templates.js';
11
+ import { buildUidFilename, hasUidInFilename } from '../lib/filenames.js';
12
+ import { setFileTimestamps } from '../lib/timestamps.js';
10
13
  import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
14
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
12
15
  import { loadIgnore } from '../lib/ignore.js';
@@ -67,7 +70,7 @@ async function detectDocumentationFile(filePath) {
67
70
  if (placement !== 'root') return null;
68
71
 
69
72
  const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
70
- if (!rel.startsWith('Documentation/')) return null;
73
+ if (!rel.startsWith('docs/')) return null;
71
74
 
72
75
  return { entity: 'extension', descriptor: 'documentation' };
73
76
  }
@@ -110,12 +113,58 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
110
113
  return await submitAdd(meta, metaPath, filePath, client, options);
111
114
  }
112
115
 
113
- // Step 3b: Auto-infer entity/descriptor for Documentation/ files when root placement is configured
116
+ // Step 3b: Directive / template path
117
+ const directive = resolveDirective(filePath);
118
+ if (directive) {
119
+ const { entity, descriptor } = directive;
120
+ const appConfig = await loadAppConfig();
121
+ const appJson = await loadAppJsonBaseline().catch(() => null);
122
+ const resolved = await resolveTemplateCols(entity, descriptor, appConfig, appJson);
123
+
124
+ if (resolved !== null) {
125
+ let { cols, templates } = resolved;
126
+
127
+ // Check if @reference col is known
128
+ const hasRefMarker = cols.some(c => c.includes('=@reference'));
129
+ if (!hasRefMarker) {
130
+ if (options.yes) {
131
+ console.warn(`Warning: No @reference column in template for ${entity}${descriptor ? '.' + descriptor : ''} — skipping content column. Update .dbo/metadata_templates.json to add Key=@reference.`);
132
+ } else {
133
+ const chosen = await promptReferenceColumn(cols, entity, descriptor);
134
+ if (chosen) {
135
+ cols = cols.map(c => c === chosen ? `${chosen}=@reference` : c);
136
+ templates = templates ?? await loadMetadataTemplates() ?? {};
137
+ setTemplateCols(templates, entity, descriptor, cols);
138
+ await saveMetadataTemplates(templates);
139
+ }
140
+ }
141
+ }
142
+
143
+ const { meta } = assembleMetadata(cols, filePath, entity, descriptor, appConfig);
144
+
145
+ // Determine metadata path
146
+ let templateMetaPath;
147
+ const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
148
+ if (rel.startsWith('docs/')) {
149
+ // docs/ files: companion metadata goes to extension/documentation/
150
+ const docDir = join(process.cwd(), 'extension', 'documentation');
151
+ await mkdir(docDir, { recursive: true });
152
+ templateMetaPath = join(docDir, `${basename(filePath, extname(filePath))}.metadata.json`);
153
+ } else {
154
+ templateMetaPath = join(dirname(filePath), `${basename(filePath, extname(filePath))}.metadata.json`);
155
+ }
156
+
157
+ await writeFile(templateMetaPath, JSON.stringify(meta, null, 2) + '\n');
158
+ return await submitAdd(meta, templateMetaPath, filePath, client, options);
159
+ }
160
+ }
161
+
162
+ // Step 3c: Auto-infer entity/descriptor for Documentation/ files when root placement is configured
114
163
  const docInfo = await detectDocumentationFile(filePath);
115
164
  if (docInfo) {
116
165
  const filenameCol = await loadDescriptorFilenamePreference('documentation') || 'Name';
117
166
  const docBase = basename(filePath, extname(filePath));
118
- const companionDir = join(process.cwd(), 'Extensions', 'Documentation');
167
+ const companionDir = join(process.cwd(), 'extension', 'documentation');
119
168
  await mkdir(companionDir, { recursive: true });
120
169
 
121
170
  const docMeta = {
@@ -337,20 +386,75 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
337
386
  result = await client.postUrlEncoded('/api/input/submit', body);
338
387
  }
339
388
 
340
- formatResponse(result, { json: options.json, jq: options.jq });
389
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
341
390
 
342
391
  if (!result.successful) {
343
392
  throw new Error('Add failed');
344
393
  }
345
394
 
346
- // Extract UID from response and update metadata
395
+ // Extract UID from response and rename files to ~uid convention
347
396
  const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
348
397
  if (addResults.length > 0) {
349
398
  const returnedUID = addResults[0].UID;
399
+ const returnedLastUpdated = addResults[0]._LastUpdated;
400
+
350
401
  if (returnedUID) {
351
- meta.UID = returnedUID;
352
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
353
- log.success(`UID assigned: ${returnedUID}`);
402
+ // Check if UID is already embedded in the filename (idempotency guard)
403
+ const currentMetaBase = basename(metaPath);
404
+ if (hasUidInFilename(currentMetaBase, returnedUID)) {
405
+ meta.UID = returnedUID;
406
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
407
+ log.success(`UID already in filename: ${returnedUID}`);
408
+ } else {
409
+ // Compute new names
410
+ const metaDir = dirname(metaPath);
411
+ const currentExt = extname(filePath).substring(1);
412
+ const currentBase = basename(filePath, extname(filePath));
413
+ const newBase = buildUidFilename(currentBase, returnedUID);
414
+ const newFilename = currentExt ? `${newBase}.${currentExt}` : newBase;
415
+ const newFilePath = join(metaDir, newFilename);
416
+ const newMetaPath = join(metaDir, `${newBase}.metadata.json`);
417
+
418
+ // Rename content file
419
+ try {
420
+ await rename(filePath, newFilePath);
421
+ log.success(`Renamed: ${basename(filePath)} → ${newFilename}`);
422
+ } catch (err) {
423
+ log.warn(` Could not rename content file: ${err.message}`);
424
+ }
425
+
426
+ // Update @reference in metadata
427
+ meta.UID = returnedUID;
428
+ for (const col of (meta._contentColumns || [])) {
429
+ const ref = meta[col];
430
+ if (ref && String(ref).startsWith('@')) {
431
+ const oldRefFile = String(ref).substring(1);
432
+ if (oldRefFile === basename(filePath)) {
433
+ meta[col] = `@${newFilename}`;
434
+ }
435
+ }
436
+ }
437
+
438
+ // Write and rename metadata file
439
+ await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
440
+ if (metaPath !== newMetaPath) {
441
+ try { await rename(metaPath, newMetaPath); } catch { /* already written */ }
442
+ }
443
+ log.dim(` Renamed metadata: ${basename(metaPath)} → ${basename(newMetaPath)}`);
444
+
445
+ // Restore timestamps from server _LastUpdated
446
+ const config = await loadConfig();
447
+ const serverTz = config.ServerTimezone;
448
+ if (serverTz && returnedLastUpdated) {
449
+ try {
450
+ await setFileTimestamps(newFilePath, returnedLastUpdated, returnedLastUpdated, serverTz);
451
+ await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
452
+ } catch { /* non-critical */ }
453
+ }
454
+
455
+ log.success(`UID assigned: ${returnedUID}`);
456
+ log.dim(` Files renamed to ~${returnedUID} convention`);
457
+ }
354
458
  log.dim(` Run "dbo pull -e ${entity} ${returnedUID}" to populate all columns`);
355
459
  }
356
460
  }