@fieldwork-ai/cli 0.24.0 → 0.26.1
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 +2 -0
- package/dist/commands/connectors.js +1 -1
- package/dist/commands/kb-upload.d.ts +4 -0
- package/dist/commands/kb-upload.js +263 -0
- package/dist/commands/kb.js +17 -1
- package/dist/commands/packages.d.ts +2 -0
- package/dist/commands/packages.js +53 -0
- package/dist/commands/tool.js +2 -2
- package/dist/http.d.ts +4 -0
- package/dist/http.js +4 -2
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ fieldwork connectors list | pull <key> | publish <dir> --message "…" | connect
|
|
|
41
41
|
|
|
42
42
|
Mutations prompt for confirmation on a TTY; pass `--yes` to skip (required in non-interactive use). `--json` prints stable machine-readable output on every verb. Secret values never travel via argv or stdout: `secrets set` reads from `--stdin`, `--from-env-file`, or `--from-env`; `secrets pull` writes a chmod-600 file atomically and refuses to overwrite an existing file without `--force`.
|
|
43
43
|
|
|
44
|
+
`packages pull <name>` and `packages publish <dir> --name <name> --message "…" --base-revision N` address the org's own code by package name, which is unique per organization, whether or not it runs on a system: a tree whose `connector.json` carries a `key` publishes onto that system, and one without is a package with no system, such as a sync that only reads and writes sheets. The `connectors pull`/`publish` verbs below are the system-addressed form of the same loop.
|
|
45
|
+
|
|
44
46
|
The connector development loop end to end: `connectors pull acme_erp` → edit the adapter → `connectors publish acme_erp --message "…" --base-revision N` (a new key creates the definition from the tree's `connector.json`) → `connectors connect acme_erp --spec spec.json` → `connectors secrets set <id>` → `automations run-now <sync> --follow` to watch the logs.
|
|
45
47
|
|
|
46
48
|
Publishing initializes a new connector from `connector.json`, but subsequent action revisions leave `connector_definitions` metadata unchanged. To edit its display name, description, branding, or REST secret-name hints, use `fieldwork connectors metadata <definition-id>`, then `fieldwork connectors update-metadata <definition-id> --changes changes.json --expected-updated-at '<exact updated_at from the read>'`. The update requires connector edit access and rejects stale reads; credential values are managed separately through `secrets`.
|
|
@@ -209,7 +209,7 @@ export function registerConnectorCommands(program) {
|
|
|
209
209
|
// fall through: the server reports the real validation error
|
|
210
210
|
}
|
|
211
211
|
if (key === "?") {
|
|
212
|
-
fail('the tree\'s connector.json must carry a "key" field', EXIT.usage);
|
|
212
|
+
fail('the tree\'s connector.json must carry a "key" field — for a package with no system, use `packages publish`', EXIT.usage);
|
|
213
213
|
}
|
|
214
214
|
await confirm(`Publish ${Object.keys(files).length} files to connector "${key}"${opts.actionName ? `, action "${opts.actionName}"` : ""}?`, yes);
|
|
215
215
|
const body = await apiJson(`/api/organizations/${requireOrgId()}/connectors/publish`, {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { api, getContext, requireOrgId } from "../http.js";
|
|
6
|
+
import { EXIT, confirm, exitCodeFor, fail, note, printData } from "../output.js";
|
|
7
|
+
/**
|
|
8
|
+
* `fieldwork kb upload <local> [folder]` — a folder (or one file) into the
|
|
9
|
+
* Knowledge Base, keeping its structure, through the same bulk upload the
|
|
10
|
+
* browser uses (/knowledge-base/uploads; ADR 20261003_kb_bulk_upload).
|
|
11
|
+
*
|
|
12
|
+
* Built for imports of tens of thousands of files: every file is hashed
|
|
13
|
+
* first, and anything already stored with the same contents is skipped, so
|
|
14
|
+
* running the same command again after an interruption resumes rather than
|
|
15
|
+
* starting over. A file that fails does not stop the rest; the summary lists
|
|
16
|
+
* them and the exit code says so.
|
|
17
|
+
*/
|
|
18
|
+
/** Kept in step with src/lib/kb/upload-ignore.ts. */
|
|
19
|
+
const IGNORED_NAMES = new Set(["Thumbs.db", "desktop.ini", "Icon\r", "node_modules", "__MACOSX"]);
|
|
20
|
+
const isIgnored = (segment) => segment.startsWith(".") || IGNORED_NAMES.has(segment) || segment.startsWith("~$");
|
|
21
|
+
const BATCH = 200;
|
|
22
|
+
const HASH_CONCURRENCY = 8;
|
|
23
|
+
const SEND_CONCURRENCY = 4;
|
|
24
|
+
const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
|
|
25
|
+
export async function uploadLocal(local, folder, opts) {
|
|
26
|
+
const { json, yes } = getContext();
|
|
27
|
+
if (!["skip", "replace", "keep-both"].includes(opts.onExisting)) {
|
|
28
|
+
fail(`--on-existing must be skip, replace or keep-both`, EXIT.usage);
|
|
29
|
+
}
|
|
30
|
+
const onExisting = opts.onExisting;
|
|
31
|
+
const target = (folder ?? "").replace(/^\/+|\/+$/g, "");
|
|
32
|
+
const { files, ignored } = collect(path.resolve(local), target);
|
|
33
|
+
if (files.length === 0)
|
|
34
|
+
fail(`no files to upload in ${local}`, EXIT.usage);
|
|
35
|
+
const base = `/api/organizations/${requireOrgId()}/knowledge-base/uploads`;
|
|
36
|
+
const limits = await call(base);
|
|
37
|
+
const tally = { uploaded: 0, unchanged: 0, skipped: 0, failed: [] };
|
|
38
|
+
const sendable = files.filter((f) => {
|
|
39
|
+
if (f.size <= limits.maxFileBytes)
|
|
40
|
+
return true;
|
|
41
|
+
tally.failed.push({ path: f.path, reason: `larger than ${formatBytes(limits.maxFileBytes)}` });
|
|
42
|
+
return false;
|
|
43
|
+
});
|
|
44
|
+
const bytes = sendable.reduce((n, f) => n + f.size, 0);
|
|
45
|
+
await confirm(`Upload ${sendable.length.toLocaleString()} files (${formatBytes(bytes)}) to ${target ? `"${target}"` : "the top level"}` +
|
|
46
|
+
`${opts.shareWithOrg ? ", shared with the whole organization" : ""}?` +
|
|
47
|
+
(ignored ? ` ${ignored} hidden or system files are left out.` : "") +
|
|
48
|
+
(bytes > limits.limitBytes - limits.usedBytes
|
|
49
|
+
? ` Only ${formatBytes(Math.max(0, limits.limitBytes - limits.usedBytes))} of space is left, so some will not fit.`
|
|
50
|
+
: ""), yes);
|
|
51
|
+
const { uploadId } = await call(base, { folder: target });
|
|
52
|
+
let done = 0;
|
|
53
|
+
for (let i = 0; i < sendable.length; i += BATCH) {
|
|
54
|
+
const batch = sendable.slice(i, i + BATCH);
|
|
55
|
+
await runBatch(`${base}/${uploadId}`, batch, onExisting, !!opts.shareWithOrg, tally);
|
|
56
|
+
done += batch.length;
|
|
57
|
+
if (!json)
|
|
58
|
+
note(`${done.toLocaleString()} of ${sendable.length.toLocaleString()} files`);
|
|
59
|
+
}
|
|
60
|
+
if (json) {
|
|
61
|
+
printData({ uploadId, ...tally }, true);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
note(`Uploaded ${tally.uploaded}, unchanged ${tally.unchanged}` +
|
|
65
|
+
(tally.skipped ? `, skipped ${tally.skipped} already there` : "") +
|
|
66
|
+
(tally.failed.length ? `, failed ${tally.failed.length}` : ""));
|
|
67
|
+
for (const f of tally.failed)
|
|
68
|
+
note(` ${f.path}: ${f.reason}`);
|
|
69
|
+
}
|
|
70
|
+
if (tally.failed.length)
|
|
71
|
+
process.exit(EXIT.server);
|
|
72
|
+
}
|
|
73
|
+
/** The files under `local` (or `local` itself), each at `target/<dir name>/<relative path>`. */
|
|
74
|
+
function collect(local, target) {
|
|
75
|
+
const files = [];
|
|
76
|
+
let ignored = 0;
|
|
77
|
+
const prefix = target ? `${target}/` : "";
|
|
78
|
+
const visit = (abs, rel) => {
|
|
79
|
+
if (isIgnored(path.basename(abs))) {
|
|
80
|
+
ignored += 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let stat;
|
|
84
|
+
try {
|
|
85
|
+
stat = statSync(abs);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (stat.isDirectory()) {
|
|
91
|
+
for (const name of readdirSync(abs).sort())
|
|
92
|
+
visit(path.join(abs, name), `${rel}/${name}`);
|
|
93
|
+
}
|
|
94
|
+
else if (stat.isFile()) {
|
|
95
|
+
files.push({ abs, path: prefix + rel, size: stat.size });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
let stat;
|
|
99
|
+
try {
|
|
100
|
+
stat = statSync(local);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
fail(`no such file or folder: ${local}`, EXIT.usage);
|
|
104
|
+
}
|
|
105
|
+
if (!stat.isDirectory() && !stat.isFile())
|
|
106
|
+
fail(`not a file or folder: ${local}`, EXIT.usage);
|
|
107
|
+
visit(local, path.basename(local));
|
|
108
|
+
return { files, ignored };
|
|
109
|
+
}
|
|
110
|
+
async function runBatch(upload, batch, onExisting, share, tally) {
|
|
111
|
+
const readable = [];
|
|
112
|
+
await pool(batch, HASH_CONCURRENCY, async (f) => {
|
|
113
|
+
try {
|
|
114
|
+
f.sha256 = await hashFile(f.abs);
|
|
115
|
+
readable.push(f);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
tally.failed.push({ path: f.path, reason: "could not be read" });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
const plan = (files, mode) => call(`${upload}/plan`, {
|
|
122
|
+
entries: files.map((f) => ({ path: f.path, sizeBytes: f.size, sha256: f.sha256 })),
|
|
123
|
+
onExisting: mode,
|
|
124
|
+
shareWithOrganization: share,
|
|
125
|
+
}).then((r) => r.results);
|
|
126
|
+
let pending = batch.filter((f) => readable.includes(f));
|
|
127
|
+
let mode = "ask";
|
|
128
|
+
for (let round = 0; round < 3 && pending.length; round++) {
|
|
129
|
+
const results = await plan(pending, mode);
|
|
130
|
+
const toSend = [];
|
|
131
|
+
const clashed = [];
|
|
132
|
+
results.forEach((result, i) => {
|
|
133
|
+
const file = pending[i];
|
|
134
|
+
if (result.status === "upload")
|
|
135
|
+
toSend.push({ file, result });
|
|
136
|
+
else if (result.status === "unchanged")
|
|
137
|
+
tally.unchanged += 1;
|
|
138
|
+
else if (result.status === "invalid")
|
|
139
|
+
tally.failed.push({ path: file.path, reason: result.reason });
|
|
140
|
+
else if (result.status === "full")
|
|
141
|
+
tally.failed.push({ path: file.path, reason: "the Knowledge Base is full" });
|
|
142
|
+
else if (onExisting === "skip")
|
|
143
|
+
tally.skipped += 1;
|
|
144
|
+
else if (!result.replaceable && onExisting === "replace") {
|
|
145
|
+
tally.failed.push({ path: file.path, reason: "a file you can't replace is already there" });
|
|
146
|
+
}
|
|
147
|
+
else if (mode !== "ask") {
|
|
148
|
+
tally.failed.push({ path: file.path, reason: "a file is already there" });
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
clashed.push(file);
|
|
152
|
+
});
|
|
153
|
+
const sent = [];
|
|
154
|
+
await pool(toSend, SEND_CONCURRENCY, async (entry) => {
|
|
155
|
+
const error = await send(entry.file, entry.result);
|
|
156
|
+
if (error)
|
|
157
|
+
tally.failed.push({ path: entry.file.path, reason: error });
|
|
158
|
+
else
|
|
159
|
+
sent.push(entry);
|
|
160
|
+
});
|
|
161
|
+
const retry = [...clashed];
|
|
162
|
+
if (sent.length) {
|
|
163
|
+
const committed = await call(`${upload}/commit`, {
|
|
164
|
+
entries: sent.map((s) => ({ revisionId: s.result.revisionId, fileId: s.result.fileId })),
|
|
165
|
+
});
|
|
166
|
+
committed.results.forEach((result, i) => {
|
|
167
|
+
const file = sent[i].file;
|
|
168
|
+
if (result.status === "done")
|
|
169
|
+
tally.uploaded += 1;
|
|
170
|
+
else if (result.status === "unchanged")
|
|
171
|
+
tally.unchanged += 1;
|
|
172
|
+
else if (result.status === "exists") {
|
|
173
|
+
if (onExisting === "skip")
|
|
174
|
+
tally.skipped += 1;
|
|
175
|
+
else
|
|
176
|
+
retry.push(file);
|
|
177
|
+
}
|
|
178
|
+
else if (result.status === "missing")
|
|
179
|
+
retry.push(file);
|
|
180
|
+
else
|
|
181
|
+
tally.failed.push({ path: file.path, reason: result.reason });
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
pending = retry;
|
|
185
|
+
if (onExisting !== "skip")
|
|
186
|
+
mode = onExisting;
|
|
187
|
+
}
|
|
188
|
+
for (const f of pending)
|
|
189
|
+
tally.failed.push({ path: f.path, reason: "could not be uploaded after several attempts" });
|
|
190
|
+
}
|
|
191
|
+
/** PUT one file to its link; returns why it failed, or null. */
|
|
192
|
+
async function send(file, result) {
|
|
193
|
+
const body = await readFile(file.abs);
|
|
194
|
+
for (let attempt = 0;; attempt++) {
|
|
195
|
+
let status = 0;
|
|
196
|
+
try {
|
|
197
|
+
// A request that never answers would hold the whole import; at 64 KB/s
|
|
198
|
+
// even the largest file finishes well inside this.
|
|
199
|
+
const timeout = AbortSignal.timeout(Math.max(120_000, (file.size / 65_536) * 1000));
|
|
200
|
+
status = (await fetch(result.url, { method: "PUT", headers: result.headers, body, signal: timeout })).status;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
status = 0;
|
|
204
|
+
}
|
|
205
|
+
if (status >= 200 && status < 300)
|
|
206
|
+
return null;
|
|
207
|
+
if (status === 400)
|
|
208
|
+
return "the file changed while it was uploading";
|
|
209
|
+
const transient = status === 0 || status === 408 || status === 429 || status >= 500;
|
|
210
|
+
if (!transient || attempt >= RETRY_MS.length)
|
|
211
|
+
return `storage refused it (${status || "network error"})`;
|
|
212
|
+
await sleep(RETRY_MS[attempt]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** A JSON request, retried through network failures and 5xx; anything else ends the command. */
|
|
216
|
+
async function call(pathname, body) {
|
|
217
|
+
for (let attempt = 0;; attempt++) {
|
|
218
|
+
let res = null;
|
|
219
|
+
try {
|
|
220
|
+
res = await api(pathname, body === undefined ? {} : { method: "POST", body: JSON.stringify(body) }, {
|
|
221
|
+
throwOnNetworkError: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
res = null;
|
|
226
|
+
}
|
|
227
|
+
if (res && res.ok)
|
|
228
|
+
return (await res.json());
|
|
229
|
+
const transient = !res || res.status === 429 || res.status >= 500;
|
|
230
|
+
if (!transient || attempt >= RETRY_MS.length) {
|
|
231
|
+
const message = res ? (await res.json().catch(() => null))?.error : null;
|
|
232
|
+
fail(message ?? `request failed (${res?.status ?? "network error"})`, res ? exitCodeFor(res.status) : EXIT.server);
|
|
233
|
+
}
|
|
234
|
+
await sleep(RETRY_MS[attempt]);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function hashFile(abs) {
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
const hash = createHash("sha256");
|
|
240
|
+
createReadStream(abs)
|
|
241
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
242
|
+
.on("end", () => resolve(hash.digest("hex")))
|
|
243
|
+
.on("error", reject);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async function pool(items, size, run) {
|
|
247
|
+
let next = 0;
|
|
248
|
+
await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
|
|
249
|
+
while (next < items.length)
|
|
250
|
+
await run(items[next++]);
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
|
+
function formatBytes(bytes) {
|
|
255
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
256
|
+
let value = bytes;
|
|
257
|
+
let unit = 0;
|
|
258
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
259
|
+
value /= 1024;
|
|
260
|
+
unit += 1;
|
|
261
|
+
}
|
|
262
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
|
|
263
|
+
}
|
package/dist/commands/kb.js
CHANGED
|
@@ -4,18 +4,34 @@ import { EXIT, confirm, fail, note, printData } from "../output.js";
|
|
|
4
4
|
import { writeBundle } from "../bundle.js";
|
|
5
5
|
import { assertSafeKbPath, lookupPage, putPage, readMarkdownTree } from "./kb-pages.js";
|
|
6
6
|
import { downloadFile, uploadFile } from "./kb-blobs.js";
|
|
7
|
+
import { uploadLocal } from "./kb-upload.js";
|
|
7
8
|
import { isMarkdown } from "../content-kind.js";
|
|
8
9
|
async function listTree() {
|
|
9
10
|
const body = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base`);
|
|
10
11
|
return body.files ?? [];
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The row at exactly `kbPath`. Never through `listTree`: a command run once
|
|
15
|
+
* per file over an organization of 60,000 files listed the whole tree each
|
|
16
|
+
* time. The server follows move aliases; a command acts on the path it was
|
|
17
|
+
* given, so a row found under another path is not a match.
|
|
18
|
+
*/
|
|
12
19
|
async function rowAt(kbPath) {
|
|
13
|
-
|
|
20
|
+
const query = new URLSearchParams({ path: kbPath, content: "false" });
|
|
21
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base?${query}`);
|
|
22
|
+
return body.files?.find((r) => r.path === kbPath) ?? null;
|
|
14
23
|
}
|
|
15
24
|
export function registerKbCommands(program) {
|
|
16
25
|
const kb = program
|
|
17
26
|
.command("kb")
|
|
18
27
|
.description("The Knowledge Base: markdown pages and the org's other files, in one tree");
|
|
28
|
+
kb
|
|
29
|
+
.command("upload <local> [folder]")
|
|
30
|
+
.description("Upload a local folder (or file) into the Knowledge Base, keeping its structure. " +
|
|
31
|
+
"Files already there with the same contents are skipped, so running it again resumes.")
|
|
32
|
+
.option("--share-with-org", "let everyone in the organization view and edit the new files")
|
|
33
|
+
.option("--on-existing <action>", "when a different file is already at a path: skip, replace or keep-both", "skip")
|
|
34
|
+
.action(uploadLocal);
|
|
19
35
|
kb
|
|
20
36
|
.command("ls [prefix]")
|
|
21
37
|
.description("List everything, optionally under a path prefix")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readBundle, writeBundle } from "../bundle.js";
|
|
2
|
+
import { apiJson, getContext, requireOrgId } from "../http.js";
|
|
3
|
+
import { confirm, note, printData } from "../output.js";
|
|
4
|
+
/**
|
|
5
|
+
* Package family: the org's own code, addressed by the package's NAME (unique
|
|
6
|
+
* per organization), whether or not it runs on a system. A tree whose
|
|
7
|
+
* connector.json carries a `key` publishes onto that system; without one it is
|
|
8
|
+
* a package with no system. Platform adapters are not pullable.
|
|
9
|
+
*/
|
|
10
|
+
function defaultDir(name) {
|
|
11
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "package";
|
|
12
|
+
}
|
|
13
|
+
export function registerPackageCommands(program) {
|
|
14
|
+
const packages = program.command("packages").description("The org's own code, by package name");
|
|
15
|
+
packages
|
|
16
|
+
.command("pull <name>")
|
|
17
|
+
.description("Download a package's source tree")
|
|
18
|
+
.option("--out <dir>", "target directory (defaults to a slug of the name)")
|
|
19
|
+
.option("--revision <n>", "a historical revision instead of head")
|
|
20
|
+
.action(async (name, opts) => {
|
|
21
|
+
const { json } = getContext();
|
|
22
|
+
const suffix = opts.revision ? `?${new URLSearchParams({ revision: opts.revision })}` : "";
|
|
23
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/packages/${encodeURIComponent(name)}/source${suffix}`);
|
|
24
|
+
const dir = opts.out ?? defaultDir(name);
|
|
25
|
+
const pulled = writeBundle(dir, body.files);
|
|
26
|
+
if (json) {
|
|
27
|
+
printData({ name: body.name, key: body.key, revision: body.revision, head: body.head, dir, pulled }, true);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
note(`Pulled "${body.name}" revision ${body.revision} into ${dir}/ — pass --base-revision ${body.head} when publishing.`);
|
|
31
|
+
});
|
|
32
|
+
packages
|
|
33
|
+
.command("publish <dir>")
|
|
34
|
+
.description("Publish a source tree as the package's next revision (creates it on first publish)")
|
|
35
|
+
.requiredOption("--name <name>", "the package's name")
|
|
36
|
+
.requiredOption("--message <message>", "what changed and why")
|
|
37
|
+
.option("--base-revision <n>", "head revision your changes are based on (required once code is published)")
|
|
38
|
+
.action(async (dir, opts) => {
|
|
39
|
+
const { json, yes } = getContext();
|
|
40
|
+
const files = readBundle(dir);
|
|
41
|
+
await confirm(`Publish ${Object.keys(files).length} files to package "${opts.name}"?`, yes);
|
|
42
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/packages/publish`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
name: opts.name,
|
|
46
|
+
message: opts.message,
|
|
47
|
+
base_revision: opts.baseRevision ? Number(opts.baseRevision) : undefined,
|
|
48
|
+
files,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
printData(body, json);
|
|
52
|
+
});
|
|
53
|
+
}
|
package/dist/commands/tool.js
CHANGED
|
@@ -68,8 +68,8 @@ export function registerToolCommands(program) {
|
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
70
|
for (const t of body.tools) {
|
|
71
|
-
// Skip leading bracketed router markers (
|
|
72
|
-
//
|
|
71
|
+
// Skip leading bracketed router markers (some providers prefix a tool's
|
|
72
|
+
// description with one) so the summary line is the real description.
|
|
73
73
|
const firstLine = t.description.split("\n").find((l) => l.trim() && !/^\[.*\]$/.test(l.trim())) ??
|
|
74
74
|
t.description.split("\n")[0] ??
|
|
75
75
|
"";
|
package/dist/http.d.ts
CHANGED
|
@@ -20,6 +20,10 @@ export declare function requireToken(): string;
|
|
|
20
20
|
export declare function requireOrgId(): string;
|
|
21
21
|
export declare function api(pathname: string, init?: RequestInit & {
|
|
22
22
|
body?: string;
|
|
23
|
+
},
|
|
24
|
+
/** Throw a network failure to the caller, which retries it, instead of exiting. */
|
|
25
|
+
options?: {
|
|
26
|
+
throwOnNetworkError?: boolean;
|
|
23
27
|
}): Promise<Response>;
|
|
24
28
|
/** api() + JSON body + fail-on-error, the shape most commands want. */
|
|
25
29
|
export declare function apiJson<T = unknown>(pathname: string, init?: RequestInit & {
|
package/dist/http.js
CHANGED
|
@@ -77,7 +77,9 @@ async function refreshLoginToken() {
|
|
|
77
77
|
return null;
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
export async function api(pathname, init = {}
|
|
80
|
+
export async function api(pathname, init = {},
|
|
81
|
+
/** Throw a network failure to the caller, which retries it, instead of exiting. */
|
|
82
|
+
options = {}) {
|
|
81
83
|
const { config } = getContext();
|
|
82
84
|
const token = requireToken();
|
|
83
85
|
const headers = new Headers(init.headers);
|
|
@@ -93,7 +95,7 @@ export async function api(pathname, init = {}) {
|
|
|
93
95
|
res = await fetch(`${config.apiUrl}${pathname}`, { ...init, headers });
|
|
94
96
|
}
|
|
95
97
|
catch (err) {
|
|
96
|
-
if (init.signal?.aborted)
|
|
98
|
+
if (init.signal?.aborted || options.throwOnNetworkError)
|
|
97
99
|
throw err;
|
|
98
100
|
fail(`could not reach ${config.apiUrl}: ${err instanceof Error ? err.message : "network error"}`, EXIT.server);
|
|
99
101
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { registerAppCommands } from "./commands/apps.js";
|
|
|
10
10
|
import { registerAutomationCommands } from "./commands/automations.js";
|
|
11
11
|
import { registerTaskCommands } from "./commands/tasks.js";
|
|
12
12
|
import { registerConnectorCommands } from "./commands/connectors.js";
|
|
13
|
+
import { registerPackageCommands } from "./commands/packages.js";
|
|
13
14
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
14
15
|
/**
|
|
15
16
|
* The Fieldwork CLI (ADR 20260804): a plain REST client of the org-scoped
|
|
@@ -41,6 +42,7 @@ registerAppCommands(program);
|
|
|
41
42
|
registerAutomationCommands(program);
|
|
42
43
|
registerTaskCommands(program);
|
|
43
44
|
registerConnectorCommands(program);
|
|
45
|
+
registerPackageCommands(program);
|
|
44
46
|
program.addCommand(sheetsCommand());
|
|
45
47
|
program.addCommand(sheetsCommand("tables"), { hidden: true });
|
|
46
48
|
program.parseAsync(process.argv).catch((err) => {
|