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.
- package/.cursor/skills/using-bod-cli/SKILL.md +66 -0
- package/dist/cli.js +435 -72
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/client.ts +1 -0
- package/src/commands/db.ts +365 -0
- package/src/commands/deploy.ts +9 -4
- package/src/commands/init/init.ts +3 -18
- package/src/utils/resolve.ts +26 -0
|
@@ -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 =
|
|
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
|
|
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 =
|
|
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,
|
|
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,
|
|
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
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
|
+
})
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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})`))
|
package/src/utils/resolve.ts
CHANGED
|
@@ -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
|