bod-cli 0.5.9 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/skills/using-bod-cli/SKILL.md +66 -0
- package/dist/cli.js +405 -53
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/client.ts +1 -0
- package/src/commands/db.ts +365 -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
|
}
|
|
@@ -19551,6 +19556,7 @@ console.log(\`Server running on :\${port}\`);
|
|
|
19551
19556
|
];
|
|
19552
19557
|
|
|
19553
19558
|
// src/commands/init/init.ts
|
|
19559
|
+
var import_yaml2 = __toESM(require_dist(), 1);
|
|
19554
19560
|
function detectAppType(dir) {
|
|
19555
19561
|
if (existsSync3(join3(dir, "api.ts")))
|
|
19556
19562
|
return "caab";
|
|
@@ -19740,7 +19746,7 @@ var init_default = defineCommand2({
|
|
|
19740
19746
|
let yamlExisted = false;
|
|
19741
19747
|
if (existsSync3(yamlPath)) {
|
|
19742
19748
|
yamlExisted = true;
|
|
19743
|
-
yamlData =
|
|
19749
|
+
yamlData = import_yaml2.parse(readFileSync4(yamlPath, "utf8")) ?? {};
|
|
19744
19750
|
}
|
|
19745
19751
|
let yamlDirty = false;
|
|
19746
19752
|
if (!yamlData.name) {
|
|
@@ -19756,7 +19762,7 @@ var init_default = defineCommand2({
|
|
|
19756
19762
|
yamlDirty = true;
|
|
19757
19763
|
}
|
|
19758
19764
|
if (yamlDirty) {
|
|
19759
|
-
writeFileSync3(yamlPath,
|
|
19765
|
+
writeFileSync3(yamlPath, import_yaml2.stringify(yamlData));
|
|
19760
19766
|
console.log(source_default.green(yamlExisted ? "✓ Updated bodify.yaml" : "✓ Created bodify.yaml"));
|
|
19761
19767
|
} else {
|
|
19762
19768
|
console.log(source_default.dim("bodify.yaml already up to date"));
|
|
@@ -19781,7 +19787,7 @@ var init_default = defineCommand2({
|
|
|
19781
19787
|
changed = true;
|
|
19782
19788
|
}
|
|
19783
19789
|
if (changed)
|
|
19784
|
-
writeFileSync3(yamlPath,
|
|
19790
|
+
writeFileSync3(yamlPath, import_yaml2.stringify(yamlData));
|
|
19785
19791
|
} catch (e2) {
|
|
19786
19792
|
console.warn(source_default.yellow(`Warning: Could not register: ${e2.message}`));
|
|
19787
19793
|
}
|
|
@@ -20070,6 +20076,348 @@ var publish_default = defineCommand2({
|
|
|
20070
20076
|
}
|
|
20071
20077
|
});
|
|
20072
20078
|
|
|
20079
|
+
// src/commands/db.ts
|
|
20080
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
20081
|
+
import { join as join6 } from "path";
|
|
20082
|
+
function readServeInfo() {
|
|
20083
|
+
const path = join6(process.cwd(), ".bodify", "serve.info.json");
|
|
20084
|
+
if (!existsSync6(path))
|
|
20085
|
+
return null;
|
|
20086
|
+
try {
|
|
20087
|
+
const info = JSON.parse(readFileSync6(path, "utf-8"));
|
|
20088
|
+
try {
|
|
20089
|
+
process.kill(info.pid, 0);
|
|
20090
|
+
} catch {
|
|
20091
|
+
return null;
|
|
20092
|
+
}
|
|
20093
|
+
return info;
|
|
20094
|
+
} catch {
|
|
20095
|
+
return null;
|
|
20096
|
+
}
|
|
20097
|
+
}
|
|
20098
|
+
function readDotEnv(key) {
|
|
20099
|
+
const path = join6(process.cwd(), ".env");
|
|
20100
|
+
if (!existsSync6(path))
|
|
20101
|
+
return null;
|
|
20102
|
+
for (const line of readFileSync6(path, "utf-8").split(`
|
|
20103
|
+
`)) {
|
|
20104
|
+
const trimmed = line.trim();
|
|
20105
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
20106
|
+
continue;
|
|
20107
|
+
const eq = trimmed.indexOf("=");
|
|
20108
|
+
if (eq === -1)
|
|
20109
|
+
continue;
|
|
20110
|
+
if (trimmed.slice(0, eq).trim() !== key)
|
|
20111
|
+
continue;
|
|
20112
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
20113
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'"))
|
|
20114
|
+
val = val.slice(1, -1);
|
|
20115
|
+
return val;
|
|
20116
|
+
}
|
|
20117
|
+
return null;
|
|
20118
|
+
}
|
|
20119
|
+
function readDbPortFromYaml() {
|
|
20120
|
+
const path = join6(process.cwd(), "bodify.yaml");
|
|
20121
|
+
if (!existsSync6(path))
|
|
20122
|
+
return 4460;
|
|
20123
|
+
try {
|
|
20124
|
+
const { parse: parse2 } = require_dist();
|
|
20125
|
+
const y3 = parse2(readFileSync6(path, "utf-8"));
|
|
20126
|
+
if (typeof y3?.database === "object" && y3.database?.port)
|
|
20127
|
+
return Number(y3.database.port);
|
|
20128
|
+
return 4460;
|
|
20129
|
+
} catch {
|
|
20130
|
+
return 4460;
|
|
20131
|
+
}
|
|
20132
|
+
}
|
|
20133
|
+
function directTarget(port, token, label) {
|
|
20134
|
+
const base = `http://127.0.0.1:${port}`;
|
|
20135
|
+
return {
|
|
20136
|
+
label: `${label} (${base})`,
|
|
20137
|
+
async request(method, sub, body) {
|
|
20138
|
+
const res = await fetch(`${base}${sub}`, {
|
|
20139
|
+
method,
|
|
20140
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
20141
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
20142
|
+
});
|
|
20143
|
+
const text = await res.text();
|
|
20144
|
+
if (!res.ok)
|
|
20145
|
+
throw new Error(`${method} ${sub} → ${res.status}: ${text}`);
|
|
20146
|
+
try {
|
|
20147
|
+
return JSON.parse(text);
|
|
20148
|
+
} catch {
|
|
20149
|
+
return text;
|
|
20150
|
+
}
|
|
20151
|
+
}
|
|
20152
|
+
};
|
|
20153
|
+
}
|
|
20154
|
+
async function resolveDbTarget(appArg) {
|
|
20155
|
+
const info = readServeInfo();
|
|
20156
|
+
if (info)
|
|
20157
|
+
return directTarget(info.dbPort, info.dbAdminPassword, "local serve");
|
|
20158
|
+
const envPw = readDotEnv("BODDB_ADMIN_PASSWORD");
|
|
20159
|
+
if (envPw)
|
|
20160
|
+
return directTarget(readDbPortFromYaml(), envPw, "direct via .env");
|
|
20161
|
+
const { url, apiKey } = getResolvedInstance(loadConfig());
|
|
20162
|
+
const client = new BodClient(url, apiKey);
|
|
20163
|
+
const appId = await resolveAppId(client, resolveAppName(appArg));
|
|
20164
|
+
return {
|
|
20165
|
+
label: `agent ${url} → app ${appId}`,
|
|
20166
|
+
request(method, sub, body) {
|
|
20167
|
+
return client.request(method, `/apps/${appId}${sub}`, body);
|
|
20168
|
+
}
|
|
20169
|
+
};
|
|
20170
|
+
}
|
|
20171
|
+
async function readBody(positional, file) {
|
|
20172
|
+
let raw;
|
|
20173
|
+
if (positional)
|
|
20174
|
+
raw = positional;
|
|
20175
|
+
else if (file)
|
|
20176
|
+
raw = readFileSync6(file, "utf-8");
|
|
20177
|
+
else if (!process.stdin.isTTY)
|
|
20178
|
+
raw = await new Response(Bun.stdin.stream()).text();
|
|
20179
|
+
if (raw === undefined || raw.trim() === "") {
|
|
20180
|
+
console.error(source_default.red("Body required. Pass JSON as positional arg, --file <path>, or pipe via stdin."));
|
|
20181
|
+
process.exit(1);
|
|
20182
|
+
}
|
|
20183
|
+
try {
|
|
20184
|
+
return JSON.parse(raw);
|
|
20185
|
+
} catch (e2) {
|
|
20186
|
+
console.error(source_default.red(`Invalid JSON: ${e2.message}`));
|
|
20187
|
+
process.exit(1);
|
|
20188
|
+
}
|
|
20189
|
+
}
|
|
20190
|
+
function printResult(data, output) {
|
|
20191
|
+
const text = JSON.stringify(data, null, 2);
|
|
20192
|
+
if (output) {
|
|
20193
|
+
writeFileSync4(output, text);
|
|
20194
|
+
console.log(source_default.green(`✓ Wrote ${output}`));
|
|
20195
|
+
} else {
|
|
20196
|
+
console.log(text);
|
|
20197
|
+
}
|
|
20198
|
+
}
|
|
20199
|
+
function normalizePath(p) {
|
|
20200
|
+
return (p ?? "").replace(/^\/+|\/+$/g, "");
|
|
20201
|
+
}
|
|
20202
|
+
function strictArgs(known) {
|
|
20203
|
+
const allowed = new Set([...known, "help", "h", "version", "v", "instance", "local", "l"]);
|
|
20204
|
+
const unknown = [];
|
|
20205
|
+
for (const a2 of process.argv.slice(2)) {
|
|
20206
|
+
if (!a2.startsWith("--") && !a2.startsWith("-"))
|
|
20207
|
+
continue;
|
|
20208
|
+
if (a2 === "--" || a2 === "-")
|
|
20209
|
+
continue;
|
|
20210
|
+
const name = a2.replace(/^-+/, "").split("=")[0];
|
|
20211
|
+
if (!name)
|
|
20212
|
+
continue;
|
|
20213
|
+
if (!allowed.has(name))
|
|
20214
|
+
unknown.push(a2);
|
|
20215
|
+
}
|
|
20216
|
+
if (unknown.length) {
|
|
20217
|
+
console.error(source_default.red(`Unknown argument(s): ${unknown.join(", ")}`));
|
|
20218
|
+
console.error(source_default.dim(`Allowed: ${[...allowed].filter((x2) => x2.length > 1).sort().map((x2) => "--" + x2).join(", ")}`));
|
|
20219
|
+
process.exit(1);
|
|
20220
|
+
}
|
|
20221
|
+
}
|
|
20222
|
+
function parseFilterShorthand(raw) {
|
|
20223
|
+
const ops = [[">=", ">="], ["<=", "<="], ["!=", "!="], ["==", "=="], ["=", "=="], [">", ">"], ["<", "<"]];
|
|
20224
|
+
for (const [token, op] of ops) {
|
|
20225
|
+
const idx = raw.indexOf(token);
|
|
20226
|
+
if (idx <= 0)
|
|
20227
|
+
continue;
|
|
20228
|
+
const field = raw.slice(0, idx);
|
|
20229
|
+
const rawValue = raw.slice(idx + token.length);
|
|
20230
|
+
let value = rawValue;
|
|
20231
|
+
try {
|
|
20232
|
+
value = JSON.parse(rawValue);
|
|
20233
|
+
} catch {}
|
|
20234
|
+
return { field, op, value };
|
|
20235
|
+
}
|
|
20236
|
+
console.error(source_default.red(`Invalid --filter: "${raw}" (expected field=value, field!=value, field>value, etc.)`));
|
|
20237
|
+
process.exit(1);
|
|
20238
|
+
}
|
|
20239
|
+
var getCmd = defineCommand2({
|
|
20240
|
+
meta: { name: "get", description: "Get value at path. Shallow by default (top-level keys only). Use -d for full deep read. Mirrors BodClient.get()." },
|
|
20241
|
+
args: {
|
|
20242
|
+
path: { type: "positional", description: "DB path (e.g. users/123, or / for root)", required: false },
|
|
20243
|
+
app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
|
|
20244
|
+
deep: { type: "boolean", alias: "d", description: "Deep read (full subtree). Warning: large nodes may be huge.", default: false },
|
|
20245
|
+
limit: { type: "string", alias: "n", description: "Shallow only: max keys to return" },
|
|
20246
|
+
offset: { type: "string", description: "Shallow only: skip N keys" },
|
|
20247
|
+
output: { type: "string", alias: "o", description: "Write to file instead of stdout" }
|
|
20248
|
+
},
|
|
20249
|
+
async run({ args }) {
|
|
20250
|
+
strictArgs(["path", "app", "a", "deep", "d", "limit", "n", "offset", "output", "o"]);
|
|
20251
|
+
const target = await resolveDbTarget(args.app);
|
|
20252
|
+
const path = normalizePath(args.path);
|
|
20253
|
+
const qs = [];
|
|
20254
|
+
if (!args.deep) {
|
|
20255
|
+
qs.push("shallow=1");
|
|
20256
|
+
if (args.limit)
|
|
20257
|
+
qs.push(`limit=${encodeURIComponent(args.limit)}`);
|
|
20258
|
+
if (args.offset)
|
|
20259
|
+
qs.push(`offset=${encodeURIComponent(args.offset)}`);
|
|
20260
|
+
}
|
|
20261
|
+
const sub = `/db${path ? "/" + path : "/"}${qs.length ? "?" + qs.join("&") : ""}`;
|
|
20262
|
+
const res = await target.request("GET", sub);
|
|
20263
|
+
printResult(res.data ?? null, args.output);
|
|
20264
|
+
if (res.shallow && !args.output) {
|
|
20265
|
+
console.error(source_default.dim(`(shallow — pass -d for deep read; target: ${target.label})`));
|
|
20266
|
+
}
|
|
20267
|
+
}
|
|
20268
|
+
});
|
|
20269
|
+
var setCmd2 = defineCommand2({
|
|
20270
|
+
meta: { name: "set", description: "Replace (overwrite) value at path. Any existing subkeys not in the new value are deleted. Use `update` for merge semantics. Mirrors BodClient.set()." },
|
|
20271
|
+
args: {
|
|
20272
|
+
path: { type: "positional", description: "DB path", required: true },
|
|
20273
|
+
value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
|
|
20274
|
+
app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
|
|
20275
|
+
file: { type: "string", alias: "f", description: "Read JSON from file" }
|
|
20276
|
+
},
|
|
20277
|
+
async run({ args }) {
|
|
20278
|
+
strictArgs(["path", "value", "app", "a", "file", "f"]);
|
|
20279
|
+
const body = await readBody(args.value, args.file);
|
|
20280
|
+
const target = await resolveDbTarget(args.app);
|
|
20281
|
+
await target.request("PUT", `/db/${normalizePath(args.path)}`, body);
|
|
20282
|
+
console.log(source_default.green(`✓ Set ${args.path}`));
|
|
20283
|
+
}
|
|
20284
|
+
});
|
|
20285
|
+
var updateCmd = defineCommand2({
|
|
20286
|
+
meta: { name: "update", description: "Merge value into path. Preserves keys not present in the new value (shallow merge). Use `set` for full replace. Mirrors BodClient.update()." },
|
|
20287
|
+
args: {
|
|
20288
|
+
path: { type: "positional", description: "DB path", required: true },
|
|
20289
|
+
value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
|
|
20290
|
+
app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
|
|
20291
|
+
file: { type: "string", alias: "f", description: "Read JSON from file" }
|
|
20292
|
+
},
|
|
20293
|
+
async run({ args }) {
|
|
20294
|
+
strictArgs(["path", "value", "app", "a", "file", "f"]);
|
|
20295
|
+
const body = await readBody(args.value, args.file);
|
|
20296
|
+
const target = await resolveDbTarget(args.app);
|
|
20297
|
+
await target.request("PATCH", `/db/${normalizePath(args.path)}`, body);
|
|
20298
|
+
console.log(source_default.green(`✓ Updated ${args.path}`));
|
|
20299
|
+
}
|
|
20300
|
+
});
|
|
20301
|
+
var pushCmd = defineCommand2({
|
|
20302
|
+
meta: { name: "push", description: "Append value under path with an auto-generated key (list-style). Returns the new key." },
|
|
20303
|
+
args: {
|
|
20304
|
+
path: { type: "positional", description: "DB path (parent/list)", required: true },
|
|
20305
|
+
value: { type: "positional", description: "JSON value (or use --file / stdin)", required: false },
|
|
20306
|
+
app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
|
|
20307
|
+
file: { type: "string", alias: "f", description: "Read JSON from file" }
|
|
20308
|
+
},
|
|
20309
|
+
async run({ args }) {
|
|
20310
|
+
strictArgs(["path", "value", "app", "a", "file", "f"]);
|
|
20311
|
+
const body = await readBody(args.value, args.file);
|
|
20312
|
+
const target = await resolveDbTarget(args.app);
|
|
20313
|
+
const res = await target.request("POST", `/db/${normalizePath(args.path)}`, body);
|
|
20314
|
+
console.log(source_default.green(`✓ Pushed → ${args.path}/${res.key}`));
|
|
20315
|
+
}
|
|
20316
|
+
});
|
|
20317
|
+
var deleteCmd = defineCommand2({
|
|
20318
|
+
meta: { name: "delete", description: "Delete the node at path and all its descendants. Requires -y/--confirm to avoid accidents." },
|
|
20319
|
+
args: {
|
|
20320
|
+
path: { type: "positional", description: "DB path", required: true },
|
|
20321
|
+
app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
|
|
20322
|
+
confirm: { type: "boolean", alias: "y", description: "Confirm destructive delete", default: false }
|
|
20323
|
+
},
|
|
20324
|
+
async run({ args }) {
|
|
20325
|
+
strictArgs(["path", "app", "a", "confirm", "y"]);
|
|
20326
|
+
if (!args.confirm) {
|
|
20327
|
+
console.error(source_default.yellow(`Refusing to delete "${args.path}" without --confirm (-y).`));
|
|
20328
|
+
process.exit(1);
|
|
20329
|
+
}
|
|
20330
|
+
const target = await resolveDbTarget(args.app);
|
|
20331
|
+
await target.request("DELETE", `/db/${normalizePath(args.path)}`);
|
|
20332
|
+
console.log(source_default.green(`✓ Deleted ${args.path}`));
|
|
20333
|
+
}
|
|
20334
|
+
});
|
|
20335
|
+
function parseFilters(where) {
|
|
20336
|
+
if (!where)
|
|
20337
|
+
return [];
|
|
20338
|
+
const list = Array.isArray(where) ? where : [where];
|
|
20339
|
+
const opMap = { eq: "==", ne: "!=", gt: ">", gte: ">=", lt: "<", lte: "<=", in: "in", contains: "contains" };
|
|
20340
|
+
return list.map((w2) => {
|
|
20341
|
+
const parts = w2.split(":");
|
|
20342
|
+
if (parts.length < 3) {
|
|
20343
|
+
console.error(source_default.red(`Invalid --where: ${w2} (expected field:op:value)`));
|
|
20344
|
+
process.exit(1);
|
|
20345
|
+
}
|
|
20346
|
+
const [field, op, ...rest] = parts;
|
|
20347
|
+
const rawValue = rest.join(":");
|
|
20348
|
+
let value = rawValue;
|
|
20349
|
+
try {
|
|
20350
|
+
value = JSON.parse(rawValue);
|
|
20351
|
+
} catch {}
|
|
20352
|
+
return { field, op: opMap[op] ?? op, value };
|
|
20353
|
+
});
|
|
20354
|
+
}
|
|
20355
|
+
var queryCmd = defineCommand2({
|
|
20356
|
+
meta: {
|
|
20357
|
+
name: "query",
|
|
20358
|
+
description: "Query a collection with filters. Shallow by default (returns only _path/_key per match). Use -d for full docs."
|
|
20359
|
+
},
|
|
20360
|
+
args: {
|
|
20361
|
+
path: { type: "positional", description: "DB path (collection)", required: true },
|
|
20362
|
+
app: { type: "string", alias: "a", description: "App name (defaults to bodify.yaml)" },
|
|
20363
|
+
filter: {
|
|
20364
|
+
type: "string",
|
|
20365
|
+
alias: "f",
|
|
20366
|
+
description: "Filter shorthand: field=value, field!=value, field>N, field>=N, field<N, field<=N. Repeatable."
|
|
20367
|
+
},
|
|
20368
|
+
where: {
|
|
20369
|
+
type: "string",
|
|
20370
|
+
alias: "w",
|
|
20371
|
+
description: "Canonical filter: field:op:value (op = eq|ne|gt|gte|lt|lte|in|contains). Repeatable. Use when value contains = or spaces."
|
|
20372
|
+
},
|
|
20373
|
+
order: { type: "string", description: "Order: field[:asc|:desc] (default asc)" },
|
|
20374
|
+
limit: { type: "string", alias: "n", description: "Max results" },
|
|
20375
|
+
offset: { type: "string", description: "Skip N results" },
|
|
20376
|
+
deep: { type: "boolean", alias: "d", description: "Return full matched documents. Default: shallow (keys only).", default: false },
|
|
20377
|
+
output: { type: "string", alias: "o", description: "Write to file instead of stdout" }
|
|
20378
|
+
},
|
|
20379
|
+
async run({ args }) {
|
|
20380
|
+
strictArgs(["path", "app", "a", "filter", "f", "where", "w", "order", "limit", "n", "offset", "deep", "d", "output", "o"]);
|
|
20381
|
+
const filters = [];
|
|
20382
|
+
const filterList = args.filter ? Array.isArray(args.filter) ? args.filter : [args.filter] : [];
|
|
20383
|
+
for (const f3 of filterList)
|
|
20384
|
+
filters.push(parseFilterShorthand(f3));
|
|
20385
|
+
filters.push(...parseFilters(args.where));
|
|
20386
|
+
let order;
|
|
20387
|
+
if (args.order) {
|
|
20388
|
+
const [field, dir] = args.order.split(":");
|
|
20389
|
+
order = { field, dir: dir === "desc" ? "desc" : "asc" };
|
|
20390
|
+
}
|
|
20391
|
+
const body = {
|
|
20392
|
+
filters: filters.length ? filters : undefined,
|
|
20393
|
+
order,
|
|
20394
|
+
limit: args.limit ? Number(args.limit) : undefined,
|
|
20395
|
+
offset: args.offset ? Number(args.offset) : undefined
|
|
20396
|
+
};
|
|
20397
|
+
const target = await resolveDbTarget(args.app);
|
|
20398
|
+
const res = await target.request("POST", `/query/${normalizePath(args.path)}`, body);
|
|
20399
|
+
let rows = res.data;
|
|
20400
|
+
if (!args.deep && Array.isArray(rows)) {
|
|
20401
|
+
rows = rows.map((r3) => ({ _path: r3._path, _key: r3._key }));
|
|
20402
|
+
}
|
|
20403
|
+
printResult(rows ?? null, args.output);
|
|
20404
|
+
if (!args.deep && !args.output && Array.isArray(rows)) {
|
|
20405
|
+
console.error(source_default.dim(`(${rows.length} match${rows.length === 1 ? "" : "es"}; shallow — pass -d for full docs)`));
|
|
20406
|
+
}
|
|
20407
|
+
}
|
|
20408
|
+
});
|
|
20409
|
+
var db_default = defineCommand2({
|
|
20410
|
+
meta: { name: "db", description: "Read/write per-app BodDB (get|set|update|push|delete|query). Names mirror BodClient SDK." },
|
|
20411
|
+
subCommands: {
|
|
20412
|
+
get: getCmd,
|
|
20413
|
+
set: setCmd2,
|
|
20414
|
+
update: updateCmd,
|
|
20415
|
+
push: pushCmd,
|
|
20416
|
+
delete: deleteCmd,
|
|
20417
|
+
query: queryCmd
|
|
20418
|
+
}
|
|
20419
|
+
});
|
|
20420
|
+
|
|
20073
20421
|
// src/cli.ts
|
|
20074
20422
|
var instanceFlag = process.argv.find((a2) => a2.startsWith("--instance="))?.split("=").slice(1).join("=");
|
|
20075
20423
|
var isLocal = process.argv.includes("--local") || process.argv.includes("-l");
|
|
@@ -20090,7 +20438,8 @@ var subCommands = {
|
|
|
20090
20438
|
open: open_default,
|
|
20091
20439
|
serve: serve_default,
|
|
20092
20440
|
ssh: ssh_default,
|
|
20093
|
-
publish: publish_default
|
|
20441
|
+
publish: publish_default,
|
|
20442
|
+
db: db_default
|
|
20094
20443
|
};
|
|
20095
20444
|
var main = defineCommand({
|
|
20096
20445
|
meta: {
|
|
@@ -20122,6 +20471,7 @@ var main = defineCommand({
|
|
|
20122
20471
|
{ value: "logs", name: "logs \u2014 View app logs" },
|
|
20123
20472
|
{ value: "open", name: "open \u2014 Open app in browser" },
|
|
20124
20473
|
{ value: "env", name: "env \u2014 Manage env vars" },
|
|
20474
|
+
{ value: "db", name: "db \u2014 Read/write app database" },
|
|
20125
20475
|
{ value: "serve", name: "serve \u2014 Run app locally" },
|
|
20126
20476
|
{ value: "ssh", name: "ssh \u2014 SSH into Bodify server" },
|
|
20127
20477
|
{ value: "init", name: "init \u2014 Initialize a project" },
|
|
@@ -20200,6 +20550,8 @@ async function getInteractiveArgs(command) {
|
|
|
20200
20550
|
}
|
|
20201
20551
|
case "publish":
|
|
20202
20552
|
return [];
|
|
20553
|
+
case "db":
|
|
20554
|
+
return [];
|
|
20203
20555
|
default:
|
|
20204
20556
|
return [];
|
|
20205
20557
|
}
|
package/package.json
CHANGED
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
|
+
})
|