bod-cli 0.5.7 → 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
  }
@@ -18830,6 +18835,16 @@ function readAppNameFromYaml() {
18830
18835
  function readInstanceFromYaml() {
18831
18836
  return readYaml()?.instance;
18832
18837
  }
18838
+ function resolveRepoFromYaml() {
18839
+ const repo = readYaml()?.repo;
18840
+ if (typeof repo === "string")
18841
+ return repo || undefined;
18842
+ if (repo === true) {
18843
+ const proc = Bun.spawnSync(["git", "remote", "get-url", "origin"]);
18844
+ return proc.exitCode === 0 ? proc.stdout.toString().trim() || undefined : undefined;
18845
+ }
18846
+ return;
18847
+ }
18833
18848
  function readDeployModeFromYaml() {
18834
18849
  const mode = readYaml()?.deploy;
18835
18850
  return mode === "upload" ? "upload" : mode === "git" ? "git" : undefined;
@@ -18876,6 +18891,18 @@ function detectSiblingDeps() {
18876
18891
  } catch {}
18877
18892
  return [...paths];
18878
18893
  }
18894
+ function readYamlConfig() {
18895
+ const yaml = readYaml();
18896
+ if (!yaml)
18897
+ return null;
18898
+ const configKeys = ["auth", "database", "ai", "analytics", "storage", "email", "prerender", "domain", "botProxyPaths", "dbMount"];
18899
+ const config = {};
18900
+ for (const key of configKeys) {
18901
+ if (yaml[key] !== undefined)
18902
+ config[key] = yaml[key];
18903
+ }
18904
+ return Object.keys(config).length ? config : null;
18905
+ }
18879
18906
  function resolveAppName(arg) {
18880
18907
  if (arg)
18881
18908
  return arg;
@@ -18967,7 +18994,7 @@ function formatSize(bytes) {
18967
18994
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18968
18995
  }
18969
18996
  async function uploadDeploy(client, appId, branch) {
18970
- const defaultExcludes = ["node_modules", ".git", "dist"];
18997
+ const defaultExcludes = ["node_modules", ".git", "dist", ".env.local", ".env.*.local"];
18971
18998
  const userExcludes = readExcludesFromYaml();
18972
18999
  const allExcludes = [...new Set([...defaultExcludes, ...userExcludes])];
18973
19000
  const excludeFlags = allExcludes.map((e2) => `--exclude=${e2}`);
@@ -19162,11 +19189,14 @@ var deploy_default = defineCommand2({
19162
19189
  const appId = await resolveAppId(client, appName);
19163
19190
  const branch = await detectBranch(args.branch);
19164
19191
  const detail = await client.get(`/apps/${appId}`);
19192
+ const yamlConfig = readYamlConfig();
19193
+ if (yamlConfig) {
19194
+ await client.put(`/apps/${appId}`, yamlConfig).catch(() => {});
19195
+ }
19165
19196
  const forceUpload = args.upload || readDeployModeFromYaml() === "upload";
19166
19197
  let hasRepo = !!detail.repo;
19167
19198
  if (!hasRepo && !forceUpload) {
19168
- const proc = Bun.spawnSync(["git", "remote", "get-url", "origin"]);
19169
- const repo = proc.exitCode === 0 ? proc.stdout.toString().trim() : "";
19199
+ const repo = resolveRepoFromYaml();
19170
19200
  if (repo) {
19171
19201
  await client.put(`/apps/${appId}`, { repo });
19172
19202
  console.log(source_default.dim(`Linked repo: ${repo}`));
@@ -19526,6 +19556,7 @@ console.log(\`Server running on :\${port}\`);
19526
19556
  ];
19527
19557
 
19528
19558
  // src/commands/init/init.ts
19559
+ var import_yaml2 = __toESM(require_dist(), 1);
19529
19560
  function detectAppType(dir) {
19530
19561
  if (existsSync3(join3(dir, "api.ts")))
19531
19562
  return "caab";
@@ -19682,16 +19713,11 @@ var init_default = defineCommand2({
19682
19713
  Bun.spawnSync(["git", "init"], { cwd, stdio: ["ignore", "ignore", "ignore"] });
19683
19714
  Bun.spawnSync(["git", "add", "."], { cwd, stdio: ["ignore", "ignore", "ignore"] });
19684
19715
  Bun.spawnSync(["git", "commit", "-m", "Initial commit (bod init)"], { cwd, stdio: ["ignore", "ignore", "ignore"] });
19685
- let repo = "";
19686
- try {
19687
- const proc = Bun.spawnSync(["git", "remote", "get-url", "origin"], { cwd });
19688
- repo = proc.exitCode === 0 ? proc.stdout.toString().trim() : "";
19689
- } catch {}
19690
19716
  console.log(source_default.dim("Registering app with Bodify..."));
19691
19717
  try {
19692
19718
  const app = await client.post("/apps", {
19693
19719
  name: appName,
19694
- repo,
19720
+ repo: "",
19695
19721
  database: useDb
19696
19722
  });
19697
19723
  console.log(source_default.green(`✓ App registered (id: ${app.id})`));
@@ -19715,17 +19741,12 @@ var init_default = defineCommand2({
19715
19741
  const detected = detectAppType(cwd);
19716
19742
  console.log(source_default.dim(`Detected type: ${detected}`));
19717
19743
  const appName = await esm_default2({ message: "App name:", default: folderName });
19718
- let repo = "";
19719
- try {
19720
- const proc = Bun.spawnSync(["git", "remote", "get-url", "origin"], { cwd });
19721
- repo = proc.stdout.toString().trim();
19722
- } catch {}
19723
19744
  const yamlPath = join3(cwd, "bodify.yaml");
19724
19745
  let yamlData = {};
19725
19746
  let yamlExisted = false;
19726
19747
  if (existsSync3(yamlPath)) {
19727
19748
  yamlExisted = true;
19728
- yamlData = $parse(readFileSync4(yamlPath, "utf8")) ?? {};
19749
+ yamlData = import_yaml2.parse(readFileSync4(yamlPath, "utf8")) ?? {};
19729
19750
  }
19730
19751
  let yamlDirty = false;
19731
19752
  if (!yamlData.name) {
@@ -19738,14 +19759,10 @@ var init_default = defineCommand2({
19738
19759
  yamlData.database = true;
19739
19760
  yamlDirty = true;
19740
19761
  }
19741
- if (repo) {
19742
- yamlData.repo = repo;
19743
- yamlDirty = true;
19744
- }
19745
19762
  yamlDirty = true;
19746
19763
  }
19747
19764
  if (yamlDirty) {
19748
- writeFileSync3(yamlPath, $stringify(yamlData));
19765
+ writeFileSync3(yamlPath, import_yaml2.stringify(yamlData));
19749
19766
  console.log(source_default.green(yamlExisted ? "✓ Updated bodify.yaml" : "✓ Created bodify.yaml"));
19750
19767
  } else {
19751
19768
  console.log(source_default.dim("bodify.yaml already up to date"));
@@ -19754,7 +19771,7 @@ var init_default = defineCommand2({
19754
19771
  try {
19755
19772
  const app = await client.post("/apps", {
19756
19773
  name: appName,
19757
- repo,
19774
+ repo: "",
19758
19775
  database: detected === "caab"
19759
19776
  });
19760
19777
  console.log(source_default.green(`✓ App registered (id: ${app.id})`));
@@ -19770,7 +19787,7 @@ var init_default = defineCommand2({
19770
19787
  changed = true;
19771
19788
  }
19772
19789
  if (changed)
19773
- writeFileSync3(yamlPath, $stringify(yamlData));
19790
+ writeFileSync3(yamlPath, import_yaml2.stringify(yamlData));
19774
19791
  } catch (e2) {
19775
19792
  console.warn(source_default.yellow(`Warning: Could not register: ${e2.message}`));
19776
19793
  }
@@ -20059,6 +20076,348 @@ var publish_default = defineCommand2({
20059
20076
  }
20060
20077
  });
20061
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
+
20062
20421
  // src/cli.ts
20063
20422
  var instanceFlag = process.argv.find((a2) => a2.startsWith("--instance="))?.split("=").slice(1).join("=");
20064
20423
  var isLocal = process.argv.includes("--local") || process.argv.includes("-l");
@@ -20079,7 +20438,8 @@ var subCommands = {
20079
20438
  open: open_default,
20080
20439
  serve: serve_default,
20081
20440
  ssh: ssh_default,
20082
- publish: publish_default
20441
+ publish: publish_default,
20442
+ db: db_default
20083
20443
  };
20084
20444
  var main = defineCommand({
20085
20445
  meta: {
@@ -20111,6 +20471,7 @@ var main = defineCommand({
20111
20471
  { value: "logs", name: "logs \u2014 View app logs" },
20112
20472
  { value: "open", name: "open \u2014 Open app in browser" },
20113
20473
  { value: "env", name: "env \u2014 Manage env vars" },
20474
+ { value: "db", name: "db \u2014 Read/write app database" },
20114
20475
  { value: "serve", name: "serve \u2014 Run app locally" },
20115
20476
  { value: "ssh", name: "ssh \u2014 SSH into Bodify server" },
20116
20477
  { value: "init", name: "init \u2014 Initialize a project" },
@@ -20189,6 +20550,8 @@ async function getInteractiveArgs(command) {
20189
20550
  }
20190
20551
  case "publish":
20191
20552
  return [];
20553
+ case "db":
20554
+ return [];
20192
20555
  default:
20193
20556
  return [];
20194
20557
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.5.7",
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
+ })
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
2
2
  import chalk from 'chalk'
3
3
  import { loadConfig, getResolvedInstance } from '../config'
4
4
  import { BodClient } from '../client'
5
- import { resolveAppId, resolveAppName, readInstanceFromYaml, readDeployModeFromYaml, readExcludesFromYaml, detectSiblingDeps } from '../utils/resolve'
5
+ import { resolveAppId, resolveAppName, readInstanceFromYaml, readDeployModeFromYaml, readExcludesFromYaml, detectSiblingDeps, readYamlConfig, resolveRepoFromYaml } from '../utils/resolve'
6
6
 
7
7
  async function detectBranch(explicit?: string): Promise<string> {
8
8
  if (explicit) return explicit
@@ -18,7 +18,7 @@ function formatSize(bytes: number): string {
18
18
  }
19
19
 
20
20
  async function uploadDeploy(client: BodClient, appId: string, branch: string) {
21
- const defaultExcludes = ['node_modules', '.git', 'dist']
21
+ const defaultExcludes = ['node_modules', '.git', 'dist', '.env.local', '.env.*.local']
22
22
  const userExcludes = readExcludesFromYaml()
23
23
  const allExcludes = [...new Set([...defaultExcludes, ...userExcludes])]
24
24
  const excludeFlags = allExcludes.map(e => `--exclude=${e}`)
@@ -236,12 +236,17 @@ export default defineCommand({
236
236
  const branch = await detectBranch(args.branch)
237
237
  const detail = await client.get<any>(`/apps/${appId}`)
238
238
 
239
+ // Sync bodify.yaml config (auth, database, ai, etc.) to the server
240
+ const yamlConfig = readYamlConfig()
241
+ if (yamlConfig) {
242
+ await client.put(`/apps/${appId}`, yamlConfig).catch(() => {})
243
+ }
244
+
239
245
  const forceUpload = args.upload || readDeployModeFromYaml() === 'upload'
240
246
 
241
247
  let hasRepo = !!detail.repo
242
248
  if (!hasRepo && !forceUpload) {
243
- const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'])
244
- const repo = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
249
+ const repo = resolveRepoFromYaml()
245
250
  if (repo) {
246
251
  await client.put(`/apps/${appId}`, { repo })
247
252
  console.log(chalk.dim(`Linked repo: ${repo}`))
@@ -184,19 +184,12 @@ export default defineCommand({
184
184
  Bun.spawnSync(['git', 'add', '.'], { cwd, stdio: ['ignore', 'ignore', 'ignore'] })
185
185
  Bun.spawnSync(['git', 'commit', '-m', 'Initial commit (bod init)'], { cwd, stdio: ['ignore', 'ignore', 'ignore'] })
186
186
 
187
- // Detect git remote (may exist if user pre-configured)
188
- let repo = ''
189
- try {
190
- const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], { cwd })
191
- repo = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
192
- } catch { /* no remote */ }
193
-
194
- // Register with bodify
187
+ // Register with bodify (no repo for new projects — user opts in via bodify.yaml)
195
188
  console.log(chalk.dim('Registering app with Bodify...'))
196
189
  try {
197
190
  const app = await client.post<any>('/apps', {
198
191
  name: appName,
199
- repo,
192
+ repo: '',
200
193
  database: useDb,
201
194
  })
202
195
  console.log(chalk.green(`✓ App registered (id: ${app.id})`))
@@ -226,13 +219,6 @@ export default defineCommand({
226
219
 
227
220
  const appName = await input({ message: 'App name:', default: folderName })
228
221
 
229
- // Detect git remote
230
- let repo = ''
231
- try {
232
- const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], { cwd })
233
- repo = proc.stdout.toString().trim()
234
- } catch { /* no git remote */ }
235
-
236
222
  // bodify.yaml — create or merge missing fields
237
223
  const yamlPath = join(cwd, 'bodify.yaml')
238
224
  let yamlData: Record<string, any> = {}
@@ -246,7 +232,6 @@ export default defineCommand({
246
232
  if (!yamlExisted) {
247
233
  const useDb = detected === 'caab' && await confirm({ message: 'Enable database?', default: false })
248
234
  if (useDb) { yamlData.database = true; yamlDirty = true }
249
- if (repo) { yamlData.repo = repo; yamlDirty = true }
250
235
  yamlDirty = true
251
236
  }
252
237
  if (yamlDirty) {
@@ -261,7 +246,7 @@ export default defineCommand({
261
246
  try {
262
247
  const app = await client.post<any>('/apps', {
263
248
  name: appName,
264
- repo,
249
+ repo: '',
265
250
  database: detected === 'caab',
266
251
  })
267
252
  console.log(chalk.green(`✓ App registered (id: ${app.id})`))
@@ -41,6 +41,20 @@ export function readInstanceFromYaml(): string | undefined {
41
41
  return readYaml()?.instance
42
42
  }
43
43
 
44
+ /** Resolve repo from bodify.yaml:
45
+ * - string → use as-is
46
+ * - true → auto-detect from git remote
47
+ * - absent/false → no repo */
48
+ export function resolveRepoFromYaml(): string | undefined {
49
+ const repo = readYaml()?.repo
50
+ if (typeof repo === 'string') return repo || undefined
51
+ if (repo === true) {
52
+ const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'])
53
+ return proc.exitCode === 0 ? proc.stdout.toString().trim() || undefined : undefined
54
+ }
55
+ return undefined
56
+ }
57
+
44
58
  export function readDeployModeFromYaml(): 'upload' | 'git' | undefined {
45
59
  const mode = readYaml()?.deploy
46
60
  return mode === 'upload' ? 'upload' : mode === 'git' ? 'git' : undefined
@@ -96,6 +110,18 @@ export function detectSiblingDeps(): string[] {
96
110
  return [...paths]
97
111
  }
98
112
 
113
+ /** Read deployable config fields from bodify.yaml (auth, database, ai, etc.) */
114
+ export function readYamlConfig(): Record<string, unknown> | null {
115
+ const yaml = readYaml()
116
+ if (!yaml) return null
117
+ const configKeys = ['auth', 'database', 'ai', 'analytics', 'storage', 'email', 'prerender', 'domain', 'botProxyPaths', 'dbMount']
118
+ const config: Record<string, unknown> = {}
119
+ for (const key of configKeys) {
120
+ if (yaml[key] !== undefined) config[key] = yaml[key]
121
+ }
122
+ return Object.keys(config).length ? config : null
123
+ }
124
+
99
125
  /** Resolve app name from explicit arg or bodify.yaml fallback. Logs clearly when falling back. */
100
126
  export function resolveAppName(arg: string | undefined): string {
101
127
  if (arg) return arg