@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
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Fieldwork CLI (`@fieldwork-ai/cli`)
|
|
2
|
+
|
|
3
|
+
The Fieldwork CLI: drive your organization's [Fieldwork](https://app.getfieldwork.ai) assets and tools from a terminal, a script, or an agent harness like Claude Code.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @fieldwork-ai/cli
|
|
7
|
+
fieldwork login # browser sign-in; the token is pinned to the org you pick
|
|
8
|
+
fieldwork whoami
|
|
9
|
+
fieldwork tool list
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Inside a Fieldwork compute container the CLI is preinstalled and pre-authenticated as the conversation's user — no login step; credentials are injected per conversation.
|
|
13
|
+
|
|
14
|
+
## Running tools
|
|
15
|
+
|
|
16
|
+
Every tool your account can use in chat (platform reads plus your org's connected integrations) is invocable by name. Arguments are one JSON object:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
fieldwork tool describe kb_search # description + input schema
|
|
20
|
+
fieldwork tool run kb_search --args '{"query": "replenishment thresholds"}'
|
|
21
|
+
echo '{"database": "Metrics DB", "sql": "select count(*) from orders"}' \
|
|
22
|
+
| fieldwork tool run db_query --args - # stdin for large payloads
|
|
23
|
+
fieldwork tool run kb_search --args '{"query": "x"}' --arg query=override
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`--arg key=value` overlays a string field; `--arg key:=json` parses the value as JSON. Tool-level errors (strings starting `Error:`) are data and exit 0; transport and auth failures use the exit codes below.
|
|
27
|
+
|
|
28
|
+
## Asset families
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
fieldwork agents list | get <slug>
|
|
32
|
+
fieldwork kb ls [prefix] | get <path> [--out <f>] | put <path> [local] | search <q> | rm <path> | mv <from> <to> | pull --dir <d> | push --dir <d>
|
|
33
|
+
fieldwork data list | schema <table> | query "select …"
|
|
34
|
+
fieldwork db list | schema <database> [table] | query <database> "select …"
|
|
35
|
+
fieldwork apps list | get | init | pull | publish <dir> --message "…"
|
|
36
|
+
fieldwork automations list | get | enable | disable | runs [--follow] | run-now [--follow] | logs <name> | delete
|
|
37
|
+
fieldwork connectors list | pull <key> | publish <dir> --message "…" | connect <key> --spec <file> | test <id> | secrets set | secrets pull --out <file>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`kb` covers the whole Knowledge Base — markdown pages and the org's other files, one tree — dispatching on the kind the server reports rather than on the extension. `kb put <path>` writes a page from `--file`/stdin; `kb put <path> <local>` uploads a document. One group, not one per storage kind: the tree stopped being two things, and the name matches the `kb_*` tools so a person and an agent describe the same operation the same way.
|
|
41
|
+
|
|
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
|
+
|
|
44
|
+
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
|
+
|
|
46
|
+
## Auth and configuration
|
|
47
|
+
|
|
48
|
+
Credential resolution order: the injected container file (`~/.fieldwork/credentials`) → `FIELDWORK_TOKEN` / `FIELDWORK_API_URL` environment variables → the `fieldwork login` config (`~/.config/fieldwork/config.json`). One token binds one organization; `fieldwork orgs use` re-runs the browser flow to switch. Revoke a login anytime in Fieldwork under Settings → Connected apps.
|
|
49
|
+
|
|
50
|
+
Prefer a native tool surface in an MCP-capable harness? Fieldwork is also a remote MCP server:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp add --transport http fieldwork https://app.getfieldwork.ai/mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Exit codes
|
|
57
|
+
|
|
58
|
+
`0` success (including tool-level `Error:` strings) · `1` server/network · `2` usage or validation · `3` unauthenticated · `4` forbidden · `5` not found · `6` conflict · `7` rate-limited
|
|
59
|
+
|
|
60
|
+
## Development and releases
|
|
61
|
+
|
|
62
|
+
Source stays in the private app repository under `cli/`. Run `pnpm install --frozen-lockfile`, `pnpm build`, and `pnpm test` here. Publish the compiled package with `npm publish --access public`; the package includes `dist/` and npm metadata, without TypeScript source or source maps. `scripts/pack-cli.sh` builds and packs this same package into the compute image without depending on an npm release.
|
|
63
|
+
|
|
64
|
+
The terminal chat client is the separate public `@fieldwork-ai/fieldwork-code` package (`fwcode`).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
const MAX_FILES = 40;
|
|
5
|
+
function validPath(name) {
|
|
6
|
+
return name.length <= 240 && /^([a-z0-9][a-z0-9._-]*\/)*[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes("..");
|
|
7
|
+
}
|
|
8
|
+
export function readAppBundle(dir) {
|
|
9
|
+
const files = Object.create(null);
|
|
10
|
+
let total = 0;
|
|
11
|
+
const walk = (rel) => {
|
|
12
|
+
const abs = path.join(dir, rel);
|
|
13
|
+
const stat = lstatSync(abs);
|
|
14
|
+
if (stat.isSymbolicLink())
|
|
15
|
+
throw new Error(`Symlinks are not supported: ${rel || dir}`);
|
|
16
|
+
if (stat.isDirectory()) {
|
|
17
|
+
for (const entry of readdirSync(abs)) {
|
|
18
|
+
if (!entry.startsWith("."))
|
|
19
|
+
walk(rel ? `${rel}/${entry}` : entry);
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!stat.isFile() || !validPath(rel))
|
|
24
|
+
throw new Error(`Invalid app file: ${rel}`);
|
|
25
|
+
total += stat.size;
|
|
26
|
+
if (total > MAX_BYTES || Object.keys(files).length >= MAX_FILES)
|
|
27
|
+
throw new Error("App exceeds 10 MiB or 40 files");
|
|
28
|
+
const bytes = readFileSync(abs);
|
|
29
|
+
const text = bytes.toString("utf8");
|
|
30
|
+
files[rel] = !text.includes("\0") && !text.includes("\uFFFD") && Buffer.from(text).equals(bytes)
|
|
31
|
+
? text : { encoding: "base64", data: bytes.toString("base64") };
|
|
32
|
+
};
|
|
33
|
+
walk("");
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
export function writeAppBundle(dir, files) {
|
|
37
|
+
const root = path.resolve(dir);
|
|
38
|
+
const safeParents = (target) => {
|
|
39
|
+
for (let current = target;; current = path.dirname(current)) {
|
|
40
|
+
try {
|
|
41
|
+
if (lstatSync(current).isSymbolicLink())
|
|
42
|
+
throw new Error(`Refusing symlink: ${current}`);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.code !== "ENOENT")
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (current === root)
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
let total = 0;
|
|
53
|
+
const entries = Object.entries(files);
|
|
54
|
+
if (entries.length > MAX_FILES)
|
|
55
|
+
throw new Error("App exceeds 40 files");
|
|
56
|
+
const prepared = entries.map(([name, file]) => {
|
|
57
|
+
if (!validPath(name))
|
|
58
|
+
throw new Error(`Invalid app path: ${name}`);
|
|
59
|
+
if (typeof file !== "string" && (file.encoding !== "base64" || typeof file.data !== "string"
|
|
60
|
+
|| file.data.length > Math.ceil(MAX_BYTES / 3) * 4 || Buffer.from(file.data, "base64").toString("base64") !== file.data))
|
|
61
|
+
throw new Error(`Invalid binary file: ${name}`);
|
|
62
|
+
const bytes = typeof file === "string" ? Buffer.from(file) : Buffer.from(file.data, "base64");
|
|
63
|
+
total += bytes.length;
|
|
64
|
+
if (total > MAX_BYTES)
|
|
65
|
+
throw new Error("App exceeds 10 MiB");
|
|
66
|
+
const target = path.join(root, name);
|
|
67
|
+
safeParents(target);
|
|
68
|
+
return { name, target, bytes };
|
|
69
|
+
});
|
|
70
|
+
for (const { target, bytes } of prepared) {
|
|
71
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
72
|
+
writeFileSync(target, bytes);
|
|
73
|
+
}
|
|
74
|
+
return prepared.map(entry => entry.name);
|
|
75
|
+
}
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Read a local bundle directory into {path: content}, files only, skipping
|
|
2
|
+
* dotfiles — publish-side validation enforces the real limits. */
|
|
3
|
+
export declare function readBundle(dir: string): Record<string, string>;
|
|
4
|
+
/** Write a pulled {path: content} tree under dir, creating directories. */
|
|
5
|
+
export declare function writeBundle(dir: string, files: Record<string, string>): string[];
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { EXIT, fail } from "./output.js";
|
|
4
|
+
/** Read a local bundle directory into {path: content}, files only, skipping
|
|
5
|
+
* dotfiles — publish-side validation enforces the real limits. */
|
|
6
|
+
export function readBundle(dir) {
|
|
7
|
+
const files = {};
|
|
8
|
+
const walk = (rel) => {
|
|
9
|
+
for (const entry of readdirSync(path.join(dir, rel))) {
|
|
10
|
+
if (entry.startsWith("."))
|
|
11
|
+
continue;
|
|
12
|
+
const relPath = rel ? `${rel}/${entry}` : entry;
|
|
13
|
+
const abs = path.join(dir, relPath);
|
|
14
|
+
if (statSync(abs).isDirectory())
|
|
15
|
+
walk(relPath);
|
|
16
|
+
else
|
|
17
|
+
files[relPath] = readFileSync(abs, "utf8");
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
walk("");
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
fail(`could not read ${dir}: ${err instanceof Error ? err.message : "unknown error"}`, EXIT.usage);
|
|
25
|
+
}
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
/** Write a pulled {path: content} tree under dir, creating directories. */
|
|
29
|
+
export function writeBundle(dir, files) {
|
|
30
|
+
const written = [];
|
|
31
|
+
for (const [file, content] of Object.entries(files)) {
|
|
32
|
+
const target = path.join(dir, file);
|
|
33
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
34
|
+
writeFileSync(target, content);
|
|
35
|
+
written.push(file);
|
|
36
|
+
}
|
|
37
|
+
return written;
|
|
38
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readAppBundle, writeAppBundle } from "../app-bundle.js";
|
|
3
|
+
import { apiJson, getContext, requireOrgId } from "../http.js";
|
|
4
|
+
import { EXIT, confirm, fail, note, printData } from "../output.js";
|
|
5
|
+
async function resolveApp(slug) {
|
|
6
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/apps?slug=${encodeURIComponent(slug)}`);
|
|
7
|
+
const app = body.apps?.[0];
|
|
8
|
+
if (!app)
|
|
9
|
+
fail(`no app at "${slug}"`, EXIT.notFound);
|
|
10
|
+
return app;
|
|
11
|
+
}
|
|
12
|
+
export function registerAppCommands(program) {
|
|
13
|
+
const apps = program.command("apps").description("Org apps (deployed mini-apps)");
|
|
14
|
+
apps
|
|
15
|
+
.command("list")
|
|
16
|
+
.description("List the apps you can access")
|
|
17
|
+
.action(async () => {
|
|
18
|
+
const { json } = getContext();
|
|
19
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/apps`);
|
|
20
|
+
if (json) {
|
|
21
|
+
printData(body, true);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const app of body.apps) {
|
|
25
|
+
printData(`${app.slug}\tr${app.latest_revision_number}\t${app.title}`, false);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
apps
|
|
29
|
+
.command("get <slug>")
|
|
30
|
+
.description("One app's metadata")
|
|
31
|
+
.action(async (slug) => {
|
|
32
|
+
printData(await resolveApp(slug), getContext().json);
|
|
33
|
+
});
|
|
34
|
+
apps
|
|
35
|
+
.command("init <slug>")
|
|
36
|
+
.description("Scaffold a new app bundle locally")
|
|
37
|
+
.option("--title <title>", "display title (defaults to the slug)")
|
|
38
|
+
.option("--out <dir>", "target directory (defaults to ./<slug>)")
|
|
39
|
+
.action(async (slug, opts) => {
|
|
40
|
+
const { json } = getContext();
|
|
41
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
42
|
+
fail(`invalid slug "${slug}" — lowercase kebab-case`, EXIT.usage);
|
|
43
|
+
}
|
|
44
|
+
const dir = opts.out ?? slug;
|
|
45
|
+
const title = opts.title ?? slug;
|
|
46
|
+
// Fetched, not vendored: the bundle's styles.css embeds the platform's
|
|
47
|
+
// live design tokens, which the CLI cannot compute locally (APP-89).
|
|
48
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/apps/scaffold?title=${encodeURIComponent(title)}`);
|
|
49
|
+
const scaffolded = writeAppBundle(dir, body.files);
|
|
50
|
+
if (json) {
|
|
51
|
+
printData({ slug, dir, scaffolded }, true);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
note(`Scaffolded ${scaffolded.length} files in ${dir}/ — edit, then: fieldwork apps publish ${dir} --slug ${slug} --message "first deploy"`);
|
|
55
|
+
});
|
|
56
|
+
apps
|
|
57
|
+
.command("pull <slug>")
|
|
58
|
+
.description("Download an app's source tree")
|
|
59
|
+
.option("--out <dir>", "target directory (defaults to ./<slug>)")
|
|
60
|
+
.option("--revision <n>", "a historical revision instead of head")
|
|
61
|
+
.action(async (slug, opts) => {
|
|
62
|
+
const { json } = getContext();
|
|
63
|
+
const app = await resolveApp(slug);
|
|
64
|
+
const suffix = opts.revision ? `?revision=${opts.revision}` : "";
|
|
65
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/apps/${app.app_id}/source${suffix}`);
|
|
66
|
+
const dir = opts.out ?? slug;
|
|
67
|
+
const pulled = writeAppBundle(dir, body.files);
|
|
68
|
+
if (json) {
|
|
69
|
+
printData({ slug, revision: body.revision, dir, pulled }, true);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
note(`Pulled "${slug}" revision ${body.revision} into ${dir}/ — pass --base-revision ${body.revision} when publishing.`);
|
|
73
|
+
});
|
|
74
|
+
apps
|
|
75
|
+
.command("publish <dir>")
|
|
76
|
+
.description("Deploy a bundle directory as a new revision (creates the app on first publish)")
|
|
77
|
+
.requiredOption("--message <message>", "what changed and why")
|
|
78
|
+
.option("--slug <slug>", "app slug (defaults to the directory name)")
|
|
79
|
+
.option("--base-revision <n>", "head revision your changes are based on (required for existing apps)")
|
|
80
|
+
.action(async (dir, opts) => {
|
|
81
|
+
const { json, yes } = getContext();
|
|
82
|
+
const slug = opts.slug ?? path.basename(path.resolve(dir));
|
|
83
|
+
const files = readAppBundle(dir);
|
|
84
|
+
await confirm(`Publish ${Object.keys(files).length} files to "${slug}"?`, yes);
|
|
85
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/apps/publish`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
slug,
|
|
89
|
+
message: opts.message,
|
|
90
|
+
base_revision: opts.baseRevision ? Number(opts.baseRevision) : undefined,
|
|
91
|
+
files,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
printData(body, json);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { apiJson, getContext, requireOrgId } from "../http.js";
|
|
2
|
+
import { EXIT, fail, printData, printTabular } from "../output.js";
|
|
3
|
+
import { applyArgEntries, readStdin } from "./tool.js";
|
|
4
|
+
export function registerAssetCommands(program) {
|
|
5
|
+
const agents = program.command("agents").description("Org agents (named personas)");
|
|
6
|
+
agents
|
|
7
|
+
.command("list")
|
|
8
|
+
.description("List the agents you can access")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
const { json } = getContext();
|
|
11
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/agents`);
|
|
12
|
+
if (json) {
|
|
13
|
+
printData(body, true);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
for (const a of body.agents) {
|
|
17
|
+
printData(`${a.slug}\tr${a.latest_revision_number}\t${a.name}${a.description ? `\t${a.description}` : ""}`, false);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
agents
|
|
21
|
+
.command("get <slug>")
|
|
22
|
+
.description("Read one agent in full")
|
|
23
|
+
.action(async (slug) => {
|
|
24
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/agents?slug=${encodeURIComponent(slug)}`);
|
|
25
|
+
const agent = body.agents?.[0];
|
|
26
|
+
if (!agent)
|
|
27
|
+
fail(`no agent at "${slug}"`, EXIT.notFound);
|
|
28
|
+
printData(agent, getContext().json);
|
|
29
|
+
});
|
|
30
|
+
const data = program.command("data").description("Curated datalake tables");
|
|
31
|
+
data
|
|
32
|
+
.command("list")
|
|
33
|
+
.description("List the curated tables you can read")
|
|
34
|
+
.action(async () => {
|
|
35
|
+
const { json } = getContext();
|
|
36
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/datalake/tables`);
|
|
37
|
+
if (json) {
|
|
38
|
+
printData(body, true);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const t of body.tables) {
|
|
42
|
+
const rows = t.latestSnapshot ? `${t.latestSnapshot.rowCount} rows` : "no data yet";
|
|
43
|
+
printData(`${t.name}\t${rows}\t${t.freshness}${t.description ? `\t${t.description}` : ""}`, false);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
data
|
|
47
|
+
.command("schema <table>")
|
|
48
|
+
.description("A table's columns and catalog documentation")
|
|
49
|
+
.action(async (table) => {
|
|
50
|
+
const { json } = getContext();
|
|
51
|
+
const list = await apiJson(`/api/organizations/${requireOrgId()}/datalake/tables`);
|
|
52
|
+
const found = list.tables.find((t) => t.name === table);
|
|
53
|
+
if (!found)
|
|
54
|
+
fail(`no table "${table}" — check fieldwork data list`, EXIT.notFound);
|
|
55
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/datalake/tables/${found.datalakeTableId}`);
|
|
56
|
+
if (json) {
|
|
57
|
+
printData(body, true);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const detail = body.table;
|
|
61
|
+
for (const c of detail.schema?.columns ?? []) {
|
|
62
|
+
printData(`${c.name}\t${c.type}${c.description ? `\t${c.description}` : ""}`, false);
|
|
63
|
+
}
|
|
64
|
+
if (detail.doc)
|
|
65
|
+
printData(`\n${detail.doc}`, false);
|
|
66
|
+
});
|
|
67
|
+
data
|
|
68
|
+
.command("query [sql]")
|
|
69
|
+
.description("Run SQL over the curated datalake tables ('-' or omitted reads stdin)")
|
|
70
|
+
.option("--param <entry...>", "bind a named parameter: key=value or key:=json", [])
|
|
71
|
+
.option("--tables <names>", "comma-separated tables to mount (default: all readable)")
|
|
72
|
+
.action(async (sql, opts) => {
|
|
73
|
+
const { json } = getContext();
|
|
74
|
+
const statement = sql === undefined || sql === "-" ? await readStdin() : sql;
|
|
75
|
+
if (!statement?.trim())
|
|
76
|
+
fail("no SQL given (pass it as an argument or on stdin)", EXIT.usage);
|
|
77
|
+
const params = applyArgEntries({}, opts.param, "--param");
|
|
78
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/datalake/query`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
sql: statement,
|
|
82
|
+
...(opts.param.length > 0 ? { params } : {}),
|
|
83
|
+
...(opts.tables ? { tables: opts.tables.split(",").map((t) => t.trim()) } : {}),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
printTabular(body, json);
|
|
87
|
+
});
|
|
88
|
+
const db = program.command("db").description("Connected databases (read-only)");
|
|
89
|
+
db.command("list")
|
|
90
|
+
.description("List connected databases")
|
|
91
|
+
.action(async () => {
|
|
92
|
+
const { json } = getContext();
|
|
93
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors`);
|
|
94
|
+
if (json) {
|
|
95
|
+
printData(body, true);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const c of body.connectors) {
|
|
99
|
+
const restricted = c.restricted_tables ? "\trestricted tables" : "";
|
|
100
|
+
printData(`${dbAddressOf(c)}\t${c.display_name ?? c.name}\t${c.engine}\t${c.label}\tread-only ${c.read_only}${restricted}`, false);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
db.command("schema <database> [table]")
|
|
104
|
+
.description("A database's tables, or one table's columns")
|
|
105
|
+
.option("--schema <schema>", "schema name (required with a table on restricted databases)")
|
|
106
|
+
.action(async (database, table, opts) => {
|
|
107
|
+
const { json } = getContext();
|
|
108
|
+
const row = await resolveDbConnector(database);
|
|
109
|
+
const query = new URLSearchParams();
|
|
110
|
+
if (opts.schema)
|
|
111
|
+
query.set("schema", opts.schema);
|
|
112
|
+
if (table)
|
|
113
|
+
query.set("table", table);
|
|
114
|
+
const suffix = query.size > 0 ? `?${query}` : "";
|
|
115
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors/${dbAddressOf(row)}/schema${suffix}`);
|
|
116
|
+
if (json) {
|
|
117
|
+
printData(body, true);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (body.columns) {
|
|
121
|
+
for (const c of body.columns) {
|
|
122
|
+
printData(`${c.name}\t${c.type}${c.nullable === false ? "\tNOT NULL" : ""}`, false);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
for (const t of body.tables ?? [])
|
|
127
|
+
printData(`${t.schema}.${t.name}`, false);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
db.command("query <database> [sql]")
|
|
131
|
+
.description("Run a read-only SQL query ('-' or omitted reads stdin; --sql kept for compatibility)")
|
|
132
|
+
.option("--sql <sql>", "the SELECT to run")
|
|
133
|
+
.option("--param <entry...>", "bind one positional parameter (repeat in order): value or :=json", [])
|
|
134
|
+
.action(async (database, sql, opts) => {
|
|
135
|
+
const { json } = getContext();
|
|
136
|
+
const statement = opts.sql ?? (sql === undefined || sql === "-" ? await readStdin() : sql);
|
|
137
|
+
if (!statement?.trim())
|
|
138
|
+
fail("no SQL given (argument, --sql, or stdin)", EXIT.usage);
|
|
139
|
+
const row = await resolveDbConnector(database);
|
|
140
|
+
const values = opts.param.map((p) => p.startsWith(":=") ? JSON.parse(p.slice(2)) : p);
|
|
141
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors/${dbAddressOf(row)}/query`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
sql: statement,
|
|
145
|
+
...(values.length > 0 ? { params: values } : {}),
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
printTabular(body, json);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
152
|
+
/** Resolve id, name, or display name against the caller's readable list —
|
|
153
|
+
* the db_query resolveDatabase semantics, client-side. */
|
|
154
|
+
/** How a connector is addressed in a URL (ADR 20260819): the definition key for
|
|
155
|
+
* a global system, its id for an org-authored one. Mirrors `connectorAddress`
|
|
156
|
+
* in the app; the CLI is its own package and shares no code with it. */
|
|
157
|
+
function dbAddressOf(c) {
|
|
158
|
+
return c.definition_is_global ? c.definition_key : c.connector_definition_id;
|
|
159
|
+
}
|
|
160
|
+
async function resolveDbConnector(database) {
|
|
161
|
+
const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors`);
|
|
162
|
+
const found = UUID_RE.test(database)
|
|
163
|
+
? body.connectors.find((c) => c.connector_definition_id === database)
|
|
164
|
+
: (body.connectors.find((c) => c.definition_key === database) ??
|
|
165
|
+
body.connectors.find((c) => c.name === database) ??
|
|
166
|
+
body.connectors.find((c) => c.display_name === database));
|
|
167
|
+
if (!found)
|
|
168
|
+
fail(`no connected database "${database}" — check fieldwork db list`, EXIT.notFound);
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { decodeToken, loginConfigPath } from "../config.js";
|
|
3
|
+
import { getContext } from "../http.js";
|
|
4
|
+
import { login } from "../oauth.js";
|
|
5
|
+
import { EXIT, fail, note, printData } from "../output.js";
|
|
6
|
+
/** Resolve the pinned org's display name with the fresh token — also the
|
|
7
|
+
* first live API call of the session, so login fails loudly if the token
|
|
8
|
+
* doesn't actually work. Falls back to the id alone. */
|
|
9
|
+
async function describeOrg(apiUrl, token, orgId) {
|
|
10
|
+
if (!orgId)
|
|
11
|
+
return "(unknown organization)";
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch(`${apiUrl}/api/organizations`, {
|
|
14
|
+
headers: { authorization: `Bearer ${token}` },
|
|
15
|
+
});
|
|
16
|
+
if (res.ok) {
|
|
17
|
+
const { organizations } = (await res.json());
|
|
18
|
+
const name = organizations.find((o) => o.organization_id === orgId)?.name;
|
|
19
|
+
if (name)
|
|
20
|
+
return `${name} (${orgId})`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Offline or slow API: the id alone is still correct.
|
|
25
|
+
}
|
|
26
|
+
return orgId;
|
|
27
|
+
}
|
|
28
|
+
export function registerAuthCommands(program) {
|
|
29
|
+
program
|
|
30
|
+
.command("login")
|
|
31
|
+
.description("Sign in via your browser (org-bound OAuth token)")
|
|
32
|
+
.action(async () => {
|
|
33
|
+
const { config, json } = getContext();
|
|
34
|
+
const result = await login(config.apiUrl);
|
|
35
|
+
const described = await describeOrg(config.apiUrl, result.access_token, result.organization_id);
|
|
36
|
+
note(`Signed in. Token pinned to ${described}.`);
|
|
37
|
+
if (json) {
|
|
38
|
+
printData({
|
|
39
|
+
user_id: decodeToken(result.access_token)?.sub ?? null,
|
|
40
|
+
organization_id: result.organization_id ?? null,
|
|
41
|
+
api_url: config.apiUrl,
|
|
42
|
+
}, true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
program
|
|
46
|
+
.command("logout")
|
|
47
|
+
.description("Forget the login-managed token (revoke the grant in Settings → Connected apps)")
|
|
48
|
+
.action(() => {
|
|
49
|
+
const { json } = getContext();
|
|
50
|
+
let removed = true;
|
|
51
|
+
try {
|
|
52
|
+
rmSync(loginConfigPath());
|
|
53
|
+
note("Logged out.");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
removed = false;
|
|
57
|
+
note("Nothing to log out of.");
|
|
58
|
+
}
|
|
59
|
+
if (json)
|
|
60
|
+
printData({ logged_out: removed }, true);
|
|
61
|
+
});
|
|
62
|
+
program
|
|
63
|
+
.command("whoami")
|
|
64
|
+
.description("Who the current credential acts as (decoded locally, verified server-side)")
|
|
65
|
+
.action(() => {
|
|
66
|
+
const { config, json } = getContext();
|
|
67
|
+
if (!config.token) {
|
|
68
|
+
fail("no credentials — run `fieldwork login`", EXIT.auth);
|
|
69
|
+
}
|
|
70
|
+
const decoded = decodeToken(config.token);
|
|
71
|
+
if (!decoded)
|
|
72
|
+
fail("the current token is unreadable", EXIT.auth);
|
|
73
|
+
printData({
|
|
74
|
+
user_id: decoded.sub ?? null,
|
|
75
|
+
organization_id: decoded.org ?? decoded.organization_id ?? null,
|
|
76
|
+
conversation_id: decoded.cnv ?? null,
|
|
77
|
+
kind: decoded.typ === "cli" ? "container" : "oauth",
|
|
78
|
+
source: config.source,
|
|
79
|
+
expires_at: decoded.exp ? new Date(decoded.exp * 1000).toISOString() : null,
|
|
80
|
+
api_url: config.apiUrl,
|
|
81
|
+
}, json);
|
|
82
|
+
});
|
|
83
|
+
const orgs = program.command("orgs").description("Organization binding");
|
|
84
|
+
orgs
|
|
85
|
+
.command("current")
|
|
86
|
+
.description("The organization the current token is pinned to")
|
|
87
|
+
.action(() => {
|
|
88
|
+
const { config, json } = getContext();
|
|
89
|
+
const decoded = config.token ? decodeToken(config.token) : null;
|
|
90
|
+
const org = decoded?.org ?? decoded?.organization_id ?? null;
|
|
91
|
+
if (!org)
|
|
92
|
+
fail("no org-pinned credential — run `fieldwork login`", EXIT.auth);
|
|
93
|
+
printData({ organization_id: org }, json);
|
|
94
|
+
});
|
|
95
|
+
orgs
|
|
96
|
+
.command("use")
|
|
97
|
+
.description("Switch organization: re-run the browser sign-in and pick a different org")
|
|
98
|
+
.action(async () => {
|
|
99
|
+
const { config, json } = getContext();
|
|
100
|
+
// One token per org (the pin lives in the credential): switching is a
|
|
101
|
+
// fresh authorize, where the org picker/consent page names the org.
|
|
102
|
+
const result = await login(config.apiUrl);
|
|
103
|
+
const described = await describeOrg(config.apiUrl, result.access_token, result.organization_id);
|
|
104
|
+
note(`Now acting in ${described}.`);
|
|
105
|
+
if (json) {
|
|
106
|
+
printData({
|
|
107
|
+
user_id: decodeToken(result.access_token)?.sub ?? null,
|
|
108
|
+
organization_id: result.organization_id ?? null,
|
|
109
|
+
api_url: config.apiUrl,
|
|
110
|
+
}, true);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|