bod-cli 0.5.9 → 0.7.4

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.
@@ -134,6 +134,72 @@ bod env set my-api -f .env # bulk set from .env file
134
134
  bod env unset my-api OLD_VAR
135
135
  ```
136
136
 
137
+ ### `bod db get|set|update|push|delete|query <path>`
138
+ Read and write the per-app BodDB. Requires `database: true` in `bodify.yaml`.
139
+
140
+ **Target resolution** (auto-detected, in order):
141
+ 1. **`.bodify/serve.info.json`** — written by `bod serve` on startup (port + admin password).
142
+ 2. **`.env` `BODDB_ADMIN_PASSWORD` + `bodify.yaml database.port`** — direct to `http://127.0.0.1:<port>`.
143
+ 3. **Bodify agent proxy** — for deployed apps. Auth via `BODIFY_API_KEY`; the agent injects the per-instance admin password when forwarding. `-l/--local` selects the local agent.
144
+
145
+ **Unknown flags throw** — typos like `--wheree` or `--filtr` exit with an "Unknown argument(s)" error listing the allowed flags. This is deliberate to prevent silent no-ops.
146
+
147
+ #### Commands
148
+
149
+ ```bash
150
+ # READ — shallow by default (prevents choking on large nodes).
151
+ bod db read / # explore root: [{key, isLeaf, count}, ...]
152
+ bod db read users # shallow listing of users/
153
+ bod db read users/123 # top-level keys of this node
154
+ bod db read users/123 -d # deep read (full subtree)
155
+ bod db read users -n 50 --offset 100 # shallow with paging
156
+ bod db read users/123 -d -o snapshot.json # deep + write to file
157
+
158
+ # WRITE — full replace (like PUT). Any existing subkeys not in the new value are deleted.
159
+ bod db write users/123 '{"name":"alice"}' # replace node
160
+ echo '{"name":"alice"}' | bod db write users/123 # via stdin
161
+ bod db write users/123 -f user.json # via file
162
+
163
+ # UPDATE — shallow merge (like PATCH). Preserves existing subkeys not in the patch.
164
+ bod db update users/123 '{"age":30}' # merges into existing node
165
+
166
+ # PUSH — append to a list-style path, auto-generates key.
167
+ bod db push messages '{"text":"hi"}' # → messages/<new-id>
168
+
169
+ # DELETE — requires -y/--confirm to execute.
170
+ bod db delete users/123 -y
171
+
172
+ # QUERY — shallow by default (returns only _path/_key per match). Pass -d for full docs.
173
+ # Two filter syntaxes (repeatable, can be mixed):
174
+ # --filter (-f) shorthand: field=value field!=value field>N field>=N field<N field<=N
175
+ # --where (-w) canonical: field:op:value where op ∈ eq|ne|gt|gte|lt|lte|in|contains
176
+ bod db query users --filter "email=alice@x.com"
177
+ bod db query users --filter "age>=18" --filter "status=active" --limit 10
178
+ bod db query users --where age:gte:18 --where status:eq:active --limit 10
179
+ bod db query users --filter "public=true" --order createdAt:desc --limit 20 -d
180
+ ```
181
+
182
+ #### Semantics
183
+
184
+ | Command | HTTP | Semantics |
185
+ |---|---|---|
186
+ | `get` | GET `/db/<p>[?shallow=1]` | Shallow = top-level keys + counts. Deep (`-d`) = full subtree. |
187
+ | `set` | PUT `/db/<p>` | **Full replace.** Removes keys not in the new value. |
188
+ | `update` | PATCH `/db/<p>` | **Shallow merge.** Preserves untouched keys. |
189
+ | `push` | POST `/db/<p>` | Appends with auto-id (list semantics). Returns `{key}`. |
190
+ | `delete` | DELETE `/db/<p>` | Recursive delete. Requires `-y`. |
191
+ | `query` | POST `/query/<p>` | Filter/order/limit over a collection. Shallow = `[{_path, _key}]`, deep = full docs. |
192
+
193
+ #### --filter vs --where
194
+
195
+ `--filter` is the ergonomic shorthand (`field=value`, `field!=value`, `field>=N`, etc.) — matches Firebase/Mongo muscle memory. Use it in most cases.
196
+
197
+ `--where` is the canonical form when your value contains `=` or spaces, or when you need ops not in the shorthand (`in`, `contains`). Both are repeatable and can be mixed in one invocation.
198
+
199
+ Value parsing: the CLI tries `JSON.parse(value)` first (so `true`, `42`, `null`, `"quoted"` become the right type), and falls back to a raw string.
200
+
201
+ **App-side prerequisite for agent-proxy mode:** the app must be deployed with `database: true`. Existing apps deployed before per-instance admin-password persistence must be redeployed once — until then the proxy returns 409 with an actionable error.
202
+
137
203
  ### `bod add <pkg>` / `bod remove <pkg>`
138
204
  Registry-aware package management. If Bodify registry is enabled, `bun add` uses it automatically.
139
205
 
package/dist/cli.js CHANGED
@@ -12287,6 +12287,55 @@ var require_public_api = __commonJS((exports) => {
12287
12287
  exports.stringify = stringify;
12288
12288
  });
12289
12289
 
12290
+ // node_modules/yaml/dist/index.js
12291
+ var require_dist = __commonJS((exports) => {
12292
+ var composer = require_composer();
12293
+ var Document = require_Document();
12294
+ var Schema = require_Schema();
12295
+ var errors3 = require_errors();
12296
+ var Alias = require_Alias();
12297
+ var identity = require_identity();
12298
+ var Pair = require_Pair();
12299
+ var Scalar = require_Scalar();
12300
+ var YAMLMap = require_YAMLMap();
12301
+ var YAMLSeq = require_YAMLSeq();
12302
+ var cst = require_cst();
12303
+ var lexer = require_lexer();
12304
+ var lineCounter = require_line_counter();
12305
+ var parser = require_parser();
12306
+ var publicApi = require_public_api();
12307
+ var visit = require_visit();
12308
+ exports.Composer = composer.Composer;
12309
+ exports.Document = Document.Document;
12310
+ exports.Schema = Schema.Schema;
12311
+ exports.YAMLError = errors3.YAMLError;
12312
+ exports.YAMLParseError = errors3.YAMLParseError;
12313
+ exports.YAMLWarning = errors3.YAMLWarning;
12314
+ exports.Alias = Alias.Alias;
12315
+ exports.isAlias = identity.isAlias;
12316
+ exports.isCollection = identity.isCollection;
12317
+ exports.isDocument = identity.isDocument;
12318
+ exports.isMap = identity.isMap;
12319
+ exports.isNode = identity.isNode;
12320
+ exports.isPair = identity.isPair;
12321
+ exports.isScalar = identity.isScalar;
12322
+ exports.isSeq = identity.isSeq;
12323
+ exports.Pair = Pair.Pair;
12324
+ exports.Scalar = Scalar.Scalar;
12325
+ exports.YAMLMap = YAMLMap.YAMLMap;
12326
+ exports.YAMLSeq = YAMLSeq.YAMLSeq;
12327
+ exports.CST = cst;
12328
+ exports.Lexer = lexer.Lexer;
12329
+ exports.LineCounter = lineCounter.LineCounter;
12330
+ exports.Parser = parser.Parser;
12331
+ exports.parse = publicApi.parse;
12332
+ exports.parseAllDocuments = publicApi.parseAllDocuments;
12333
+ exports.parseDocument = publicApi.parseDocument;
12334
+ exports.stringify = publicApi.stringify;
12335
+ exports.visit = visit.visit;
12336
+ exports.visitAsync = visit.visitAsync;
12337
+ });
12338
+
12290
12339
  // src/cli.ts
12291
12340
  init_dist2();
12292
12341
  // node_modules/@inquirer/core/dist/esm/lib/key.js
@@ -18678,6 +18727,9 @@ class BodClient {
18678
18727
  del(path) {
18679
18728
  return this.request("DELETE", path);
18680
18729
  }
18730
+ patch(path, body) {
18731
+ return this.request("PATCH", path, body);
18732
+ }
18681
18733
  }
18682
18734
 
18683
18735
  // src/utils/output.ts
@@ -18755,61 +18807,14 @@ var login_default = defineCommand2({
18755
18807
  });
18756
18808
 
18757
18809
  // src/utils/resolve.ts
18810
+ var import_yaml = __toESM(require_dist(), 1);
18758
18811
  import { readFileSync as readFileSync3 } from "fs";
18759
-
18760
- // node_modules/yaml/dist/index.js
18761
- var composer = require_composer();
18762
- var Document = require_Document();
18763
- var Schema = require_Schema();
18764
- var errors3 = require_errors();
18765
- var Alias = require_Alias();
18766
- var identity = require_identity();
18767
- var Pair = require_Pair();
18768
- var Scalar = require_Scalar();
18769
- var YAMLMap = require_YAMLMap();
18770
- var YAMLSeq = require_YAMLSeq();
18771
- var cst = require_cst();
18772
- var lexer = require_lexer();
18773
- var lineCounter = require_line_counter();
18774
- var parser = require_parser();
18775
- var publicApi = require_public_api();
18776
- var visit = require_visit();
18777
- var $Composer = composer.Composer;
18778
- var $Document = Document.Document;
18779
- var $Schema = Schema.Schema;
18780
- var $YAMLError = errors3.YAMLError;
18781
- var $YAMLParseError = errors3.YAMLParseError;
18782
- var $YAMLWarning = errors3.YAMLWarning;
18783
- var $Alias = Alias.Alias;
18784
- var $isAlias = identity.isAlias;
18785
- var $isCollection = identity.isCollection;
18786
- var $isDocument = identity.isDocument;
18787
- var $isMap = identity.isMap;
18788
- var $isNode = identity.isNode;
18789
- var $isPair = identity.isPair;
18790
- var $isScalar = identity.isScalar;
18791
- var $isSeq = identity.isSeq;
18792
- var $Pair = Pair.Pair;
18793
- var $Scalar = Scalar.Scalar;
18794
- var $YAMLMap = YAMLMap.YAMLMap;
18795
- var $YAMLSeq = YAMLSeq.YAMLSeq;
18796
- var $Lexer = lexer.Lexer;
18797
- var $LineCounter = lineCounter.LineCounter;
18798
- var $Parser = parser.Parser;
18799
- var $parse = publicApi.parse;
18800
- var $parseAllDocuments = publicApi.parseAllDocuments;
18801
- var $parseDocument = publicApi.parseDocument;
18802
- var $stringify = publicApi.stringify;
18803
- var $visit = visit.visit;
18804
- var $visitAsync = visit.visitAsync;
18805
-
18806
- // src/utils/resolve.ts
18807
18812
  var _parsedYaml;
18808
18813
  function readYaml() {
18809
18814
  if (_parsedYaml !== undefined)
18810
18815
  return _parsedYaml;
18811
18816
  try {
18812
- _parsedYaml = $parse(readFileSync3("bodify.yaml", "utf-8"));
18817
+ _parsedYaml = import_yaml.parse(readFileSync3("bodify.yaml", "utf-8"));
18813
18818
  } catch {
18814
18819
  _parsedYaml = null;
18815
18820
  }
@@ -19551,6 +19556,7 @@ console.log(\`Server running on :\${port}\`);
19551
19556
  ];
19552
19557
 
19553
19558
  // src/commands/init/init.ts
19559
+ var import_yaml2 = __toESM(require_dist(), 1);
19554
19560
  function detectAppType(dir) {
19555
19561
  if (existsSync3(join3(dir, "api.ts")))
19556
19562
  return "caab";
@@ -19740,7 +19746,7 @@ var init_default = defineCommand2({
19740
19746
  let yamlExisted = false;
19741
19747
  if (existsSync3(yamlPath)) {
19742
19748
  yamlExisted = true;
19743
- yamlData = $parse(readFileSync4(yamlPath, "utf8")) ?? {};
19749
+ yamlData = import_yaml2.parse(readFileSync4(yamlPath, "utf8")) ?? {};
19744
19750
  }
19745
19751
  let yamlDirty = false;
19746
19752
  if (!yamlData.name) {
@@ -19756,7 +19762,7 @@ var init_default = defineCommand2({
19756
19762
  yamlDirty = true;
19757
19763
  }
19758
19764
  if (yamlDirty) {
19759
- writeFileSync3(yamlPath, $stringify(yamlData));
19765
+ writeFileSync3(yamlPath, import_yaml2.stringify(yamlData));
19760
19766
  console.log(source_default.green(yamlExisted ? "✓ Updated bodify.yaml" : "✓ Created bodify.yaml"));
19761
19767
  } else {
19762
19768
  console.log(source_default.dim("bodify.yaml already up to date"));
@@ -19781,7 +19787,7 @@ var init_default = defineCommand2({
19781
19787
  changed = true;
19782
19788
  }
19783
19789
  if (changed)
19784
- writeFileSync3(yamlPath, $stringify(yamlData));
19790
+ writeFileSync3(yamlPath, import_yaml2.stringify(yamlData));
19785
19791
  } catch (e2) {
19786
19792
  console.warn(source_default.yellow(`Warning: Could not register: ${e2.message}`));
19787
19793
  }
@@ -20070,8 +20076,364 @@ var publish_default = defineCommand2({
20070
20076
  }
20071
20077
  });
20072
20078
 
20079
+ // src/commands/db.ts
20080
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
20081
+ import { join as join6 } from "path";
20082
+ function readServeInfo() {
20083
+ const path = join6(process.cwd(), ".bodify", "serve.info.json");
20084
+ if (!existsSync6(path))
20085
+ return null;
20086
+ try {
20087
+ const info = JSON.parse(readFileSync6(path, "utf-8"));
20088
+ try {
20089
+ process.kill(info.pid, 0);
20090
+ } catch {
20091
+ return null;
20092
+ }
20093
+ return info;
20094
+ } catch {
20095
+ return null;
20096
+ }
20097
+ }
20098
+ function readDotEnv(key) {
20099
+ const path = join6(process.cwd(), ".env");
20100
+ if (!existsSync6(path))
20101
+ return null;
20102
+ for (const line of readFileSync6(path, "utf-8").split(`
20103
+ `)) {
20104
+ const trimmed = line.trim();
20105
+ if (!trimmed || trimmed.startsWith("#"))
20106
+ continue;
20107
+ const eq = trimmed.indexOf("=");
20108
+ if (eq === -1)
20109
+ continue;
20110
+ if (trimmed.slice(0, eq).trim() !== key)
20111
+ continue;
20112
+ let val = trimmed.slice(eq + 1).trim();
20113
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'"))
20114
+ val = val.slice(1, -1);
20115
+ return val;
20116
+ }
20117
+ return null;
20118
+ }
20119
+ function readDbPortFromYaml() {
20120
+ const path = join6(process.cwd(), "bodify.yaml");
20121
+ if (!existsSync6(path))
20122
+ return 4460;
20123
+ try {
20124
+ const { parse: parse2 } = require_dist();
20125
+ const y3 = parse2(readFileSync6(path, "utf-8"));
20126
+ if (typeof y3?.database === "object" && y3.database?.port)
20127
+ return Number(y3.database.port);
20128
+ return 4460;
20129
+ } catch {
20130
+ return 4460;
20131
+ }
20132
+ }
20133
+ function directTarget(port, token, label) {
20134
+ const base = `http://127.0.0.1:${port}`;
20135
+ return {
20136
+ label: `${label} (${base})`,
20137
+ async request(method, sub, body) {
20138
+ const res = await fetch(`${base}${sub}`, {
20139
+ method,
20140
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
20141
+ body: body !== undefined ? JSON.stringify(body) : undefined
20142
+ });
20143
+ const text = await res.text();
20144
+ if (!res.ok)
20145
+ throw new Error(`${method} ${sub} → ${res.status}: ${text}`);
20146
+ try {
20147
+ return JSON.parse(text);
20148
+ } catch {
20149
+ return text;
20150
+ }
20151
+ }
20152
+ };
20153
+ }
20154
+ async function resolveDbTarget(appArg) {
20155
+ const explicitInstance = !!(process.env._BOD_INSTANCE_OVERRIDE || process.env.BOD_INSTANCE);
20156
+ if (!explicitInstance) {
20157
+ const info = readServeInfo();
20158
+ if (info)
20159
+ return directTarget(info.dbPort, info.dbAdminPassword, "local serve");
20160
+ const envPw = readDotEnv("BODDB_ADMIN_PASSWORD");
20161
+ if (envPw)
20162
+ return directTarget(readDbPortFromYaml(), envPw, "direct via .env");
20163
+ }
20164
+ const { url, apiKey } = getResolvedInstance(loadConfig());
20165
+ const client = new BodClient(url, apiKey);
20166
+ const appId = await resolveAppId(client, resolveAppName(appArg));
20167
+ return {
20168
+ label: `agent ${url} → app ${appId}`,
20169
+ request(method, sub, body) {
20170
+ return client.request(method, `/apps/${appId}${sub}`, body);
20171
+ }
20172
+ };
20173
+ }
20174
+ async function readBody(positional, file) {
20175
+ let raw;
20176
+ if (positional)
20177
+ raw = positional;
20178
+ else if (file)
20179
+ raw = readFileSync6(file, "utf-8");
20180
+ else if (!process.stdin.isTTY)
20181
+ raw = await new Response(Bun.stdin.stream()).text();
20182
+ if (raw === undefined || raw.trim() === "") {
20183
+ console.error(source_default.red("Body required. Pass JSON as positional arg, --file <path>, or pipe via stdin."));
20184
+ process.exit(1);
20185
+ }
20186
+ try {
20187
+ return JSON.parse(raw);
20188
+ } catch (e2) {
20189
+ console.error(source_default.red(`Invalid JSON: ${e2.message}`));
20190
+ process.exit(1);
20191
+ }
20192
+ }
20193
+ function printResult(data, output) {
20194
+ const text = JSON.stringify(data, null, 2);
20195
+ if (output) {
20196
+ writeFileSync4(output, text);
20197
+ console.log(source_default.green(`✓ Wrote ${output}`));
20198
+ } else {
20199
+ console.log(text);
20200
+ }
20201
+ }
20202
+ function normalizePath(p) {
20203
+ return (p ?? "").replace(/^\/+|\/+$/g, "");
20204
+ }
20205
+ function strictArgs(known) {
20206
+ const allowed = new Set([...known, "help", "h", "version", "v", "instance", "local", "l"]);
20207
+ const unknown = [];
20208
+ for (const a2 of process.argv.slice(2)) {
20209
+ if (!a2.startsWith("--") && !a2.startsWith("-"))
20210
+ continue;
20211
+ if (a2 === "--" || a2 === "-")
20212
+ continue;
20213
+ const name = a2.replace(/^-+/, "").split("=")[0];
20214
+ if (!name)
20215
+ continue;
20216
+ if (!allowed.has(name))
20217
+ unknown.push(a2);
20218
+ }
20219
+ if (unknown.length) {
20220
+ console.error(source_default.red(`Unknown argument(s): ${unknown.join(", ")}`));
20221
+ console.error(source_default.dim(`Allowed: ${[...allowed].filter((x2) => x2.length > 1).sort().map((x2) => "--" + x2).join(", ")}`));
20222
+ process.exit(1);
20223
+ }
20224
+ }
20225
+ function parseFilterShorthand(raw) {
20226
+ const ops = [[">=", ">="], ["<=", "<="], ["!=", "!="], ["==", "=="], ["=", "=="], [">", ">"], ["<", "<"]];
20227
+ for (const [token, op] of ops) {
20228
+ const idx = raw.indexOf(token);
20229
+ if (idx <= 0)
20230
+ continue;
20231
+ const field = raw.slice(0, idx);
20232
+ const rawValue = raw.slice(idx + token.length);
20233
+ let value = rawValue;
20234
+ try {
20235
+ value = JSON.parse(rawValue);
20236
+ } catch {}
20237
+ return { field, op, value };
20238
+ }
20239
+ console.error(source_default.red(`Invalid --filter: "${raw}" (expected field=value, field!=value, field>value, etc.)`));
20240
+ process.exit(1);
20241
+ }
20242
+ var getCmd = defineCommand2({
20243
+ meta: { name: "get", description: "Get value at path. Shallow by default (top-level keys only). Use -d for full deep read. Mirrors BodClient.get()." },
20244
+ args: {
20245
+ path: { type: "positional", description: "DB path (e.g. users/123, or / for root)", required: false },
20246
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20247
+ deep: { type: "boolean", alias: "d", description: "Deep read (full subtree). Warning: large nodes may be huge.", default: false },
20248
+ limit: { type: "string", alias: "n", description: "Shallow only: max keys to return" },
20249
+ offset: { type: "string", description: "Shallow only: skip N keys" },
20250
+ output: { type: "string", alias: "o", description: "Write to file instead of stdout" }
20251
+ },
20252
+ async run({ args }) {
20253
+ strictArgs(["path", "app", "a", "deep", "d", "limit", "n", "offset", "output", "o"]);
20254
+ const target = await resolveDbTarget(args.app);
20255
+ const path = normalizePath(args.path);
20256
+ const qs = [];
20257
+ if (!args.deep) {
20258
+ qs.push("shallow=1");
20259
+ if (args.limit)
20260
+ qs.push(`limit=${encodeURIComponent(args.limit)}`);
20261
+ if (args.offset)
20262
+ qs.push(`offset=${encodeURIComponent(args.offset)}`);
20263
+ }
20264
+ const sub = `/db${path ? "/" + path : "/"}${qs.length ? "?" + qs.join("&") : ""}`;
20265
+ const res = await target.request("GET", sub);
20266
+ printResult(res.data ?? null, args.output);
20267
+ if (res.shallow && !args.output) {
20268
+ console.error(source_default.dim(`(shallow — pass -d for deep read; target: ${target.label})`));
20269
+ }
20270
+ }
20271
+ });
20272
+ var setCmd2 = defineCommand2({
20273
+ meta: { name: "set", description: "Replace (overwrite) value at path. Any existing subkeys not in the new value are deleted. Use `update` for merge semantics. Mirrors BodClient.set()." },
20274
+ args: {
20275
+ path: { type: "positional", description: "DB path", required: true },
20276
+ value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
20277
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20278
+ file: { type: "string", alias: "f", description: "Read JSON from file" }
20279
+ },
20280
+ async run({ args }) {
20281
+ strictArgs(["path", "value", "app", "a", "file", "f"]);
20282
+ const body = await readBody(args.value, args.file);
20283
+ const target = await resolveDbTarget(args.app);
20284
+ await target.request("PUT", `/db/${normalizePath(args.path)}`, body);
20285
+ console.log(source_default.green(`✓ Set ${args.path}`));
20286
+ }
20287
+ });
20288
+ var updateCmd = defineCommand2({
20289
+ meta: { name: "update", description: "Merge value into path. Preserves keys not present in the new value (shallow merge). Use `set` for full replace. Mirrors BodClient.update()." },
20290
+ args: {
20291
+ path: { type: "positional", description: "DB path", required: true },
20292
+ value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
20293
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20294
+ file: { type: "string", alias: "f", description: "Read JSON from file" }
20295
+ },
20296
+ async run({ args }) {
20297
+ strictArgs(["path", "value", "app", "a", "file", "f"]);
20298
+ const body = await readBody(args.value, args.file);
20299
+ const target = await resolveDbTarget(args.app);
20300
+ await target.request("PATCH", `/db/${normalizePath(args.path)}`, body);
20301
+ console.log(source_default.green(`✓ Updated ${args.path}`));
20302
+ }
20303
+ });
20304
+ var pushCmd = defineCommand2({
20305
+ meta: { name: "push", description: "Append value under path with an auto-generated key (list-style). Returns the new key." },
20306
+ args: {
20307
+ path: { type: "positional", description: "DB path (parent/list)", required: true },
20308
+ value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
20309
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20310
+ file: { type: "string", alias: "f", description: "Read JSON from file" }
20311
+ },
20312
+ async run({ args }) {
20313
+ strictArgs(["path", "value", "app", "a", "file", "f"]);
20314
+ const body = await readBody(args.value, args.file);
20315
+ const target = await resolveDbTarget(args.app);
20316
+ const res = await target.request("POST", `/db/${normalizePath(args.path)}`, body);
20317
+ console.log(source_default.green(`✓ Pushed → ${args.path}/${res.key}`));
20318
+ }
20319
+ });
20320
+ var deleteCmd = defineCommand2({
20321
+ meta: { name: "delete", description: "Delete the node at path and all its descendants. Requires -y/--confirm to avoid accidents." },
20322
+ args: {
20323
+ path: { type: "positional", description: "DB path", required: true },
20324
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20325
+ confirm: { type: "boolean", alias: "y", description: "Confirm destructive delete", default: false }
20326
+ },
20327
+ async run({ args }) {
20328
+ strictArgs(["path", "app", "a", "confirm", "y"]);
20329
+ if (!args.confirm) {
20330
+ console.error(source_default.yellow(`Refusing to delete "${args.path}" without --confirm (-y).`));
20331
+ process.exit(1);
20332
+ }
20333
+ const target = await resolveDbTarget(args.app);
20334
+ await target.request("DELETE", `/db/${normalizePath(args.path)}`);
20335
+ console.log(source_default.green(`✓ Deleted ${args.path}`));
20336
+ }
20337
+ });
20338
+ function parseFilters(where) {
20339
+ if (!where)
20340
+ return [];
20341
+ const list = Array.isArray(where) ? where : [where];
20342
+ const opMap = { eq: "==", ne: "!=", gt: ">", gte: ">=", lt: "<", lte: "<=", in: "in", contains: "contains" };
20343
+ return list.map((w2) => {
20344
+ const parts = w2.split(":");
20345
+ if (parts.length < 3) {
20346
+ console.error(source_default.red(`Invalid --where: ${w2} (expected field:op:value)`));
20347
+ process.exit(1);
20348
+ }
20349
+ const [field, op, ...rest] = parts;
20350
+ const rawValue = rest.join(":");
20351
+ let value = rawValue;
20352
+ try {
20353
+ value = JSON.parse(rawValue);
20354
+ } catch {}
20355
+ return { field, op: opMap[op] ?? op, value };
20356
+ });
20357
+ }
20358
+ var queryCmd = defineCommand2({
20359
+ meta: {
20360
+ name: "query",
20361
+ description: "Query a collection with filters. Shallow by default (returns only _path/_key per match). Use -d for full docs."
20362
+ },
20363
+ args: {
20364
+ path: { type: "positional", description: "DB path (collection)", required: true },
20365
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20366
+ filter: {
20367
+ type: "string",
20368
+ alias: "f",
20369
+ description: "Filter shorthand: field=value, field!=value, field>N, field>=N, field<N, field<=N. Repeatable."
20370
+ },
20371
+ where: {
20372
+ type: "string",
20373
+ alias: "w",
20374
+ description: "Canonical filter: field:op:value (op = eq|ne|gt|gte|lt|lte|in|contains). Repeatable. Use when value contains = or spaces."
20375
+ },
20376
+ order: { type: "string", description: "Order: field[:asc|:desc] (default asc)" },
20377
+ limit: { type: "string", alias: "n", description: "Max results" },
20378
+ offset: { type: "string", description: "Skip N results" },
20379
+ deep: { type: "boolean", alias: "d", description: "Return full matched documents. Default: shallow (keys only).", default: false },
20380
+ output: { type: "string", alias: "o", description: "Write to file instead of stdout" }
20381
+ },
20382
+ async run({ args }) {
20383
+ strictArgs(["path", "app", "a", "filter", "f", "where", "w", "order", "limit", "n", "offset", "deep", "d", "output", "o"]);
20384
+ const filters = [];
20385
+ const filterList = args.filter ? Array.isArray(args.filter) ? args.filter : [args.filter] : [];
20386
+ for (const f3 of filterList)
20387
+ filters.push(parseFilterShorthand(f3));
20388
+ filters.push(...parseFilters(args.where));
20389
+ let order;
20390
+ if (args.order) {
20391
+ const [field, dir] = args.order.split(":");
20392
+ order = { field, dir: dir === "desc" ? "desc" : "asc" };
20393
+ }
20394
+ const body = {
20395
+ filters: filters.length ? filters : undefined,
20396
+ order,
20397
+ limit: args.limit ? Number(args.limit) : undefined,
20398
+ offset: args.offset ? Number(args.offset) : undefined
20399
+ };
20400
+ const target = await resolveDbTarget(args.app);
20401
+ const res = await target.request("POST", `/query/${normalizePath(args.path)}`, body);
20402
+ let rows = res.data;
20403
+ if (!args.deep && Array.isArray(rows)) {
20404
+ rows = rows.map((r3) => ({ _path: r3._path, _key: r3._key }));
20405
+ }
20406
+ printResult(rows ?? null, args.output);
20407
+ if (!args.deep && !args.output && Array.isArray(rows)) {
20408
+ console.error(source_default.dim(`(${rows.length} match${rows.length === 1 ? "" : "es"}; shallow — pass -d for full docs)`));
20409
+ }
20410
+ }
20411
+ });
20412
+ var db_default = defineCommand2({
20413
+ meta: { name: "db", description: "Read/write per-app BodDB (get|set|update|push|delete|query). Names mirror BodClient SDK." },
20414
+ subCommands: {
20415
+ get: getCmd,
20416
+ set: setCmd2,
20417
+ update: updateCmd,
20418
+ push: pushCmd,
20419
+ delete: deleteCmd,
20420
+ query: queryCmd
20421
+ }
20422
+ });
20423
+
20073
20424
  // src/cli.ts
20074
- var instanceFlag = process.argv.find((a2) => a2.startsWith("--instance="))?.split("=").slice(1).join("=");
20425
+ function parseInstanceFlag() {
20426
+ const argv2 = process.argv;
20427
+ for (let i2 = 0;i2 < argv2.length; i2++) {
20428
+ const a2 = argv2[i2];
20429
+ if (a2.startsWith("--instance="))
20430
+ return a2.slice("--instance=".length);
20431
+ if (a2 === "--instance" && i2 + 1 < argv2.length)
20432
+ return argv2[i2 + 1];
20433
+ }
20434
+ return;
20435
+ }
20436
+ var instanceFlag = parseInstanceFlag();
20075
20437
  var isLocal = process.argv.includes("--local") || process.argv.includes("-l");
20076
20438
  if (instanceFlag)
20077
20439
  setInstanceOverride(instanceFlag);
@@ -20090,7 +20452,8 @@ var subCommands = {
20090
20452
  open: open_default,
20091
20453
  serve: serve_default,
20092
20454
  ssh: ssh_default,
20093
- publish: publish_default
20455
+ publish: publish_default,
20456
+ db: db_default
20094
20457
  };
20095
20458
  var main = defineCommand({
20096
20459
  meta: {
@@ -20122,6 +20485,7 @@ var main = defineCommand({
20122
20485
  { value: "logs", name: "logs \u2014 View app logs" },
20123
20486
  { value: "open", name: "open \u2014 Open app in browser" },
20124
20487
  { value: "env", name: "env \u2014 Manage env vars" },
20488
+ { value: "db", name: "db \u2014 Read/write app database" },
20125
20489
  { value: "serve", name: "serve \u2014 Run app locally" },
20126
20490
  { value: "ssh", name: "ssh \u2014 SSH into Bodify server" },
20127
20491
  { value: "init", name: "init \u2014 Initialize a project" },
@@ -20200,6 +20564,8 @@ async function getInteractiveArgs(command) {
20200
20564
  }
20201
20565
  case "publish":
20202
20566
  return [];
20567
+ case "db":
20568
+ return [];
20203
20569
  default:
20204
20570
  return [];
20205
20571
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.5.9",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./dist/cli.js"
package/src/cli.ts CHANGED
@@ -15,9 +15,20 @@ import openCmd from './commands/open'
15
15
  import serveCmd from './commands/serve'
16
16
  import sshCmd from './commands/ssh'
17
17
  import publishCmd from './commands/publish'
18
+ import dbCmd from './commands/db'
18
19
 
19
- // Parse --instance / --local / -l early so it's set before citty dispatches subcommands
20
- const instanceFlag = process.argv.find(a => a.startsWith('--instance='))?.split('=').slice(1).join('=')
20
+ // Parse --instance / --local / -l early so it's set before citty dispatches subcommands.
21
+ // Supports both `--instance=NAME` and `--instance NAME` forms.
22
+ function parseInstanceFlag(): string | undefined {
23
+ const argv = process.argv
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i]
26
+ if (a.startsWith('--instance=')) return a.slice('--instance='.length)
27
+ if (a === '--instance' && i + 1 < argv.length) return argv[i + 1]
28
+ }
29
+ return undefined
30
+ }
31
+ const instanceFlag = parseInstanceFlag()
21
32
  const isLocal = process.argv.includes('--local') || process.argv.includes('-l')
22
33
  if (instanceFlag) setInstanceOverride(instanceFlag)
23
34
  else if (isLocal) setInstanceOverride('local')
@@ -36,6 +47,7 @@ const subCommands = {
36
47
  serve: serveCmd,
37
48
  ssh: sshCmd,
38
49
  publish: publishCmd,
50
+ db: dbCmd,
39
51
  }
40
52
 
41
53
  const main = defineCommand({
@@ -71,6 +83,7 @@ const main = defineCommand({
71
83
  { value: 'logs', name: 'logs — View app logs' },
72
84
  { value: 'open', name: 'open — Open app in browser' },
73
85
  { value: 'env', name: 'env — Manage env vars' },
86
+ { value: 'db', name: 'db — Read/write app database' },
74
87
  { value: 'serve', name: 'serve — Run app locally' },
75
88
  { value: 'ssh', name: 'ssh — SSH into Bodify server' },
76
89
  { value: 'init', name: 'init — Initialize a project' },
@@ -141,6 +154,7 @@ async function getInteractiveArgs(command: string): Promise<string[] | null> {
141
154
  return [pkg]
142
155
  }
143
156
  case 'publish': return []
157
+ case 'db': return []
144
158
  default: return []
145
159
  }
146
160
  } catch (e) {
package/src/client.ts CHANGED
@@ -39,4 +39,5 @@ export class BodClient {
39
39
  post<T = unknown>(path: string, body?: unknown) { return this.request<T>('POST', path, body) }
40
40
  put<T = unknown>(path: string, body?: unknown) { return this.request<T>('PUT', path, body) }
41
41
  del<T = unknown>(path: string) { return this.request<T>('DELETE', path) }
42
+ patch<T = unknown>(path: string, body?: unknown) { return this.request<T>('PATCH', path, body) }
42
43
  }
@@ -0,0 +1,376 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { loadConfig, getResolvedInstance } from '../config'
6
+ import { BodClient } from '../client'
7
+ import { resolveAppId, resolveAppName } from '../utils/resolve'
8
+
9
+ interface DbResponse { ok: boolean; data?: unknown; key?: string; error?: string }
10
+
11
+ interface ServeInfo { dbPort: number; dbAdminPort: number; dbAdminPassword: string; dbPath: string; pid: number; startedAt: number }
12
+
13
+ /** Read `.bodify/serve.info.json` if present (created by `bod serve`). */
14
+ function readServeInfo(): ServeInfo | null {
15
+ const path = join(process.cwd(), '.bodify', 'serve.info.json')
16
+ if (!existsSync(path)) return null
17
+ try {
18
+ const info = JSON.parse(readFileSync(path, 'utf-8')) as ServeInfo
19
+ // Sanity: is the pid still alive? If not, file is stale.
20
+ try { process.kill(info.pid, 0) } catch { return null }
21
+ return info
22
+ } catch { return null }
23
+ }
24
+
25
+ /** Parse cwd's .env for a single key. No-op if missing. */
26
+ function readDotEnv(key: string): string | null {
27
+ const path = join(process.cwd(), '.env')
28
+ if (!existsSync(path)) return null
29
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
30
+ const trimmed = line.trim()
31
+ if (!trimmed || trimmed.startsWith('#')) continue
32
+ const eq = trimmed.indexOf('=')
33
+ if (eq === -1) continue
34
+ if (trimmed.slice(0, eq).trim() !== key) continue
35
+ let val = trimmed.slice(eq + 1).trim()
36
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1)
37
+ return val
38
+ }
39
+ return null
40
+ }
41
+
42
+ /** Read database port from bodify.yaml (`database.port`), else default 4460. */
43
+ function readDbPortFromYaml(): number {
44
+ const path = join(process.cwd(), 'bodify.yaml')
45
+ if (!existsSync(path)) return 4460
46
+ try {
47
+ const { parse } = require('yaml')
48
+ const y = parse(readFileSync(path, 'utf-8')) as { database?: boolean | { port?: number } }
49
+ if (typeof y?.database === 'object' && y.database?.port) return Number(y.database.port)
50
+ return 4460
51
+ } catch { return 4460 }
52
+ }
53
+
54
+ /**
55
+ * A BodDB target — either via the Bodify agent proxy (deployed apps) or
56
+ * directly to a locally-running `bod serve` BodDB.
57
+ */
58
+ interface DbTarget {
59
+ /** HTTP verb + url path suffix (after the /db or /query base) + optional body → response */
60
+ request<T = unknown>(method: string, sub: string, body?: unknown): Promise<T>
61
+ /** Display label for errors/hints */
62
+ label: string
63
+ }
64
+
65
+ function directTarget(port: number, token: string, label: string): DbTarget {
66
+ const base = `http://127.0.0.1:${port}`
67
+ return {
68
+ label: `${label} (${base})`,
69
+ async request<T>(method: string, sub: string, body?: unknown): Promise<T> {
70
+ const res = await fetch(`${base}${sub}`, {
71
+ method,
72
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
73
+ body: body !== undefined ? JSON.stringify(body) : undefined,
74
+ })
75
+ const text = await res.text()
76
+ if (!res.ok) throw new Error(`${method} ${sub} → ${res.status}: ${text}`)
77
+ try { return JSON.parse(text) as T } catch { return text as T }
78
+ },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolve a target for DB commands.
84
+ *
85
+ * Precedence:
86
+ * 1. Explicit `--instance <name>` / `BOD_INSTANCE` → always the Bodify agent
87
+ * proxy for that instance. Local shortcuts are skipped so the flag isn't
88
+ * silently ignored while `bod serve` runs in the same dir.
89
+ * 2. Otherwise, prefer direct-to-BodDB if we can discover the port + admin
90
+ * password locally (`.bodify/serve.info.json` from `bod serve`, or
91
+ * `.env BODDB_ADMIN_PASSWORD` + `bodify.yaml database.port`).
92
+ * 3. Fall back to the Bodify agent proxy for the default instance.
93
+ */
94
+ async function resolveDbTarget(appArg: string | undefined): Promise<DbTarget> {
95
+ const explicitInstance = !!(process.env._BOD_INSTANCE_OVERRIDE || process.env.BOD_INSTANCE)
96
+
97
+ if (!explicitInstance) {
98
+ // 1. Live `bod serve` info file — authoritative (correct port even if scanned).
99
+ const info = readServeInfo()
100
+ if (info) return directTarget(info.dbPort, info.dbAdminPassword, 'local serve')
101
+ // 2. .env BODDB_ADMIN_PASSWORD + bodify.yaml database.port — works without restarting serve.
102
+ const envPw = readDotEnv('BODDB_ADMIN_PASSWORD')
103
+ if (envPw) return directTarget(readDbPortFromYaml(), envPw, 'direct via .env')
104
+ }
105
+
106
+ // 3. Bodify agent proxy (deployed apps — or explicitly-requested instance).
107
+ const { url, apiKey } = getResolvedInstance(loadConfig())
108
+ const client = new BodClient(url, apiKey)
109
+ const appId = await resolveAppId(client, resolveAppName(appArg))
110
+ return {
111
+ label: `agent ${url} → app ${appId}`,
112
+ request<T>(method: string, sub: string, body?: unknown): Promise<T> {
113
+ return client.request<T>(method, `/apps/${appId}${sub}`, body)
114
+ },
115
+ }
116
+ }
117
+
118
+ /** Read body from positional arg, --file, or stdin (in that order). */
119
+ async function readBody(positional: string | undefined, file: string | undefined): Promise<unknown> {
120
+ let raw: string | undefined
121
+ if (positional) raw = positional
122
+ else if (file) raw = readFileSync(file, 'utf-8')
123
+ else if (!process.stdin.isTTY) raw = await new Response(Bun.stdin.stream()).text()
124
+ if (raw === undefined || raw.trim() === '') {
125
+ console.error(chalk.red('Body required. Pass JSON as positional arg, --file <path>, or pipe via stdin.'))
126
+ process.exit(1)
127
+ }
128
+ try { return JSON.parse(raw) }
129
+ catch (e) {
130
+ console.error(chalk.red(`Invalid JSON: ${(e as Error).message}`))
131
+ process.exit(1)
132
+ }
133
+ }
134
+
135
+ function printResult(data: unknown, output: string | undefined) {
136
+ const text = JSON.stringify(data, null, 2)
137
+ if (output) {
138
+ writeFileSync(output, text)
139
+ console.log(chalk.green(`✓ Wrote ${output}`))
140
+ } else {
141
+ console.log(text)
142
+ }
143
+ }
144
+
145
+ /** Strip leading/trailing slashes; '/' or '' → '' (root). */
146
+ function normalizePath(p: string | undefined): string {
147
+ return (p ?? '').replace(/^\/+|\/+$/g, '')
148
+ }
149
+
150
+ /**
151
+ * Reject any raw argv flag that isn't declared in `known`. citty accepts
152
+ * unknown flags silently, so this guards against typos (e.g. --wheree).
153
+ */
154
+ function strictArgs(known: string[]): void {
155
+ const allowed = new Set([...known, 'help', 'h', 'version', 'v', 'instance', 'local', 'l'])
156
+ const unknown: string[] = []
157
+ for (const a of process.argv.slice(2)) {
158
+ if (!a.startsWith('--') && !a.startsWith('-')) continue
159
+ if (a === '--' || a === '-') continue
160
+ const name = a.replace(/^-+/, '').split('=')[0]
161
+ if (!name) continue
162
+ if (!allowed.has(name)) unknown.push(a)
163
+ }
164
+ if (unknown.length) {
165
+ console.error(chalk.red(`Unknown argument(s): ${unknown.join(', ')}`))
166
+ console.error(chalk.dim(`Allowed: ${[...allowed].filter(x => x.length > 1).sort().map(x => '--' + x).join(', ')}`))
167
+ process.exit(1)
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Parse a single --filter shorthand: `field=value`, `field!=value`, `field>value`,
173
+ * `field>=value`, `field<value`, `field<=value`. Value is parsed as JSON if possible.
174
+ * Returns a QueryFilter in the canonical bod-db shape.
175
+ */
176
+ function parseFilterShorthand(raw: string): { field: string; op: string; value: unknown } {
177
+ // Order matters: longest ops first so >= beats > etc.
178
+ const ops: Array<[string, string]> = [['>=', '>='], ['<=', '<='], ['!=', '!='], ['==', '=='], ['=', '=='], ['>', '>'], ['<', '<']]
179
+ for (const [token, op] of ops) {
180
+ const idx = raw.indexOf(token)
181
+ if (idx <= 0) continue
182
+ const field = raw.slice(0, idx)
183
+ const rawValue = raw.slice(idx + token.length)
184
+ let value: unknown = rawValue
185
+ try { value = JSON.parse(rawValue) } catch { /* keep as string */ }
186
+ return { field, op, value }
187
+ }
188
+ console.error(chalk.red(`Invalid --filter: "${raw}" (expected field=value, field!=value, field>value, etc.)`))
189
+ process.exit(1)
190
+ }
191
+
192
+ const getCmd = defineCommand({
193
+ meta: { name: 'get', description: 'Get value at path. Shallow by default (top-level keys only). Use -d for full deep read. Mirrors BodClient.get().' },
194
+ args: {
195
+ path: { type: 'positional', description: 'DB path (e.g. users/123, or / for root)', required: false },
196
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
197
+ deep: { type: 'boolean', alias: 'd', description: 'Deep read (full subtree). Warning: large nodes may be huge.', default: false },
198
+ limit: { type: 'string', alias: 'n', description: 'Shallow only: max keys to return' },
199
+ offset: { type: 'string', description: 'Shallow only: skip N keys' },
200
+ output: { type: 'string', alias: 'o', description: 'Write to file instead of stdout' },
201
+ },
202
+ async run({ args }) {
203
+ strictArgs(['path', 'app', 'a', 'deep', 'd', 'limit', 'n', 'offset', 'output', 'o'])
204
+ const target = await resolveDbTarget(args.app)
205
+ const path = normalizePath(args.path)
206
+ const qs: string[] = []
207
+ if (!args.deep) {
208
+ qs.push('shallow=1')
209
+ if (args.limit) qs.push(`limit=${encodeURIComponent(args.limit)}`)
210
+ if (args.offset) qs.push(`offset=${encodeURIComponent(args.offset)}`)
211
+ }
212
+ const sub = `/db${path ? '/' + path : '/'}${qs.length ? '?' + qs.join('&') : ''}`
213
+ const res = await target.request<DbResponse & { shallow?: boolean }>('GET', sub)
214
+ printResult(res.data ?? null, args.output)
215
+ if (res.shallow && !args.output) {
216
+ console.error(chalk.dim(`(shallow — pass -d for deep read; target: ${target.label})`))
217
+ }
218
+ },
219
+ })
220
+
221
+ const setCmd = defineCommand({
222
+ meta: { name: 'set', description: 'Replace (overwrite) value at path. Any existing subkeys not in the new value are deleted. Use `update` for merge semantics. Mirrors BodClient.set().' },
223
+ args: {
224
+ path: { type: 'positional', description: 'DB path', required: true },
225
+ value: { type: 'positional', description: 'JSON value (or use --file / stdin)', required: false },
226
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
227
+ file: { type: 'string', alias: 'f', description: 'Read JSON from file' },
228
+ },
229
+ async run({ args }) {
230
+ strictArgs(['path', 'value', 'app', 'a', 'file', 'f'])
231
+ const body = await readBody(args.value, args.file)
232
+ const target = await resolveDbTarget(args.app)
233
+ await target.request('PUT', `/db/${normalizePath(args.path)}`, body)
234
+ console.log(chalk.green(`✓ Set ${args.path}`))
235
+ },
236
+ })
237
+
238
+ const updateCmd = defineCommand({
239
+ meta: { name: 'update', description: 'Merge value into path. Preserves keys not present in the new value (shallow merge). Use `set` for full replace. Mirrors BodClient.update().' },
240
+ args: {
241
+ path: { type: 'positional', description: 'DB path', required: true },
242
+ value: { type: 'positional', description: 'JSON value (or use --file / stdin)', required: false },
243
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
244
+ file: { type: 'string', alias: 'f', description: 'Read JSON from file' },
245
+ },
246
+ async run({ args }) {
247
+ strictArgs(['path', 'value', 'app', 'a', 'file', 'f'])
248
+ const body = await readBody(args.value, args.file)
249
+ const target = await resolveDbTarget(args.app)
250
+ await target.request('PATCH', `/db/${normalizePath(args.path)}`, body)
251
+ console.log(chalk.green(`✓ Updated ${args.path}`))
252
+ },
253
+ })
254
+
255
+ const pushCmd = defineCommand({
256
+ meta: { name: 'push', description: 'Append value under path with an auto-generated key (list-style). Returns the new key.' },
257
+ args: {
258
+ path: { type: 'positional', description: 'DB path (parent/list)', required: true },
259
+ value: { type: 'positional', description: 'JSON value (or use --file / stdin)', required: false },
260
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
261
+ file: { type: 'string', alias: 'f', description: 'Read JSON from file' },
262
+ },
263
+ async run({ args }) {
264
+ strictArgs(['path', 'value', 'app', 'a', 'file', 'f'])
265
+ const body = await readBody(args.value, args.file)
266
+ const target = await resolveDbTarget(args.app)
267
+ const res = await target.request<DbResponse>('POST', `/db/${normalizePath(args.path)}`, body)
268
+ console.log(chalk.green(`✓ Pushed → ${args.path}/${res.key}`))
269
+ },
270
+ })
271
+
272
+ const deleteCmd = defineCommand({
273
+ meta: { name: 'delete', description: 'Delete the node at path and all its descendants. Requires -y/--confirm to avoid accidents.' },
274
+ args: {
275
+ path: { type: 'positional', description: 'DB path', required: true },
276
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
277
+ confirm: { type: 'boolean', alias: 'y', description: 'Confirm destructive delete', default: false },
278
+ },
279
+ async run({ args }) {
280
+ strictArgs(['path', 'app', 'a', 'confirm', 'y'])
281
+ if (!args.confirm) {
282
+ console.error(chalk.yellow(`Refusing to delete "${args.path}" without --confirm (-y).`))
283
+ process.exit(1)
284
+ }
285
+ const target = await resolveDbTarget(args.app)
286
+ await target.request('DELETE', `/db/${normalizePath(args.path)}`)
287
+ console.log(chalk.green(`✓ Deleted ${args.path}`))
288
+ },
289
+ })
290
+
291
+ /** Parse --where field:op:value (op = eq|ne|gt|gte|lt|lte|in|contains). Repeatable. */
292
+ function parseFilters(where: string | string[] | undefined): Array<{ field: string; op: string; value: unknown }> {
293
+ if (!where) return []
294
+ const list = Array.isArray(where) ? where : [where]
295
+ const opMap: Record<string, string> = { eq: '==', ne: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=', in: 'in', contains: 'contains' }
296
+ return list.map(w => {
297
+ const parts = w.split(':')
298
+ if (parts.length < 3) {
299
+ console.error(chalk.red(`Invalid --where: ${w} (expected field:op:value)`))
300
+ process.exit(1)
301
+ }
302
+ const [field, op, ...rest] = parts
303
+ const rawValue = rest.join(':')
304
+ let value: unknown = rawValue
305
+ try { value = JSON.parse(rawValue) } catch { /* keep as string */ }
306
+ return { field, op: opMap[op] ?? op, value }
307
+ })
308
+ }
309
+
310
+ const queryCmd = defineCommand({
311
+ meta: {
312
+ name: 'query',
313
+ description: 'Query a collection with filters. Shallow by default (returns only _path/_key per match). Use -d for full docs.',
314
+ },
315
+ args: {
316
+ path: { type: 'positional', description: 'DB path (collection)', required: true },
317
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
318
+ filter: {
319
+ type: 'string',
320
+ alias: 'f',
321
+ description: 'Filter shorthand: field=value, field!=value, field>N, field>=N, field<N, field<=N. Repeatable.',
322
+ },
323
+ where: {
324
+ type: 'string',
325
+ alias: 'w',
326
+ description: 'Canonical filter: field:op:value (op = eq|ne|gt|gte|lt|lte|in|contains). Repeatable. Use when value contains = or spaces.',
327
+ },
328
+ order: { type: 'string', description: 'Order: field[:asc|:desc] (default asc)' },
329
+ limit: { type: 'string', alias: 'n', description: 'Max results' },
330
+ offset: { type: 'string', description: 'Skip N results' },
331
+ deep: { type: 'boolean', alias: 'd', description: 'Return full matched documents. Default: shallow (keys only).', default: false },
332
+ output: { type: 'string', alias: 'o', description: 'Write to file instead of stdout' },
333
+ },
334
+ async run({ args }) {
335
+ strictArgs(['path', 'app', 'a', 'filter', 'f', 'where', 'w', 'order', 'limit', 'n', 'offset', 'deep', 'd', 'output', 'o'])
336
+ // Merge --filter (shorthand) and --where (canonical). Both are repeatable.
337
+ const filters: Array<{ field: string; op: string; value: unknown }> = []
338
+ const filterList = args.filter ? (Array.isArray(args.filter) ? args.filter : [args.filter]) : []
339
+ for (const f of filterList) filters.push(parseFilterShorthand(f))
340
+ filters.push(...parseFilters(args.where as any))
341
+
342
+ let order: { field: string; dir?: 'asc' | 'desc' } | undefined
343
+ if (args.order) {
344
+ const [field, dir] = args.order.split(':')
345
+ order = { field, dir: (dir === 'desc' ? 'desc' : 'asc') }
346
+ }
347
+ const body = {
348
+ filters: filters.length ? filters : undefined,
349
+ order,
350
+ limit: args.limit ? Number(args.limit) : undefined,
351
+ offset: args.offset ? Number(args.offset) : undefined,
352
+ }
353
+ const target = await resolveDbTarget(args.app)
354
+ const res = await target.request<DbResponse>('POST', `/query/${normalizePath(args.path)}`, body)
355
+ let rows = res.data as Array<Record<string, unknown>> | null
356
+ if (!args.deep && Array.isArray(rows)) {
357
+ rows = rows.map(r => ({ _path: r._path, _key: r._key }))
358
+ }
359
+ printResult(rows ?? null, args.output)
360
+ if (!args.deep && !args.output && Array.isArray(rows)) {
361
+ console.error(chalk.dim(`(${rows.length} match${rows.length === 1 ? '' : 'es'}; shallow — pass -d for full docs)`))
362
+ }
363
+ },
364
+ })
365
+
366
+ export default defineCommand({
367
+ meta: { name: 'db', description: 'Read/write per-app BodDB (get|set|update|push|delete|query). Names mirror BodClient SDK.' },
368
+ subCommands: {
369
+ get: getCmd,
370
+ set: setCmd,
371
+ update: updateCmd,
372
+ push: pushCmd,
373
+ delete: deleteCmd,
374
+ query: queryCmd,
375
+ },
376
+ })