@dboio/cli 0.19.2 → 0.19.7

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,7 +157,9 @@ my-project/
157
157
  ├── test/ # Project-level tests
158
158
  ├── trash/ # Staged soft-deleted files from dbo rm
159
159
  ├── docs/ # Project documentation and docs entities
160
- ├── manifest.json # PWA web app manifest (auto-generated from app metadata)
160
+ ├── manifest.json # PWA web app manifest (root content file — server-tracked)
161
+ ├── CLAUDE.md # Claude Code instructions (root content file — server-tracked)
162
+ ├── README.md # Project readme (root content file — server-tracked)
161
163
  ├── .gitignore # Tells Git to ignore files in the repo sync
162
164
  ├── .dboignore # Tells dbo cli to ignore files in commands
163
165
  ├── package.json # Metadata, scripts, and dependency list
@@ -244,6 +246,7 @@ Use `dbo status` to see how many pending migrations exist.
244
246
  | `TicketSuggestionOutput` | string | Output UID for ticket suggestions (auto-set during init, default `ojaie9t3o0kfvliahnuuda`) |
245
247
  | `cloneSource` | `"default"` \| file path \| URL | Where the last `dbo clone` fetched app JSON from. `"default"` = server fetch via `AppShortName`; any other value = the explicit local file path or URL used. Set automatically after each successful clone. |
246
248
  | `ContentPlacement` | `bin` \| `path` | Where to place content files during clone (default: `bin`) |
249
+ | `UserMedia` | `true` \| `false` | Whether to download user media (`/media/<app>/user/...`) during clone. Set on first clone via prompt (or `false` in non-interactive mode). |
247
250
  | `<Entity>FilenameCol` | column name | Filename column for entity-dir records (e.g., `ExtensionFilenameCol`) |
248
251
  | `dependencies` | string[] | Array of app short-names to auto-clone as read-only local checkouts. Default: `["_system"]` |
249
252
  | `dependencyLastUpdated` | object | Map of dependency short-name to last-cloned `_LastUpdated` ISO string |
@@ -286,20 +289,46 @@ Clone of the original app JSON from the server with entity entries replaced by `
286
289
 
287
290
  The `_domain` field in the app metadata file 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.
288
291
 
289
- #### `manifest.json`
292
+ #### Root content files (`manifest.json`, `CLAUDE.md`, `README.md`)
290
293
 
291
- A [PWA web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) auto-generated during scaffolding (`dbo init` or `dbo clone`). Values are derived from `app.json` when available:
294
+ Certain files live at the **project root** (not inside `lib/`) but are still tracked and synced to the server as `content` entities. Their metadata companions live in `lib/bins/app/` with a root-relative `@/` reference (`Content: "@/manifest.json"`).
295
+
296
+ | File | Server entity | Public | Description |
297
+ |------|--------------|--------|-------------|
298
+ | `manifest.json` | content | Yes | [PWA web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) |
299
+ | `CLAUDE.md` | content | No | Claude Code project instructions |
300
+ | `README.md` | content | Yes | Project readme |
301
+
302
+ **`dbo clone`** writes all three to the project root. If the server has no record for a file, a stub is generated automatically (`manifest.json` from app metadata, `CLAUDE.md` and `README.md` from app name/description).
303
+
304
+ **`dbo push`** auto-creates the companion metadata in `lib/bins/app/` for any listed root file that exists locally but has no tracked metadata yet — no manual `dbo adopt` needed before the first push.
305
+
306
+ **`dbo adopt <filename>`** called from the project root also works for any root content file: it bypasses the `.dboignore` check and entity inference, and writes the metadata directly to `lib/bins/app/`.
307
+
308
+ **`manifest.json`** generated fields (when produced as a stub):
292
309
 
293
310
  | Field | Source |
294
311
  |-------|--------|
295
- | `name` | `<AppName> \| <domain>` |
312
+ | `name` | `AppName` |
296
313
  | `short_name` | `AppShortName` |
297
314
  | `description` | `App.Description` |
298
315
  | `start_url` / `scope` | `/app/<ShortName>/ui/` |
299
- | `background_color` | Extracted from the `widget` extension matching the app's `ShortName` (field `String4`), defaults to `#ffffff` |
316
+ | `background_color` | Extracted from the `widget` extension matching `ShortName` (field `String4`), defaults to `#ffffff` |
300
317
  | `theme_color` | `#000000` |
301
318
 
302
- The file is only created if absent — it is never overwritten by subsequent clones, so local edits (icons, screenshots, display overrides) are preserved. A companion `manifest.metadata.json` is auto-created by `dbo push` if missing, allowing the manifest to be pushed to the server as a content record.
319
+ #### `rootContentFiles` config
320
+
321
+ The list of root content files is stored in `.app/config.json` under `rootContentFiles`:
322
+
323
+ ```json
324
+ {
325
+ "rootContentFiles": ["CLAUDE.md", "README.md", "manifest.json"]
326
+ }
327
+ ```
328
+
329
+ - **Absent**: defaults are written on the first `dbo push` — `["CLAUDE.md", "README.md", "manifest.json"]`
330
+ - **`[]`, `false`, or `null`**: feature disabled — no root content files are auto-managed
331
+ - **Custom list**: any filename can be added; metadata is derived from the file extension
303
332
 
304
333
  ### `.dboignore`
305
334
 
@@ -460,7 +489,7 @@ The project's reference domain is stored in `.app/<shortName>.metadata.json._dom
460
489
  6. **Creates directories** — processes `children.bin` to build the directory hierarchy based on `ParentBinID` relationships
461
490
  7. **Saves `.app/directories.json`** — maps BinIDs to directory paths for file placement
462
491
  8. **Writes content files** — decodes base64 content, creates `*.metadata.json` + content files in the correct bin directory. Filename columns and companion file extraction preferences are auto-applied with sensible defaults on first clone (use `--configure` to re-prompt)
463
- 9. **Downloads media files** — fetches binary files (images, CSS, fonts) from the server using a fallback chain: `FullPath` directly (`/media/{app}/{path}`) → `/dir/` route → `/api/media/{uid}`, and saves with metadata. 404 errors create stale metadata to prevent re-prompting. Errors are logged to `.app/errors.log`
492
+ 9. **Downloads media files** — classifies records into three scopes before downloading: `app` (`/media/<app>/app/...` or no FullPath — always downloaded), `user` (`/media/<app>/user/...` — opt-in, preference saved as `UserMedia` in `config.json`), and `foreign` (`/media/<other_app>/...` — silently skipped with stale metadata written). Fetches using a fallback chain: `FullPath` directly (`/media/{app}/{path}`) → `/dir/` route → `/api/media/{uid}`. 404 errors create stale metadata to prevent re-prompting. Errors are logged to `.app/errors.log`
464
493
  10. **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/`)
465
494
  11. **Processes other entities** — remaining entities with a `BinID` are placed in the corresponding bin directory
466
495
  12. **Saves `.app/<shortName>.metadata.json`** — clone of the original JSON with processed entries replaced by `@path/to/*.metadata.json` references
@@ -526,6 +555,24 @@ This helps keep your app clean by removing database records for media files that
526
555
 
527
556
  In non-interactive mode (`-y`), stale cleanup is skipped (conservative default).
528
557
 
558
+ #### Media scope filtering
559
+
560
+ During `dbo clone`, media records are classified by their `FullPath` relative to the app short name:
561
+
562
+ | Scope | Path pattern | Behavior |
563
+ |-------|-------------|----------|
564
+ | `app` | `/media/<app>/app/...` or no `FullPath` | Always downloaded |
565
+ | `user` | `/media/<app>/user/...` | Downloaded only if `UserMedia=true` in `.app/config.json` |
566
+ | `foreign` | `/media/<other_app>/...` | Never downloaded — stale metadata written to prevent re-prompts |
567
+
568
+ On the first clone with user media present, you'll be prompted once:
569
+
570
+ ```
571
+ App has 14 user media file(s) (e.g. "avatar.jpg"). Download user media? (saves preference)
572
+ ```
573
+
574
+ The answer is saved as `UserMedia` in `.app/config.json` and reused on subsequent clones. In non-interactive mode (`-y`), user media defaults to skipped (`UserMedia=false`).
575
+
529
576
  #### Path resolution
530
577
 
531
578
  When a content record has both `Path` and `BinID`, the CLI prompts:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.19.2",
3
+ "version": "0.19.7",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,11 @@
15
15
  "path": "skills/white-paper/SKILL.md",
16
16
  "name": "white-paper",
17
17
  "description": "DBO project context and architecture guide — project structure, file-to-server mapping, dependencies, CLI vs API boundaries"
18
+ },
19
+ {
20
+ "path": "skills/cookbook/SKILL.md",
21
+ "name": "cookbook",
22
+ "description": "Operational DOs and DON'Ts for working correctly in any DBO project — file operations, build recipes, metadata handling, gotchas"
18
23
  }
19
24
  ]
20
25
  }
@@ -43,7 +43,7 @@ dbo $ARGUMENTS
43
43
  | `init` | Initialize `.app/` configuration |
44
44
  | `login` / `logout` | Authenticate / clear session |
45
45
  | `status` | Show config, domain, session info |
46
- | `clone` | Clone an app to local project structure |
46
+ | `clone` | Clone an app to local project structure (media filtered by scope: app/user/foreign) |
47
47
  | `pull` | Pull records to local files |
48
48
  | `push` | Push local changes to server |
49
49
  | `add` | Add a new file to DBO.io |
@@ -157,7 +157,9 @@ my-project/
157
157
  ├── test/ # Project-level tests
158
158
  ├── trash/ # Staged soft-deleted files from dbo rm
159
159
  ├── docs/ # Project documentation and docs entities
160
- ├── manifest.json # PWA web app manifest (auto-generated from app metadata)
160
+ ├── manifest.json # PWA web app manifest (root content file — server-tracked)
161
+ ├── CLAUDE.md # Claude Code instructions (root content file — server-tracked)
162
+ ├── README.md # Project readme (root content file — server-tracked)
161
163
  ├── .gitignore # Tells Git to ignore files in the repo sync
162
164
  ├── .dboignore # Tells dbo cli to ignore files in commands
163
165
  ├── package.json # Metadata, scripts, and dependency list
@@ -244,6 +246,7 @@ Use `dbo status` to see how many pending migrations exist.
244
246
  | `TicketSuggestionOutput` | string | Output UID for ticket suggestions (auto-set during init, default `ojaie9t3o0kfvliahnuuda`) |
245
247
  | `cloneSource` | `"default"` \| file path \| URL | Where the last `dbo clone` fetched app JSON from. `"default"` = server fetch via `AppShortName`; any other value = the explicit local file path or URL used. Set automatically after each successful clone. |
246
248
  | `ContentPlacement` | `bin` \| `path` | Where to place content files during clone (default: `bin`) |
249
+ | `UserMedia` | `true` \| `false` | Whether to download user media (`/media/<app>/user/...`) during clone. Set on first clone via prompt (or `false` in non-interactive mode). |
247
250
  | `<Entity>FilenameCol` | column name | Filename column for entity-dir records (e.g., `ExtensionFilenameCol`) |
248
251
  | `dependencies` | string[] | Array of app short-names to auto-clone as read-only local checkouts. Default: `["_system"]` |
249
252
  | `dependencyLastUpdated` | object | Map of dependency short-name to last-cloned `_LastUpdated` ISO string |
@@ -286,20 +289,46 @@ Clone of the original app JSON from the server with entity entries replaced by `
286
289
 
287
290
  The `_domain` field in the app metadata file 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.
288
291
 
289
- #### `manifest.json`
292
+ #### Root content files (`manifest.json`, `CLAUDE.md`, `README.md`)
290
293
 
291
- A [PWA web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) auto-generated during scaffolding (`dbo init` or `dbo clone`). Values are derived from `app.json` when available:
294
+ Certain files live at the **project root** (not inside `lib/`) but are still tracked and synced to the server as `content` entities. Their metadata companions live in `lib/bins/app/` with a root-relative `@/` reference (`Content: "@/manifest.json"`).
295
+
296
+ | File | Server entity | Public | Description |
297
+ |------|--------------|--------|-------------|
298
+ | `manifest.json` | content | Yes | [PWA web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) |
299
+ | `CLAUDE.md` | content | No | Claude Code project instructions |
300
+ | `README.md` | content | Yes | Project readme |
301
+
302
+ **`dbo clone`** writes all three to the project root. If the server has no record for a file, a stub is generated automatically (`manifest.json` from app metadata, `CLAUDE.md` and `README.md` from app name/description).
303
+
304
+ **`dbo push`** auto-creates the companion metadata in `lib/bins/app/` for any listed root file that exists locally but has no tracked metadata yet — no manual `dbo adopt` needed before the first push.
305
+
306
+ **`dbo adopt <filename>`** called from the project root also works for any root content file: it bypasses the `.dboignore` check and entity inference, and writes the metadata directly to `lib/bins/app/`.
307
+
308
+ **`manifest.json`** generated fields (when produced as a stub):
292
309
 
293
310
  | Field | Source |
294
311
  |-------|--------|
295
- | `name` | `<AppName> \| <domain>` |
312
+ | `name` | `AppName` |
296
313
  | `short_name` | `AppShortName` |
297
314
  | `description` | `App.Description` |
298
315
  | `start_url` / `scope` | `/app/<ShortName>/ui/` |
299
- | `background_color` | Extracted from the `widget` extension matching the app's `ShortName` (field `String4`), defaults to `#ffffff` |
316
+ | `background_color` | Extracted from the `widget` extension matching `ShortName` (field `String4`), defaults to `#ffffff` |
300
317
  | `theme_color` | `#000000` |
301
318
 
302
- The file is only created if absent — it is never overwritten by subsequent clones, so local edits (icons, screenshots, display overrides) are preserved. A companion `manifest.metadata.json` is auto-created by `dbo push` if missing, allowing the manifest to be pushed to the server as a content record.
319
+ #### `rootContentFiles` config
320
+
321
+ The list of root content files is stored in `.app/config.json` under `rootContentFiles`:
322
+
323
+ ```json
324
+ {
325
+ "rootContentFiles": ["CLAUDE.md", "README.md", "manifest.json"]
326
+ }
327
+ ```
328
+
329
+ - **Absent**: defaults are written on the first `dbo push` — `["CLAUDE.md", "README.md", "manifest.json"]`
330
+ - **`[]`, `false`, or `null`**: feature disabled — no root content files are auto-managed
331
+ - **Custom list**: any filename can be added; metadata is derived from the file extension
303
332
 
304
333
  ### `.dboignore`
305
334
 
@@ -460,7 +489,7 @@ The project's reference domain is stored in `.app/<shortName>.metadata.json._dom
460
489
  6. **Creates directories** — processes `children.bin` to build the directory hierarchy based on `ParentBinID` relationships
461
490
  7. **Saves `.app/directories.json`** — maps BinIDs to directory paths for file placement
462
491
  8. **Writes content files** — decodes base64 content, creates `*.metadata.json` + content files in the correct bin directory. Filename columns and companion file extraction preferences are auto-applied with sensible defaults on first clone (use `--configure` to re-prompt)
463
- 9. **Downloads media files** — fetches binary files (images, CSS, fonts) from the server using a fallback chain: `FullPath` directly (`/media/{app}/{path}`) → `/dir/` route → `/api/media/{uid}`, and saves with metadata. 404 errors create stale metadata to prevent re-prompting. Errors are logged to `.app/errors.log`
492
+ 9. **Downloads media files** — classifies records into three scopes before downloading: `app` (`/media/<app>/app/...` or no FullPath — always downloaded), `user` (`/media/<app>/user/...` — opt-in, preference saved as `UserMedia` in `config.json`), and `foreign` (`/media/<other_app>/...` — silently skipped with stale metadata written). Fetches using a fallback chain: `FullPath` directly (`/media/{app}/{path}`) → `/dir/` route → `/api/media/{uid}`. 404 errors create stale metadata to prevent re-prompting. Errors are logged to `.app/errors.log`
464
493
  10. **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/`)
465
494
  11. **Processes other entities** — remaining entities with a `BinID` are placed in the corresponding bin directory
466
495
  12. **Saves `.app/<shortName>.metadata.json`** — clone of the original JSON with processed entries replaced by `@path/to/*.metadata.json` references
@@ -526,6 +555,24 @@ This helps keep your app clean by removing database records for media files that
526
555
 
527
556
  In non-interactive mode (`-y`), stale cleanup is skipped (conservative default).
528
557
 
558
+ #### Media scope filtering
559
+
560
+ During `dbo clone`, media records are classified by their `FullPath` relative to the app short name:
561
+
562
+ | Scope | Path pattern | Behavior |
563
+ |-------|-------------|----------|
564
+ | `app` | `/media/<app>/app/...` or no `FullPath` | Always downloaded |
565
+ | `user` | `/media/<app>/user/...` | Downloaded only if `UserMedia=true` in `.app/config.json` |
566
+ | `foreign` | `/media/<other_app>/...` | Never downloaded — stale metadata written to prevent re-prompts |
567
+
568
+ On the first clone with user media present, you'll be prompted once:
569
+
570
+ ```
571
+ App has 14 user media file(s) (e.g. "avatar.jpg"). Download user media? (saves preference)
572
+ ```
573
+
574
+ The answer is saved as `UserMedia` in `.app/config.json` and reused on subsequent clones. In non-interactive mode (`-y`), user media defaults to skipped (`UserMedia=false`).
575
+
529
576
  #### Path resolution
530
577
 
531
578
  When a content record has both `Path` and `BinID`, the CLI prompts:
@@ -0,0 +1,152 @@
1
+ ---
2
+ name: cookbook
3
+ description: >-
4
+ DBO project cookbook — operational DOs and DON'Ts for working correctly in any
5
+ DBO project. Load this skill when editing files, adding new features, debugging,
6
+ or when the white-paper provides architecture context but you need behavioral
7
+ rules. Covers file operations, build recipes, metadata handling, and common
8
+ gotchas.
9
+ user-invokable: true
10
+ ---
11
+
12
+ # DBO Project Cookbook
13
+
14
+ Operational rules for working correctly in a DBO project. This is a living document — rules are added as new patterns and pitfalls are discovered.
15
+
16
+ For architecture context, load the **white-paper** skill. For CLI command reference, use `/dbo`. This cookbook focuses on *how to behave* when making changes.
17
+
18
+ ---
19
+
20
+ ## DOs
21
+
22
+ - **DO use UIDs, not numeric IDs, in templates.** Numeric IDs change across environments (dev → staging → prod). UIDs are stable and portable.
23
+ - **DO run `dbo adopt` before pushing new files.** Every file in `lib/` needs a metadata companion. `dbo adopt path/to/file -e {entity}` creates one. Without it, `dbo push` won't know what server record to target.
24
+ - **DO build from `src/` before pushing compiled assets.** Check `.app/scripts.json` for the build command. Run `npm run build` or the specific build script, then push the compiled output from `lib/`.
25
+ - **DO check `.app/scripts.json` for build/postpush chains before manual deploys.** A file may have postpush hooks that auto-deploy dependent assets. Pushing manually with `dbo deploy` bypasses these chains.
26
+ - **DO read the metadata file before modifying a server-tracked file.** The `.metadata~{uid}.json` companion tells you what entity, column, and record the file maps to. This context prevents wrong-target pushes.
27
+ - **DO use `dbo diff` to verify local vs server state before bulk pushes.** Catches unexpected server-side changes that would be overwritten.
28
+ - **DO check `app_dependencies/` for parent app patterns when building extensions.** Descriptor definitions, CSS design tokens, and JS APIs from the parent app define what's available.
29
+ - **DO use the REST API with `.app/cookies.txt` for data operations, CLI for file operations.** The CLI handles metadata sync; the API handles data reads/writes. Don't cross these boundaries.
30
+ - **DO check `.app/deploy_config.json` for existing shorthands before creating deploy commands.** Shorthands are auto-generated by `dbo clone` and `dbo adopt`.
31
+ - **DO use `dbo push --confirm false` to validate before committing a push.** Dry-run catches issues without touching the server.
32
+
33
+ ## DON'Ts
34
+
35
+ - **DON'T push `src/`, `test/`, `node_modules/`, `app_dependencies/`, or `trash/` to the server.** These are strictly local. Only `lib/` and `docs/` contents map to server records.
36
+ - **DON'T use numeric IDs in templates.** They break across environments. Always use UIDs.
37
+ - **DON'T create files in `lib/` without a metadata companion.** Use `dbo adopt` to assign one. Files without metadata are invisible to the CLI and won't sync.
38
+ - **DON'T bypass `dbo push` by deploying raw files.** Push handles build scripts, metadata sync, and postpush hooks. Deploying directly skips all of that.
39
+ - **DON'T assume a deploy shorthand exists — check `.app/deploy_config.json` first.** Not all files have shorthands. Use `dbo push` for files without one.
40
+ - **DON'T modify vendor files in `node_modules/` or `app_dependencies/`.** These are read-only references. Vendor changes belong upstream; dependency changes belong in the source project.
41
+ - **DON'T edit files in `app_dependencies/` directly.** They are cloned read-only mirrors. Edit in the source project and re-clone.
42
+ - **DON'T push minified files without also pushing their unminified source.** The server tracks both. Pushing only `.min.css` leaves the content record out of sync.
43
+ - **DON'T delete metadata files manually.** Use `dbo rm` to remove a file and its metadata together. Orphaned metadata causes ghost records.
44
+
45
+ ---
46
+
47
+ ## File Operation Recipes
48
+
49
+ ### Add a new file to the server
50
+ ```bash
51
+ # 1. Create the file in the correct lib/ subdirectory
52
+ # 2. Create metadata companion (entity is usually "content" for text, "media" for binary)
53
+ dbo adopt lib/bins/app/assets/css/new-file.css -e content
54
+
55
+ # 3. Push — inserts a new server record
56
+ dbo push lib/bins/app/assets/css/new-file.css
57
+ ```
58
+
59
+ ### Modify an existing server-tracked file
60
+ ```bash
61
+ # 1. Edit the file locally
62
+ # 2. Push the change (metadata already exists from clone)
63
+ dbo push lib/bins/app/assets/css/colors.css
64
+ ```
65
+
66
+ ### Delete a file from the server
67
+ ```bash
68
+ # Removes the local file, its metadata, and the server record
69
+ dbo rm lib/bins/app/assets/css/old-file.css
70
+ ```
71
+
72
+ ### Rename or move a file
73
+ There is no rename command. Instead:
74
+ ```bash
75
+ # 1. Create the new file with the desired name/location
76
+ # 2. Adopt it as a new record
77
+ dbo adopt lib/bins/app/assets/css/renamed.css -e content
78
+ # 3. Push the new file
79
+ dbo push lib/bins/app/assets/css/renamed.css
80
+ # 4. Remove the old file
81
+ dbo rm lib/bins/app/assets/css/old-name.css
82
+ ```
83
+
84
+ ### Move a file between bins
85
+ Same as rename — the bin is determined by the directory path. Moving to a new directory means a new BinID, which requires a new record.
86
+
87
+ ---
88
+
89
+ ## Build Recipes
90
+
91
+ ### CSS: SASS source → compiled → minified
92
+ ```bash
93
+ # Build compiles src/sass/ → lib/bins/.../css/ and minifies
94
+ npm run build
95
+ # Or run the specific target from .app/scripts.json:
96
+ sass src/sass/colors:lib/bins/app/assets/css
97
+ csso lib/bins/app/assets/css/colors.css --output lib/bins/app/assets/css/colors.min.css
98
+ ```
99
+ Then push both the unminified and minified versions.
100
+
101
+ ### JS: Rollup bundle
102
+ ```bash
103
+ # Bundles ES6 modules from src/ into lib/bins/.../js/app.min.js
104
+ rollup --config src/rollup.config.mjs
105
+ # Then push
106
+ dbo push lib/bins/app/assets/js/app.min.js
107
+ ```
108
+
109
+ ### When does push auto-compile?
110
+ It doesn't. `dbo push` triggers **postpush** scripts (defined in `.app/scripts.json` under `targets.{file}.postpush`), but these are for deploying *dependent* assets, not for compiling. Always build first, then push.
111
+
112
+ ### Check what builds exist
113
+ ```bash
114
+ # Read the scripts config
115
+ cat .app/scripts.json
116
+ ```
117
+ Look at `targets.{file}.build` for compile commands and `targets.{file}.postpush` for post-push deploy chains.
118
+
119
+ ---
120
+
121
+ ## Gotchas
122
+
123
+ ### Metadata mismatch after manual file creation
124
+ **Symptom:** `dbo push` says "no changes detected" for a file you just created.
125
+ **Cause:** No metadata companion exists. The CLI doesn't track unregistered files.
126
+ **Fix:** Run `dbo adopt path/to/file -e {entity}` first.
127
+
128
+ ### Postpush chain didn't fire
129
+ **Symptom:** You pushed a JS file but the documentation deploy didn't happen.
130
+ **Cause:** You used `dbo deploy` instead of `dbo push`. Deploy bypasses postpush hooks.
131
+ **Fix:** Use `dbo push` for files with postpush chains, or manually run the postpush commands.
132
+
133
+ ### Content and media records out of sync
134
+ **Symptom:** CSS looks wrong on the server even though you pushed the file.
135
+ **Cause:** You pushed only the content record but not the media companion (or vice versa).
136
+ **Fix:** Push both — check `deploy_config.json` for paired `{name}` and `{name}_media` entries.
137
+
138
+ ### Wrong entity type on adopt
139
+ **Symptom:** Pushed file lands in unexpected server location.
140
+ **Cause:** Used wrong `-e` flag on `dbo adopt` (e.g., `-e media` for a text file).
141
+ **Fix:** Remove with `dbo rm`, re-adopt with correct entity type, re-push.
142
+
143
+ ### Extension files not picked up
144
+ **Symptom:** New extension file doesn't appear in the app.
145
+ **Cause:** Extension files follow strict naming: `{Name}.{ContentColumnName}.{ext}` with metadata companion.
146
+ **Fix:** Verify the file name matches the pattern and the descriptor type exists in `app_dependencies/`.
147
+
148
+ ---
149
+
150
+ ## Project-Specific Overlays
151
+
152
+ Individual DBO projects may provide an additional conventions file at `docs/Operator_Claude.md` (or equivalent). When present, load it alongside this cookbook for project-specific DOs, DON'Ts, and patterns. When the project is a dependency of another app, the overlay appears at `app_dependencies/{app}/docs/{App}_Claude.md`.
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: install
2
+ name: install-track-changelog
3
3
  description: Install the Track changelog integration — creates ~/.claude/track-changelog login script.
4
4
  user-invokable: true
5
5
  allowed-tools: Write, Bash(chmod:*)
@@ -3,7 +3,7 @@ import { readFile, writeFile, stat, mkdir } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { log } from '../lib/logger.js';
5
5
  import { loadIgnore } from '../lib/ignore.js';
6
- import { loadAppConfig, loadAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference } from '../lib/config.js';
6
+ import { loadAppConfig, loadAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadRootContentFiles } from '../lib/config.js';
7
7
  import {
8
8
  resolveDirective, resolveTemplateCols, assembleMetadata,
9
9
  promptReferenceColumn, getTemplateCols, setTemplateCols,
@@ -11,7 +11,7 @@ import {
11
11
  } from '../lib/metadata-schema.js';
12
12
  import { loadStructureFile, findBinByPath, BINS_DIR } from '../lib/structure.js';
13
13
  import { isMetadataFile } from '../lib/filenames.js';
14
- import { findUnaddedFiles, MIME_TYPES, seedMetadataTemplate, detectDocumentationFile, detectManifestFile } from '../lib/insert.js';
14
+ import { findUnaddedFiles, MIME_TYPES, seedMetadataTemplate, detectDocumentationFile } from '../lib/insert.js';
15
15
  import { runPendingMigrations } from '../lib/migrations.js';
16
16
 
17
17
  export const adoptCommand = new Command('adopt')
@@ -79,6 +79,15 @@ function parseEntitySpec(spec, metadataSchema) {
79
79
  return { entity, descriptor: null, column: sub };
80
80
  }
81
81
 
82
+ /**
83
+ * Metadata templates for known root content files (mirrors push.js ROOT_FILE_TEMPLATES).
84
+ */
85
+ const ROOT_FILE_TEMPLATES = {
86
+ 'manifest.json': { Extension: 'JSON', Public: 1, Active: 1, Title: 'PWA Manifest' },
87
+ 'CLAUDE.md': { Extension: 'MD', Public: 0, Active: 1, Title: 'Claude Code Instructions' },
88
+ 'README.md': { Extension: 'MD', Public: 1, Active: 1, Title: 'README' },
89
+ };
90
+
82
91
  /**
83
92
  * Build entity-specific metadata for a file in or outside lib/bins/.
84
93
  */
@@ -144,6 +153,61 @@ async function buildBinMetadata(filePath, entity, appConfig, structure) {
144
153
  async function adoptSingleFile(filePath, entityArg, options) {
145
154
  const ig = await loadIgnore();
146
155
  const relPath = relative(process.cwd(), filePath).replace(/\\/g, '/');
156
+ const fileName = basename(filePath);
157
+
158
+ // --- Root content file check (before ignore): CLAUDE.md, README.md, manifest.json, etc. ---
159
+ // These files live at the project root but their metadata goes in lib/bins/app/.
160
+ // They bypass the dboignore check and entity inference.
161
+ const rootFiles = await loadRootContentFiles();
162
+ if (rootFiles.includes(fileName) && relPath === fileName) {
163
+ const appConfig = await loadAppConfig();
164
+ const structure = await loadStructureFile();
165
+ const appBin = findBinByPath('app', structure);
166
+ const binsAppDir = join(process.cwd(), BINS_DIR, 'app');
167
+ const ext = extname(fileName).replace('.', '').toUpperCase() || 'TXT';
168
+ const stem = basename(fileName, extname(fileName));
169
+
170
+ const metaFilename = `${stem}.metadata.json`;
171
+ const metaPath = join(binsAppDir, metaFilename);
172
+
173
+ // Check for existing metadata
174
+ let existingMeta = null;
175
+ try { existingMeta = JSON.parse(await readFile(metaPath, 'utf8')); } catch {}
176
+ if (existingMeta) {
177
+ if (existingMeta.UID || existingMeta._CreatedOn) {
178
+ log.warn(`"${fileName}" is already on the server (has UID/_CreatedOn) — skipping.`);
179
+ return;
180
+ }
181
+ if (!options.yes) {
182
+ const inquirer = (await import('inquirer')).default;
183
+ const { overwrite } = await inquirer.prompt([{
184
+ type: 'confirm', name: 'overwrite',
185
+ message: `Metadata already exists for "${fileName}" (no UID). Overwrite?`,
186
+ default: false,
187
+ }]);
188
+ if (!overwrite) return;
189
+ }
190
+ }
191
+
192
+ const tmpl = ROOT_FILE_TEMPLATES[fileName] || { Extension: ext, Public: 0, Active: 1, Title: fileName };
193
+ const meta = {
194
+ _entity: 'content',
195
+ _companionReferenceColumns: ['Content'],
196
+ ...tmpl,
197
+ Content: `@/${fileName}`,
198
+ Path: fileName,
199
+ Name: fileName,
200
+ };
201
+ if (appBin) meta.BinID = appBin.binId;
202
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
203
+
204
+ await mkdir(binsAppDir, { recursive: true });
205
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
206
+ log.success(`Created ${metaFilename} for ${fileName}`);
207
+ log.dim(` Run "dbo push" to insert this record on the server.`);
208
+ return;
209
+ }
210
+
147
211
  if (ig.ignores(relPath)) {
148
212
  log.dim(`Skipped (dboignored): ${relPath}`);
149
213
  return;
@@ -152,7 +216,6 @@ async function adoptSingleFile(filePath, entityArg, options) {
152
216
  const dir = dirname(filePath);
153
217
  const ext = extname(filePath);
154
218
  const base = basename(filePath, ext);
155
- const fileName = basename(filePath);
156
219
 
157
220
  // Check for existing metadata
158
221
  const metaPath = join(dir, `${base}.metadata.json`);
@@ -204,16 +267,6 @@ async function adoptSingleFile(filePath, entityArg, options) {
204
267
 
205
268
  const appConfig = await loadAppConfig();
206
269
 
207
- // --- Special case: manifest.json ---
208
- const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
209
- if (rel.toLowerCase() === 'manifest.json') {
210
- const manifestResult = await detectManifestFile(filePath);
211
- if (manifestResult) {
212
- log.success(`Created manifest metadata at ${manifestResult.metaPath}`);
213
- return;
214
- }
215
- }
216
-
217
270
  // --- content or media entities: build entity-specific metadata ---
218
271
  if ((entity === 'content' || entity === 'media') && !column) {
219
272
  const structure = await loadStructureFile();
@@ -238,6 +291,7 @@ async function adoptSingleFile(filePath, entityArg, options) {
238
291
  Descriptor: 'documentation',
239
292
  Name: docBase,
240
293
  };
294
+ if (appConfig.AppID) docMeta.AppID = appConfig.AppID;
241
295
  const contentCol = 'String10';
242
296
  docMeta[contentCol] = `@/${relPath}`;
243
297
  docMeta._companionReferenceColumns = [contentCol];
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes } from 'fs/promises';
2
+ import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes, unlink } from 'fs/promises';
3
3
  import { join, basename, extname, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { DboClient } from '../lib/client.js';
6
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
6
+ import { loadConfig, updateConfigWithApp, updateConfigUserMedia, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement, loadRootContentFiles } from '../lib/config.js';
7
7
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath, resolveFieldValue } from '../lib/structure.js';
8
8
  import { log } from '../lib/logger.js';
9
9
  import { buildUidFilename, buildContentFileName, buildMetaFilename, isMetadataFile, parseMetaFilename, stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
@@ -19,6 +19,7 @@ import { runPendingMigrations } from '../lib/migrations.js';
19
19
  import { upsertDeployEntry } from '../lib/deploy-config.js';
20
20
  import { syncDependencies, parseDependenciesColumn } from '../lib/dependencies.js';
21
21
  import { sep } from 'path';
22
+ import { installOrUpdateClaudeCommands } from './install.js';
22
23
 
23
24
  /** True when cwd is inside app_dependencies/ (dependency checkout clone). */
24
25
  function isDependencyCheckout() {
@@ -359,6 +360,26 @@ export function resolveMediaPaths(record, structure) {
359
360
  return { dir, filename: companionFilename, metaPath };
360
361
  }
361
362
 
363
+ /**
364
+ * Classify a media record based on its FullPath relative to the app short name.
365
+ *
366
+ * Returns:
367
+ * 'app' — /media/<appShortName>/app/... or no FullPath (assumed app media)
368
+ * 'user' — /media/<appShortName>/user/...
369
+ * 'foreign' — /media/<other_app>/... (belongs to a different app entirely)
370
+ */
371
+ function classifyMediaRecord(record, appShortName) {
372
+ const fp = record.FullPath;
373
+ if (!fp || !appShortName) return 'app';
374
+ const normalized = fp.startsWith('/') ? fp.slice(1) : fp;
375
+ // Must start with media/<appShortName>/
376
+ const appPrefix = `media/${appShortName.toLowerCase()}/`;
377
+ if (!normalized.toLowerCase().startsWith(appPrefix)) return 'foreign';
378
+ const rest = normalized.slice(appPrefix.length);
379
+ if (rest.toLowerCase().startsWith('user/')) return 'user';
380
+ return 'app';
381
+ }
382
+
362
383
  /**
363
384
  * Extract path components for entity-dir records.
364
385
  * Simplified from processEntityDirEntries() for collision detection.
@@ -1392,9 +1413,10 @@ export async function performClone(source, options = {}) {
1392
1413
  );
1393
1414
  }
1394
1415
 
1395
- // Step 5a: Write manifest.json to project root (skip for dependency checkouts)
1416
+ // Step 5a: Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to project root.
1417
+ // Also fixes the duplicate bug: relocates companions from lib/bins/app/ to root and rewrites metadata.
1396
1418
  if ((!entityFilter || entityFilter.has('content')) && !isDependencyCheckout()) {
1397
- await writeManifestJson(appJson, contentRefs);
1419
+ await writeRootContentFiles(appJson, contentRefs);
1398
1420
  }
1399
1421
 
1400
1422
  // Step 5b: Process media → download binary files + metadata (skip rejected records)
@@ -1480,6 +1502,29 @@ export async function performClone(source, options = {}) {
1480
1502
  if (!options.pullMode) {
1481
1503
  log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
1482
1504
  }
1505
+
1506
+ // Offer to install Claude Code plugins on fresh clone (not pull, not dependency checkout)
1507
+ if (!options.pullMode && !isDependencyCheckout()) {
1508
+ const pluginsDir = join(process.cwd(), '.claude', 'plugins');
1509
+ let pluginsAlreadyInstalled = false;
1510
+ try {
1511
+ const entries = await readdir(pluginsDir);
1512
+ pluginsAlreadyInstalled = entries.length > 0;
1513
+ } catch { /* directory doesn't exist */ }
1514
+
1515
+ if (!pluginsAlreadyInstalled) {
1516
+ const inquirer = (await import('inquirer')).default;
1517
+ const { install } = await inquirer.prompt([{
1518
+ type: 'confirm',
1519
+ name: 'install',
1520
+ message: 'Install Claude Code plugins for this project? (adds /dbo and /track commands)',
1521
+ default: true,
1522
+ }]);
1523
+ if (install) {
1524
+ await installOrUpdateClaudeCommands({ local: true });
1525
+ }
1526
+ }
1527
+ }
1483
1528
  }
1484
1529
 
1485
1530
  /**
@@ -2751,16 +2796,86 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2751
2796
  async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set(), resolvedFilenames = new Map()) {
2752
2797
  if (!mediaRecords || mediaRecords.length === 0) return [];
2753
2798
 
2799
+ // ── Step 0: Classify records by path scope ──────────────────────────────
2800
+ // foreign = /media/<other_app>/... (not this app — never download)
2801
+ // user = /media/<appShortName>/user/... (opt-in via UserMedia config)
2802
+ // app = /media/<appShortName>/app/... or no FullPath (always download)
2803
+ const foreignRecords = [];
2804
+ const userRecords = [];
2805
+ const appRecords = [];
2806
+
2807
+ for (const record of mediaRecords) {
2808
+ if (skipUIDs.has(record.UID)) continue;
2809
+ const cls = classifyMediaRecord(record, appShortName);
2810
+ if (cls === 'foreign') foreignRecords.push(record);
2811
+ else if (cls === 'user') userRecords.push(record);
2812
+ else appRecords.push(record);
2813
+ }
2814
+
2815
+ // Silently write stale metadata for foreign records so they are never
2816
+ // re-prompted on future clones. No download is attempted.
2817
+ const foreignRefs = [];
2818
+ for (const record of foreignRecords) {
2819
+ const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
2820
+ const resolvedName = resolvedFilenames.get(record.UID);
2821
+ const effectiveMetaPath = resolvedName
2822
+ ? join(scanDir, buildMetaFilename(resolvedName, String(record.UID || record._id || 'untitled')))
2823
+ : scanMetaPath;
2824
+ if (!(await fileExists(effectiveMetaPath))) {
2825
+ const staleMeta = { _entity: 'media', _foreignApp: true };
2826
+ for (const [k, v] of Object.entries(record)) {
2827
+ if (k !== 'children') staleMeta[k] = v;
2828
+ }
2829
+ try {
2830
+ await mkdir(scanDir, { recursive: true });
2831
+ await writeFile(effectiveMetaPath, JSON.stringify(staleMeta, null, 2) + '\n');
2832
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
2833
+ await setFileTimestamps(effectiveMetaPath, record._CreatedOn, record._LastUpdated, serverTz);
2834
+ }
2835
+ } catch { /* non-critical */ }
2836
+ }
2837
+ foreignRefs.push({ uid: record.UID, metaPath: effectiveMetaPath });
2838
+ }
2839
+ if (foreignRecords.length > 0) {
2840
+ log.dim(` Skipped ${foreignRecords.length} foreign media file(s) (not in /media/${appShortName || '?'}/)`);
2841
+ }
2842
+
2843
+ // ── User media: prompt once if preference is unset ───────────────────────
2844
+ // config.UserMedia: true = include, false = skip, undefined = not yet asked
2845
+ let includeUserMedia = config.UserMedia ?? null;
2846
+ if (userRecords.length > 0 && includeUserMedia === null && !options.yes) {
2847
+ const inquirer = (await import('inquirer')).default;
2848
+ const exampleFile = userRecords[0].Filename || userRecords[0].Name || 'user file';
2849
+ const { includeUser } = await inquirer.prompt([{
2850
+ type: 'confirm',
2851
+ name: 'includeUser',
2852
+ message: `App has ${userRecords.length} user media file(s) (e.g. "${exampleFile}"). Download user media? (saves preference)`,
2853
+ default: false,
2854
+ }]);
2855
+ includeUserMedia = includeUser;
2856
+ await updateConfigUserMedia(includeUserMedia);
2857
+ } else if (userRecords.length > 0 && options.yes && includeUserMedia === null) {
2858
+ // Non-interactive: default to false, save preference
2859
+ includeUserMedia = false;
2860
+ await updateConfigUserMedia(false);
2861
+ }
2862
+
2863
+ if (!includeUserMedia && userRecords.length > 0) {
2864
+ log.dim(` Skipped ${userRecords.length} user media file(s) (UserMedia=false)`);
2865
+ }
2866
+
2867
+ // Active records = app media + user media (if opted in)
2868
+ const activeRecords = includeUserMedia ? [...appRecords, ...userRecords] : appRecords;
2869
+
2754
2870
  // Track stale records (404s) for cleanup prompt
2755
2871
  const staleRecords = [];
2756
2872
 
2757
2873
  // Pre-scan: determine which media files actually need downloading
2758
2874
  // (new files or files with newer server timestamps)
2759
2875
  const needsDownload = [];
2760
- const upToDateRefs = [];
2876
+ const upToDateRefs = [...foreignRefs];
2761
2877
 
2762
- for (const record of mediaRecords) {
2763
- if (skipUIDs.has(record.UID)) continue;
2878
+ for (const record of activeRecords) {
2764
2879
 
2765
2880
  const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
2766
2881
  // Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
@@ -2791,7 +2906,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2791
2906
  }
2792
2907
 
2793
2908
  if (needsDownload.length === 0) {
2794
- log.dim(` All ${mediaRecords.length} media file(s) up to date`);
2909
+ log.dim(` All ${activeRecords.length} media file(s) up to date`);
2795
2910
  return upToDateRefs;
2796
2911
  }
2797
2912
 
@@ -2804,7 +2919,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2804
2919
  const { download } = await inquirer.prompt([{
2805
2920
  type: 'confirm',
2806
2921
  name: 'download',
2807
- message: `${needsDownload.length} media file(s) need to be downloaded (${mediaRecords.length - needsDownload.length} up to date). Attempt download now?`,
2922
+ message: `${needsDownload.length} media file(s) need to be downloaded (${activeRecords.length - needsDownload.length} up to date). Attempt download now?`,
2808
2923
  default: true,
2809
2924
  }]);
2810
2925
  canDownload = download;
@@ -2833,9 +2948,9 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2833
2948
  let downloaded = 0;
2834
2949
  let failed = 0;
2835
2950
 
2836
- log.info(`Downloading ${mediaRecords.length} media file(s)...`);
2951
+ log.info(`Downloading ${activeRecords.length} media file(s)...`);
2837
2952
 
2838
- for (const record of mediaRecords) {
2953
+ for (const record of activeRecords) {
2839
2954
  const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
2840
2955
 
2841
2956
  if (skipUIDs.has(record.UID)) {
@@ -4089,53 +4204,136 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
4089
4204
  }
4090
4205
 
4091
4206
  /**
4092
- * Write manifest.json to project root.
4093
- * If a manifest content record was cloned from the server, use its Content value.
4094
- * Otherwise, generate from appJson values (empty strings for missing fields).
4207
+ * Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to the project root.
4208
+ *
4209
+ * For each filename in rootContentFiles:
4210
+ * - If a matching content record exists in contentRefs: relocate the companion from
4211
+ * lib/bins/app/<filename> to the project root, delete the bins/app copy, and rewrite
4212
+ * the metadata to use Content: "@/<filename>" (root-relative). This fixes the duplicate
4213
+ * bug where processRecord always writes companions next to the metadata file.
4214
+ * - If no server record: generate a fallback stub at the project root.
4215
+ */
4216
+ async function writeRootContentFiles(appJson, contentRefs) {
4217
+ const rootFiles = await loadRootContentFiles();
4218
+ if (!rootFiles.length) return;
4219
+
4220
+ for (const filename of rootFiles) {
4221
+ const handled = await _writeRootFile(filename, appJson, contentRefs);
4222
+ if (!handled) {
4223
+ await _generateRootFileStub(filename, appJson);
4224
+ }
4225
+ }
4226
+ }
4227
+
4228
+ /**
4229
+ * Find the content record for a root file and relocate its companion to the project root.
4230
+ * Returns true if handled, false if no matching record was found.
4095
4231
  */
4096
- async function writeManifestJson(appJson, contentRefs) {
4097
- // 1. Search contentRefs for a manifest content record
4232
+ async function _writeRootFile(filename, appJson, contentRefs) {
4233
+ const filenameLower = filename.toLowerCase();
4234
+ const stemLower = filenameLower.includes('.') ? filenameLower.slice(0, filenameLower.lastIndexOf('.')) : filenameLower;
4235
+ const extLower = filenameLower.includes('.') ? filenameLower.slice(filenameLower.lastIndexOf('.') + 1) : '';
4236
+
4098
4237
  for (const ref of contentRefs) {
4099
4238
  let meta;
4239
+ try { meta = JSON.parse(await readFile(ref.metaPath, 'utf8')); } catch { continue; }
4240
+
4241
+ // Match by Name+Extension, or by Content @reference, or by Path
4242
+ const metaName = (meta.Name || '').toLowerCase();
4243
+ const metaExt = (meta.Extension || '').toLowerCase();
4244
+ const metaContent = String(meta.Content || '');
4245
+ const metaPath2 = String(meta.Path || '');
4246
+ const contentRef = metaContent.startsWith('@/') ? metaContent.slice(2)
4247
+ : metaContent.startsWith('@') ? metaContent.slice(1)
4248
+ : null;
4249
+
4250
+ const matchByName = metaName.startsWith(stemLower) && metaExt === extLower;
4251
+ const matchByContent = contentRef && contentRef.toLowerCase() === filenameLower;
4252
+ const matchByPath = metaPath2.replace(/^\//, '').toLowerCase() === filenameLower;
4253
+
4254
+ if (!matchByName && !matchByContent && !matchByPath) continue;
4255
+
4256
+ // Found a matching record — read companion content from bins/app dir
4257
+ const metaDir = dirname(ref.metaPath);
4258
+ const localCompanion = join(metaDir, filename);
4259
+ let content;
4100
4260
  try {
4101
- meta = JSON.parse(await readFile(ref.metaPath, 'utf8'));
4102
- } catch { continue; }
4103
-
4104
- const name = (meta.Name || '').toLowerCase();
4105
- const ext = (meta.Extension || '').toLowerCase();
4106
- if (name.startsWith('manifest') && ext === 'json') {
4107
- // Found manifest — read content file and write to project root
4108
- const contentRef = meta.Content;
4109
- if (contentRef && String(contentRef).startsWith('@')) {
4110
- const refFile = String(contentRef).substring(1);
4111
- // Try root-relative first, then sibling of metadata, then project root fallback
4112
- const candidates = refFile.startsWith('/')
4113
- ? [join(process.cwd(), refFile)]
4114
- : [join(dirname(ref.metaPath), refFile), join(process.cwd(), refFile)];
4115
- let content;
4116
- for (const candidate of candidates) {
4117
- try {
4118
- content = await readFile(candidate, 'utf8');
4119
- break;
4120
- } catch { /* try next */ }
4121
- }
4122
- if (content !== undefined) {
4123
- await writeFile('manifest.json', content);
4124
- log.dim(' manifest.json written to project root (from server content)');
4125
- } else {
4126
- log.warn(` Could not find manifest.json companion file`);
4127
- }
4261
+ content = await readFile(localCompanion, 'utf8');
4262
+ } catch {
4263
+ // Companion may already be at root (re-clone scenario) or never written (no Content on server)
4264
+ try { content = await readFile(join(process.cwd(), filename), 'utf8'); } catch { /* nothing */ }
4265
+ }
4266
+
4267
+ if (content !== undefined) {
4268
+ await writeFile(join(process.cwd(), filename), content);
4269
+ log.dim(` ${filename} written to project root (from server content)`);
4270
+
4271
+ // Delete stale companion in bins/app if it exists there
4272
+ if (localCompanion !== join(process.cwd(), filename)) {
4273
+ try { await unlink(localCompanion); } catch { /* already gone or at root */ }
4128
4274
  }
4129
- return;
4275
+ } else {
4276
+ log.warn(` Could not find companion file for ${filename}`);
4130
4277
  }
4278
+
4279
+ // Rewrite metadata: root-relative Content reference
4280
+ try {
4281
+ const updated = { ...meta, Content: `@/${filename}` };
4282
+ if (!updated._companionReferenceColumns) updated._companionReferenceColumns = ['Content'];
4283
+ await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
4284
+ } catch { /* non-critical */ }
4285
+
4286
+ return true;
4131
4287
  }
4132
4288
 
4133
- // 2. No manifest content record — generate from appJson values
4289
+ return false; // No server record found
4290
+ }
4291
+
4292
+ /**
4293
+ * Generate a fallback stub for a root content file when the server has no record.
4294
+ * Skips if the file already exists at the project root.
4295
+ */
4296
+ async function _generateRootFileStub(filename, appJson) {
4297
+ const rootPath = join(process.cwd(), filename);
4298
+ try { await access(rootPath); return; } catch { /* doesn't exist — generate */ }
4299
+
4300
+ const appName = appJson.Name || appJson.ShortName || '';
4301
+ const description = appJson.Description || '';
4302
+ const filenameLower = filename.toLowerCase();
4303
+
4304
+ if (filenameLower === 'manifest.json') {
4305
+ await _generateManifestFallback(appJson);
4306
+ return;
4307
+ }
4308
+
4309
+ if (filenameLower === 'claude.md') {
4310
+ const stub = `# ${appName}\n\nAdd Claude Code instructions for this project here.\n`;
4311
+ await writeFile(rootPath, stub);
4312
+ log.dim(` CLAUDE.md generated at project root (stub)`);
4313
+ return;
4314
+ }
4315
+
4316
+ if (filenameLower === 'readme.md') {
4317
+ const parts = [`# ${appName}`];
4318
+ if (description) parts.push('', description);
4319
+ parts.push('');
4320
+ await writeFile(rootPath, parts.join('\n'));
4321
+ log.dim(` README.md generated at project root (stub)`);
4322
+ return;
4323
+ }
4324
+
4325
+ // Unknown file type — no stub generated
4326
+ }
4327
+
4328
+ /**
4329
+ * Generate manifest.json at project root from appJson values.
4330
+ * Preserves existing behaviour from the old writeManifestJson fallback.
4331
+ */
4332
+ async function _generateManifestFallback(appJson) {
4134
4333
  const shortName = appJson.ShortName || '';
4135
4334
  const appName = appJson.Name || '';
4136
4335
  const description = appJson.Description || '';
4137
4336
 
4138
- // Find background_color from extension children (widget descriptor matching ShortName)
4139
4337
  let bgColor = '#ffffff';
4140
4338
  if (shortName) {
4141
4339
  const extensions = appJson.children?.extension || [];
@@ -4167,7 +4365,7 @@ async function writeManifestJson(appJson, contentRefs) {
4167
4365
  icons: [],
4168
4366
  };
4169
4367
 
4170
- await writeFile('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
4368
+ await writeFile(join(process.cwd(), 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
4171
4369
  log.dim(' manifest.json generated at project root (from app metadata)');
4172
4370
  }
4173
4371
 
@@ -6,7 +6,7 @@ import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../li
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 { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry } from '../lib/config.js';
9
+ import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry, loadRootContentFiles } from '../lib/config.js';
10
10
  import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle, runPushLifecycle } from '../lib/scripts.js';
11
11
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
12
12
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
@@ -343,6 +343,33 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
343
343
  metaPath = found;
344
344
  try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch {}
345
345
  }
346
+ if (!meta) {
347
+ // Last resort: project-wide search for cross-directory @reference.
348
+ // Handles cases where the companion file is in a different directory from its metadata
349
+ // (e.g. docs/Operator_Claude.md → lib/extension/Documentation/Operator_Claude.metadata~uid.json).
350
+ const absoluteFilePath = filePath.startsWith('/') ? filePath : join(process.cwd(), filePath);
351
+ const ig = await loadIgnore();
352
+ const allMetaFiles = await findMetadataFiles(process.cwd(), ig);
353
+ for (const candidatePath of allMetaFiles) {
354
+ try {
355
+ const candidateMeta = JSON.parse(await readFile(candidatePath, 'utf8'));
356
+ const metaDir = dirname(candidatePath);
357
+ const cols = [...(candidateMeta._companionReferenceColumns || candidateMeta._contentColumns || [])];
358
+ if (candidateMeta._mediaFile) cols.push('_mediaFile');
359
+ for (const col of cols) {
360
+ const ref = candidateMeta[col];
361
+ if (!ref || !String(ref).startsWith('@')) continue;
362
+ const resolved = resolveAtReference(String(ref).substring(1), metaDir);
363
+ if (resolved === absoluteFilePath) {
364
+ metaPath = candidatePath;
365
+ meta = candidateMeta;
366
+ break;
367
+ }
368
+ }
369
+ } catch { /* skip unreadable */ }
370
+ if (meta) break;
371
+ }
372
+ }
346
373
  if (!meta) {
347
374
  // AUTO-ADD DISABLED: local-file-first approach is being replaced by server-first workflow.
348
375
  // New records should be created via the DBO REST API first, then `dbo pull` to populate locally.
@@ -399,70 +426,130 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
399
426
  }
400
427
  // ── End script hooks ────────────────────────────────────────────────
401
428
 
402
- await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
429
+ const success = await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
430
+ if (success) {
431
+ const baseline = await loadAppJsonBaseline();
432
+ if (baseline) {
433
+ await updateBaselineAfterPush(baseline, [{ meta, metaPath, changedColumns: null }]);
434
+ }
435
+ }
436
+ return success;
403
437
  }
404
438
  /**
405
- * Ensure manifest.json at project root has companion metadata in lib/bins/app/.
406
- * Only creates metadata if manifest.json exists at root AND no existing metadata
407
- * anywhere in the project already references @/manifest.json (prevents duplicates).
439
+ * Metadata templates for known root content files.
440
+ * Keyed by filename (case-sensitive). Unknown files in rootContentFiles
441
+ * get a sensible derived template (extension from name, Public: 0).
408
442
  */
409
- async function ensureManifestMetadata() {
410
- // Check if manifest.json exists at project root
411
- try {
412
- await access(join(process.cwd(), 'manifest.json'));
413
- } catch {
414
- return; // No manifest.json — nothing to do
415
- }
443
+ const ROOT_FILE_TEMPLATES = {
444
+ 'manifest.json': {
445
+ _entity: 'content',
446
+ _companionReferenceColumns: ['Content'],
447
+ Extension: 'JSON',
448
+ Public: 1,
449
+ Active: 1,
450
+ Title: 'PWA Manifest',
451
+ },
452
+ 'CLAUDE.md': {
453
+ _entity: 'content',
454
+ _companionReferenceColumns: ['Content'],
455
+ Extension: 'MD',
456
+ Public: 0,
457
+ Active: 1,
458
+ Title: 'Claude Code Instructions',
459
+ },
460
+ 'README.md': {
461
+ _entity: 'content',
462
+ _companionReferenceColumns: ['Content'],
463
+ Extension: 'MD',
464
+ Public: 1,
465
+ Active: 1,
466
+ Title: 'README',
467
+ },
468
+ };
469
+
470
+ /**
471
+ * For each filename in rootContentFiles, ensure a companion metadata file
472
+ * exists in lib/bins/app/. Skips if the file is absent at root or if metadata
473
+ * already tracks it. Creates metadata with Content: "@/<filename>" so push
474
+ * always reads from the project root.
475
+ */
476
+ async function ensureRootContentFiles() {
477
+ const rootFiles = await loadRootContentFiles();
478
+ if (!rootFiles.length) return;
416
479
 
417
- // Scan the entire project for any metadata file that already references manifest.json.
418
- // This prevents creating duplicates when the metadata lives in an unexpected location.
419
- // Check both @/manifest.json (root-relative) and @manifest.json (local) references,
420
- // as well as Path: manifest.json which indicates a server record for this file.
421
480
  const ig = await loadIgnore();
422
481
  const allMeta = await findMetadataFiles(process.cwd(), ig);
482
+
483
+ // Build a set of already-tracked filenames from existing metadata.
484
+ // Also strip stale fields (e.g. Descriptor) from content-entity root file metadata.
485
+ const tracked = new Set();
423
486
  for (const metaPath of allMeta) {
424
487
  try {
425
488
  const raw = await readFile(metaPath, 'utf8');
426
489
  const parsed = JSON.parse(raw);
427
- if (parsed.Content === '@/manifest.json' || parsed.Content === '@manifest.json') return;
428
- if (parsed.Path === 'manifest.json' || parsed.Path === '/manifest.json') return;
490
+ const content = parsed.Content || '';
491
+ const path = parsed.Path || '';
492
+ // Detect both @/filename (root-relative) and @filename (local) references
493
+ const refName = content.startsWith('@/') ? content.slice(2)
494
+ : content.startsWith('@') ? content.slice(1)
495
+ : null;
496
+ if (refName) tracked.add(refName);
497
+ if (path) tracked.add(path.replace(/^\//, ''));
498
+
499
+ // Clean up stale Descriptor field: content entities never have Descriptor.
500
+ // This fixes metadata written by an earlier buggy version of the tool.
501
+ if (parsed._entity === 'content' && parsed.Descriptor !== undefined) {
502
+ const cleaned = { ...parsed };
503
+ delete cleaned.Descriptor;
504
+ await writeFile(metaPath, JSON.stringify(cleaned, null, 2) + '\n');
505
+ log.dim(` Removed stale Descriptor field from ${basename(metaPath)}`);
506
+ }
429
507
  } catch { /* skip unreadable */ }
430
508
  }
431
509
 
432
- // No existing metadata references manifest.json — create one
433
510
  const appConfig = await loadAppConfig();
434
511
  const structure = await loadStructureFile();
435
512
  const appBin = findBinByPath('app', structure);
436
-
437
513
  const binsAppDir = join(process.cwd(), BINS_DIR, 'app');
438
- await mkdir(binsAppDir, { recursive: true });
439
514
 
440
- const meta = {
441
- _entity: 'content',
442
- _companionReferenceColumns: ['Content'],
443
- Content: '@/manifest.json',
444
- Path: 'manifest.json',
445
- Name: 'manifest.json',
446
- Extension: 'JSON',
447
- Public: 1,
448
- Active: 1,
449
- Title: 'PWA Manifest',
450
- };
451
-
452
- if (appBin) meta.BinID = appBin.binId;
453
- if (appConfig.AppID) meta.AppID = appConfig.AppID;
454
-
455
- const metaPath = join(binsAppDir, 'manifest.metadata.json');
456
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
457
- log.info('Auto-created manifest.metadata.json for manifest.json');
515
+ for (const filename of rootFiles) {
516
+ // Skip if file doesn't exist at project root
517
+ try { await access(join(process.cwd(), filename)); } catch { continue; }
518
+ // Skip if already tracked by existing metadata
519
+ if (tracked.has(filename)) continue;
520
+
521
+ const tmpl = ROOT_FILE_TEMPLATES[filename] || {
522
+ _entity: 'content',
523
+ _companionReferenceColumns: ['Content'],
524
+ Extension: (filename.includes('.') ? filename.split('.').pop().toUpperCase() : 'TXT'),
525
+ Public: 0,
526
+ Active: 1,
527
+ Title: filename,
528
+ };
529
+
530
+ const meta = {
531
+ ...tmpl,
532
+ Content: `@/${filename}`,
533
+ Path: filename,
534
+ Name: filename,
535
+ };
536
+ if (appBin) meta.BinID = appBin.binId;
537
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
538
+
539
+ await mkdir(binsAppDir, { recursive: true });
540
+ const stem = filename.replace(/\.[^.]+$/, '');
541
+ const metaFilename = `${stem}.metadata.json`;
542
+ await writeFile(join(binsAppDir, metaFilename), JSON.stringify(meta, null, 2) + '\n');
543
+ log.info(`Auto-created ${metaFilename} for ${filename}`);
544
+ }
458
545
  }
459
546
 
460
547
  /**
461
548
  * Push all records found in a directory (recursive)
462
549
  */
463
550
  async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID', deletedCount = 0) {
464
- // Auto-create manifest.metadata.json if manifest.json exists at root without companion metadata
465
- await ensureManifestMetadata();
551
+ // Auto-create metadata for root content files (manifest.json, CLAUDE.md, README.md, etc.)
552
+ await ensureRootContentFiles();
466
553
 
467
554
  // AUTO-ADD DISABLED: local-file-first approach is being replaced by server-first workflow.
468
555
  // New records should be created via the DBO REST API first, then `dbo pull` to populate locally.
@@ -1378,12 +1465,14 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1378
1465
  if (editResults.length > 0) {
1379
1466
  const updated = editResults[0]._LastUpdated || editResults[0].LastUpdated;
1380
1467
  if (updated) {
1468
+ // Always update _LastUpdated in memory and on disk (required for baseline sync
1469
+ // even when ServerTimezone is not configured)
1470
+ meta._LastUpdated = updated;
1471
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
1381
1472
  const config = await loadConfig();
1382
1473
  const serverTz = config.ServerTimezone;
1383
1474
  if (serverTz) {
1384
- // Update metadata _LastUpdated and set file mtime
1385
- meta._LastUpdated = updated;
1386
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
1475
+ // Set file mtimes to server timestamp (requires timezone for correct conversion)
1387
1476
  await setFileTimestamps(metaPath, meta._CreatedOn, updated, serverTz);
1388
1477
  // Update content file mtime too
1389
1478
  const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
package/src/lib/config.js CHANGED
@@ -173,6 +173,20 @@ export async function updateConfigWithApp({ AppID, AppUID, AppName, AppShortName
173
173
  await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
174
174
  }
175
175
 
176
+ /**
177
+ * Persist the UserMedia preference to .app/config.json.
178
+ * @param {boolean} value - true = download user media, false = skip
179
+ */
180
+ export async function updateConfigUserMedia(value) {
181
+ await mkdir(projectDir(), { recursive: true });
182
+ let existing = {};
183
+ try {
184
+ existing = JSON.parse(await readFile(configPath(), 'utf8'));
185
+ } catch { /* no existing config */ }
186
+ existing.UserMedia = value;
187
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
188
+ }
189
+
176
190
  // ─── Dependency helpers ───────────────────────────────────────────────────
177
191
 
178
192
  /**
@@ -1070,6 +1084,39 @@ export async function loadScriptsLocal() {
1070
1084
  }
1071
1085
  }
1072
1086
 
1087
+ // ─── Root Content Files ───────────────────────────────────────────────────────
1088
+
1089
+ const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json'];
1090
+
1091
+ /**
1092
+ * Load rootContentFiles from .app/config.json.
1093
+ * If the key is absent, writes the defaults and returns them.
1094
+ * If the key is [], false, or null, returns [] (disabled).
1095
+ */
1096
+ export async function loadRootContentFiles() {
1097
+ let existing = {};
1098
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
1099
+ if (!('rootContentFiles' in existing)) {
1100
+ await saveRootContentFiles(ROOT_CONTENT_FILES_DEFAULTS);
1101
+ return ROOT_CONTENT_FILES_DEFAULTS;
1102
+ }
1103
+ const val = existing.rootContentFiles;
1104
+ if (!val || (Array.isArray(val) && val.length === 0)) return [];
1105
+ return Array.isArray(val) ? val : [];
1106
+ }
1107
+
1108
+ /**
1109
+ * Persist rootContentFiles to .app/config.json.
1110
+ * Pass [] to disable root content file tracking.
1111
+ */
1112
+ export async function saveRootContentFiles(list) {
1113
+ await mkdir(projectDir(), { recursive: true });
1114
+ let existing = {};
1115
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
1116
+ existing.rootContentFiles = list;
1117
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
1118
+ }
1119
+
1073
1120
  // ─── Tag Config ───────────────────────────────────────────────────────────────
1074
1121
 
1075
1122
  /**