@fieldwork-ai/cli 0.24.0 → 0.27.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 CHANGED
@@ -41,9 +41,11 @@ 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
- 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`.
48
+ Publishing initializes a new connector from `connector.json`, but subsequent package revisions leave the `connectors` row's 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`.
47
49
 
48
50
  ## Auth and configuration
49
51
 
@@ -169,17 +169,14 @@ export function registerConnectorCommands(program) {
169
169
  .description("Download an org-authored connector definition's adapter source")
170
170
  .option("--out <dir>", "target directory (defaults to ./<key>)")
171
171
  .option("--revision <n>", "a historical revision instead of head")
172
- .option("--action-name <name>", "the named action under this connector")
172
+ .option("--package-name <name>", "the named package on this connector")
173
173
  .action(async (key, opts) => {
174
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()}/connectors/${definition.connector_id}/source${suffix}`);
175
+ const suffix = opts.revision ? `?${new URLSearchParams({ revision: opts.revision })}` : "";
176
+ const path = opts.packageName
177
+ ? `/api/organizations/${requireOrgId()}/packages/${encodeURIComponent(opts.packageName)}/source${suffix}`
178
+ : `/api/organizations/${requireOrgId()}/connectors/${(await resolveDefinition(key)).connector_id}/source${suffix}`;
179
+ const body = await apiJson(path);
183
180
  const dir = opts.out ?? key;
184
181
  const pulled = writeBundle(dir, body.files);
185
182
  if (json) {
@@ -193,7 +190,7 @@ export function registerConnectorCommands(program) {
193
190
  .description("Publish an adapter directory as a new revision (creates the definition on first publish)")
194
191
  .requiredOption("--message <message>", "what changed and why")
195
192
  .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")
193
+ .option("--package-name <name>", "create or update this named package on the connector")
197
194
  .action(async (dir, opts) => {
198
195
  const { json, yes } = getContext();
199
196
  const files = readBundle(dir);
@@ -209,15 +206,15 @@ export function registerConnectorCommands(program) {
209
206
  // fall through: the server reports the real validation error
210
207
  }
211
208
  if (key === "?") {
212
- fail('the tree\'s connector.json must carry a "key" field', EXIT.usage);
209
+ fail('the tree\'s connector.json must carry a "key" field — for a package with no system, use `packages publish`', EXIT.usage);
213
210
  }
214
- await confirm(`Publish ${Object.keys(files).length} files to connector "${key}"${opts.actionName ? `, action "${opts.actionName}"` : ""}?`, yes);
211
+ await confirm(`Publish ${Object.keys(files).length} files to connector "${key}"${opts.packageName ? `, package "${opts.packageName}"` : ""}?`, yes);
215
212
  const body = await apiJson(`/api/organizations/${requireOrgId()}/connectors/publish`, {
216
213
  method: "POST",
217
214
  body: JSON.stringify({
218
215
  message: opts.message,
219
216
  base_revision: opts.baseRevision ? Number(opts.baseRevision) : undefined,
220
- action_name: opts.actionName,
217
+ name: opts.packageName,
221
218
  files,
222
219
  }),
223
220
  });
@@ -229,7 +226,7 @@ export function registerConnectorCommands(program) {
229
226
  printData(await apiJson(`/api/organizations/${requireOrgId()}/connectors/${id}/metadata`), getContext().json);
230
227
  });
231
228
  connectors.command("update-metadata <definition-id>")
232
- .description("Edit connector metadata separately from action code")
229
+ .description("Edit connector metadata separately from package code")
233
230
  .requiredOption("--changes <json-file>", "metadata fields to change")
234
231
  .requiredOption("--expected-updated-at <timestamp>", "exact updated_at from metadata read")
235
232
  .action(async (id, opts) => {
@@ -0,0 +1,4 @@
1
+ export declare function uploadLocal(local: string, folder: string | undefined, opts: {
2
+ shareWithOrg?: boolean;
3
+ onExisting: string;
4
+ }): Promise<void>;
@@ -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
+ }
@@ -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
- return (await listTree()).find((r) => r.path === kbPath) ?? null;
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,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerPackageCommands(program: Command): void;
@@ -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
+ }
@@ -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 (Composio prefixes every
72
- // "direct" tool with one) so the summary line is the real description.
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldwork-ai/cli",
3
- "version": "0.24.0",
3
+ "version": "0.27.0",
4
4
  "description": "Fieldwork asset-management CLI",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",