bod-cli 0.5.9 → 0.7.2

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,6 +20076,348 @@ 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 info = readServeInfo();
20156
+ if (info)
20157
+ return directTarget(info.dbPort, info.dbAdminPassword, "local serve");
20158
+ const envPw = readDotEnv("BODDB_ADMIN_PASSWORD");
20159
+ if (envPw)
20160
+ return directTarget(readDbPortFromYaml(), envPw, "direct via .env");
20161
+ const { url, apiKey } = getResolvedInstance(loadConfig());
20162
+ const client = new BodClient(url, apiKey);
20163
+ const appId = await resolveAppId(client, resolveAppName(appArg));
20164
+ return {
20165
+ label: `agent ${url} → app ${appId}`,
20166
+ request(method, sub, body) {
20167
+ return client.request(method, `/apps/${appId}${sub}`, body);
20168
+ }
20169
+ };
20170
+ }
20171
+ async function readBody(positional, file) {
20172
+ let raw;
20173
+ if (positional)
20174
+ raw = positional;
20175
+ else if (file)
20176
+ raw = readFileSync6(file, "utf-8");
20177
+ else if (!process.stdin.isTTY)
20178
+ raw = await new Response(Bun.stdin.stream()).text();
20179
+ if (raw === undefined || raw.trim() === "") {
20180
+ console.error(source_default.red("Body required. Pass JSON as positional arg, --file <path>, or pipe via stdin."));
20181
+ process.exit(1);
20182
+ }
20183
+ try {
20184
+ return JSON.parse(raw);
20185
+ } catch (e2) {
20186
+ console.error(source_default.red(`Invalid JSON: ${e2.message}`));
20187
+ process.exit(1);
20188
+ }
20189
+ }
20190
+ function printResult(data, output) {
20191
+ const text = JSON.stringify(data, null, 2);
20192
+ if (output) {
20193
+ writeFileSync4(output, text);
20194
+ console.log(source_default.green(`✓ Wrote ${output}`));
20195
+ } else {
20196
+ console.log(text);
20197
+ }
20198
+ }
20199
+ function normalizePath(p) {
20200
+ return (p ?? "").replace(/^\/+|\/+$/g, "");
20201
+ }
20202
+ function strictArgs(known) {
20203
+ const allowed = new Set([...known, "help", "h", "version", "v", "instance", "local", "l"]);
20204
+ const unknown = [];
20205
+ for (const a2 of process.argv.slice(2)) {
20206
+ if (!a2.startsWith("--") && !a2.startsWith("-"))
20207
+ continue;
20208
+ if (a2 === "--" || a2 === "-")
20209
+ continue;
20210
+ const name = a2.replace(/^-+/, "").split("=")[0];
20211
+ if (!name)
20212
+ continue;
20213
+ if (!allowed.has(name))
20214
+ unknown.push(a2);
20215
+ }
20216
+ if (unknown.length) {
20217
+ console.error(source_default.red(`Unknown argument(s): ${unknown.join(", ")}`));
20218
+ console.error(source_default.dim(`Allowed: ${[...allowed].filter((x2) => x2.length > 1).sort().map((x2) => "--" + x2).join(", ")}`));
20219
+ process.exit(1);
20220
+ }
20221
+ }
20222
+ function parseFilterShorthand(raw) {
20223
+ const ops = [[">=", ">="], ["<=", "<="], ["!=", "!="], ["==", "=="], ["=", "=="], [">", ">"], ["<", "<"]];
20224
+ for (const [token, op] of ops) {
20225
+ const idx = raw.indexOf(token);
20226
+ if (idx <= 0)
20227
+ continue;
20228
+ const field = raw.slice(0, idx);
20229
+ const rawValue = raw.slice(idx + token.length);
20230
+ let value = rawValue;
20231
+ try {
20232
+ value = JSON.parse(rawValue);
20233
+ } catch {}
20234
+ return { field, op, value };
20235
+ }
20236
+ console.error(source_default.red(`Invalid --filter: "${raw}" (expected field=value, field!=value, field>value, etc.)`));
20237
+ process.exit(1);
20238
+ }
20239
+ var getCmd = defineCommand2({
20240
+ meta: { name: "get", description: "Get value at path. Shallow by default (top-level keys only). Use -d for full deep read. Mirrors BodClient.get()." },
20241
+ args: {
20242
+ path: { type: "positional", description: "DB path (e.g. users/123, or / for root)", required: false },
20243
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20244
+ deep: { type: "boolean", alias: "d", description: "Deep read (full subtree). Warning: large nodes may be huge.", default: false },
20245
+ limit: { type: "string", alias: "n", description: "Shallow only: max keys to return" },
20246
+ offset: { type: "string", description: "Shallow only: skip N keys" },
20247
+ output: { type: "string", alias: "o", description: "Write to file instead of stdout" }
20248
+ },
20249
+ async run({ args }) {
20250
+ strictArgs(["path", "app", "a", "deep", "d", "limit", "n", "offset", "output", "o"]);
20251
+ const target = await resolveDbTarget(args.app);
20252
+ const path = normalizePath(args.path);
20253
+ const qs = [];
20254
+ if (!args.deep) {
20255
+ qs.push("shallow=1");
20256
+ if (args.limit)
20257
+ qs.push(`limit=${encodeURIComponent(args.limit)}`);
20258
+ if (args.offset)
20259
+ qs.push(`offset=${encodeURIComponent(args.offset)}`);
20260
+ }
20261
+ const sub = `/db${path ? "/" + path : "/"}${qs.length ? "?" + qs.join("&") : ""}`;
20262
+ const res = await target.request("GET", sub);
20263
+ printResult(res.data ?? null, args.output);
20264
+ if (res.shallow && !args.output) {
20265
+ console.error(source_default.dim(`(shallow — pass -d for deep read; target: ${target.label})`));
20266
+ }
20267
+ }
20268
+ });
20269
+ var setCmd2 = defineCommand2({
20270
+ 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()." },
20271
+ args: {
20272
+ path: { type: "positional", description: "DB path", required: true },
20273
+ value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
20274
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20275
+ file: { type: "string", alias: "f", description: "Read JSON from file" }
20276
+ },
20277
+ async run({ args }) {
20278
+ strictArgs(["path", "value", "app", "a", "file", "f"]);
20279
+ const body = await readBody(args.value, args.file);
20280
+ const target = await resolveDbTarget(args.app);
20281
+ await target.request("PUT", `/db/${normalizePath(args.path)}`, body);
20282
+ console.log(source_default.green(`✓ Set ${args.path}`));
20283
+ }
20284
+ });
20285
+ var updateCmd = defineCommand2({
20286
+ 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()." },
20287
+ args: {
20288
+ path: { type: "positional", description: "DB path", required: true },
20289
+ value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
20290
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20291
+ file: { type: "string", alias: "f", description: "Read JSON from file" }
20292
+ },
20293
+ async run({ args }) {
20294
+ strictArgs(["path", "value", "app", "a", "file", "f"]);
20295
+ const body = await readBody(args.value, args.file);
20296
+ const target = await resolveDbTarget(args.app);
20297
+ await target.request("PATCH", `/db/${normalizePath(args.path)}`, body);
20298
+ console.log(source_default.green(`✓ Updated ${args.path}`));
20299
+ }
20300
+ });
20301
+ var pushCmd = defineCommand2({
20302
+ meta: { name: "push", description: "Append value under path with an auto-generated key (list-style). Returns the new key." },
20303
+ args: {
20304
+ path: { type: "positional", description: "DB path (parent/list)", required: true },
20305
+ value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
20306
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20307
+ file: { type: "string", alias: "f", description: "Read JSON from file" }
20308
+ },
20309
+ async run({ args }) {
20310
+ strictArgs(["path", "value", "app", "a", "file", "f"]);
20311
+ const body = await readBody(args.value, args.file);
20312
+ const target = await resolveDbTarget(args.app);
20313
+ const res = await target.request("POST", `/db/${normalizePath(args.path)}`, body);
20314
+ console.log(source_default.green(`✓ Pushed → ${args.path}/${res.key}`));
20315
+ }
20316
+ });
20317
+ var deleteCmd = defineCommand2({
20318
+ meta: { name: "delete", description: "Delete the node at path and all its descendants. Requires -y/--confirm to avoid accidents." },
20319
+ args: {
20320
+ path: { type: "positional", description: "DB path", required: true },
20321
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20322
+ confirm: { type: "boolean", alias: "y", description: "Confirm destructive delete", default: false }
20323
+ },
20324
+ async run({ args }) {
20325
+ strictArgs(["path", "app", "a", "confirm", "y"]);
20326
+ if (!args.confirm) {
20327
+ console.error(source_default.yellow(`Refusing to delete "${args.path}" without --confirm (-y).`));
20328
+ process.exit(1);
20329
+ }
20330
+ const target = await resolveDbTarget(args.app);
20331
+ await target.request("DELETE", `/db/${normalizePath(args.path)}`);
20332
+ console.log(source_default.green(`✓ Deleted ${args.path}`));
20333
+ }
20334
+ });
20335
+ function parseFilters(where) {
20336
+ if (!where)
20337
+ return [];
20338
+ const list = Array.isArray(where) ? where : [where];
20339
+ const opMap = { eq: "==", ne: "!=", gt: ">", gte: ">=", lt: "<", lte: "<=", in: "in", contains: "contains" };
20340
+ return list.map((w2) => {
20341
+ const parts = w2.split(":");
20342
+ if (parts.length < 3) {
20343
+ console.error(source_default.red(`Invalid --where: ${w2} (expected field:op:value)`));
20344
+ process.exit(1);
20345
+ }
20346
+ const [field, op, ...rest] = parts;
20347
+ const rawValue = rest.join(":");
20348
+ let value = rawValue;
20349
+ try {
20350
+ value = JSON.parse(rawValue);
20351
+ } catch {}
20352
+ return { field, op: opMap[op] ?? op, value };
20353
+ });
20354
+ }
20355
+ var queryCmd = defineCommand2({
20356
+ meta: {
20357
+ name: "query",
20358
+ description: "Query a collection with filters. Shallow by default (returns only _path/_key per match). Use -d for full docs."
20359
+ },
20360
+ args: {
20361
+ path: { type: "positional", description: "DB path (collection)", required: true },
20362
+ app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
20363
+ filter: {
20364
+ type: "string",
20365
+ alias: "f",
20366
+ description: "Filter shorthand: field=value, field!=value, field>N, field>=N, field<N, field<=N. Repeatable."
20367
+ },
20368
+ where: {
20369
+ type: "string",
20370
+ alias: "w",
20371
+ description: "Canonical filter: field:op:value (op = eq|ne|gt|gte|lt|lte|in|contains). Repeatable. Use when value contains = or spaces."
20372
+ },
20373
+ order: { type: "string", description: "Order: field[:asc|:desc] (default asc)" },
20374
+ limit: { type: "string", alias: "n", description: "Max results" },
20375
+ offset: { type: "string", description: "Skip N results" },
20376
+ deep: { type: "boolean", alias: "d", description: "Return full matched documents. Default: shallow (keys only).", default: false },
20377
+ output: { type: "string", alias: "o", description: "Write to file instead of stdout" }
20378
+ },
20379
+ async run({ args }) {
20380
+ strictArgs(["path", "app", "a", "filter", "f", "where", "w", "order", "limit", "n", "offset", "deep", "d", "output", "o"]);
20381
+ const filters = [];
20382
+ const filterList = args.filter ? Array.isArray(args.filter) ? args.filter : [args.filter] : [];
20383
+ for (const f3 of filterList)
20384
+ filters.push(parseFilterShorthand(f3));
20385
+ filters.push(...parseFilters(args.where));
20386
+ let order;
20387
+ if (args.order) {
20388
+ const [field, dir] = args.order.split(":");
20389
+ order = { field, dir: dir === "desc" ? "desc" : "asc" };
20390
+ }
20391
+ const body = {
20392
+ filters: filters.length ? filters : undefined,
20393
+ order,
20394
+ limit: args.limit ? Number(args.limit) : undefined,
20395
+ offset: args.offset ? Number(args.offset) : undefined
20396
+ };
20397
+ const target = await resolveDbTarget(args.app);
20398
+ const res = await target.request("POST", `/query/${normalizePath(args.path)}`, body);
20399
+ let rows = res.data;
20400
+ if (!args.deep && Array.isArray(rows)) {
20401
+ rows = rows.map((r3) => ({ _path: r3._path, _key: r3._key }));
20402
+ }
20403
+ printResult(rows ?? null, args.output);
20404
+ if (!args.deep && !args.output && Array.isArray(rows)) {
20405
+ console.error(source_default.dim(`(${rows.length} match${rows.length === 1 ? "" : "es"}; shallow — pass -d for full docs)`));
20406
+ }
20407
+ }
20408
+ });
20409
+ var db_default = defineCommand2({
20410
+ meta: { name: "db", description: "Read/write per-app BodDB (get|set|update|push|delete|query). Names mirror BodClient SDK." },
20411
+ subCommands: {
20412
+ get: getCmd,
20413
+ set: setCmd2,
20414
+ update: updateCmd,
20415
+ push: pushCmd,
20416
+ delete: deleteCmd,
20417
+ query: queryCmd
20418
+ }
20419
+ });
20420
+
20073
20421
  // src/cli.ts
20074
20422
  var instanceFlag = process.argv.find((a2) => a2.startsWith("--instance="))?.split("=").slice(1).join("=");
20075
20423
  var isLocal = process.argv.includes("--local") || process.argv.includes("-l");
@@ -20090,7 +20438,8 @@ var subCommands = {
20090
20438
  open: open_default,
20091
20439
  serve: serve_default,
20092
20440
  ssh: ssh_default,
20093
- publish: publish_default
20441
+ publish: publish_default,
20442
+ db: db_default
20094
20443
  };
20095
20444
  var main = defineCommand({
20096
20445
  meta: {
@@ -20122,6 +20471,7 @@ var main = defineCommand({
20122
20471
  { value: "logs", name: "logs \u2014 View app logs" },
20123
20472
  { value: "open", name: "open \u2014 Open app in browser" },
20124
20473
  { value: "env", name: "env \u2014 Manage env vars" },
20474
+ { value: "db", name: "db \u2014 Read/write app database" },
20125
20475
  { value: "serve", name: "serve \u2014 Run app locally" },
20126
20476
  { value: "ssh", name: "ssh \u2014 SSH into Bodify server" },
20127
20477
  { value: "init", name: "init \u2014 Initialize a project" },
@@ -20200,6 +20550,8 @@ async function getInteractiveArgs(command) {
20200
20550
  }
20201
20551
  case "publish":
20202
20552
  return [];
20553
+ case "db":
20554
+ return [];
20203
20555
  default:
20204
20556
  return [];
20205
20557
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.5.9",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./dist/cli.js"
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ 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
20
  // Parse --instance / --local / -l early so it's set before citty dispatches subcommands
20
21
  const instanceFlag = process.argv.find(a => a.startsWith('--instance='))?.split('=').slice(1).join('=')
@@ -36,6 +37,7 @@ const subCommands = {
36
37
  serve: serveCmd,
37
38
  ssh: sshCmd,
38
39
  publish: publishCmd,
40
+ db: dbCmd,
39
41
  }
40
42
 
41
43
  const main = defineCommand({
@@ -71,6 +73,7 @@ const main = defineCommand({
71
73
  { value: 'logs', name: 'logs — View app logs' },
72
74
  { value: 'open', name: 'open — Open app in browser' },
73
75
  { value: 'env', name: 'env — Manage env vars' },
76
+ { value: 'db', name: 'db — Read/write app database' },
74
77
  { value: 'serve', name: 'serve — Run app locally' },
75
78
  { value: 'ssh', name: 'ssh — SSH into Bodify server' },
76
79
  { value: 'init', name: 'init — Initialize a project' },
@@ -141,6 +144,7 @@ async function getInteractiveArgs(command: string): Promise<string[] | null> {
141
144
  return [pkg]
142
145
  }
143
146
  case 'publish': return []
147
+ case 'db': return []
144
148
  default: return []
145
149
  }
146
150
  } 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,365 @@
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. Direct-to-BodDB takes precedence when we
84
+ * can discover the port + admin password locally — this covers `bod serve`
85
+ * running in the same dir (via `.bodify/serve.info.json` or `.env`). Falls
86
+ * back to the Bodify agent proxy for deployed apps.
87
+ */
88
+ async function resolveDbTarget(appArg: string | undefined): Promise<DbTarget> {
89
+ // 1. Live `bod serve` info file — authoritative (correct port even if scanned).
90
+ const info = readServeInfo()
91
+ if (info) return directTarget(info.dbPort, info.dbAdminPassword, 'local serve')
92
+ // 2. .env BODDB_ADMIN_PASSWORD + bodify.yaml database.port — works without restarting serve.
93
+ const envPw = readDotEnv('BODDB_ADMIN_PASSWORD')
94
+ if (envPw) return directTarget(readDbPortFromYaml(), envPw, 'direct via .env')
95
+ // 3. Bodify agent proxy (deployed apps).
96
+ const { url, apiKey } = getResolvedInstance(loadConfig())
97
+ const client = new BodClient(url, apiKey)
98
+ const appId = await resolveAppId(client, resolveAppName(appArg))
99
+ return {
100
+ label: `agent ${url} → app ${appId}`,
101
+ request<T>(method: string, sub: string, body?: unknown): Promise<T> {
102
+ return client.request<T>(method, `/apps/${appId}${sub}`, body)
103
+ },
104
+ }
105
+ }
106
+
107
+ /** Read body from positional arg, --file, or stdin (in that order). */
108
+ async function readBody(positional: string | undefined, file: string | undefined): Promise<unknown> {
109
+ let raw: string | undefined
110
+ if (positional) raw = positional
111
+ else if (file) raw = readFileSync(file, 'utf-8')
112
+ else if (!process.stdin.isTTY) raw = await new Response(Bun.stdin.stream()).text()
113
+ if (raw === undefined || raw.trim() === '') {
114
+ console.error(chalk.red('Body required. Pass JSON as positional arg, --file <path>, or pipe via stdin.'))
115
+ process.exit(1)
116
+ }
117
+ try { return JSON.parse(raw) }
118
+ catch (e) {
119
+ console.error(chalk.red(`Invalid JSON: ${(e as Error).message}`))
120
+ process.exit(1)
121
+ }
122
+ }
123
+
124
+ function printResult(data: unknown, output: string | undefined) {
125
+ const text = JSON.stringify(data, null, 2)
126
+ if (output) {
127
+ writeFileSync(output, text)
128
+ console.log(chalk.green(`✓ Wrote ${output}`))
129
+ } else {
130
+ console.log(text)
131
+ }
132
+ }
133
+
134
+ /** Strip leading/trailing slashes; '/' or '' → '' (root). */
135
+ function normalizePath(p: string | undefined): string {
136
+ return (p ?? '').replace(/^\/+|\/+$/g, '')
137
+ }
138
+
139
+ /**
140
+ * Reject any raw argv flag that isn't declared in `known`. citty accepts
141
+ * unknown flags silently, so this guards against typos (e.g. --wheree).
142
+ */
143
+ function strictArgs(known: string[]): void {
144
+ const allowed = new Set([...known, 'help', 'h', 'version', 'v', 'instance', 'local', 'l'])
145
+ const unknown: string[] = []
146
+ for (const a of process.argv.slice(2)) {
147
+ if (!a.startsWith('--') && !a.startsWith('-')) continue
148
+ if (a === '--' || a === '-') continue
149
+ const name = a.replace(/^-+/, '').split('=')[0]
150
+ if (!name) continue
151
+ if (!allowed.has(name)) unknown.push(a)
152
+ }
153
+ if (unknown.length) {
154
+ console.error(chalk.red(`Unknown argument(s): ${unknown.join(', ')}`))
155
+ console.error(chalk.dim(`Allowed: ${[...allowed].filter(x => x.length > 1).sort().map(x => '--' + x).join(', ')}`))
156
+ process.exit(1)
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Parse a single --filter shorthand: `field=value`, `field!=value`, `field>value`,
162
+ * `field>=value`, `field<value`, `field<=value`. Value is parsed as JSON if possible.
163
+ * Returns a QueryFilter in the canonical bod-db shape.
164
+ */
165
+ function parseFilterShorthand(raw: string): { field: string; op: string; value: unknown } {
166
+ // Order matters: longest ops first so >= beats > etc.
167
+ const ops: Array<[string, string]> = [['>=', '>='], ['<=', '<='], ['!=', '!='], ['==', '=='], ['=', '=='], ['>', '>'], ['<', '<']]
168
+ for (const [token, op] of ops) {
169
+ const idx = raw.indexOf(token)
170
+ if (idx <= 0) continue
171
+ const field = raw.slice(0, idx)
172
+ const rawValue = raw.slice(idx + token.length)
173
+ let value: unknown = rawValue
174
+ try { value = JSON.parse(rawValue) } catch { /* keep as string */ }
175
+ return { field, op, value }
176
+ }
177
+ console.error(chalk.red(`Invalid --filter: "${raw}" (expected field=value, field!=value, field>value, etc.)`))
178
+ process.exit(1)
179
+ }
180
+
181
+ const getCmd = defineCommand({
182
+ meta: { name: 'get', description: 'Get value at path. Shallow by default (top-level keys only). Use -d for full deep read. Mirrors BodClient.get().' },
183
+ args: {
184
+ path: { type: 'positional', description: 'DB path (e.g. users/123, or / for root)', required: false },
185
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
186
+ deep: { type: 'boolean', alias: 'd', description: 'Deep read (full subtree). Warning: large nodes may be huge.', default: false },
187
+ limit: { type: 'string', alias: 'n', description: 'Shallow only: max keys to return' },
188
+ offset: { type: 'string', description: 'Shallow only: skip N keys' },
189
+ output: { type: 'string', alias: 'o', description: 'Write to file instead of stdout' },
190
+ },
191
+ async run({ args }) {
192
+ strictArgs(['path', 'app', 'a', 'deep', 'd', 'limit', 'n', 'offset', 'output', 'o'])
193
+ const target = await resolveDbTarget(args.app)
194
+ const path = normalizePath(args.path)
195
+ const qs: string[] = []
196
+ if (!args.deep) {
197
+ qs.push('shallow=1')
198
+ if (args.limit) qs.push(`limit=${encodeURIComponent(args.limit)}`)
199
+ if (args.offset) qs.push(`offset=${encodeURIComponent(args.offset)}`)
200
+ }
201
+ const sub = `/db${path ? '/' + path : '/'}${qs.length ? '?' + qs.join('&') : ''}`
202
+ const res = await target.request<DbResponse & { shallow?: boolean }>('GET', sub)
203
+ printResult(res.data ?? null, args.output)
204
+ if (res.shallow && !args.output) {
205
+ console.error(chalk.dim(`(shallow — pass -d for deep read; target: ${target.label})`))
206
+ }
207
+ },
208
+ })
209
+
210
+ const setCmd = defineCommand({
211
+ 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().' },
212
+ args: {
213
+ path: { type: 'positional', description: 'DB path', required: true },
214
+ value: { type: 'positional', description: 'JSON value (or use --file / stdin)', required: false },
215
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
216
+ file: { type: 'string', alias: 'f', description: 'Read JSON from file' },
217
+ },
218
+ async run({ args }) {
219
+ strictArgs(['path', 'value', 'app', 'a', 'file', 'f'])
220
+ const body = await readBody(args.value, args.file)
221
+ const target = await resolveDbTarget(args.app)
222
+ await target.request('PUT', `/db/${normalizePath(args.path)}`, body)
223
+ console.log(chalk.green(`✓ Set ${args.path}`))
224
+ },
225
+ })
226
+
227
+ const updateCmd = defineCommand({
228
+ 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().' },
229
+ args: {
230
+ path: { type: 'positional', description: 'DB path', required: true },
231
+ value: { type: 'positional', description: 'JSON value (or use --file / stdin)', required: false },
232
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
233
+ file: { type: 'string', alias: 'f', description: 'Read JSON from file' },
234
+ },
235
+ async run({ args }) {
236
+ strictArgs(['path', 'value', 'app', 'a', 'file', 'f'])
237
+ const body = await readBody(args.value, args.file)
238
+ const target = await resolveDbTarget(args.app)
239
+ await target.request('PATCH', `/db/${normalizePath(args.path)}`, body)
240
+ console.log(chalk.green(`✓ Updated ${args.path}`))
241
+ },
242
+ })
243
+
244
+ const pushCmd = defineCommand({
245
+ meta: { name: 'push', description: 'Append value under path with an auto-generated key (list-style). Returns the new key.' },
246
+ args: {
247
+ path: { type: 'positional', description: 'DB path (parent/list)', required: true },
248
+ value: { type: 'positional', description: 'JSON value (or use --file / stdin)', required: false },
249
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
250
+ file: { type: 'string', alias: 'f', description: 'Read JSON from file' },
251
+ },
252
+ async run({ args }) {
253
+ strictArgs(['path', 'value', 'app', 'a', 'file', 'f'])
254
+ const body = await readBody(args.value, args.file)
255
+ const target = await resolveDbTarget(args.app)
256
+ const res = await target.request<DbResponse>('POST', `/db/${normalizePath(args.path)}`, body)
257
+ console.log(chalk.green(`✓ Pushed → ${args.path}/${res.key}`))
258
+ },
259
+ })
260
+
261
+ const deleteCmd = defineCommand({
262
+ meta: { name: 'delete', description: 'Delete the node at path and all its descendants. Requires -y/--confirm to avoid accidents.' },
263
+ args: {
264
+ path: { type: 'positional', description: 'DB path', required: true },
265
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
266
+ confirm: { type: 'boolean', alias: 'y', description: 'Confirm destructive delete', default: false },
267
+ },
268
+ async run({ args }) {
269
+ strictArgs(['path', 'app', 'a', 'confirm', 'y'])
270
+ if (!args.confirm) {
271
+ console.error(chalk.yellow(`Refusing to delete "${args.path}" without --confirm (-y).`))
272
+ process.exit(1)
273
+ }
274
+ const target = await resolveDbTarget(args.app)
275
+ await target.request('DELETE', `/db/${normalizePath(args.path)}`)
276
+ console.log(chalk.green(`✓ Deleted ${args.path}`))
277
+ },
278
+ })
279
+
280
+ /** Parse --where field:op:value (op = eq|ne|gt|gte|lt|lte|in|contains). Repeatable. */
281
+ function parseFilters(where: string | string[] | undefined): Array<{ field: string; op: string; value: unknown }> {
282
+ if (!where) return []
283
+ const list = Array.isArray(where) ? where : [where]
284
+ const opMap: Record<string, string> = { eq: '==', ne: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=', in: 'in', contains: 'contains' }
285
+ return list.map(w => {
286
+ const parts = w.split(':')
287
+ if (parts.length < 3) {
288
+ console.error(chalk.red(`Invalid --where: ${w} (expected field:op:value)`))
289
+ process.exit(1)
290
+ }
291
+ const [field, op, ...rest] = parts
292
+ const rawValue = rest.join(':')
293
+ let value: unknown = rawValue
294
+ try { value = JSON.parse(rawValue) } catch { /* keep as string */ }
295
+ return { field, op: opMap[op] ?? op, value }
296
+ })
297
+ }
298
+
299
+ const queryCmd = defineCommand({
300
+ meta: {
301
+ name: 'query',
302
+ description: 'Query a collection with filters. Shallow by default (returns only _path/_key per match). Use -d for full docs.',
303
+ },
304
+ args: {
305
+ path: { type: 'positional', description: 'DB path (collection)', required: true },
306
+ app: { type: 'string', alias: 'a', description: 'App name (defaults to bodify.yaml)' },
307
+ filter: {
308
+ type: 'string',
309
+ alias: 'f',
310
+ description: 'Filter shorthand: field=value, field!=value, field>N, field>=N, field<N, field<=N. Repeatable.',
311
+ },
312
+ where: {
313
+ type: 'string',
314
+ alias: 'w',
315
+ description: 'Canonical filter: field:op:value (op = eq|ne|gt|gte|lt|lte|in|contains). Repeatable. Use when value contains = or spaces.',
316
+ },
317
+ order: { type: 'string', description: 'Order: field[:asc|:desc] (default asc)' },
318
+ limit: { type: 'string', alias: 'n', description: 'Max results' },
319
+ offset: { type: 'string', description: 'Skip N results' },
320
+ deep: { type: 'boolean', alias: 'd', description: 'Return full matched documents. Default: shallow (keys only).', default: false },
321
+ output: { type: 'string', alias: 'o', description: 'Write to file instead of stdout' },
322
+ },
323
+ async run({ args }) {
324
+ strictArgs(['path', 'app', 'a', 'filter', 'f', 'where', 'w', 'order', 'limit', 'n', 'offset', 'deep', 'd', 'output', 'o'])
325
+ // Merge --filter (shorthand) and --where (canonical). Both are repeatable.
326
+ const filters: Array<{ field: string; op: string; value: unknown }> = []
327
+ const filterList = args.filter ? (Array.isArray(args.filter) ? args.filter : [args.filter]) : []
328
+ for (const f of filterList) filters.push(parseFilterShorthand(f))
329
+ filters.push(...parseFilters(args.where as any))
330
+
331
+ let order: { field: string; dir?: 'asc' | 'desc' } | undefined
332
+ if (args.order) {
333
+ const [field, dir] = args.order.split(':')
334
+ order = { field, dir: (dir === 'desc' ? 'desc' : 'asc') }
335
+ }
336
+ const body = {
337
+ filters: filters.length ? filters : undefined,
338
+ order,
339
+ limit: args.limit ? Number(args.limit) : undefined,
340
+ offset: args.offset ? Number(args.offset) : undefined,
341
+ }
342
+ const target = await resolveDbTarget(args.app)
343
+ const res = await target.request<DbResponse>('POST', `/query/${normalizePath(args.path)}`, body)
344
+ let rows = res.data as Array<Record<string, unknown>> | null
345
+ if (!args.deep && Array.isArray(rows)) {
346
+ rows = rows.map(r => ({ _path: r._path, _key: r._key }))
347
+ }
348
+ printResult(rows ?? null, args.output)
349
+ if (!args.deep && !args.output && Array.isArray(rows)) {
350
+ console.error(chalk.dim(`(${rows.length} match${rows.length === 1 ? '' : 'es'}; shallow — pass -d for full docs)`))
351
+ }
352
+ },
353
+ })
354
+
355
+ export default defineCommand({
356
+ meta: { name: 'db', description: 'Read/write per-app BodDB (get|set|update|push|delete|query). Names mirror BodClient SDK.' },
357
+ subCommands: {
358
+ get: getCmd,
359
+ set: setCmd,
360
+ update: updateCmd,
361
+ push: pushCmd,
362
+ delete: deleteCmd,
363
+ query: queryCmd,
364
+ },
365
+ })