@fieldwork-ai/cli 0.16.0
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 +64 -0
- package/dist/app-bundle.d.ts +6 -0
- package/dist/app-bundle.js +75 -0
- package/dist/bundle.d.ts +5 -0
- package/dist/bundle.js +38 -0
- package/dist/commands/apps.d.ts +2 -0
- package/dist/commands/apps.js +96 -0
- package/dist/commands/assets.d.ts +2 -0
- package/dist/commands/assets.js +170 -0
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +113 -0
- package/dist/commands/automations.d.ts +2 -0
- package/dist/commands/automations.js +178 -0
- package/dist/commands/connectors.d.ts +11 -0
- package/dist/commands/connectors.js +272 -0
- package/dist/commands/kb-blobs.d.ts +16 -0
- package/dist/commands/kb-blobs.js +98 -0
- package/dist/commands/kb-pages.d.ts +39 -0
- package/dist/commands/kb-pages.js +97 -0
- package/dist/commands/kb.d.ts +2 -0
- package/dist/commands/kb.js +196 -0
- package/dist/commands/tasks.d.ts +28 -0
- package/dist/commands/tasks.js +320 -0
- package/dist/commands/tool.d.ts +12 -0
- package/dist/commands/tool.js +106 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.js +93 -0
- package/dist/content-kind.d.ts +14 -0
- package/dist/content-kind.js +18 -0
- package/dist/http.d.ts +27 -0
- package/dist/http.js +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +46 -0
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +123 -0
- package/dist/output.d.ts +40 -0
- package/dist/output.js +90 -0
- package/package.json +39 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { api, apiJson, getContext, requireOrgId } from "../http.js";
|
|
2
|
+
import { EXIT, confirm, exitCodeFor, fail, note, printData } from "../output.js";
|
|
3
|
+
const TERMINAL_STATUSES = new Set(["succeeded", "failed"]);
|
|
4
|
+
async function resolveAutomation(name) {
|
|
5
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/automations?name=${encodeURIComponent(name)}`);
|
|
6
|
+
const automation = body.automations?.[0];
|
|
7
|
+
if (!automation)
|
|
8
|
+
fail(`no automation named "${name}"`, EXIT.notFound);
|
|
9
|
+
return automation;
|
|
10
|
+
}
|
|
11
|
+
async function fetchRuns(automationId, query) {
|
|
12
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${automationId}/runs${query}`);
|
|
13
|
+
return body.runs ?? [];
|
|
14
|
+
}
|
|
15
|
+
function runStatusLine(run) {
|
|
16
|
+
const parts = [
|
|
17
|
+
`run ${run.automation_run_id}`,
|
|
18
|
+
run.status,
|
|
19
|
+
`triggered by ${run.source ?? "unknown"}`,
|
|
20
|
+
`created ${run.created_at}`,
|
|
21
|
+
];
|
|
22
|
+
if (run.finished_at)
|
|
23
|
+
parts.push(`finished ${run.finished_at}`);
|
|
24
|
+
return parts.join(" · ");
|
|
25
|
+
}
|
|
26
|
+
/** Print one finished (or latest) run: status line to stderr, log tail to
|
|
27
|
+
* stdout. Exit code reflects the outcome; an in-flight run exits 0. */
|
|
28
|
+
function printRun(run, json) {
|
|
29
|
+
if (json) {
|
|
30
|
+
printData(run, true);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
note(runStatusLine(run));
|
|
34
|
+
if (run.error)
|
|
35
|
+
note(`error: ${run.error}`);
|
|
36
|
+
if (run.log_tail)
|
|
37
|
+
printData(run.log_tail, false);
|
|
38
|
+
}
|
|
39
|
+
if (run.status === "failed")
|
|
40
|
+
process.exit(EXIT.server);
|
|
41
|
+
if (!TERMINAL_STATUSES.has(run.status)) {
|
|
42
|
+
note("still running — follow it with: fieldwork automations runs <name> --follow");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Poll one run to a terminal status, narrating transitions on stderr. */
|
|
46
|
+
async function followRun(automationId, runId, intervalSeconds, json) {
|
|
47
|
+
let lastStatus = null;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const run = (await fetchRuns(automationId, `?run=${runId}`))[0];
|
|
50
|
+
if (!run)
|
|
51
|
+
fail(`run ${runId} disappeared`, EXIT.notFound);
|
|
52
|
+
if (run.status !== lastStatus) {
|
|
53
|
+
note(runStatusLine(run));
|
|
54
|
+
lastStatus = run.status;
|
|
55
|
+
}
|
|
56
|
+
if (TERMINAL_STATUSES.has(run.status)) {
|
|
57
|
+
if (json)
|
|
58
|
+
printData(run, true);
|
|
59
|
+
else {
|
|
60
|
+
if (run.error)
|
|
61
|
+
note(`error: ${run.error}`);
|
|
62
|
+
if (run.log_tail)
|
|
63
|
+
printData(run.log_tail, false);
|
|
64
|
+
}
|
|
65
|
+
if (run.status === "failed")
|
|
66
|
+
process.exit(EXIT.server);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function registerAutomationCommands(program) {
|
|
73
|
+
const automations = program
|
|
74
|
+
.command("automations")
|
|
75
|
+
.description("Standing instructions that run unattended (schedules, webhooks)");
|
|
76
|
+
automations
|
|
77
|
+
.command("list")
|
|
78
|
+
.description("List the automations you can see")
|
|
79
|
+
.option("--kind <kind>", "filter by kind: action | agent")
|
|
80
|
+
.action(async (opts) => {
|
|
81
|
+
const { json } = getContext();
|
|
82
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/automations`);
|
|
83
|
+
const rows = opts.kind
|
|
84
|
+
? body.automations.filter((a) => a.kind === opts.kind)
|
|
85
|
+
: body.automations;
|
|
86
|
+
if (json) {
|
|
87
|
+
printData({ automations: rows }, true);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const a of rows) {
|
|
91
|
+
printData(`${a.name}\t${a.kind}\t${a.enabled ? "enabled" : "DISABLED"}`, false);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
automations
|
|
95
|
+
.command("get <name>")
|
|
96
|
+
.description("One automation in full (includes the webhook URL — treat as a credential)")
|
|
97
|
+
.action(async (name) => {
|
|
98
|
+
const found = await resolveAutomation(name);
|
|
99
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}`);
|
|
100
|
+
printData(body, getContext().json);
|
|
101
|
+
});
|
|
102
|
+
const setEnabled = (enabled) => async (name) => {
|
|
103
|
+
const { json, yes } = getContext();
|
|
104
|
+
const found = await resolveAutomation(name);
|
|
105
|
+
await confirm(`${enabled ? "Enable" : "Disable"} "${name}"?`, yes);
|
|
106
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
|
|
107
|
+
printData(body, json);
|
|
108
|
+
};
|
|
109
|
+
automations.command("enable <name>").description("Enable an automation").action(setEnabled(true));
|
|
110
|
+
automations
|
|
111
|
+
.command("disable <name>")
|
|
112
|
+
.description("Disable an automation")
|
|
113
|
+
.action(setEnabled(false));
|
|
114
|
+
automations
|
|
115
|
+
.command("delete <name>")
|
|
116
|
+
.description("Delete an automation")
|
|
117
|
+
.action(async (name) => {
|
|
118
|
+
const { json, yes } = getContext();
|
|
119
|
+
const found = await resolveAutomation(name);
|
|
120
|
+
await confirm(`Delete "${name}"? Its run history is retained.`, yes);
|
|
121
|
+
const res = await api(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}`, { method: "DELETE" });
|
|
122
|
+
if (res.status !== 204) {
|
|
123
|
+
const body = (await res.json().catch(() => null));
|
|
124
|
+
fail(body?.error ?? `delete failed (${res.status})`, exitCodeFor(res.status));
|
|
125
|
+
}
|
|
126
|
+
printData({ deleted: true, automation_id: found.automation_id, name }, json);
|
|
127
|
+
});
|
|
128
|
+
automations
|
|
129
|
+
.command("runs <name>")
|
|
130
|
+
.description("Recent runs; --follow polls the latest run to completion")
|
|
131
|
+
.option("--limit <n>", "how many (default 20, max 100)")
|
|
132
|
+
.option("--follow", "poll the latest run until it succeeds or fails")
|
|
133
|
+
.option("--interval <s>", "poll interval in seconds (default 3)", "3")
|
|
134
|
+
.action(async (name, opts) => {
|
|
135
|
+
const { json } = getContext();
|
|
136
|
+
const found = await resolveAutomation(name);
|
|
137
|
+
if (opts.follow) {
|
|
138
|
+
const latest = (await fetchRuns(found.automation_id, "?limit=1"))[0];
|
|
139
|
+
if (!latest)
|
|
140
|
+
fail(`"${name}" has no runs yet`, EXIT.notFound);
|
|
141
|
+
await followRun(found.automation_id, latest.automation_run_id, Number(opts.interval) || 3, json);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const suffix = opts.limit ? `?limit=${opts.limit}` : "";
|
|
145
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}/runs${suffix}`);
|
|
146
|
+
printData(body, json);
|
|
147
|
+
});
|
|
148
|
+
automations
|
|
149
|
+
.command("logs <name>")
|
|
150
|
+
.description("A run's captured log tail (default: the latest run)")
|
|
151
|
+
.option("--run <id>", "a specific run id (from automations runs)")
|
|
152
|
+
.action(async (name, opts) => {
|
|
153
|
+
const { json } = getContext();
|
|
154
|
+
const found = await resolveAutomation(name);
|
|
155
|
+
const query = opts.run ? `?run=${encodeURIComponent(opts.run)}` : "?limit=1";
|
|
156
|
+
const run = (await fetchRuns(found.automation_id, query))[0];
|
|
157
|
+
if (!run) {
|
|
158
|
+
fail(opts.run ? `no run ${opts.run} for "${name}"` : `"${name}" has no runs yet`, EXIT.notFound);
|
|
159
|
+
}
|
|
160
|
+
printRun(run, json);
|
|
161
|
+
});
|
|
162
|
+
automations
|
|
163
|
+
.command("run-now <name>")
|
|
164
|
+
.description("Create a manual run")
|
|
165
|
+
.option("--follow", "poll the run until it succeeds or fails")
|
|
166
|
+
.option("--interval <s>", "poll interval in seconds (default 3)", "3")
|
|
167
|
+
.action(async (name, opts) => {
|
|
168
|
+
const { json, yes } = getContext();
|
|
169
|
+
const found = await resolveAutomation(name);
|
|
170
|
+
await confirm(`Run "${name}" now?`, yes);
|
|
171
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}/runs`, { method: "POST" });
|
|
172
|
+
if (opts.follow) {
|
|
173
|
+
await followRun(found.automation_id, body.run.automation_run_id, Number(opts.interval) || 3, json);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
printData(body, json);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* Secret files are written via a temp file in the target directory: openSync's
|
|
4
|
+
* mode only applies on create, so writing straight over an existing
|
|
5
|
+
* looser-mode file would silently keep that mode on the credentials; the
|
|
6
|
+
* rename also means no reader ever observes a truncated or partial file.
|
|
7
|
+
*/
|
|
8
|
+
export declare function writeSecretsFile(out: string, secrets: Record<string, string>, opts: {
|
|
9
|
+
force: boolean;
|
|
10
|
+
}): void;
|
|
11
|
+
export declare function registerConnectorCommands(program: Command): void;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { closeSync, existsSync, fchmodSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { readBundle, writeBundle } from "../bundle.js";
|
|
4
|
+
import { apiJson, getContext, requireOrgId } from "../http.js";
|
|
5
|
+
import { EXIT, confirm, fail, note, printData } from "../output.js";
|
|
6
|
+
async function resolveDefinition(key) {
|
|
7
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/connector-definitions?include_drafts=true`);
|
|
8
|
+
const matches = (body.definitions ?? []).filter((d) => d.key === key);
|
|
9
|
+
const definition = matches.find((d) => d.org_private) ?? matches[0];
|
|
10
|
+
if (!definition) {
|
|
11
|
+
fail(`no connector definition with key "${key}"`, EXIT.notFound);
|
|
12
|
+
}
|
|
13
|
+
return definition;
|
|
14
|
+
}
|
|
15
|
+
function parseDotenv(content) {
|
|
16
|
+
const values = {};
|
|
17
|
+
for (const line of content.split("\n")) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
20
|
+
continue;
|
|
21
|
+
const idx = trimmed.indexOf("=");
|
|
22
|
+
if (idx <= 0)
|
|
23
|
+
continue;
|
|
24
|
+
values[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
|
|
25
|
+
}
|
|
26
|
+
return values;
|
|
27
|
+
}
|
|
28
|
+
async function readSecretInput(opts) {
|
|
29
|
+
if (opts.stdin) {
|
|
30
|
+
if (process.stdin.isTTY)
|
|
31
|
+
fail("--stdin expects a JSON object piped in", EXIT.usage);
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for await (const chunk of process.stdin)
|
|
34
|
+
chunks.push(chunk);
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
37
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
38
|
+
throw new Error("not an object");
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
fail("stdin must carry a JSON object of {name: value}", EXIT.usage);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (opts.fromEnvFile) {
|
|
46
|
+
try {
|
|
47
|
+
return parseDotenv(readFileSync(opts.fromEnvFile, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
fail(`could not read ${opts.fromEnvFile}`, EXIT.usage);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (opts.fromEnv) {
|
|
54
|
+
const values = {};
|
|
55
|
+
for (const name of opts.fromEnv.split(",").map((n) => n.trim())) {
|
|
56
|
+
const value = process.env[name];
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
fail(`environment variable ${name} is not set`, EXIT.usage);
|
|
59
|
+
values[name] = value;
|
|
60
|
+
}
|
|
61
|
+
return values;
|
|
62
|
+
}
|
|
63
|
+
fail("provide secrets via --stdin, --from-env-file <file>, or --from-env NAME,...", EXIT.usage);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Secret files are written via a temp file in the target directory: openSync's
|
|
67
|
+
* mode only applies on create, so writing straight over an existing
|
|
68
|
+
* looser-mode file would silently keep that mode on the credentials; the
|
|
69
|
+
* rename also means no reader ever observes a truncated or partial file.
|
|
70
|
+
*/
|
|
71
|
+
export function writeSecretsFile(out, secrets, opts) {
|
|
72
|
+
if (!opts.force && existsSync(out)) {
|
|
73
|
+
fail(`${out} already exists — pass --force to overwrite`, EXIT.usage);
|
|
74
|
+
}
|
|
75
|
+
const tmp = join(dirname(out), `.${basename(out)}.tmp-${process.pid}`);
|
|
76
|
+
rmSync(tmp, { force: true });
|
|
77
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
78
|
+
try {
|
|
79
|
+
fchmodSync(fd, 0o600); // openSync's mode is masked by umask; this is not
|
|
80
|
+
const lines = Object.entries(secrets).map(([name, value]) => `${name}=${value}`);
|
|
81
|
+
writeFileSync(fd, `${lines.join("\n")}\n`);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
renameSync(tmp, out);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
rmSync(tmp, { force: true });
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** How a connector is addressed in a URL (ADR 20260819): the definition key for
|
|
95
|
+
* a global system, its id for an org-authored one whose key is only unique
|
|
96
|
+
* within the org. Mirrors `connectorAddress` in the app. */
|
|
97
|
+
function addressOf(c) {
|
|
98
|
+
return c.definition_is_global ? c.definition_key : c.connector_definition_id;
|
|
99
|
+
}
|
|
100
|
+
async function listConnectors() {
|
|
101
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/connectors`);
|
|
102
|
+
return body.connectors;
|
|
103
|
+
}
|
|
104
|
+
export function registerConnectorCommands(program) {
|
|
105
|
+
const connectors = program.command("connectors").description("Org connectors");
|
|
106
|
+
connectors
|
|
107
|
+
.command("list")
|
|
108
|
+
.description("List the org's connectors")
|
|
109
|
+
.action(async () => {
|
|
110
|
+
const { json } = getContext();
|
|
111
|
+
const connectors = await listConnectors();
|
|
112
|
+
if (json) {
|
|
113
|
+
printData({ connectors }, true);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const c of connectors) {
|
|
117
|
+
printData(`${addressOf(c)}\t${c.name}${c.connected ? "\tconnected" : ""}`, false);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
connectors
|
|
121
|
+
.command("connect <key>")
|
|
122
|
+
.description("Provision a data source: connector + tables + schedule against an existing definition")
|
|
123
|
+
.requiredOption("--spec <file>", 'JSON spec {connector: {name, config}, tables: [...], schedule: {...}} ("-" reads stdin)')
|
|
124
|
+
.action(async (key, opts) => {
|
|
125
|
+
const { json, yes } = getContext();
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = opts.spec === "-" ? readFileSync(0, "utf8") : readFileSync(opts.spec, "utf8");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
fail(`could not read ${opts.spec}`, EXIT.usage);
|
|
132
|
+
}
|
|
133
|
+
let spec;
|
|
134
|
+
try {
|
|
135
|
+
spec = JSON.parse(raw);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
fail(`${opts.spec} is not valid JSON`, EXIT.usage);
|
|
139
|
+
}
|
|
140
|
+
await confirm(`Connect "${key}" as "${spec.connector?.name ?? "?"}" — ${spec.tables?.length ?? 0} table(s), every ${spec.schedule?.interval ?? "?"}?`, yes);
|
|
141
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/connectors/sync`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: JSON.stringify({ definition_key: key, ...spec }),
|
|
144
|
+
});
|
|
145
|
+
if (body.connector.awaiting_credentials) {
|
|
146
|
+
note(`Awaiting credentials — set them with: fieldwork connectors secrets set ${key}`);
|
|
147
|
+
}
|
|
148
|
+
printData(body, json);
|
|
149
|
+
});
|
|
150
|
+
connectors
|
|
151
|
+
.command("test <keyOrName>")
|
|
152
|
+
.description("Test a saved database connector's stored credentials")
|
|
153
|
+
.action(async (keyOrName) => {
|
|
154
|
+
const { json } = getContext();
|
|
155
|
+
const connectors = await listConnectors();
|
|
156
|
+
const match = connectors.find((c) => c.definition_key === keyOrName) ??
|
|
157
|
+
connectors.find((c) => c.name === keyOrName);
|
|
158
|
+
if (!match)
|
|
159
|
+
fail(`no connector "${keyOrName}"`, EXIT.notFound);
|
|
160
|
+
// No body on purpose: a body is treated as an unsaved-override test and
|
|
161
|
+
// skips the persisted read-only re-validation.
|
|
162
|
+
const body = await apiJson(`/api/connectors/${addressOf(match)}/test`, { method: "POST" });
|
|
163
|
+
printData(body, json);
|
|
164
|
+
if (!body.ok)
|
|
165
|
+
process.exit(EXIT.server);
|
|
166
|
+
});
|
|
167
|
+
connectors
|
|
168
|
+
.command("pull <key>")
|
|
169
|
+
.description("Download an org-authored connector definition's adapter source")
|
|
170
|
+
.option("--out <dir>", "target directory (defaults to ./<key>)")
|
|
171
|
+
.option("--revision <n>", "a historical revision instead of head")
|
|
172
|
+
.option("--action-name <name>", "the named action under this connector")
|
|
173
|
+
.action(async (key, opts) => {
|
|
174
|
+
const { json } = getContext();
|
|
175
|
+
const definition = await resolveDefinition(key);
|
|
176
|
+
const query = new URLSearchParams();
|
|
177
|
+
if (opts.revision)
|
|
178
|
+
query.set("revision", opts.revision);
|
|
179
|
+
if (opts.actionName)
|
|
180
|
+
query.set("action_name", opts.actionName);
|
|
181
|
+
const suffix = query.size ? `?${query}` : "";
|
|
182
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/connector-definitions/${definition.connector_definition_id}/source${suffix}`);
|
|
183
|
+
const dir = opts.out ?? key;
|
|
184
|
+
const pulled = writeBundle(dir, body.files);
|
|
185
|
+
if (json) {
|
|
186
|
+
printData({ key, revision: body.revision, dir, pulled }, true);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
note(`Pulled "${key}" revision ${body.revision} into ${dir}/ — pass --base-revision ${body.revision} when publishing.`);
|
|
190
|
+
});
|
|
191
|
+
connectors
|
|
192
|
+
.command("publish <dir>")
|
|
193
|
+
.description("Publish an adapter directory as a new revision (creates the definition on first publish)")
|
|
194
|
+
.requiredOption("--message <message>", "what changed and why")
|
|
195
|
+
.option("--base-revision <n>", "head revision your changes are based on (required once code is published)")
|
|
196
|
+
.option("--action-name <name>", "create or update this named action under the connector")
|
|
197
|
+
.action(async (dir, opts) => {
|
|
198
|
+
const { json, yes } = getContext();
|
|
199
|
+
const files = readBundle(dir);
|
|
200
|
+
// Local parse is only for the confirm prompt — the server re-parses
|
|
201
|
+
// the manifest authoritatively.
|
|
202
|
+
let key = "?";
|
|
203
|
+
try {
|
|
204
|
+
const manifest = JSON.parse(files["connector.json"] ?? "");
|
|
205
|
+
if (typeof manifest?.key === "string")
|
|
206
|
+
key = manifest.key;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// fall through: the server reports the real validation error
|
|
210
|
+
}
|
|
211
|
+
if (key === "?") {
|
|
212
|
+
fail('the tree\'s connector.json must carry a "key" field', EXIT.usage);
|
|
213
|
+
}
|
|
214
|
+
await confirm(`Publish ${Object.keys(files).length} files to connector "${key}"${opts.actionName ? `, action "${opts.actionName}"` : ""}?`, yes);
|
|
215
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/connector-definitions/publish`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
message: opts.message,
|
|
219
|
+
base_revision: opts.baseRevision ? Number(opts.baseRevision) : undefined,
|
|
220
|
+
action_name: opts.actionName,
|
|
221
|
+
files,
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
printData(body, json);
|
|
225
|
+
});
|
|
226
|
+
const secrets = connectors
|
|
227
|
+
.command("secrets")
|
|
228
|
+
.description("Connector credentials (values never transit argv or stdout)");
|
|
229
|
+
secrets
|
|
230
|
+
.command("names <connector>")
|
|
231
|
+
.description("The secret names currently set, plus the definition's hints")
|
|
232
|
+
.action(async (connector) => {
|
|
233
|
+
printData(await apiJson(`/api/connectors/${connector}/secrets`), getContext().json);
|
|
234
|
+
});
|
|
235
|
+
secrets
|
|
236
|
+
.command("set <connector>")
|
|
237
|
+
.description("Replace the connector's secrets envelope (write-only)")
|
|
238
|
+
.option("--stdin", "read a JSON object of {name: value} from stdin")
|
|
239
|
+
.option("--from-env-file <file>", "read NAME=value lines from a dotenv file")
|
|
240
|
+
.option("--from-env <names>", "comma-separated env var names to copy")
|
|
241
|
+
.action(async (connector, opts) => {
|
|
242
|
+
const { json, yes } = getContext();
|
|
243
|
+
const values = await readSecretInput(opts);
|
|
244
|
+
const names = Object.keys(values);
|
|
245
|
+
if (names.length === 0)
|
|
246
|
+
fail("no secrets to set", EXIT.usage);
|
|
247
|
+
await confirm(`Replace the secrets envelope with ${names.length} value(s)?`, yes);
|
|
248
|
+
const body = await apiJson(`/api/connectors/${connector}/secrets`, {
|
|
249
|
+
method: "PUT",
|
|
250
|
+
body: JSON.stringify({ secrets: values }),
|
|
251
|
+
});
|
|
252
|
+
printData(body, json);
|
|
253
|
+
});
|
|
254
|
+
secrets
|
|
255
|
+
.command("pull <connector>")
|
|
256
|
+
.description("Materialize an org-authored connector's secret values into a file")
|
|
257
|
+
.requiredOption("--out <file>", "target dotenv file (written chmod 600)")
|
|
258
|
+
.option("--force", "overwrite an existing file")
|
|
259
|
+
.action(async (connector, opts) => {
|
|
260
|
+
const { json } = getContext();
|
|
261
|
+
const body = await apiJson(`/api/connectors/${connector}/secrets/values`);
|
|
262
|
+
writeSecretsFile(opts.out, body.secrets, { force: Boolean(opts.force) });
|
|
263
|
+
const names = Object.keys(body.secrets);
|
|
264
|
+
// Names only — the values are in the file, never on a stream.
|
|
265
|
+
if (json) {
|
|
266
|
+
printData({ out: opts.out, names }, true);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
note(`Wrote ${names.length} secret(s) to ${opts.out}: ${names.join(", ")}`);
|
|
270
|
+
note(`Use them without echoing: set -a && . ${opts.out} && set +a`);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface FileRow {
|
|
2
|
+
kb_file_id: string;
|
|
3
|
+
path: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The three-step upload: POST the row, PUT to the presigned URL, confirm.
|
|
7
|
+
* Extracted so `kb put` can reach it — the unified command dispatches on
|
|
8
|
+
* kind, and a document takes exactly this path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function uploadFile(localPath: string, remotePath: string | undefined, contentTypeOverride: string | undefined, yes: boolean): Promise<{
|
|
11
|
+
fileId: string;
|
|
12
|
+
path: string;
|
|
13
|
+
bytes: number;
|
|
14
|
+
}>;
|
|
15
|
+
/** Write one row's bytes to `out` ("-" streams to stdout). */
|
|
16
|
+
export declare function downloadFile(file: FileRow, out: string | undefined, json: boolean): Promise<void>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { apiJson, requireOrgId } from "../http.js";
|
|
4
|
+
import { EXIT, confirm, fail, note, printData } from "../output.js";
|
|
5
|
+
/**
|
|
6
|
+
* Document primitives for the `kb_files` group: the three-step upload flow
|
|
7
|
+
* (POST row → PUT to the presigned URL → confirm) and the presigned download.
|
|
8
|
+
* The presigned URLs are absolute and carry no bearer — raw fetch, never api().
|
|
9
|
+
*
|
|
10
|
+
* No command group of its own: `files` split the tree by storage kind, which
|
|
11
|
+
* is what the Knowledge Base merge removed.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
14
|
+
const CONTENT_TYPES = {
|
|
15
|
+
".csv": "text/csv",
|
|
16
|
+
".json": "application/json",
|
|
17
|
+
".jsonl": "application/jsonl",
|
|
18
|
+
".md": "text/markdown",
|
|
19
|
+
".txt": "text/plain",
|
|
20
|
+
".pdf": "application/pdf",
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".webp": "image/webp",
|
|
25
|
+
".svg": "image/svg+xml",
|
|
26
|
+
".parquet": "application/vnd.apache.parquet",
|
|
27
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
28
|
+
".zip": "application/zip",
|
|
29
|
+
};
|
|
30
|
+
function guessContentType(filename) {
|
|
31
|
+
return CONTENT_TYPES[path.extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The three-step upload: POST the row, PUT to the presigned URL, confirm.
|
|
35
|
+
* Extracted so `kb put` can reach it — the unified command dispatches on
|
|
36
|
+
* kind, and a document takes exactly this path.
|
|
37
|
+
*/
|
|
38
|
+
export async function uploadFile(localPath, remotePath, contentTypeOverride, yes) {
|
|
39
|
+
let size;
|
|
40
|
+
try {
|
|
41
|
+
size = statSync(localPath).size;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
fail(`could not read ${localPath}`, EXIT.usage);
|
|
45
|
+
}
|
|
46
|
+
if (size > MAX_FILE_BYTES) {
|
|
47
|
+
fail(`${localPath} is ${size} bytes — the server caps uploads at 100MB`, EXIT.usage);
|
|
48
|
+
}
|
|
49
|
+
const remote = remotePath ?? path.basename(localPath);
|
|
50
|
+
const contentType = contentTypeOverride ?? guessContentType(remote);
|
|
51
|
+
await confirm(`Upload ${localPath} (${size} bytes) to "${remote}"?`, yes);
|
|
52
|
+
const created = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
body: JSON.stringify({ filename: remote, contentType, sizeBytes: size }),
|
|
55
|
+
});
|
|
56
|
+
// Content-Type must match the presign's pinned value exactly. A failed
|
|
57
|
+
// PUT leaves only an invisible pending row — nothing to clean up.
|
|
58
|
+
const put = await fetch(created.uploadUrl, {
|
|
59
|
+
method: "PUT",
|
|
60
|
+
headers: { "content-type": contentType },
|
|
61
|
+
body: readFileSync(localPath),
|
|
62
|
+
}).catch((err) => {
|
|
63
|
+
fail(`upload failed: ${err instanceof Error ? err.message : "network error"}`, EXIT.server);
|
|
64
|
+
});
|
|
65
|
+
if (!put.ok) {
|
|
66
|
+
fail(`upload failed: storage returned ${put.status}`, EXIT.server);
|
|
67
|
+
}
|
|
68
|
+
// Confirm names the revision the bytes landed on: nothing is visible at the
|
|
69
|
+
// path until it does, so an abandoned upload cannot hold a path.
|
|
70
|
+
await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base/${created.fileId}/confirm`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body: JSON.stringify({ path: remote, revisionId: created.revisionId, contentType }),
|
|
73
|
+
});
|
|
74
|
+
return { fileId: created.fileId, path: remote, bytes: size };
|
|
75
|
+
}
|
|
76
|
+
/** Write one row's bytes to `out` ("-" streams to stdout). */
|
|
77
|
+
export async function downloadFile(file, out, json) {
|
|
78
|
+
const remotePath = file.path;
|
|
79
|
+
const { url } = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base/${file.kb_file_id}/download`);
|
|
80
|
+
const res = await fetch(url).catch((err) => {
|
|
81
|
+
fail(`download failed: ${err instanceof Error ? err.message : "network error"}`, EXIT.server);
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok)
|
|
84
|
+
fail(`download failed: storage returned ${res.status}`, EXIT.server);
|
|
85
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
86
|
+
if (out === "-") {
|
|
87
|
+
// Binary-safe: raw bytes to stdout, nothing else on the stream.
|
|
88
|
+
process.stdout.write(bytes);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const target = out ?? path.basename(remotePath);
|
|
92
|
+
writeFileSync(target, bytes);
|
|
93
|
+
if (json) {
|
|
94
|
+
printData({ file_id: file.kb_file_id, path: remotePath, bytes: bytes.length, out: target }, true);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
note(`Wrote ${bytes.length} bytes to ${target}`);
|
|
98
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge base family (ADR 20260804 revision: purpose verbs are
|
|
3
|
+
* REST-backed). Mutations ride the same UI routes the in-app editor uses —
|
|
4
|
+
* writeKbPage stays the single server-side write path. Local files carry the
|
|
5
|
+
* page's full content including its frontmatter block, so pull/push
|
|
6
|
+
* round-trips byte-for-byte.
|
|
7
|
+
*
|
|
8
|
+
* Callers resolve a path to a row against the tree listing and pass the id in.
|
|
9
|
+
* There is deliberately no path lookup here: the merged API addresses a file
|
|
10
|
+
* by id, and a second path→row mechanism is what left this module pointed at
|
|
11
|
+
* routes that no longer existed.
|
|
12
|
+
*/
|
|
13
|
+
export interface KbPageDetail {
|
|
14
|
+
kb_file_id: string;
|
|
15
|
+
path: string;
|
|
16
|
+
summary: string | null;
|
|
17
|
+
latest_revision_number: number;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
/** One page's full detail, including its serialized content. */
|
|
21
|
+
export declare function lookupPage(kbFileId: string): Promise<KbPageDetail>;
|
|
22
|
+
/** The server's line-based frontmatter shape (src/lib/kb/frontmatter.ts):
|
|
23
|
+
* `summary:` between `---` fences, body verbatim after the closing fence. */
|
|
24
|
+
export declare function parseKbContent(content: string): {
|
|
25
|
+
summary: string;
|
|
26
|
+
body: string;
|
|
27
|
+
} | null;
|
|
28
|
+
/**
|
|
29
|
+
* Create-or-update one page from full local content. Returns what happened.
|
|
30
|
+
* `existing` is the tree row when the path is already taken, resolved by the
|
|
31
|
+
* caller — null creates.
|
|
32
|
+
*/
|
|
33
|
+
export declare function putPage(pagePath: string, content: string, summaryFlag: string | undefined, existing: {
|
|
34
|
+
kb_file_id: string;
|
|
35
|
+
latest_revision_number: number;
|
|
36
|
+
} | null): Promise<"created" | "updated">;
|
|
37
|
+
export declare function assertSafeKbPath(pagePath: string): void;
|
|
38
|
+
/** All .md files under dir, keyed by their dir-relative (= KB) path. */
|
|
39
|
+
export declare function readMarkdownTree(dir: string): Record<string, string>;
|