@dboio/cli 0.6.5 → 0.6.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 +65 -2
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +274 -0
- package/src/commands/add.js +33 -4
- package/src/commands/init.js +1 -1
- package/src/commands/input.js +32 -8
- package/src/commands/install.js +74 -27
- package/src/commands/login.js +1 -1
- package/src/commands/push.js +54 -7
- package/src/lib/input-parser.js +180 -9
- package/src/lib/ticketing.js +189 -0
package/README.md
CHANGED
|
@@ -128,11 +128,12 @@ All configuration is **directory-scoped**. Each project folder maintains its own
|
|
|
128
128
|
|------|---------|-----|
|
|
129
129
|
| `config.json` | Domain, app metadata, placement preferences | Committable (shared) |
|
|
130
130
|
| `config.local.json` | Per-user settings: plugin scopes, future user prefs | Gitignored (per-user) |
|
|
131
|
+
| `ticketing.local.json` | Stored ticket IDs for submission error recovery | Gitignored (per-user) |
|
|
131
132
|
| `credentials.json` | Username, user ID, UID, name, email (no password) | Gitignored (per-user) |
|
|
132
133
|
| `cookies.txt` | Session cookie (Netscape format) | Gitignored (per-user) |
|
|
133
134
|
| `structure.json` | Bin directory mapping (created by `dbo clone`) | Committable (shared) |
|
|
134
135
|
|
|
135
|
-
`dbo init` automatically adds `.dbo/credentials.json`, `.dbo/cookies.txt`, and `.dbo/
|
|
136
|
+
`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).
|
|
136
137
|
|
|
137
138
|
#### config.json reference
|
|
138
139
|
|
|
@@ -1090,7 +1091,35 @@ The `add` and `push` commands never submit these server-managed columns:
|
|
|
1090
1091
|
|
|
1091
1092
|
The `input`, `push`, and `add` commands automatically detect recoverable server errors and prompt for missing values instead of failing immediately.
|
|
1092
1093
|
|
|
1093
|
-
#### Ticket
|
|
1094
|
+
#### Ticket error recovery
|
|
1095
|
+
|
|
1096
|
+
When the server returns a `ticket_error` (record update requires a Ticket ID), the CLI prompts with interactive recovery options:
|
|
1097
|
+
|
|
1098
|
+
```
|
|
1099
|
+
⚠ This record update requires a Ticket ID.
|
|
1100
|
+
? Record update requires a Ticket ID:
|
|
1101
|
+
❯ Apply a Ticket ID to this record and resubmit
|
|
1102
|
+
Apply a Ticket ID to all updates in this transaction, and update my current Ticket ID reference
|
|
1103
|
+
Skip this record update
|
|
1104
|
+
Skip all updates that require a Ticket ID
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
When the server returns a `repo_mismatch` (Ticket ID belongs to a different repository), the CLI prompts:
|
|
1108
|
+
|
|
1109
|
+
```
|
|
1110
|
+
⚠ Ticket "TICKET-123" is for another repository.
|
|
1111
|
+
? The Ticket ID of "TICKET-123" is for another Repository:
|
|
1112
|
+
❯ Commit anyway
|
|
1113
|
+
Submit with another Ticket ID
|
|
1114
|
+
Skip this record
|
|
1115
|
+
Commit all transactions with this ID anyway
|
|
1116
|
+
Commit all transactions with another Ticket ID, and update my current Ticket ID reference
|
|
1117
|
+
Skip all
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
Ticket selections are stored in `.dbo/ticketing.local.json` for reuse across submissions. Per-record tickets are cleaned up after successful submission; the global ticket persists until explicitly cleared. The `--ticket` flag always takes precedence over stored tickets.
|
|
1121
|
+
|
|
1122
|
+
#### Ticket ID required (legacy)
|
|
1094
1123
|
|
|
1095
1124
|
When the server returns `ticket_lookup_required_error`, the CLI prompts:
|
|
1096
1125
|
|
|
@@ -1101,6 +1130,40 @@ When the server returns `ticket_lookup_required_error`, the CLI prompts:
|
|
|
1101
1130
|
|
|
1102
1131
|
The submission is then retried with `_OverrideTicketID`. To skip the prompt, pass `--ticket <id>` upfront.
|
|
1103
1132
|
|
|
1133
|
+
#### Pre-submission ticket prompt
|
|
1134
|
+
|
|
1135
|
+
When a stored ticket exists in `.dbo/ticketing.local.json`, the CLI prompts before batch submissions:
|
|
1136
|
+
|
|
1137
|
+
```
|
|
1138
|
+
? Use stored Ticket ID "TICKET-123" for this submission?
|
|
1139
|
+
❯ Yes, use "TICKET-123"
|
|
1140
|
+
No, clear stored ticket
|
|
1141
|
+
Cancel submission
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
#### `.dbo/ticketing.local.json`
|
|
1145
|
+
|
|
1146
|
+
Stores ticket IDs for automatic application during submissions:
|
|
1147
|
+
|
|
1148
|
+
```json
|
|
1149
|
+
{
|
|
1150
|
+
"ticket_id": "TICKET-123",
|
|
1151
|
+
"records": [
|
|
1152
|
+
{
|
|
1153
|
+
"UID": "a2dxvg23rk6xsmnum7pdxa",
|
|
1154
|
+
"RowID": 16012,
|
|
1155
|
+
"entity": "content",
|
|
1156
|
+
"ticket_id": "TICKET-456",
|
|
1157
|
+
"expression": "RowID:16012;column:content._LastUpdatedTicketID=TICKET-456"
|
|
1158
|
+
}
|
|
1159
|
+
]
|
|
1160
|
+
}
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
- `ticket_id` — Global ticket applied to all submissions until cleared
|
|
1164
|
+
- `records` — Per-record tickets (auto-cleared after successful submission)
|
|
1165
|
+
- `--ticket` flag always takes precedence over stored tickets
|
|
1166
|
+
|
|
1104
1167
|
#### User identity required
|
|
1105
1168
|
|
|
1106
1169
|
When the server returns an error mentioning `LoggedInUser_UID`, `LoggedInUserID`, `CurrentUserID`, or `UserID`, the CLI checks for a stored user identity from `dbo login`:
|
package/package.json
CHANGED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# DBO CLI Command
|
|
2
|
+
|
|
3
|
+
The dbo CLI interacts with DBO.io — a database-driven application framework.
|
|
4
|
+
|
|
5
|
+
**STEP 1: Check if `$ARGUMENTS` is empty or blank.**
|
|
6
|
+
|
|
7
|
+
If `$ARGUMENTS` is empty — meaning the user typed just `/dbo` with nothing after it — then you MUST NOT run any bash command. Do NOT run `dbo`, `dbo --help`, or anything else. Instead, respond ONLY with this text message:
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Hi there! I can help you with running dbo commands. What would you like to do?
|
|
12
|
+
|
|
13
|
+
Here are the available commands:
|
|
14
|
+
|
|
15
|
+
| Command | Description |
|
|
16
|
+
|-----------|--------------------------------------------------|
|
|
17
|
+
| init | Initialize .dbo/ configuration |
|
|
18
|
+
| login | Authenticate with a DBO.io instance |
|
|
19
|
+
| logout | Clear session |
|
|
20
|
+
| status | Show config, domain, and session info |
|
|
21
|
+
| input | Submit CRUD operations (add/edit/delete records) |
|
|
22
|
+
| output | Query data from outputs or entities |
|
|
23
|
+
| content | Get or deploy content |
|
|
24
|
+
| media | Get media files |
|
|
25
|
+
| upload | Upload a file |
|
|
26
|
+
| message | Send messages (email, SMS, chatbot) |
|
|
27
|
+
| pull | Pull records to local files |
|
|
28
|
+
| push | Push local files back to DBO.io |
|
|
29
|
+
| add | Add a new file to DBO.io |
|
|
30
|
+
| clone | Clone an app to local project structure |
|
|
31
|
+
| diff | Compare local files with server versions |
|
|
32
|
+
| rm | Remove a file and stage server deletion |
|
|
33
|
+
| deploy | Deploy via manifest |
|
|
34
|
+
| cache | Manage cache |
|
|
35
|
+
| install | Install or upgrade CLI, plugins, Claude commands (shorthand: `i`) |
|
|
36
|
+
|
|
37
|
+
Just tell me what you'd like to do and I'll help you build the right command!
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
Then STOP. Wait for the user to respond. Guide them step by step — asking about entities, UIDs, file paths, flags, etc. to construct the correct `dbo` command. Run `dbo status` proactively to check if the CLI is initialized and authenticated before suggesting data commands.
|
|
42
|
+
|
|
43
|
+
**STEP 2: If `$ARGUMENTS` is NOT empty, run the command:**
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
dbo $ARGUMENTS
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Command Reference
|
|
50
|
+
|
|
51
|
+
Available subcommands:
|
|
52
|
+
- `init` — Initialize .dbo/ configuration for the current directory
|
|
53
|
+
- `login` — Authenticate with a DBO.io instance
|
|
54
|
+
- `logout` — Clear session
|
|
55
|
+
- `status` — Show config, domain, and session info
|
|
56
|
+
- `input -d '<expr>'` — CRUD operations (add/edit/delete records)
|
|
57
|
+
- `output -e <entity>` — Query data from entities
|
|
58
|
+
- `output <uid>` — Query custom outputs
|
|
59
|
+
- `content <uid>` — Get content, `content deploy <uid> <file>` to deploy
|
|
60
|
+
- `pull [uid]` — Pull records to local files (default: content entity)
|
|
61
|
+
- `pull -e <entity> [uid]` — Pull from any entity
|
|
62
|
+
- `push <path>` — Push local files back to DBO using metadata
|
|
63
|
+
- `add <path>` — Add a new file to DBO (creates record on server)
|
|
64
|
+
- `media <uid>` — Get media files
|
|
65
|
+
- `upload <file>` — Upload binary files
|
|
66
|
+
- `message <uid>` — Send messages (email, SMS, chatbot)
|
|
67
|
+
- `cache list|refresh` — Manage cache
|
|
68
|
+
- `clone [source]` — Clone an app to local project (from file or server)
|
|
69
|
+
- `clone --app <name>` — Clone by app short name from server
|
|
70
|
+
- `diff [path]` — Compare local files against server and selectively merge changes
|
|
71
|
+
- `diff -y` — Accept all server changes without prompting
|
|
72
|
+
- `diff --no-interactive` — Show diffs without prompting to accept
|
|
73
|
+
- `rm <file>` — Remove a file locally and stage server deletion for next push
|
|
74
|
+
- `rm <directory>` — Remove a directory, all files, and sub-directories recursively
|
|
75
|
+
- `rm -f <path>` — Remove without confirmation prompts
|
|
76
|
+
- `rm --keep-local <path>` — Stage server deletions without deleting local files/directories
|
|
77
|
+
- `deploy [name]` — Deploy via dbo.deploy.json manifest
|
|
78
|
+
- `install` (alias: `i`) — Install or upgrade CLI, plugins, or Claude commands
|
|
79
|
+
- `i dbo` or `i dbo@latest` — Install/upgrade the CLI from npm
|
|
80
|
+
- `i dbo@0.4.1` — Install a specific CLI version
|
|
81
|
+
- `install /path/to/src` — Install CLI from local source
|
|
82
|
+
- `install plugins` — Install/upgrade Claude command plugins
|
|
83
|
+
- `install plugins --global` — Install plugins to `~/.claude/commands/` (shared across projects)
|
|
84
|
+
- `install plugins --local` — Install plugins to `.claude/commands/` (project only)
|
|
85
|
+
- `install claudecommands` — Install/upgrade Claude Code commands
|
|
86
|
+
- `install claudecode` — Install Claude Code CLI + commands
|
|
87
|
+
- `install --claudecommand dbo --global` — Install a specific command globally
|
|
88
|
+
|
|
89
|
+
## Change Detection (pull, clone, diff)
|
|
90
|
+
|
|
91
|
+
When pulling or cloning records that already exist locally, the CLI compares file modification times against the server's `_LastUpdated` timestamp. If the server has newer data, you'll be prompted with options:
|
|
92
|
+
|
|
93
|
+
1. **Overwrite** — Replace local files with server version
|
|
94
|
+
2. **Compare** — Show a line-by-line diff and selectively merge
|
|
95
|
+
3. **Skip** — Keep local files unchanged
|
|
96
|
+
4. **Overwrite all** — Accept all remaining server changes
|
|
97
|
+
5. **Skip all** — Skip all remaining files
|
|
98
|
+
|
|
99
|
+
Use `dbo diff [path]` to compare without pulling. Use `-y` to auto-accept all changes.
|
|
100
|
+
|
|
101
|
+
## Smart Command Building
|
|
102
|
+
|
|
103
|
+
When helping the user build a command interactively:
|
|
104
|
+
|
|
105
|
+
1. **Check readiness first**: Run `dbo status` to see if initialized and authenticated. If not, guide them through `dbo init` and `dbo login` first.
|
|
106
|
+
2. **Understand intent**: Ask what they want to do (query data, deploy a file, add a record, etc.)
|
|
107
|
+
3. **Gather parameters**: Ask for the specific values needed (entity name, UID, file path, filters, etc.)
|
|
108
|
+
4. **Build the command**: Construct the full `dbo` command with proper flags and syntax
|
|
109
|
+
5. **Execute**: Run it and explain the results
|
|
110
|
+
|
|
111
|
+
### Common workflows to suggest:
|
|
112
|
+
|
|
113
|
+
- **"I want to query data"** → Guide toward `dbo output -e <entity>` with filters
|
|
114
|
+
- **"I want to deploy/update a file"** → Check if `.metadata.json` exists → `dbo push` or `dbo content deploy`
|
|
115
|
+
- **"I want to add a new file"** → `dbo add <path>` (will create metadata interactively)
|
|
116
|
+
- **"I want to pull files from the server"** → `dbo pull` or `dbo pull -e <entity>`
|
|
117
|
+
- **"I want to delete/remove a file"** → `dbo rm <file>` (stages deletion for next `dbo push`)
|
|
118
|
+
- **"I want to delete a directory"** → `dbo rm <directory>` (removes all files + sub-dirs, stages bin deletions)
|
|
119
|
+
- **"I want to see what's on the server"** → `dbo output -e <entity> --format json`
|
|
120
|
+
- **"I need to set up this project"** → `dbo init` → `dbo login` → `dbo status`
|
|
121
|
+
- **"I want to clone an app"** → `dbo clone --app <name>` or `dbo clone <local.json>`
|
|
122
|
+
- **"I want to set up and clone"** → `dbo init --domain <host> --app <name> --clone`
|
|
123
|
+
|
|
124
|
+
## Add Command Details
|
|
125
|
+
|
|
126
|
+
`dbo add <path>` registers a new local file with the DBO server by creating an insert record.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Add a single file (interactive metadata wizard if no .metadata.json exists)
|
|
130
|
+
dbo add assets/css/colors.css
|
|
131
|
+
|
|
132
|
+
# Add with auto-accept and ticket
|
|
133
|
+
dbo add assets/css/colors.css -y --ticket abc123
|
|
134
|
+
|
|
135
|
+
# Scan current directory for all un-added files
|
|
136
|
+
dbo add .
|
|
137
|
+
|
|
138
|
+
# Scan a specific directory
|
|
139
|
+
dbo add assets/
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Flags: `-C/--confirm <true|false>`, `--ticket <id>`, `-y/--yes`, `--json`, `--jq <expr>`, `-v/--verbose`, `--domain <host>`
|
|
143
|
+
|
|
144
|
+
### How add works
|
|
145
|
+
|
|
146
|
+
1. Checks for a companion `<basename>.metadata.json` next to the file
|
|
147
|
+
2. If metadata exists with `_CreatedOn` → already on server, skips (use `push` instead)
|
|
148
|
+
3. If metadata exists without `_CreatedOn` → uses it to insert the record
|
|
149
|
+
4. If no metadata → interactive wizard prompts for: entity, content column, AppID, BinID, SiteID, Path
|
|
150
|
+
5. Creates `.metadata.json`, submits insert to `/api/input/submit`
|
|
151
|
+
6. Writes returned UID back to metadata file
|
|
152
|
+
7. Suggests running `dbo pull -e <entity> <uid>` to populate all server columns
|
|
153
|
+
|
|
154
|
+
### Non-interactive add (for scripting)
|
|
155
|
+
|
|
156
|
+
To add without prompts, create the `.metadata.json` first, then run `dbo add <file> -y`:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"Name": "colors",
|
|
161
|
+
"Path": "assets/css/colors.css",
|
|
162
|
+
"Content": "@colors.css",
|
|
163
|
+
"_entity": "content",
|
|
164
|
+
"_contentColumns": ["Content"]
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Optional fields like `AppID`, `BinID`, `SiteID` are only included if the user provides values — they have no defaults and are omitted when left blank.
|
|
169
|
+
|
|
170
|
+
The `@colors.css` value means "read content from colors.css in the same directory".
|
|
171
|
+
|
|
172
|
+
### Directory scan (`dbo add .`)
|
|
173
|
+
|
|
174
|
+
Finds files that have no `.metadata.json` or whose metadata lacks `_CreatedOn`. Skips `.dbo/`, `.git/`, `node_modules/`, dotfiles, and `.metadata.json` files. When adding multiple files, defaults from the first file are reused.
|
|
175
|
+
|
|
176
|
+
## Push Command Details
|
|
177
|
+
|
|
178
|
+
`dbo push <path>` pushes local file changes back to existing DBO records using their `.metadata.json`.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
dbo push assets/css/colors.css # single file
|
|
182
|
+
dbo push assets/ # all records in directory
|
|
183
|
+
dbo push assets/ --content-only # only file content, skip metadata
|
|
184
|
+
dbo push assets/ --meta-only # only metadata columns, skip files
|
|
185
|
+
dbo push assets/ -y --ticket abc123 # auto-accept + ticket
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Flags: `-C/--confirm`, `--ticket <id>`, `--meta-only`, `--content-only`, `-y/--yes`, `--json`, `--jq`, `-v/--verbose`, `--domain`
|
|
189
|
+
|
|
190
|
+
## Column Filtering (add & push)
|
|
191
|
+
|
|
192
|
+
These columns are **never submitted** in add or push payloads:
|
|
193
|
+
- `_CreatedOn`, `_LastUpdated` — server-managed timestamps
|
|
194
|
+
- `_LastUpdatedUserID`, `_LastUpdatedTicketID` — session-provided values
|
|
195
|
+
- `UID` — server-assigned on insert; used as identifier on push (not as column value)
|
|
196
|
+
- `_id`, `_entity`, `_contentColumns`, `_mediaFile` — internal/metadata fields
|
|
197
|
+
|
|
198
|
+
## Pull → Edit → Push/Add Workflow
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Pull existing records
|
|
202
|
+
dbo pull -e content --filter 'AppID=10100'
|
|
203
|
+
|
|
204
|
+
# Edit files locally, then push changes back
|
|
205
|
+
dbo push assets/css/colors.css
|
|
206
|
+
|
|
207
|
+
# Or add a brand new file
|
|
208
|
+
dbo add assets/css/newstyle.css
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Clone Command Details
|
|
212
|
+
|
|
213
|
+
`dbo clone` scaffolds a local project from a DBO.io app export JSON, creating directories, files, metadata, and config.
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# Clone from a local JSON export file
|
|
217
|
+
dbo clone /path/to/app_export.json
|
|
218
|
+
|
|
219
|
+
# Clone from server by app short name (requires login)
|
|
220
|
+
dbo clone --app myapp
|
|
221
|
+
|
|
222
|
+
# Clone using AppShortName already in config
|
|
223
|
+
dbo clone
|
|
224
|
+
|
|
225
|
+
# Init + clone in one step
|
|
226
|
+
dbo init --domain my-domain.com --app myapp --clone
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Flags: `--app <name>`, `--domain <host>`, `-y/--yes`, `-v/--verbose`
|
|
230
|
+
|
|
231
|
+
### What clone does
|
|
232
|
+
|
|
233
|
+
1. Loads app JSON (local file, server API, or prompt)
|
|
234
|
+
2. Updates `.dbo/config.json` with `AppID`, `AppUID`, `AppName`, `AppShortName`
|
|
235
|
+
3. Updates `package.json` with `name`, `productName`, `description`, `homepage`, and `deploy` script
|
|
236
|
+
4. Creates directory structure from `children.bin` hierarchy → saves `.dbo/structure.json`
|
|
237
|
+
5. Writes content files (decodes base64) with `*.metadata.json` into bin directories
|
|
238
|
+
6. Downloads media files from server via `/api/media/{uid}` with `*.metadata.json`
|
|
239
|
+
7. Processes entity-dir records (`extension`, `app_version`, `data_source`, `site`, `group`, `integration`, `automation`) into project directories (`Extensions/`, `Data Sources/`, etc.) as `.metadata.json` files with optional companion content files
|
|
240
|
+
8. Processes remaining entities with BinID into corresponding bin directories
|
|
241
|
+
9. Saves `app.json` to project root with `@path/to/*.metadata.json` references
|
|
242
|
+
|
|
243
|
+
### Placement preferences
|
|
244
|
+
|
|
245
|
+
When a record has both `Path`/`FullPath` and `BinID`, clone prompts the user to choose placement. Preferences are saved to `.dbo/config.json` and reused on future clones:
|
|
246
|
+
|
|
247
|
+
- `ContentPlacement`: `bin` | `path` | `ask` — for content and other entities
|
|
248
|
+
- `MediaPlacement`: `bin` | `fullpath` | `ask` — for media files
|
|
249
|
+
|
|
250
|
+
Pre-set these in config.json to skip prompts. `.dbo/config.json` and `.dbo/structure.json` are shared via git; `.dbo/credentials.json` and `.dbo/cookies.txt` are gitignored (per-user).
|
|
251
|
+
|
|
252
|
+
### AppID awareness (add & input)
|
|
253
|
+
|
|
254
|
+
After cloning, the config has an `AppID`. When running `dbo add` or `dbo input` without an AppID in the data, the CLI prompts:
|
|
255
|
+
1. Yes, use AppID from config
|
|
256
|
+
2. No
|
|
257
|
+
3. Enter custom AppID
|
|
258
|
+
|
|
259
|
+
### Init flags for clone
|
|
260
|
+
|
|
261
|
+
`dbo init` now supports `--app <shortName>` and `--clone` flags to combine initialization with app cloning.
|
|
262
|
+
|
|
263
|
+
## Error Handling
|
|
264
|
+
|
|
265
|
+
- If the command fails with a session/authentication error, suggest: `dbo login`
|
|
266
|
+
- If it fails with "No domain configured", suggest: `dbo init`
|
|
267
|
+
- If a command is not found, suggest: `dbo --help`
|
|
268
|
+
|
|
269
|
+
## Output
|
|
270
|
+
|
|
271
|
+
When showing results:
|
|
272
|
+
- Format JSON output readably
|
|
273
|
+
- For pull/push/add operations, list the files created or modified
|
|
274
|
+
- For query operations, summarize the row count and key fields
|
package/src/commands/add.js
CHANGED
|
@@ -7,6 +7,7 @@ import { formatResponse, formatError } from '../lib/formatter.js';
|
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
8
|
import { shouldSkipColumn } from '../lib/columns.js';
|
|
9
9
|
import { loadAppConfig } from '../lib/config.js';
|
|
10
|
+
import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
|
|
10
11
|
|
|
11
12
|
// Directories and patterns to skip when scanning with `dbo add .`
|
|
12
13
|
const IGNORE_DIRS = new Set(['.dbo', '.git', 'node_modules', '.svn', '.hg']);
|
|
@@ -245,10 +246,21 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
|
245
246
|
let body = await buildInputBody(dataExprs, extraParams);
|
|
246
247
|
let result = await client.postUrlEncoded('/api/input/submit', body);
|
|
247
248
|
|
|
248
|
-
// Retry with prompted params if needed (ticket, user)
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
|
|
249
|
+
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
250
|
+
const retryResult = await checkSubmitErrors(result);
|
|
251
|
+
if (retryResult) {
|
|
252
|
+
if (retryResult.skipRecord) {
|
|
253
|
+
log.warn(' Skipping record');
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (retryResult.skipAll) {
|
|
257
|
+
throw new Error('SKIP_ALL');
|
|
258
|
+
}
|
|
259
|
+
if (retryResult.ticketExpressions?.length > 0) {
|
|
260
|
+
dataExprs.push(...retryResult.ticketExpressions);
|
|
261
|
+
}
|
|
262
|
+
const params = retryResult.retryParams || retryResult;
|
|
263
|
+
Object.assign(extraParams, params);
|
|
252
264
|
body = await buildInputBody(dataExprs, extraParams);
|
|
253
265
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
254
266
|
}
|
|
@@ -302,6 +314,19 @@ async function addDirectory(dirPath, client, options) {
|
|
|
302
314
|
if (!proceed) return;
|
|
303
315
|
}
|
|
304
316
|
|
|
317
|
+
// Pre-flight ticket validation
|
|
318
|
+
if (!options.ticket) {
|
|
319
|
+
const ticketCheck = await checkStoredTicket(options);
|
|
320
|
+
if (ticketCheck.cancel) {
|
|
321
|
+
log.info('Submission cancelled');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (ticketCheck.clearTicket) {
|
|
325
|
+
await clearGlobalTicket();
|
|
326
|
+
log.dim(' Cleared stored ticket');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
305
330
|
let succeeded = 0;
|
|
306
331
|
let failed = 0;
|
|
307
332
|
let batchDefaults = null;
|
|
@@ -314,6 +339,10 @@ async function addDirectory(dirPath, client, options) {
|
|
|
314
339
|
succeeded++;
|
|
315
340
|
}
|
|
316
341
|
} catch (err) {
|
|
342
|
+
if (err.message === 'SKIP_ALL') {
|
|
343
|
+
log.info('Skipping remaining records');
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
317
346
|
log.error(`Failed: ${relative(process.cwd(), filePath)} — ${err.message}`);
|
|
318
347
|
failed++;
|
|
319
348
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -62,7 +62,7 @@ export const initCommand = new Command('init')
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Ensure sensitive files are gitignored
|
|
65
|
-
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json']);
|
|
65
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
|
|
66
66
|
|
|
67
67
|
log.success(`Initialized .dbo/ for ${domain}`);
|
|
68
68
|
log.dim(' Run "dbo login" to authenticate.');
|
package/src/commands/input.js
CHANGED
|
@@ -3,6 +3,7 @@ import { DboClient } from '../lib/client.js';
|
|
|
3
3
|
import { buildInputBody, parseFileArg, checkSubmitErrors } from '../lib/input-parser.js';
|
|
4
4
|
import { formatResponse, formatError } from '../lib/formatter.js';
|
|
5
5
|
import { loadAppConfig } from '../lib/config.js';
|
|
6
|
+
import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
|
|
6
7
|
import { log } from '../lib/logger.js';
|
|
7
8
|
|
|
8
9
|
function collect(value, previous) {
|
|
@@ -31,6 +32,19 @@ export const inputCommand = new Command('input')
|
|
|
31
32
|
if (options.login) extraParams['_login'] = 'true';
|
|
32
33
|
if (options.transactional) extraParams['_transactional'] = 'true';
|
|
33
34
|
|
|
35
|
+
// Pre-flight ticket validation
|
|
36
|
+
if (!options.ticket) {
|
|
37
|
+
const ticketCheck = await checkStoredTicket(options);
|
|
38
|
+
if (ticketCheck.cancel) {
|
|
39
|
+
log.info('Submission cancelled');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (ticketCheck.clearTicket) {
|
|
43
|
+
await clearGlobalTicket();
|
|
44
|
+
log.dim(' Cleared stored ticket');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
// Check if data expressions include AppID; if not and config has one, prompt
|
|
35
49
|
const allDataText = options.data.join(' ');
|
|
36
50
|
const hasAppId = /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
|
|
@@ -79,10 +93,15 @@ export const inputCommand = new Command('input')
|
|
|
79
93
|
const files = options.file.map(parseFileArg);
|
|
80
94
|
let result = await client.postMultipart('/api/input/submit', fields, files);
|
|
81
95
|
|
|
82
|
-
// Retry with prompted params if needed (ticket, user)
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
96
|
+
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
97
|
+
const retryResult = await checkSubmitErrors(result);
|
|
98
|
+
if (retryResult) {
|
|
99
|
+
if (retryResult.skipRecord || retryResult.skipAll) {
|
|
100
|
+
log.warn('Skipping submission');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const params = retryResult.retryParams || retryResult;
|
|
104
|
+
Object.assign(fields, params);
|
|
86
105
|
result = await client.postMultipart('/api/input/submit', fields, files);
|
|
87
106
|
}
|
|
88
107
|
|
|
@@ -93,10 +112,15 @@ export const inputCommand = new Command('input')
|
|
|
93
112
|
let body = await buildInputBody(options.data, extraParams);
|
|
94
113
|
let result = await client.postUrlEncoded('/api/input/submit', body);
|
|
95
114
|
|
|
96
|
-
// Retry with prompted params if needed (ticket, user)
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
115
|
+
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
116
|
+
const retryResult = await checkSubmitErrors(result);
|
|
117
|
+
if (retryResult) {
|
|
118
|
+
if (retryResult.skipRecord || retryResult.skipAll) {
|
|
119
|
+
log.warn('Skipping submission');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const params = retryResult.retryParams || retryResult;
|
|
123
|
+
Object.assign(extraParams, params);
|
|
100
124
|
body = await buildInputBody(options.data, extraParams);
|
|
101
125
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
102
126
|
}
|
package/src/commands/install.js
CHANGED
|
@@ -116,6 +116,41 @@ async function unregisterPlugin(pluginName) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Extract commands/*.md from a directory plugin to ~/.claude/commands/ or .claude/commands/.
|
|
121
|
+
* This provides the /commandName shorthand alongside the /plugin:skill format.
|
|
122
|
+
* @param {string} pluginSourcePath - Path to the plugin source directory
|
|
123
|
+
* @param {'project' | 'global'} scope - Installation scope
|
|
124
|
+
* @returns {Promise<number>} Number of commands extracted
|
|
125
|
+
*/
|
|
126
|
+
async function extractPluginCommands(pluginSourcePath, scope) {
|
|
127
|
+
const commandsSourceDir = join(pluginSourcePath, 'commands');
|
|
128
|
+
if (!existsSync(commandsSourceDir)) return 0;
|
|
129
|
+
|
|
130
|
+
const commandsTargetDir = getCommandsDir(scope);
|
|
131
|
+
await mkdir(commandsTargetDir, { recursive: true });
|
|
132
|
+
|
|
133
|
+
let extracted = 0;
|
|
134
|
+
const files = await readdir(commandsSourceDir);
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
if (!file.endsWith('.md')) continue;
|
|
137
|
+
const srcPath = join(commandsSourceDir, file);
|
|
138
|
+
const destPath = join(commandsTargetDir, file);
|
|
139
|
+
const srcContent = await readFile(srcPath, 'utf8');
|
|
140
|
+
|
|
141
|
+
if (await fileExists(destPath)) {
|
|
142
|
+
const destContent = await readFile(destPath, 'utf8');
|
|
143
|
+
if (fileHash(srcContent) === fileHash(destContent)) continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await copyFile(srcPath, destPath);
|
|
147
|
+
const label = scope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
|
|
148
|
+
log.dim(` Extracted command: ${label}${file}`);
|
|
149
|
+
extracted++;
|
|
150
|
+
}
|
|
151
|
+
return extracted;
|
|
152
|
+
}
|
|
153
|
+
|
|
119
154
|
/**
|
|
120
155
|
* Check if a plugin source is a directory-based plugin (has .claude-plugin/).
|
|
121
156
|
* @param {string} pluginPath - Path to the plugin directory
|
|
@@ -680,6 +715,9 @@ export async function installOrUpdateClaudeCommands(options = {}) {
|
|
|
680
715
|
}
|
|
681
716
|
}
|
|
682
717
|
|
|
718
|
+
// Extract commands/*.md to ~/.claude/commands/ or .claude/commands/
|
|
719
|
+
await extractPluginCommands(plugin.path, targetScope);
|
|
720
|
+
|
|
683
721
|
// Persist scope + metadata to config.local.json
|
|
684
722
|
if (hasProject && (options.global || options.local || !await getPluginScope(plugin.name))) {
|
|
685
723
|
await setPluginScope(plugin.name, {
|
|
@@ -691,19 +729,22 @@ export async function installOrUpdateClaudeCommands(options = {}) {
|
|
|
691
729
|
log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
|
|
692
730
|
}
|
|
693
731
|
|
|
694
|
-
// Clean up legacy
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
await
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
await
|
|
706
|
-
|
|
732
|
+
// Clean up legacy command files (but not ones we just extracted from commands/)
|
|
733
|
+
const hasPluginCommand = existsSync(join(plugin.path, 'commands', legacyFileName));
|
|
734
|
+
if (!hasPluginCommand) {
|
|
735
|
+
const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
|
|
736
|
+
const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
|
|
737
|
+
if (await fileExists(legacyProjectPath)) {
|
|
738
|
+
const { unlink } = await import('fs/promises');
|
|
739
|
+
await unlink(legacyProjectPath);
|
|
740
|
+
await removeFromGitignore(`.claude/commands/${legacyFileName}`);
|
|
741
|
+
log.dim(` Removed legacy command: .claude/commands/${legacyFileName}`);
|
|
742
|
+
}
|
|
743
|
+
if (await fileExists(legacyGlobalPath)) {
|
|
744
|
+
const { unlink } = await import('fs/promises');
|
|
745
|
+
await unlink(legacyGlobalPath);
|
|
746
|
+
log.dim(` Removed legacy command: ~/.claude/commands/${legacyFileName}`);
|
|
747
|
+
}
|
|
707
748
|
}
|
|
708
749
|
|
|
709
750
|
if (targetScope === 'global' && locations.project) {
|
|
@@ -766,7 +807,7 @@ export async function installOrUpdateClaudeCommands(options = {}) {
|
|
|
766
807
|
if (upToDate > 0) log.dim(`${upToDate} plugin(s) already up to date.`);
|
|
767
808
|
if (skipped > 0) log.dim(`${skipped} plugin(s) skipped.`);
|
|
768
809
|
if (installed > 0 || updated > 0) {
|
|
769
|
-
log.info('Use /dbo
|
|
810
|
+
log.info('Use /dbo in Claude Code.');
|
|
770
811
|
log.warn('Note: Plugins will be available in new Claude Code sessions (restart any active session).');
|
|
771
812
|
}
|
|
772
813
|
}
|
|
@@ -872,6 +913,9 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
|
|
|
872
913
|
}
|
|
873
914
|
}
|
|
874
915
|
|
|
916
|
+
// Extract commands/*.md to ~/.claude/commands/ or .claude/commands/
|
|
917
|
+
await extractPluginCommands(plugin.path, targetScope);
|
|
918
|
+
|
|
875
919
|
// Persist scope + metadata to config.local.json
|
|
876
920
|
if (hasProject && (options.global || options.local || !await getPluginScope(pluginName))) {
|
|
877
921
|
await setPluginScope(pluginName, {
|
|
@@ -883,19 +927,22 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
|
|
|
883
927
|
log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
|
|
884
928
|
}
|
|
885
929
|
|
|
886
|
-
// Clean up legacy files
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
await
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
await
|
|
898
|
-
|
|
930
|
+
// Clean up legacy command files (but not ones we just extracted from commands/)
|
|
931
|
+
const hasPluginCommand = existsSync(join(plugin.path, 'commands', legacyFileName));
|
|
932
|
+
if (!hasPluginCommand) {
|
|
933
|
+
const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
|
|
934
|
+
const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
|
|
935
|
+
if (await fileExists(legacyProjectPath)) {
|
|
936
|
+
const { unlink } = await import('fs/promises');
|
|
937
|
+
await unlink(legacyProjectPath);
|
|
938
|
+
await removeFromGitignore(`.claude/commands/${legacyFileName}`);
|
|
939
|
+
log.dim(` Removed legacy command: .claude/commands/${legacyFileName}`);
|
|
940
|
+
}
|
|
941
|
+
if (await fileExists(legacyGlobalPath)) {
|
|
942
|
+
const { unlink } = await import('fs/promises');
|
|
943
|
+
await unlink(legacyGlobalPath);
|
|
944
|
+
log.dim(` Removed legacy command: ~/.claude/commands/${legacyFileName}`);
|
|
945
|
+
}
|
|
899
946
|
}
|
|
900
947
|
|
|
901
948
|
if (targetScope === 'global' && locations.project) {
|
package/src/commands/login.js
CHANGED
|
@@ -48,7 +48,7 @@ export const loginCommand = new Command('login')
|
|
|
48
48
|
log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
|
|
49
49
|
|
|
50
50
|
// Ensure sensitive files are gitignored
|
|
51
|
-
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt']);
|
|
51
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
|
|
52
52
|
|
|
53
53
|
// Fetch current user info to store ID and UID for future submissions
|
|
54
54
|
try {
|
package/src/commands/push.js
CHANGED
|
@@ -7,6 +7,7 @@ import { formatResponse, formatError } from '../lib/formatter.js';
|
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
8
|
import { shouldSkipColumn } from '../lib/columns.js';
|
|
9
9
|
import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
|
|
10
|
+
import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
|
|
10
11
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
11
12
|
import { findMetadataFiles } from '../lib/diff.js';
|
|
12
13
|
import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
|
|
@@ -68,9 +69,15 @@ async function processPendingDeletes(client, options) {
|
|
|
68
69
|
const result = await client.postUrlEncoded('/api/input/submit', body);
|
|
69
70
|
|
|
70
71
|
// Retry with prompted params if needed
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
72
|
+
const retryResult = await checkSubmitErrors(result);
|
|
73
|
+
if (retryResult) {
|
|
74
|
+
if (retryResult.skipRecord || retryResult.skipAll) {
|
|
75
|
+
log.warn(` Skipping deletion of "${entry.name}"`);
|
|
76
|
+
remaining.push(entry);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const params = retryResult.retryParams || retryResult;
|
|
80
|
+
Object.assign(extraParams, params);
|
|
74
81
|
const retryBody = await buildInputBody([entry.expression], extraParams);
|
|
75
82
|
const retryResult = await client.postUrlEncoded('/api/input/submit', retryBody);
|
|
76
83
|
if (retryResult.successful) {
|
|
@@ -217,6 +224,19 @@ async function pushDirectory(dirPath, client, options) {
|
|
|
217
224
|
return;
|
|
218
225
|
}
|
|
219
226
|
|
|
227
|
+
// Pre-flight ticket validation (only if no --ticket flag)
|
|
228
|
+
if (!options.ticket && toPush.length > 0) {
|
|
229
|
+
const ticketCheck = await checkStoredTicket(options);
|
|
230
|
+
if (ticketCheck.cancel) {
|
|
231
|
+
log.info('Submission cancelled');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (ticketCheck.clearTicket) {
|
|
235
|
+
await clearGlobalTicket();
|
|
236
|
+
log.dim(' Cleared stored ticket');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
220
240
|
// Group by entity and apply dependency ordering
|
|
221
241
|
const byEntity = {};
|
|
222
242
|
for (const item of toPush) {
|
|
@@ -240,6 +260,10 @@ async function pushDirectory(dirPath, client, options) {
|
|
|
240
260
|
failed++;
|
|
241
261
|
}
|
|
242
262
|
} catch (err) {
|
|
263
|
+
if (err.message === 'SKIP_ALL') {
|
|
264
|
+
log.info('Skipping remaining records');
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
243
267
|
log.error(`Failed: ${item.metaPath} — ${err.message}`);
|
|
244
268
|
failed++;
|
|
245
269
|
}
|
|
@@ -321,16 +345,36 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
321
345
|
const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
|
|
322
346
|
log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) — ${fieldLabel}`);
|
|
323
347
|
|
|
348
|
+
// Apply stored ticket if no --ticket flag
|
|
349
|
+
await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options);
|
|
350
|
+
|
|
324
351
|
const extraParams = { '_confirm': options.confirm };
|
|
325
352
|
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
326
353
|
|
|
327
354
|
let body = await buildInputBody(dataExprs, extraParams);
|
|
328
355
|
let result = await client.postUrlEncoded('/api/input/submit', body);
|
|
329
356
|
|
|
330
|
-
// Retry with prompted params if needed (ticket, user)
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
|
|
357
|
+
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
358
|
+
const retryResult = await checkSubmitErrors(result);
|
|
359
|
+
if (retryResult) {
|
|
360
|
+
// Handle skip actions
|
|
361
|
+
if (retryResult.skipRecord) {
|
|
362
|
+
log.warn(' Skipping record');
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
if (retryResult.skipAll) {
|
|
366
|
+
throw new Error('SKIP_ALL');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Append ticket expressions
|
|
370
|
+
if (retryResult.ticketExpressions?.length > 0) {
|
|
371
|
+
dataExprs.push(...retryResult.ticketExpressions);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Merge retry params (new-style has retryParams nested, legacy is flat)
|
|
375
|
+
const params = retryResult.retryParams || retryResult;
|
|
376
|
+
Object.assign(extraParams, params);
|
|
377
|
+
|
|
334
378
|
body = await buildInputBody(dataExprs, extraParams);
|
|
335
379
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
336
380
|
}
|
|
@@ -347,6 +391,9 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
347
391
|
return false;
|
|
348
392
|
}
|
|
349
393
|
|
|
394
|
+
// Clean up per-record ticket on success
|
|
395
|
+
await clearRecordTicket(uid);
|
|
396
|
+
|
|
350
397
|
// Update file timestamps from server response
|
|
351
398
|
try {
|
|
352
399
|
const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
|
package/src/lib/input-parser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'fs/promises';
|
|
2
2
|
import { log } from './logger.js';
|
|
3
3
|
import { loadUserInfo } from './config.js';
|
|
4
|
+
import { buildTicketExpression, setGlobalTicket, setRecordTicket } from './ticketing.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Parse DBO input syntax and build form data.
|
|
@@ -96,24 +97,41 @@ const USER_ID_PATTERNS = [
|
|
|
96
97
|
* resolved by prompting the user for missing parameters.
|
|
97
98
|
*
|
|
98
99
|
* Detects:
|
|
99
|
-
* -
|
|
100
|
+
* - ticket_error → interactive ticket recovery (4 options)
|
|
101
|
+
* - repo_mismatch → repository mismatch recovery (6 options)
|
|
102
|
+
* - ticket_lookup_required_error → prompts for Ticket ID (legacy)
|
|
100
103
|
* - LoggedInUser_UID, LoggedInUserID, CurrentUserID, UserID not found
|
|
101
104
|
* → prompts for User ID or UID (session not authenticated)
|
|
102
105
|
*
|
|
103
|
-
* Returns an object
|
|
104
|
-
*
|
|
106
|
+
* Returns an object with retry information, or null if no recoverable errors found.
|
|
107
|
+
*
|
|
108
|
+
* Return shape for ticket errors:
|
|
109
|
+
* { retryParams, ticketExpressions, skipRecord, skipAll }
|
|
110
|
+
*
|
|
111
|
+
* Return shape for legacy/user errors (backward compatible):
|
|
112
|
+
* { _OverrideTicketID, _OverrideUserUID, _OverrideUserID, ... }
|
|
105
113
|
*/
|
|
106
114
|
export async function checkSubmitErrors(result) {
|
|
107
115
|
const messages = result.messages || result.data?.Messages || [];
|
|
108
116
|
const allText = messages.filter(m => typeof m === 'string').join(' ');
|
|
109
117
|
|
|
110
|
-
|
|
118
|
+
// --- Ticket error detection (new interactive handling) ---
|
|
119
|
+
const hasTicketError = allText.includes('ticket_error');
|
|
120
|
+
const hasRepoMismatch = allText.includes('repo_mismatch');
|
|
121
|
+
|
|
122
|
+
if (hasTicketError) {
|
|
123
|
+
return await handleTicketError(allText);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (hasRepoMismatch) {
|
|
127
|
+
return await handleRepoMismatch(allText);
|
|
128
|
+
}
|
|
111
129
|
|
|
112
|
-
//
|
|
130
|
+
// --- Legacy ticket and user identity handling ---
|
|
131
|
+
const needsTicket = allText.includes('ticket_lookup_required_error');
|
|
113
132
|
const matchedUserPattern = USER_ID_PATTERNS.find(p => allText.includes(p));
|
|
114
133
|
const needsUser = !!matchedUserPattern;
|
|
115
134
|
const needsUserUid = matchedUserPattern && matchedUserPattern.includes('UID');
|
|
116
|
-
const needsUserId = matchedUserPattern && !needsUserUid;
|
|
117
135
|
|
|
118
136
|
if (!needsTicket && !needsUser) return null;
|
|
119
137
|
|
|
@@ -126,7 +144,6 @@ export async function checkSubmitErrors(result) {
|
|
|
126
144
|
log.dim(' Your session may have expired, or you may not be logged in.');
|
|
127
145
|
log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
|
|
128
146
|
|
|
129
|
-
// Check for stored user info from a previous login
|
|
130
147
|
const stored = await loadUserInfo();
|
|
131
148
|
const storedValue = needsUserUid ? stored.userUid : (stored.userId || stored.userUid);
|
|
132
149
|
const storedLabel = needsUserUid
|
|
@@ -176,11 +193,9 @@ export async function checkSubmitErrors(result) {
|
|
|
176
193
|
|
|
177
194
|
if (answers.ticketId) retryParams['_OverrideTicketID'] = answers.ticketId.trim();
|
|
178
195
|
|
|
179
|
-
// Resolve user identity from choice or direct input
|
|
180
196
|
const userValue = answers.userValue
|
|
181
197
|
|| (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
|
|
182
198
|
if (userValue) {
|
|
183
|
-
// Use the appropriate override param based on what the server asked for
|
|
184
199
|
if (needsUserUid) {
|
|
185
200
|
retryParams['_OverrideUserUID'] = userValue.trim();
|
|
186
201
|
} else {
|
|
@@ -191,6 +206,162 @@ export async function checkSubmitErrors(result) {
|
|
|
191
206
|
return retryParams;
|
|
192
207
|
}
|
|
193
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Handle ticket_error: Record update requires a Ticket ID but none was provided.
|
|
211
|
+
* Prompts the user with 4 recovery options.
|
|
212
|
+
*/
|
|
213
|
+
async function handleTicketError(allText) {
|
|
214
|
+
const inquirer = (await import('inquirer')).default;
|
|
215
|
+
|
|
216
|
+
// Try to extract record details from error text
|
|
217
|
+
const entityMatch = allText.match(/entity:(\w+)/);
|
|
218
|
+
const rowIdMatch = allText.match(/RowID:(\d+)/);
|
|
219
|
+
const uidMatch = allText.match(/UID:([a-zA-Z0-9]+)/);
|
|
220
|
+
const entity = entityMatch?.[1];
|
|
221
|
+
const rowId = rowIdMatch?.[1];
|
|
222
|
+
const uid = uidMatch?.[1];
|
|
223
|
+
|
|
224
|
+
log.warn('This record update requires a Ticket ID.');
|
|
225
|
+
|
|
226
|
+
const answers = await inquirer.prompt([
|
|
227
|
+
{
|
|
228
|
+
type: 'list',
|
|
229
|
+
name: 'ticketAction',
|
|
230
|
+
message: 'Record update requires a Ticket ID:',
|
|
231
|
+
choices: [
|
|
232
|
+
{ name: 'Apply a Ticket ID to this record and resubmit', value: 'apply_one' },
|
|
233
|
+
{ name: 'Apply a Ticket ID to all updates in this transaction, and update my current Ticket ID reference', value: 'apply_all' },
|
|
234
|
+
{ name: 'Skip this record update', value: 'skip_one' },
|
|
235
|
+
{ name: 'Skip all updates that require a Ticket ID', value: 'skip_all' },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: 'input',
|
|
240
|
+
name: 'ticketId',
|
|
241
|
+
message: 'Enter Ticket ID:',
|
|
242
|
+
when: (a) => a.ticketAction === 'apply_one' || a.ticketAction === 'apply_all',
|
|
243
|
+
validate: v => v.trim() ? true : 'Ticket ID is required',
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
if (answers.ticketAction === 'skip_one') {
|
|
248
|
+
return { skipRecord: true };
|
|
249
|
+
}
|
|
250
|
+
if (answers.ticketAction === 'skip_all') {
|
|
251
|
+
return { skipAll: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ticketId = answers.ticketId.trim();
|
|
255
|
+
const ticketExpressions = [];
|
|
256
|
+
|
|
257
|
+
if (entity && rowId) {
|
|
258
|
+
ticketExpressions.push(buildTicketExpression(entity, rowId, ticketId));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Store ticket based on scope
|
|
262
|
+
if (answers.ticketAction === 'apply_all') {
|
|
263
|
+
await setGlobalTicket(ticketId);
|
|
264
|
+
log.dim(` Stored ticket "${ticketId}" for all future submissions`);
|
|
265
|
+
} else if (uid) {
|
|
266
|
+
await setRecordTicket(uid, rowId, entity, ticketId);
|
|
267
|
+
log.dim(` Stored ticket "${ticketId}" for record ${uid}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
retryParams: { '_OverrideTicketID': ticketId },
|
|
272
|
+
ticketExpressions,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Handle repo_mismatch: The provided Ticket ID belongs to a different repository.
|
|
278
|
+
* Prompts the user with 6 recovery options.
|
|
279
|
+
*/
|
|
280
|
+
async function handleRepoMismatch(allText) {
|
|
281
|
+
const inquirer = (await import('inquirer')).default;
|
|
282
|
+
|
|
283
|
+
// Try to extract ticket ID from error text
|
|
284
|
+
const ticketMatch = allText.match(/Ticket(?:\s+ID)?\s+(?:of\s+)?([A-Za-z0-9_-]+)/i);
|
|
285
|
+
const ticketId = ticketMatch?.[1] || 'unknown';
|
|
286
|
+
|
|
287
|
+
// Try to extract record details
|
|
288
|
+
const entityMatch = allText.match(/entity:(\w+)/);
|
|
289
|
+
const rowIdMatch = allText.match(/RowID:(\d+)/);
|
|
290
|
+
const uidMatch = allText.match(/UID:([a-zA-Z0-9]+)/);
|
|
291
|
+
const entity = entityMatch?.[1];
|
|
292
|
+
const rowId = rowIdMatch?.[1];
|
|
293
|
+
const uid = uidMatch?.[1];
|
|
294
|
+
|
|
295
|
+
log.warn(`Ticket "${ticketId}" is for another repository.`);
|
|
296
|
+
|
|
297
|
+
const answers = await inquirer.prompt([
|
|
298
|
+
{
|
|
299
|
+
type: 'list',
|
|
300
|
+
name: 'repoAction',
|
|
301
|
+
message: `The Ticket ID of "${ticketId}" is for another Repository:`,
|
|
302
|
+
choices: [
|
|
303
|
+
{ name: 'Commit anyway', value: 'commit_one' },
|
|
304
|
+
{ name: 'Submit with another Ticket ID', value: 'change_one' },
|
|
305
|
+
{ name: 'Skip this record', value: 'skip_one' },
|
|
306
|
+
{ name: 'Commit all transactions with this ID anyway', value: 'commit_all' },
|
|
307
|
+
{ name: 'Commit all transactions with another Ticket ID, and update my current Ticket ID reference', value: 'change_all' },
|
|
308
|
+
{ name: 'Skip all', value: 'skip_all' },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
type: 'input',
|
|
313
|
+
name: 'newTicketId',
|
|
314
|
+
message: 'Enter new Ticket ID:',
|
|
315
|
+
when: (a) => a.repoAction === 'change_one' || a.repoAction === 'change_all',
|
|
316
|
+
validate: v => v.trim() ? true : 'Ticket ID is required',
|
|
317
|
+
},
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
if (answers.repoAction === 'skip_one') {
|
|
321
|
+
return { skipRecord: true };
|
|
322
|
+
}
|
|
323
|
+
if (answers.repoAction === 'skip_all') {
|
|
324
|
+
return { skipAll: true };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// "Commit anyway" options — retry with same ticket
|
|
328
|
+
if (answers.repoAction === 'commit_one') {
|
|
329
|
+
return {
|
|
330
|
+
retryParams: { '_OverrideTicketID': ticketId },
|
|
331
|
+
ticketExpressions: [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (answers.repoAction === 'commit_all') {
|
|
335
|
+
await setGlobalTicket(ticketId);
|
|
336
|
+
log.dim(` Will use ticket "${ticketId}" for all remaining submissions`);
|
|
337
|
+
return {
|
|
338
|
+
retryParams: { '_OverrideTicketID': ticketId },
|
|
339
|
+
ticketExpressions: [],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// "Change ticket" options
|
|
344
|
+
const newTicketId = answers.newTicketId.trim();
|
|
345
|
+
const ticketExpressions = [];
|
|
346
|
+
|
|
347
|
+
if (entity && rowId) {
|
|
348
|
+
ticketExpressions.push(buildTicketExpression(entity, rowId, newTicketId));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (answers.repoAction === 'change_all') {
|
|
352
|
+
await setGlobalTicket(newTicketId);
|
|
353
|
+
log.dim(` Stored ticket "${newTicketId}" for all future submissions`);
|
|
354
|
+
} else if (uid) {
|
|
355
|
+
await setRecordTicket(uid, rowId, entity, newTicketId);
|
|
356
|
+
log.dim(` Stored ticket "${newTicketId}" for record ${uid}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
retryParams: { '_OverrideTicketID': newTicketId },
|
|
361
|
+
ticketExpressions,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
194
365
|
/**
|
|
195
366
|
* Parse file arguments in the format: field=@path or just @path
|
|
196
367
|
* Returns objects suitable for multipart upload.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { log } from './logger.js';
|
|
4
|
+
|
|
5
|
+
const DBO_DIR = '.dbo';
|
|
6
|
+
const TICKETING_FILE = 'ticketing.local.json';
|
|
7
|
+
|
|
8
|
+
function dboDir() {
|
|
9
|
+
return join(process.cwd(), DBO_DIR);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ticketingPath() {
|
|
13
|
+
return join(dboDir(), TICKETING_FILE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TICKETING = { ticket_id: null, records: [] };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load ticketing.local.json. Returns default structure if missing or corrupted.
|
|
20
|
+
*/
|
|
21
|
+
export async function loadTicketing() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(ticketingPath(), 'utf8');
|
|
24
|
+
const data = JSON.parse(raw);
|
|
25
|
+
return {
|
|
26
|
+
ticket_id: data.ticket_id || null,
|
|
27
|
+
records: Array.isArray(data.records) ? data.records : [],
|
|
28
|
+
};
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== 'ENOENT') {
|
|
31
|
+
log.warn('Ticketing config is corrupted or unreadable — starting fresh.');
|
|
32
|
+
}
|
|
33
|
+
return { ...DEFAULT_TICKETING, records: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Save ticketing.local.json.
|
|
39
|
+
*/
|
|
40
|
+
export async function saveTicketing(data) {
|
|
41
|
+
await mkdir(dboDir(), { recursive: true });
|
|
42
|
+
await writeFile(ticketingPath(), JSON.stringify(data, null, 2) + '\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the global ticket_id, or null if not set.
|
|
47
|
+
*/
|
|
48
|
+
export async function getGlobalTicket() {
|
|
49
|
+
const data = await loadTicketing();
|
|
50
|
+
return data.ticket_id || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the per-record ticket for a specific UID, or null.
|
|
55
|
+
*/
|
|
56
|
+
export async function getRecordTicket(uid) {
|
|
57
|
+
const data = await loadTicketing();
|
|
58
|
+
const record = data.records.find(r => r.UID === uid);
|
|
59
|
+
return record ? record.ticket_id : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Set the global ticket_id.
|
|
64
|
+
*/
|
|
65
|
+
export async function setGlobalTicket(ticketId) {
|
|
66
|
+
const data = await loadTicketing();
|
|
67
|
+
data.ticket_id = ticketId;
|
|
68
|
+
await saveTicketing(data);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Store a per-record ticket entry. Deduplicates by UID.
|
|
73
|
+
*/
|
|
74
|
+
export async function setRecordTicket(uid, rowId, entity, ticketId) {
|
|
75
|
+
const data = await loadTicketing();
|
|
76
|
+
const idx = data.records.findIndex(r => r.UID === uid);
|
|
77
|
+
const entry = {
|
|
78
|
+
UID: uid,
|
|
79
|
+
RowID: rowId,
|
|
80
|
+
entity,
|
|
81
|
+
ticket_id: ticketId,
|
|
82
|
+
expression: buildTicketExpression(entity, rowId, ticketId),
|
|
83
|
+
};
|
|
84
|
+
if (idx >= 0) {
|
|
85
|
+
data.records[idx] = entry;
|
|
86
|
+
} else {
|
|
87
|
+
data.records.push(entry);
|
|
88
|
+
}
|
|
89
|
+
await saveTicketing(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Clear the global ticket_id (preserves records).
|
|
94
|
+
*/
|
|
95
|
+
export async function clearGlobalTicket() {
|
|
96
|
+
const data = await loadTicketing();
|
|
97
|
+
data.ticket_id = null;
|
|
98
|
+
await saveTicketing(data);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remove a specific per-record ticket by UID.
|
|
103
|
+
*/
|
|
104
|
+
export async function clearRecordTicket(uid) {
|
|
105
|
+
const data = await loadTicketing();
|
|
106
|
+
const before = data.records.length;
|
|
107
|
+
data.records = data.records.filter(r => r.UID !== uid);
|
|
108
|
+
if (data.records.length !== before) {
|
|
109
|
+
await saveTicketing(data);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Clear all per-record tickets (preserves global ticket_id).
|
|
115
|
+
*/
|
|
116
|
+
export async function clearAllRecordTickets() {
|
|
117
|
+
const data = await loadTicketing();
|
|
118
|
+
if (data.records.length > 0) {
|
|
119
|
+
data.records = [];
|
|
120
|
+
await saveTicketing(data);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a ticket column expression for DBO input syntax.
|
|
126
|
+
*/
|
|
127
|
+
export function buildTicketExpression(entity, rowId, ticketId) {
|
|
128
|
+
return `RowID:${rowId};column:${entity}._LastUpdatedTicketID=${ticketId}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check for a stored ticket before batch submission and prompt the user.
|
|
133
|
+
* Returns { useTicket, clearTicket, cancel }.
|
|
134
|
+
*
|
|
135
|
+
* @param {Object} options - Command options (checks options.ticket for flag override)
|
|
136
|
+
*/
|
|
137
|
+
export async function checkStoredTicket(options) {
|
|
138
|
+
// --ticket flag takes precedence; skip stored-ticket prompt
|
|
139
|
+
if (options.ticket) {
|
|
140
|
+
return { useTicket: false, clearTicket: false, cancel: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data = await loadTicketing();
|
|
144
|
+
if (!data.ticket_id) {
|
|
145
|
+
return { useTicket: false, clearTicket: false, cancel: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const inquirer = (await import('inquirer')).default;
|
|
149
|
+
const { action } = await inquirer.prompt([{
|
|
150
|
+
type: 'list',
|
|
151
|
+
name: 'action',
|
|
152
|
+
message: `Use stored Ticket ID "${data.ticket_id}" for this submission?`,
|
|
153
|
+
choices: [
|
|
154
|
+
{ name: `Yes, use "${data.ticket_id}"`, value: 'use' },
|
|
155
|
+
{ name: 'No, clear stored ticket', value: 'clear' },
|
|
156
|
+
{ name: 'Cancel submission', value: 'cancel' },
|
|
157
|
+
],
|
|
158
|
+
}]);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
useTicket: action === 'use',
|
|
162
|
+
clearTicket: action === 'clear',
|
|
163
|
+
cancel: action === 'cancel',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Apply a stored ticket to submission data expressions if no --ticket flag is set.
|
|
169
|
+
* Checks per-record ticket first, then global ticket.
|
|
170
|
+
*
|
|
171
|
+
* @param {string[]} dataExprs - The data expressions array (mutated in place)
|
|
172
|
+
* @param {string} entity - Entity name
|
|
173
|
+
* @param {string|number} rowId - Row ID or UID used in the submission
|
|
174
|
+
* @param {string} uid - Record UID for per-record lookup
|
|
175
|
+
* @param {Object} options - Command options
|
|
176
|
+
*/
|
|
177
|
+
export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, uid, options) {
|
|
178
|
+
if (options.ticket) return; // --ticket flag takes precedence
|
|
179
|
+
|
|
180
|
+
const recordTicket = await getRecordTicket(uid);
|
|
181
|
+
const globalTicket = await getGlobalTicket();
|
|
182
|
+
const ticketToUse = recordTicket || globalTicket;
|
|
183
|
+
|
|
184
|
+
if (ticketToUse) {
|
|
185
|
+
const ticketExpr = buildTicketExpression(entity, rowId, ticketToUse);
|
|
186
|
+
dataExprs.push(ticketExpr);
|
|
187
|
+
log.dim(` Applying ticket: ${ticketToUse}`);
|
|
188
|
+
}
|
|
189
|
+
}
|