@synapsor/client 0.1.1 → 0.1.3
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/README.md +107 -2
- package/bin/synapsor.mjs +442 -0
- package/external-db-writeback.mjs +266 -0
- package/generated-contract.mjs +251 -1
- package/package.json +11 -4
- package/synapsor.mjs +815 -5
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ npm install @synapsor/client
|
|
|
45
45
|
```js
|
|
46
46
|
import { Synapsor } from "@synapsor/client";
|
|
47
47
|
|
|
48
|
-
const db = await Synapsor.connect("https://
|
|
48
|
+
const db = await Synapsor.connect("https://synapsor.ai", {
|
|
49
49
|
apiKey: process.env.SYNAPSOR_API_KEY,
|
|
50
50
|
});
|
|
51
51
|
|
|
@@ -61,11 +61,112 @@ const ctx = await db.invokeAgentCapability("chat.prepare_llm_context", { questio
|
|
|
61
61
|
|
|
62
62
|
Use database-scoped API keys from the Synapsor control panel for hosted projects.
|
|
63
63
|
|
|
64
|
+
## Agent Workflows
|
|
65
|
+
|
|
66
|
+
Use `agentRuns` when an agent framework owns routing, but Synapsor should own
|
|
67
|
+
the durable workflow contract: session scope, capability calls, evidence,
|
|
68
|
+
proposal branches, outbox actions, settlement, and replay.
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
const run = await db.agentRuns.start({
|
|
72
|
+
workflow: "billing.late_fee_waiver_flow",
|
|
73
|
+
version: "2026-05-27",
|
|
74
|
+
input: { user_request: "Can we waive this late fee?" },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const answer = await run.invokeCapability("support.answer_ticket_question", {
|
|
78
|
+
stepKey: "answer_ticket_question",
|
|
79
|
+
arguments: { question: "Can we waive this late fee?" },
|
|
80
|
+
responseEnvelope: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const proposal = await run.invokeCapability("billing.propose_late_fee_waiver", {
|
|
84
|
+
stepKey: "propose_waiver",
|
|
85
|
+
arguments: { amount_cents: 2500 },
|
|
86
|
+
mode: "propose_only",
|
|
87
|
+
autoBranch: true,
|
|
88
|
+
responseEnvelope: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const action = await run.proposeExternalAction("stripe.issue_refund", {
|
|
92
|
+
stepKey: "refund_customer",
|
|
93
|
+
arguments: { charge_id: "ch_123", amount_cents: 2500 },
|
|
94
|
+
idempotencyKey: "refund:TCK_1001:2500",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await run.checkpoint("before_worker_claim", {
|
|
98
|
+
payload: { reason: "external action queued" },
|
|
99
|
+
});
|
|
100
|
+
await run.complete({ decision: "waiver_proposed" }, { status: "waiting_approval" });
|
|
101
|
+
const graph = await run.explain();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Replay a stored capability run by using the numeric `agent_run_id` returned by
|
|
105
|
+
Synapsor. Deterministic replay returns the captured persisted run; comparison
|
|
106
|
+
modes can inspect the original snapshot, current state, a commit version, a
|
|
107
|
+
timestamp, or a review branch.
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
const replay = await db.replayAgentRun(123, {
|
|
111
|
+
mode: "original_snapshot",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const branchReplay = await db.replayAgentRun(123, {
|
|
115
|
+
mode: "branch",
|
|
116
|
+
branchName: "review_run_123",
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Workers should claim and confirm side effects through the outbox namespace:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
const task = await db.externalActions.claim({
|
|
124
|
+
queue: "billing_external_actions",
|
|
125
|
+
workerId: "billing-worker-1",
|
|
126
|
+
});
|
|
127
|
+
await db.externalActions.confirm(task.action_instance_id, {
|
|
128
|
+
status: "succeeded",
|
|
129
|
+
providerRequestId: "re_456",
|
|
130
|
+
response: { status: "succeeded" },
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## External DB Writeback Worker
|
|
135
|
+
|
|
136
|
+
For existing Postgres/MySQL integrations, keep Synapsor in proposal mode. The
|
|
137
|
+
agent stages an evidence-backed external write proposal, a human or settlement
|
|
138
|
+
policy approves it, and a trusted worker in your app environment applies the
|
|
139
|
+
approved change back to your existing database with parameterized SQL.
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import { createExternalDbWritebackWorker } from "@synapsor/client/external-db-writeback";
|
|
143
|
+
|
|
144
|
+
const worker = createExternalDbWritebackWorker({
|
|
145
|
+
synapsorApiKey: process.env.SYNAPSOR_API_KEY,
|
|
146
|
+
sourceName: "app_postgres",
|
|
147
|
+
execute: async ({ sql, params }) => {
|
|
148
|
+
// Use your own Postgres/MySQL client here. The model never provides SQL.
|
|
149
|
+
return appDb.query(sql, params);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await worker.pollOnce();
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The worker validates Synapsor-provided mapping metadata, only writes allowlisted
|
|
157
|
+
columns, adds tenant and primary-key guards, checks optional conflict columns,
|
|
158
|
+
and reports `applied`, `conflict`, or `failed` back to Synapsor idempotently.
|
|
159
|
+
|
|
64
160
|
## API Surface
|
|
65
161
|
|
|
66
162
|
- `execute(sql)` and `query(sql)`
|
|
67
163
|
- `setSession({...})`
|
|
68
164
|
- `invokeAgentCapability(name, args, options)`
|
|
165
|
+
- `agentRuns.start(...)`, `run.invokeCapability(...)`, `run.checkpoint(...)`, `run.complete(...)`, `run.explain(...)`
|
|
166
|
+
- `replayAgentRun(id, { mode, version, timestamp, branchName })`
|
|
167
|
+
- `externalActions.claim(...)` and `externalActions.confirm(...)`
|
|
168
|
+
- `createAgentEval(...)` / `evals.create(...)` for run-history or `sourceTable` dataset evals
|
|
169
|
+
- `evals.run(...).failures()`
|
|
69
170
|
- `listCapabilities(query)`
|
|
70
171
|
- `rememberFact({...})`
|
|
71
172
|
- `proposeMemoryFact({...})`
|
|
@@ -77,6 +178,10 @@ Use database-scoped API keys from the Synapsor control panel for hosted projects
|
|
|
77
178
|
- `checkFactForAction({...})`
|
|
78
179
|
- branch helpers: `createBranch`, `useBranch`, `diffBranch`, `mergeBranch`, `dropBranch`
|
|
79
180
|
- write lifecycle helpers: `previewWrite`, `approveWrite`, `commitWrite`, `rejectWrite`, `settleWrite`
|
|
181
|
+
- external DB writeback worker: `createExternalDbWritebackWorker` from `@synapsor/client/external-db-writeback`
|
|
80
182
|
- `readResource(uri)`
|
|
81
183
|
|
|
82
|
-
Errors from Synapsor become `SynapsorError` with `status
|
|
184
|
+
Errors from Synapsor become `SynapsorError` with `status`, `code`,
|
|
185
|
+
`requestId`, `retryable`, and the raw `payload`. Include `requestId` when
|
|
186
|
+
opening support or incident tickets so server, gateway, and runtime logs can be
|
|
187
|
+
joined without exposing secrets.
|
package/bin/synapsor.mjs
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
9
|
+
|
|
10
|
+
const PACKAGE_VERSION = "0.1.2";
|
|
11
|
+
const DEFAULT_BASE_URL = "https://synapsor.ai";
|
|
12
|
+
const ERROR_HINTS = new Map([
|
|
13
|
+
["AUTH_REQUIRED", "Set SYNAPSOR_API_KEY or run `synapsor config set api-key`."],
|
|
14
|
+
["BILLING_REQUIRED", "Builder resources require active or trialing Builder billing."],
|
|
15
|
+
["LIMIT_EXCEEDED", "You reached a controlled-beta hard limit. Upgrade or reduce usage."],
|
|
16
|
+
["FORBIDDEN", "The API key is not allowed to perform this operation."],
|
|
17
|
+
["NOT_FOUND", "Check the project, database, handle, or run id."],
|
|
18
|
+
["BETA_UNAVAILABLE", "This feature is not self-serve in the controlled beta."],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function configPath() {
|
|
22
|
+
const override = process.env.SYNAPSOR_CLI_CONFIG_HOME;
|
|
23
|
+
return join(override || join(homedir(), ".config"), "synapsor", "config.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadConfig(profile = "default") {
|
|
27
|
+
const path = configPath();
|
|
28
|
+
if (!existsSync(path)) return { path, profile, data: {}, current: {} };
|
|
29
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
30
|
+
const profiles = raw.profiles && typeof raw.profiles === "object" ? raw.profiles : {};
|
|
31
|
+
return { path, profile, data: raw, current: profiles[profile] || {} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function saveConfig(profile, patch) {
|
|
35
|
+
const existing = await loadConfig(profile);
|
|
36
|
+
const data = existing.data.profiles ? existing.data : { profiles: {} };
|
|
37
|
+
data.profiles[profile] = { ...(data.profiles[profile] || {}), ...patch };
|
|
38
|
+
const path = configPath();
|
|
39
|
+
await mkdir(dirname(path), { recursive: true });
|
|
40
|
+
await writeFile(path, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
41
|
+
try {
|
|
42
|
+
await chmod(path, 0o600);
|
|
43
|
+
} catch {
|
|
44
|
+
// Best effort on platforms without POSIX modes.
|
|
45
|
+
}
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parse(argv) {
|
|
50
|
+
const globals = { json: false, profile: "default", project: "", db: "", baseUrl: "" };
|
|
51
|
+
const args = [];
|
|
52
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
53
|
+
const arg = argv[i];
|
|
54
|
+
if (arg === "--json") {
|
|
55
|
+
const next = argv[i + 1] || "";
|
|
56
|
+
if (/^\s*[\[{]/.test(next)) {
|
|
57
|
+
args.push(arg);
|
|
58
|
+
args.push(argv[++i]);
|
|
59
|
+
} else {
|
|
60
|
+
globals.json = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (arg === "--yes" || arg === "-y") globals.yes = true;
|
|
64
|
+
else if (["--profile", "--project", "--db", "--base-url"].includes(arg)) {
|
|
65
|
+
const key = arg === "--base-url" ? "baseUrl" : arg.slice(2);
|
|
66
|
+
globals[key] = argv[++i] || "";
|
|
67
|
+
}
|
|
68
|
+
else args.push(arg);
|
|
69
|
+
}
|
|
70
|
+
return { globals, args };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function redact(value) {
|
|
74
|
+
const text = String(value || "");
|
|
75
|
+
if (!text) return "";
|
|
76
|
+
return `${text.slice(0, 8)}...redacted`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function print(payload, globals) {
|
|
80
|
+
if (globals.json) {
|
|
81
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(payload)) {
|
|
85
|
+
for (const item of payload) console.log(formatLine(item));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (payload && typeof payload === "object") {
|
|
89
|
+
console.log(formatLine(payload));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
console.log(String(payload));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatLine(item) {
|
|
96
|
+
if (!item || typeof item !== "object") return String(item);
|
|
97
|
+
const preferred = ["project_id", "database_id", "name", "plan", "status", "billing_status", "runtime_branch", "key_id"];
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (const key of preferred) {
|
|
100
|
+
if (item[key] !== undefined && item[key] !== null && item[key] !== "") parts.push(`${key}=${item[key]}`);
|
|
101
|
+
}
|
|
102
|
+
return parts.length ? parts.join(" ") : JSON.stringify(item);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function requireApiKey(config) {
|
|
106
|
+
const key = process.env.SYNAPSOR_API_KEY || config.current.apiKey || "";
|
|
107
|
+
if (!key) {
|
|
108
|
+
const error = new Error("AUTH_REQUIRED: configure an API key from the Synapsor dashboard. Public demo: https://synapsor.ai/demo");
|
|
109
|
+
error.code = "AUTH_REQUIRED";
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return key;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function client(globals) {
|
|
116
|
+
const config = await loadConfig(globals.profile);
|
|
117
|
+
const baseUrl = (globals.baseUrl || process.env.SYNAPSOR_BASE_URL || config.current.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
118
|
+
const apiKey = await requireApiKey(config);
|
|
119
|
+
return { baseUrl, apiKey };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function request(globals, method, path, body) {
|
|
123
|
+
const { baseUrl, apiKey } = await client(globals);
|
|
124
|
+
const init = { method, headers: { accept: "application/json", authorization: `Bearer ${apiKey}` } };
|
|
125
|
+
if (body !== undefined) {
|
|
126
|
+
init.headers["content-type"] = "application/json";
|
|
127
|
+
init.body = JSON.stringify(body);
|
|
128
|
+
}
|
|
129
|
+
const response = await fetch(`${baseUrl}${path}`, init);
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
let payload = {};
|
|
132
|
+
if (text.trim()) {
|
|
133
|
+
try {
|
|
134
|
+
payload = JSON.parse(text);
|
|
135
|
+
} catch {
|
|
136
|
+
payload = { ok: false, error: "INVALID_JSON_RESPONSE", body_preview: text.slice(0, 240) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!response.ok || payload.ok === false) {
|
|
140
|
+
const errorCode = mapError(payload.error || response.status);
|
|
141
|
+
const error = new Error(`${errorCode}: ${payload.message || payload.error || `HTTP ${response.status}`}`);
|
|
142
|
+
error.code = errorCode;
|
|
143
|
+
error.payload = payload;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
return payload;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function mapError(error) {
|
|
150
|
+
const text = String(error || "").toLowerCase();
|
|
151
|
+
if (text.includes("auth") || text.includes("session") || text.includes("token")) return "AUTH_REQUIRED";
|
|
152
|
+
if (text.includes("payment") || text.includes("billing")) return "BILLING_REQUIRED";
|
|
153
|
+
if (text.includes("quota") || text.includes("limit")) return "LIMIT_EXCEEDED";
|
|
154
|
+
if (text.includes("denied") || text.includes("forbidden")) return "FORBIDDEN";
|
|
155
|
+
if (text.includes("not_found")) return "NOT_FOUND";
|
|
156
|
+
if (text.includes("plan_not_available")) return "BETA_UNAVAILABLE";
|
|
157
|
+
return String(error || "REQUEST_FAILED").toUpperCase();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function flagValue(args, names, fallback = "") {
|
|
161
|
+
const aliases = Array.isArray(names) ? names : [names];
|
|
162
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
163
|
+
if (aliases.includes(args[i])) return args[i + 1] || fallback;
|
|
164
|
+
}
|
|
165
|
+
return fallback;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function csvFlag(args, name) {
|
|
169
|
+
const value = flagValue(args, name, "");
|
|
170
|
+
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveSecretRef(value) {
|
|
174
|
+
const text = String(value || "").trim();
|
|
175
|
+
if (text.startsWith("env:")) {
|
|
176
|
+
const envName = text.slice(4);
|
|
177
|
+
const resolved = process.env[envName] || "";
|
|
178
|
+
if (!resolved) throw new Error(`SECRET_ENV_REQUIRED: ${envName} is not set`);
|
|
179
|
+
return resolved;
|
|
180
|
+
}
|
|
181
|
+
return text;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scopedProject(globals) {
|
|
185
|
+
return globals.project || process.env.SYNAPSOR_PROJECT_ID || "";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function scopedDatabase(globals) {
|
|
189
|
+
return globals.db || process.env.SYNAPSOR_DATABASE_ID || "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function resolveSourceId(globals, nameOrId) {
|
|
193
|
+
const text = String(nameOrId || "").trim();
|
|
194
|
+
if (!text) throw new Error("SOURCE_REQUIRED: provide an external source name or source_id");
|
|
195
|
+
if (text.startsWith("src_")) return text;
|
|
196
|
+
const query = scopedProject(globals) ? `?project_id=${encodeURIComponent(scopedProject(globals))}` : "";
|
|
197
|
+
const listed = await request(globals, "GET", `/v1/control/external-sources${query}`);
|
|
198
|
+
const match = (listed.sources || []).find((source) => source.name === text || source.source_id === text);
|
|
199
|
+
if (!match) throw new Error(`NOT_FOUND: external source not found: ${text}`);
|
|
200
|
+
return match.source_id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function confirm(globals, label) {
|
|
204
|
+
if (globals.yes) return;
|
|
205
|
+
const rl = createInterface({ input, output });
|
|
206
|
+
const answer = await rl.question(`${label} Type yes to continue: `);
|
|
207
|
+
rl.close();
|
|
208
|
+
if (answer.trim().toLowerCase() !== "yes") throw new Error("FORBIDDEN: confirmation required");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function readStdinSecret() {
|
|
212
|
+
const rl = createInterface({ input, output });
|
|
213
|
+
const value = await rl.question("Paste Synapsor API key: ");
|
|
214
|
+
rl.close();
|
|
215
|
+
return value.trim();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function main(argv = process.argv.slice(2)) {
|
|
219
|
+
const { globals, args } = parse(argv);
|
|
220
|
+
const [cmd, sub, third, ...rest] = args;
|
|
221
|
+
if (!cmd || cmd === "--help" || cmd === "help") return help();
|
|
222
|
+
if (cmd === "--version" || cmd === "version") return console.log(PACKAGE_VERSION);
|
|
223
|
+
|
|
224
|
+
if (cmd === "config") {
|
|
225
|
+
if (sub === "get") {
|
|
226
|
+
const config = await loadConfig(globals.profile);
|
|
227
|
+
return print({ profile: globals.profile, baseUrl: config.current.baseUrl || DEFAULT_BASE_URL, apiKey: redact(process.env.SYNAPSOR_API_KEY || config.current.apiKey) }, globals);
|
|
228
|
+
}
|
|
229
|
+
if (sub === "set" && third === "base-url") {
|
|
230
|
+
const value = rest[0];
|
|
231
|
+
if (!value) throw new Error("BASE_URL_REQUIRED: usage `synapsor config set base-url https://synapsor.ai`");
|
|
232
|
+
await saveConfig(globals.profile, { baseUrl: value.replace(/\/+$/, "") });
|
|
233
|
+
return print({ ok: true, profile: globals.profile, baseUrl: value.replace(/\/+$/, "") }, globals);
|
|
234
|
+
}
|
|
235
|
+
if (sub === "set" && third === "api-key") {
|
|
236
|
+
const value = rest[0] || await readStdinSecret();
|
|
237
|
+
if (!value) throw new Error("AUTH_REQUIRED: API key required");
|
|
238
|
+
await saveConfig(globals.profile, { apiKey: value });
|
|
239
|
+
return print({ ok: true, profile: globals.profile, apiKey: redact(value) }, globals);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (cmd === "projects") {
|
|
244
|
+
if (sub === "list") {
|
|
245
|
+
const payload = await request(globals, "GET", "/v1/control/projects");
|
|
246
|
+
return print(payload.projects || [], globals);
|
|
247
|
+
}
|
|
248
|
+
if (sub === "create") {
|
|
249
|
+
const name = third;
|
|
250
|
+
if (!name) throw new Error("PROJECT_NAME_REQUIRED: usage `synapsor projects create <name>`");
|
|
251
|
+
return print(await request(globals, "POST", "/v1/control/projects", { name, plan: "free" }), globals);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (cmd === "db") {
|
|
256
|
+
if (sub === "list") {
|
|
257
|
+
const payload = await request(globals, "GET", "/v1/control/databases");
|
|
258
|
+
return print(payload.databases || [], globals);
|
|
259
|
+
}
|
|
260
|
+
if (sub === "create") {
|
|
261
|
+
const name = third;
|
|
262
|
+
if (!name) throw new Error("DATABASE_NAME_REQUIRED: usage `synapsor db create <name> --project <project>`");
|
|
263
|
+
return print(await request(globals, "POST", "/v1/control/databases", { name, project_id: globals.project, plan: "free" }), globals);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (cmd === "sources") {
|
|
268
|
+
if (sub === "list") {
|
|
269
|
+
const query = scopedProject(globals) ? `?project_id=${encodeURIComponent(scopedProject(globals))}` : "";
|
|
270
|
+
const payload = await request(globals, "GET", `/v1/control/external-sources${query}`);
|
|
271
|
+
return print(payload.sources || [], globals);
|
|
272
|
+
}
|
|
273
|
+
if (sub === "show") {
|
|
274
|
+
const sourceId = await resolveSourceId(globals, third);
|
|
275
|
+
return print(await request(globals, "GET", `/v1/control/external-sources/${encodeURIComponent(sourceId)}`), globals);
|
|
276
|
+
}
|
|
277
|
+
if (sub === "create" && ["postgres", "mysql"].includes(third)) {
|
|
278
|
+
const kind = third;
|
|
279
|
+
const defaultName = kind === "mysql" ? "app_mysql" : "app_postgres";
|
|
280
|
+
const envHint = kind === "mysql" ? "APP_MYSQL_URL" : "APP_POSTGRES_URL";
|
|
281
|
+
const name = flagValue(rest, "--name", defaultName);
|
|
282
|
+
const url = resolveSecretRef(flagValue(rest, "--url", ""));
|
|
283
|
+
const ssl = flagValue(rest, "--ssl", "require");
|
|
284
|
+
const mode = flagValue(rest, "--mode", "read-only");
|
|
285
|
+
const projectId = scopedProject(globals);
|
|
286
|
+
const databaseId = scopedDatabase(globals);
|
|
287
|
+
if (!projectId || !databaseId) throw new Error("PROJECT_AND_DATABASE_REQUIRED: use --project <project_id> --db <database_id> or SYNAPSOR_PROJECT_ID/SYNAPSOR_DATABASE_ID");
|
|
288
|
+
if (!url) throw new Error(`${kind.toUpperCase()}_URL_REQUIRED: usage \`synapsor sources create ${kind} --name ${defaultName} --url env:${envHint} --project <project> --db <database>\``);
|
|
289
|
+
return print(await request(globals, "POST", "/v1/control/external-sources", {
|
|
290
|
+
project_id: projectId,
|
|
291
|
+
database_id: databaseId,
|
|
292
|
+
kind,
|
|
293
|
+
name,
|
|
294
|
+
connection: { url, ssl_mode: ssl },
|
|
295
|
+
mode,
|
|
296
|
+
}), globals);
|
|
297
|
+
}
|
|
298
|
+
if (["test", "inspect", "generate", "doctor", "disable"].includes(sub)) {
|
|
299
|
+
const sourceId = await resolveSourceId(globals, third);
|
|
300
|
+
if (sub === "test") return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/test`, {}), globals);
|
|
301
|
+
if (sub === "inspect") {
|
|
302
|
+
const schema = flagValue(rest, "--schema", "public");
|
|
303
|
+
const database = flagValue(rest, "--database", "");
|
|
304
|
+
const payload = database ? { database, include_views: true } : { schemas: [schema], include_views: true };
|
|
305
|
+
return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/inspect`, payload), globals);
|
|
306
|
+
}
|
|
307
|
+
if (sub === "generate") {
|
|
308
|
+
const generated = await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/generate`, {
|
|
309
|
+
template: flagValue(rest, "--template", "support-ticket-agent"),
|
|
310
|
+
namespace: flagValue(rest, "--namespace", "support"),
|
|
311
|
+
});
|
|
312
|
+
const outDir = flagValue(rest, "--out", "");
|
|
313
|
+
if (outDir && Array.isArray(generated.files)) {
|
|
314
|
+
await mkdir(outDir, { recursive: true });
|
|
315
|
+
const written = [];
|
|
316
|
+
for (const file of generated.files) {
|
|
317
|
+
const relative = String(file.path || "generated.txt").replace(/^[/\\]+/, "").replace(/\.\.[/\\]/g, "");
|
|
318
|
+
const path = join(outDir, relative);
|
|
319
|
+
await mkdir(dirname(path), { recursive: true });
|
|
320
|
+
await writeFile(path, String(file.contents || ""));
|
|
321
|
+
written.push(path);
|
|
322
|
+
}
|
|
323
|
+
return print({ ok: true, source_id: generated.source_id, capability: generated.capability, out: outDir, files_written: written }, globals);
|
|
324
|
+
}
|
|
325
|
+
return print(generated, globals);
|
|
326
|
+
}
|
|
327
|
+
if (sub === "doctor") {
|
|
328
|
+
const database = flagValue(rest, "--database", "");
|
|
329
|
+
const payload = database ? { database } : { schemas: [flagValue(rest, "--schema", "public")] };
|
|
330
|
+
return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/doctor`, payload), globals);
|
|
331
|
+
}
|
|
332
|
+
await confirm(globals, "Disabling an external source blocks future reads but keeps audit/evidence history.");
|
|
333
|
+
return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/disable`, {}), globals);
|
|
334
|
+
}
|
|
335
|
+
if (sub === "import") {
|
|
336
|
+
const sourceId = await resolveSourceId(globals, third);
|
|
337
|
+
const schema = flagValue(rest, "--schema", "public");
|
|
338
|
+
const database = flagValue(rest, "--database", "");
|
|
339
|
+
const tables = csvFlag(rest, "--tables");
|
|
340
|
+
const tenantColumn = flagValue(rest, "--tenant-column", "");
|
|
341
|
+
const mode = flagValue(rest, "--mode", "live-read");
|
|
342
|
+
if (!tables.length) throw new Error("TABLES_REQUIRED: pass --tables tickets,customers,policy_chunks");
|
|
343
|
+
if (!tenantColumn && !rest.includes("--single-tenant")) throw new Error("TENANT_COLUMN_REQUIRED: pass --tenant-column tenant_id or --single-tenant");
|
|
344
|
+
return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/import`, {
|
|
345
|
+
tables: tables.map((table) => ({
|
|
346
|
+
...(database ? { database } : {}),
|
|
347
|
+
schema,
|
|
348
|
+
name: table,
|
|
349
|
+
alias_schema: flagValue(rest, "--alias-schema", database ? "external_shop" : "external_support"),
|
|
350
|
+
alias_name: table,
|
|
351
|
+
tenant_column: tenantColumn || undefined,
|
|
352
|
+
single_tenant: rest.includes("--single-tenant"),
|
|
353
|
+
mode,
|
|
354
|
+
})),
|
|
355
|
+
}), globals);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (cmd === "sql") {
|
|
360
|
+
const db = globals.db || "";
|
|
361
|
+
let sql = args.slice(1).join(" ").trim();
|
|
362
|
+
const fileIndex = args.indexOf("--file");
|
|
363
|
+
if (fileIndex >= 0) sql = await readFile(args[fileIndex + 1], "utf8");
|
|
364
|
+
if (!db) throw new Error("DATABASE_REQUIRED: use `--db <database_id>`");
|
|
365
|
+
if (!sql) throw new Error("SQL_REQUIRED: pass SQL after --db or use --file");
|
|
366
|
+
return print(await request(globals, "POST", "/v1/control/console/sql", { project_id: globals.project, database_id: db, sql }), globals);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (cmd === "invoke") {
|
|
370
|
+
const capability = sub;
|
|
371
|
+
const jsonIndex = args.indexOf("--json");
|
|
372
|
+
const body = jsonIndex >= 0 ? JSON.parse(args[jsonIndex + 1] || "{}") : {};
|
|
373
|
+
if (!capability) throw new Error("CAPABILITY_REQUIRED: usage `synapsor invoke <capability> --db <database> --json '{...}'`");
|
|
374
|
+
if (!globals.db) throw new Error("DATABASE_REQUIRED: use `--db <database_id>`");
|
|
375
|
+
return print(await request(globals, "POST", "/v1/control/console/invoke", { project_id: globals.project, database_id: globals.db, capability, arguments: body }), globals);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (cmd === "evidence" && sub === "get") {
|
|
379
|
+
if (!third || !globals.db) throw new Error("EVIDENCE_OR_DATABASE_REQUIRED: usage `synapsor evidence get <handle> --db <database>`");
|
|
380
|
+
return print(await request(globals, "POST", "/v1/control/console/evidence", { project_id: globals.project, database_id: globals.db, uri: third }), globals);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (cmd === "proposal" && ["preview", "approve", "reject"].includes(sub)) {
|
|
384
|
+
if (!third || !globals.db) throw new Error("PROPOSAL_OR_DATABASE_REQUIRED: usage `synapsor proposal preview <handle> --db <database>`");
|
|
385
|
+
if (sub !== "preview") await confirm(globals, `Proposal ${sub} is auditable and may change workflow state.`);
|
|
386
|
+
return print(await request(globals, "POST", `/v1/control/console/proposals/${sub}`, { project_id: globals.project, database_id: globals.db, proposal: third }), globals);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (cmd === "replay") {
|
|
390
|
+
if (!sub || !globals.db) throw new Error("RUN_OR_DATABASE_REQUIRED: usage `synapsor replay <run-id> --db <database>`");
|
|
391
|
+
return print(await request(globals, "POST", "/v1/control/console/replay", { project_id: globals.project, database_id: globals.db, run_id: sub }), globals);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
throw new Error(`UNKNOWN_COMMAND: ${args.join(" ")}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function help() {
|
|
398
|
+
console.log(`Synapsor CLI ${PACKAGE_VERSION}
|
|
399
|
+
|
|
400
|
+
Usage:
|
|
401
|
+
synapsor --version
|
|
402
|
+
synapsor config set base-url https://synapsor.ai
|
|
403
|
+
synapsor config set api-key
|
|
404
|
+
synapsor config get
|
|
405
|
+
synapsor projects list
|
|
406
|
+
synapsor projects create <name>
|
|
407
|
+
synapsor db list --project <project>
|
|
408
|
+
synapsor db create <name> --project <project>
|
|
409
|
+
synapsor sources create postgres --name app_postgres --url env:APP_POSTGRES_URL --ssl require --mode read-only --project <project> --db <database>
|
|
410
|
+
synapsor sources create mysql --name app_mysql --url env:APP_MYSQL_URL --ssl require --mode read-only --project <project> --db <database>
|
|
411
|
+
synapsor sources test app_postgres --project <project>
|
|
412
|
+
synapsor sources inspect app_postgres --schema public --project <project>
|
|
413
|
+
synapsor sources inspect app_mysql --database shopdb --project <project>
|
|
414
|
+
synapsor sources import app_postgres --schema public --tables tickets,customers,policy_chunks --tenant-column tenant_id --project <project>
|
|
415
|
+
synapsor sources import app_mysql --database shopdb --tables orders,customers,refund_policies --tenant-column tenant_id --project <project>
|
|
416
|
+
synapsor sources generate app_postgres --template support-ticket-agent --namespace support --project <project>
|
|
417
|
+
synapsor sources generate app_mysql --template ecommerce-order-agent --namespace ecommerce --project <project>
|
|
418
|
+
synapsor sources list --project <project>
|
|
419
|
+
synapsor sources show app_postgres --project <project>
|
|
420
|
+
synapsor sources doctor app_postgres --project <project>
|
|
421
|
+
synapsor sources disable app_postgres --project <project> --yes
|
|
422
|
+
synapsor sql --db <database> "SELECT 1;"
|
|
423
|
+
synapsor sql --db <database> --file ./query.sql
|
|
424
|
+
synapsor invoke <capability> --db <database> --json '{"ticket_id":"TICK-1001"}'
|
|
425
|
+
synapsor evidence get <evidence-handle> --db <database>
|
|
426
|
+
synapsor proposal preview <proposal-handle> --db <database>
|
|
427
|
+
synapsor proposal approve <proposal-handle> --db <database> --yes
|
|
428
|
+
synapsor proposal reject <proposal-handle> --db <database> --yes
|
|
429
|
+
synapsor replay <run-id> --db <database>
|
|
430
|
+
|
|
431
|
+
Environment:
|
|
432
|
+
SYNAPSOR_API_KEY, SYNAPSOR_BASE_URL
|
|
433
|
+
|
|
434
|
+
Synapsor CLI runs Synapsor SQL/API commands only. It never executes server shell commands.`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
main().catch((error) => {
|
|
438
|
+
const code = error.code || String(error.message || "REQUEST_FAILED").split(":", 1)[0];
|
|
439
|
+
console.error(error.message || String(error));
|
|
440
|
+
if (ERROR_HINTS.has(code)) console.error(ERROR_HINTS.get(code));
|
|
441
|
+
process.exitCode = 1;
|
|
442
|
+
});
|