@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,106 @@
|
|
|
1
|
+
import { apiJson, getContext } from "../http.js";
|
|
2
|
+
import { EXIT, fail, printData } from "../output.js";
|
|
3
|
+
/**
|
|
4
|
+
* `fieldwork tool run <name>` argument assembly (the httpie convention):
|
|
5
|
+
* --args takes a JSON object ('-' reads stdin); repeated --arg entries merge
|
|
6
|
+
* over it — `k=v` as a string, `k:=v` JSON-parsed.
|
|
7
|
+
*/
|
|
8
|
+
export function buildToolArgs(argsOption, argEntries, stdin) {
|
|
9
|
+
let base = {};
|
|
10
|
+
const rawJson = argsOption === "-" ? stdin : argsOption;
|
|
11
|
+
if (argsOption === "-" && stdin === null) {
|
|
12
|
+
fail("--args - requires JSON on stdin", EXIT.usage);
|
|
13
|
+
}
|
|
14
|
+
if (rawJson !== undefined && rawJson !== null) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(rawJson);
|
|
17
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
18
|
+
throw new Error("not an object");
|
|
19
|
+
}
|
|
20
|
+
base = parsed;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
fail("--args must be a JSON object", EXIT.usage);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return applyArgEntries(base, argEntries, "--arg");
|
|
27
|
+
}
|
|
28
|
+
/** Merge `k=v` (string) / `k:=json` entries into base — shared by tool run's
|
|
29
|
+
* --arg and the query verbs' --param. */
|
|
30
|
+
export function applyArgEntries(base, entries, flag) {
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const jsonMatch = /^([^=:]+):=([\s\S]*)$/.exec(entry);
|
|
33
|
+
if (jsonMatch) {
|
|
34
|
+
try {
|
|
35
|
+
base[jsonMatch[1]] = JSON.parse(jsonMatch[2]);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
fail(`${flag} ${jsonMatch[1]}:= expects valid JSON`, EXIT.usage);
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const stringMatch = /^([^=]+)=([\s\S]*)$/.exec(entry);
|
|
43
|
+
if (!stringMatch) {
|
|
44
|
+
fail(`${flag} must be key=value or key:=json (got "${entry}")`, EXIT.usage);
|
|
45
|
+
}
|
|
46
|
+
base[stringMatch[1]] = stringMatch[2];
|
|
47
|
+
}
|
|
48
|
+
return base;
|
|
49
|
+
}
|
|
50
|
+
export async function readStdin() {
|
|
51
|
+
if (process.stdin.isTTY)
|
|
52
|
+
return null;
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for await (const chunk of process.stdin)
|
|
55
|
+
chunks.push(chunk);
|
|
56
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
57
|
+
}
|
|
58
|
+
export function registerToolCommands(program) {
|
|
59
|
+
const tool = program.command("tool").description("List and run gateway tools");
|
|
60
|
+
tool
|
|
61
|
+
.command("list")
|
|
62
|
+
.description("List the tools you can run (personalized to your connections)")
|
|
63
|
+
.action(async () => {
|
|
64
|
+
const { json } = getContext();
|
|
65
|
+
const body = await apiJson("/api/tools");
|
|
66
|
+
if (json) {
|
|
67
|
+
printData(body, true);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
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.
|
|
73
|
+
const firstLine = t.description.split("\n").find((l) => l.trim() && !/^\[.*\]$/.test(l.trim())) ??
|
|
74
|
+
t.description.split("\n")[0] ??
|
|
75
|
+
"";
|
|
76
|
+
printData(`${t.name}\t${firstLine.slice(0, 100)}`, false);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
tool
|
|
80
|
+
.command("describe <name>")
|
|
81
|
+
.description("One tool's description and input schema (JSON Schema)")
|
|
82
|
+
.action(async (name) => {
|
|
83
|
+
const { json } = getContext();
|
|
84
|
+
const body = await apiJson("/api/tools");
|
|
85
|
+
const found = body.tools.find((t) => t.name === name);
|
|
86
|
+
if (!found)
|
|
87
|
+
fail(`no such tool: ${name} — check fieldwork tool list`, EXIT.notFound);
|
|
88
|
+
printData(found, json);
|
|
89
|
+
});
|
|
90
|
+
tool
|
|
91
|
+
.command("run <name>")
|
|
92
|
+
.description("Run a tool by name")
|
|
93
|
+
.option("--args <json>", "arguments as a JSON object, or - for stdin")
|
|
94
|
+
.option("--arg <entry...>", "merge one argument: key=value (string) or key:=json", [])
|
|
95
|
+
.action(async (name, opts) => {
|
|
96
|
+
const { json } = getContext();
|
|
97
|
+
const stdin = opts.args === "-" ? await readStdin() : null;
|
|
98
|
+
const args = buildToolArgs(opts.args, opts.arg, stdin);
|
|
99
|
+
const body = await apiJson("/api/tools/run", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: JSON.stringify({ name, args }),
|
|
102
|
+
});
|
|
103
|
+
// Tool-error strings are data (chat semantics): printed, exit 0.
|
|
104
|
+
printData(body.result, json);
|
|
105
|
+
});
|
|
106
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolution (ADR 20260804), in order:
|
|
3
|
+
* 1. FIELDWORK_TOKEN / FIELDWORK_API_URL environment variables — how the
|
|
4
|
+
* Firecracker backend delivers per-conversation credentials (env on each
|
|
5
|
+
* bash call); env must beat the file, or a stale machine-scoped file
|
|
6
|
+
* would shadow the conversation-scoped identity in a shared VM;
|
|
7
|
+
* 2. ~/.fieldwork/credentials — the dotenv file the platform injects into
|
|
8
|
+
* Fargate compute containers at claim time;
|
|
9
|
+
* 3. ~/.config/fieldwork/config.json — written by `fieldwork login`.
|
|
10
|
+
* --token / --api-url flags override everything.
|
|
11
|
+
*
|
|
12
|
+
* The token is org-pinned at mint (container conversation tokens and OAuth
|
|
13
|
+
* JWTs both carry the org), so there is no per-request org selection: the org
|
|
14
|
+
* id is decoded from the token to build org-scoped route paths, and the
|
|
15
|
+
* server re-verifies everything — the local decode is a convenience, never
|
|
16
|
+
* an authority.
|
|
17
|
+
*/
|
|
18
|
+
export interface LoginConfig {
|
|
19
|
+
api_url: string;
|
|
20
|
+
access_token: string;
|
|
21
|
+
refresh_token?: string;
|
|
22
|
+
expires_at?: number;
|
|
23
|
+
organization_id?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ResolvedConfig {
|
|
26
|
+
apiUrl: string;
|
|
27
|
+
token: string | null;
|
|
28
|
+
source: "flags" | "container" | "env" | "login" | "none";
|
|
29
|
+
/** Set when source is "login": enables the refresh-once path in http.ts. */
|
|
30
|
+
login?: LoginConfig;
|
|
31
|
+
}
|
|
32
|
+
export declare const CONTAINER_CREDENTIALS_PATH: string;
|
|
33
|
+
export declare function loginConfigPath(): string;
|
|
34
|
+
export declare function readLoginConfig(): LoginConfig | null;
|
|
35
|
+
export declare function resolveConfig(flags: {
|
|
36
|
+
apiUrl?: string;
|
|
37
|
+
token?: string;
|
|
38
|
+
}): ResolvedConfig;
|
|
39
|
+
interface DecodedToken {
|
|
40
|
+
sub?: string;
|
|
41
|
+
org?: string;
|
|
42
|
+
cnv?: string;
|
|
43
|
+
organization_id?: string;
|
|
44
|
+
exp?: number;
|
|
45
|
+
typ?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Best-effort local decode of either token kind (2-segment envelope, 3-segment
|
|
49
|
+
* JWT) — for building org-scoped paths and `whoami` output. Never an
|
|
50
|
+
* authority: the server verifies the signature and re-resolves membership.
|
|
51
|
+
*/
|
|
52
|
+
export declare function decodeToken(token: string): DecodedToken | null;
|
|
53
|
+
export declare function orgIdFromToken(token: string): string | null;
|
|
54
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const CONTAINER_CREDENTIALS_PATH = path.join(homedir(), ".fieldwork", "credentials");
|
|
5
|
+
export function loginConfigPath() {
|
|
6
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
|
|
7
|
+
return path.join(base, "fieldwork", "config.json");
|
|
8
|
+
}
|
|
9
|
+
function parseDotenv(content) {
|
|
10
|
+
const values = {};
|
|
11
|
+
for (const line of content.split("\n")) {
|
|
12
|
+
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
|
|
13
|
+
if (match)
|
|
14
|
+
values[match[1]] = match[2];
|
|
15
|
+
}
|
|
16
|
+
return values;
|
|
17
|
+
}
|
|
18
|
+
export function readLoginConfig() {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(loginConfigPath(), "utf8"));
|
|
21
|
+
if (typeof parsed?.api_url === "string" && typeof parsed?.access_token === "string") {
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function resolveConfig(flags) {
|
|
31
|
+
const flagUrl = flags.apiUrl;
|
|
32
|
+
const flagToken = flags.token;
|
|
33
|
+
if (flagToken && flagUrl) {
|
|
34
|
+
return { apiUrl: flagUrl, token: flagToken, source: "flags" };
|
|
35
|
+
}
|
|
36
|
+
if (process.env.FIELDWORK_TOKEN && process.env.FIELDWORK_API_URL) {
|
|
37
|
+
return {
|
|
38
|
+
apiUrl: flagUrl ?? process.env.FIELDWORK_API_URL,
|
|
39
|
+
token: flagToken ?? process.env.FIELDWORK_TOKEN,
|
|
40
|
+
source: "env",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
let container = {};
|
|
44
|
+
try {
|
|
45
|
+
container = parseDotenv(readFileSync(CONTAINER_CREDENTIALS_PATH, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Not in a container (or credentials not injected) — fall through.
|
|
49
|
+
}
|
|
50
|
+
if (container.FIELDWORK_TOKEN && container.FIELDWORK_API_URL) {
|
|
51
|
+
return {
|
|
52
|
+
apiUrl: flagUrl ?? container.FIELDWORK_API_URL,
|
|
53
|
+
token: flagToken ?? container.FIELDWORK_TOKEN,
|
|
54
|
+
source: "container",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const login = readLoginConfig();
|
|
58
|
+
if (login) {
|
|
59
|
+
return {
|
|
60
|
+
apiUrl: flagUrl ?? login.api_url,
|
|
61
|
+
token: flagToken ?? login.access_token,
|
|
62
|
+
source: "login",
|
|
63
|
+
login,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
apiUrl: flagUrl ?? process.env.FIELDWORK_API_URL ?? "https://app.getfieldwork.ai",
|
|
68
|
+
token: flagToken ?? null,
|
|
69
|
+
source: flagToken ? "flags" : "none",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Best-effort local decode of either token kind (2-segment envelope, 3-segment
|
|
74
|
+
* JWT) — for building org-scoped paths and `whoami` output. Never an
|
|
75
|
+
* authority: the server verifies the signature and re-resolves membership.
|
|
76
|
+
*/
|
|
77
|
+
export function decodeToken(token) {
|
|
78
|
+
const segments = token.split(".");
|
|
79
|
+
const payloadSegment = segments.length === 2 ? segments[0] : segments.length === 3 ? segments[1] : null;
|
|
80
|
+
if (!payloadSegment)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function orgIdFromToken(token) {
|
|
90
|
+
const decoded = decodeToken(token);
|
|
91
|
+
const org = decoded?.org ?? decoded?.organization_id;
|
|
92
|
+
return typeof org === "string" && org ? org : null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Knowledge Base row holds, answered from its content type.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors src/lib/kb/content-kind.ts. The CLI ships as its own package and
|
|
5
|
+
* cannot import from the app, so the discriminator is duplicated rather than
|
|
6
|
+
* re-derived: there is no `kind` field on the wire, and guessing from the
|
|
7
|
+
* extension sends a `.md` document down the page write path.
|
|
8
|
+
*/
|
|
9
|
+
/** What the store writes for markdown it authors. */
|
|
10
|
+
export declare const MARKDOWN_CONTENT_TYPE = "text/markdown";
|
|
11
|
+
/** `text/markdown; charset=utf-8` → `text/markdown`. */
|
|
12
|
+
export declare function normalizeContentType(contentType: string): string;
|
|
13
|
+
/** Markdown: read as a page, edited in place, pulled and pushed as text. */
|
|
14
|
+
export declare function isMarkdown(contentType: string | null | undefined): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Knowledge Base row holds, answered from its content type.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors src/lib/kb/content-kind.ts. The CLI ships as its own package and
|
|
5
|
+
* cannot import from the app, so the discriminator is duplicated rather than
|
|
6
|
+
* re-derived: there is no `kind` field on the wire, and guessing from the
|
|
7
|
+
* extension sends a `.md` document down the page write path.
|
|
8
|
+
*/
|
|
9
|
+
/** What the store writes for markdown it authors. */
|
|
10
|
+
export const MARKDOWN_CONTENT_TYPE = "text/markdown";
|
|
11
|
+
/** `text/markdown; charset=utf-8` → `text/markdown`. */
|
|
12
|
+
export function normalizeContentType(contentType) {
|
|
13
|
+
return contentType.split(";")[0].trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
/** Markdown: read as a page, edited in place, pulled and pushed as text. */
|
|
16
|
+
export function isMarkdown(contentType) {
|
|
17
|
+
return contentType != null && normalizeContentType(contentType) === MARKDOWN_CONTENT_TYPE;
|
|
18
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ResolvedConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* The one HTTP door: resolves credentials, attaches the bearer, maps failures
|
|
4
|
+
* to exit codes, and — for login-managed tokens — refreshes once on 401.
|
|
5
|
+
*/
|
|
6
|
+
export interface CliContext {
|
|
7
|
+
config: ResolvedConfig;
|
|
8
|
+
json: boolean;
|
|
9
|
+
yes: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function initContext(flags: {
|
|
12
|
+
apiUrl?: string;
|
|
13
|
+
token?: string;
|
|
14
|
+
json?: boolean;
|
|
15
|
+
yes?: boolean;
|
|
16
|
+
}): CliContext;
|
|
17
|
+
export declare function getContext(): CliContext;
|
|
18
|
+
export declare function requireToken(): string;
|
|
19
|
+
/** The org the token is pinned to — used to build org-scoped route paths. */
|
|
20
|
+
export declare function requireOrgId(): string;
|
|
21
|
+
export declare function api(pathname: string, init?: RequestInit & {
|
|
22
|
+
body?: string;
|
|
23
|
+
}): Promise<Response>;
|
|
24
|
+
/** api() + JSON body + fail-on-error, the shape most commands want. */
|
|
25
|
+
export declare function apiJson<T = unknown>(pathname: string, init?: RequestInit & {
|
|
26
|
+
body?: string;
|
|
27
|
+
}): Promise<T>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loginConfigPath, orgIdFromToken, resolveConfig, } from "./config.js";
|
|
4
|
+
import { EXIT, exitCodeFor, fail } from "./output.js";
|
|
5
|
+
let ctx = null;
|
|
6
|
+
export function initContext(flags) {
|
|
7
|
+
ctx = {
|
|
8
|
+
config: resolveConfig(flags),
|
|
9
|
+
json: flags.json ?? false,
|
|
10
|
+
yes: flags.yes ?? false,
|
|
11
|
+
};
|
|
12
|
+
return ctx;
|
|
13
|
+
}
|
|
14
|
+
export function getContext() {
|
|
15
|
+
if (!ctx)
|
|
16
|
+
throw new Error("CLI context not initialized");
|
|
17
|
+
return ctx;
|
|
18
|
+
}
|
|
19
|
+
export function requireToken() {
|
|
20
|
+
const { config } = getContext();
|
|
21
|
+
if (!config.token) {
|
|
22
|
+
fail("no credentials — run `fieldwork login`, or set FIELDWORK_TOKEN and FIELDWORK_API_URL", EXIT.auth);
|
|
23
|
+
}
|
|
24
|
+
return config.token;
|
|
25
|
+
}
|
|
26
|
+
/** The org the token is pinned to — used to build org-scoped route paths. */
|
|
27
|
+
export function requireOrgId() {
|
|
28
|
+
const org = orgIdFromToken(requireToken());
|
|
29
|
+
if (!org) {
|
|
30
|
+
fail("the current token carries no organization — run `fieldwork login` again", EXIT.auth);
|
|
31
|
+
}
|
|
32
|
+
return org;
|
|
33
|
+
}
|
|
34
|
+
async function refreshLoginToken() {
|
|
35
|
+
const { config } = getContext();
|
|
36
|
+
if (config.source !== "login" || !config.login?.refresh_token)
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
const discovery = await fetch(`${config.apiUrl}/.well-known/oauth-authorization-server/api/auth`);
|
|
40
|
+
if (!discovery.ok)
|
|
41
|
+
return null;
|
|
42
|
+
const { token_endpoint } = (await discovery.json());
|
|
43
|
+
if (!token_endpoint)
|
|
44
|
+
return null;
|
|
45
|
+
const res = await fetch(token_endpoint, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
48
|
+
body: new URLSearchParams({
|
|
49
|
+
grant_type: "refresh_token",
|
|
50
|
+
refresh_token: config.login.refresh_token,
|
|
51
|
+
client_id: "fieldwork-cli",
|
|
52
|
+
resource: config.apiUrl,
|
|
53
|
+
}).toString(),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
return null;
|
|
57
|
+
const tokens = (await res.json());
|
|
58
|
+
if (!tokens.access_token)
|
|
59
|
+
return null;
|
|
60
|
+
const updated = {
|
|
61
|
+
...config.login,
|
|
62
|
+
access_token: tokens.access_token,
|
|
63
|
+
refresh_token: tokens.refresh_token ?? config.login.refresh_token,
|
|
64
|
+
expires_at: tokens.expires_in
|
|
65
|
+
? Math.floor(Date.now() / 1000) + tokens.expires_in
|
|
66
|
+
: undefined,
|
|
67
|
+
organization_id: orgIdFromToken(tokens.access_token) ?? config.login.organization_id,
|
|
68
|
+
};
|
|
69
|
+
const file = loginConfigPath();
|
|
70
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
71
|
+
writeFileSync(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
72
|
+
config.token = tokens.access_token;
|
|
73
|
+
config.login = updated;
|
|
74
|
+
return tokens.access_token;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function api(pathname, init = {}) {
|
|
81
|
+
const { config } = getContext();
|
|
82
|
+
const token = requireToken();
|
|
83
|
+
const headers = new Headers(init.headers);
|
|
84
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
85
|
+
const orgId = orgIdFromToken(token);
|
|
86
|
+
if (orgId)
|
|
87
|
+
headers.set("X-Organization-Id", orgId);
|
|
88
|
+
if (init.body && !headers.has("content-type")) {
|
|
89
|
+
headers.set("content-type", "application/json");
|
|
90
|
+
}
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(`${config.apiUrl}${pathname}`, { ...init, headers });
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (init.signal?.aborted)
|
|
97
|
+
throw err;
|
|
98
|
+
fail(`could not reach ${config.apiUrl}: ${err instanceof Error ? err.message : "network error"}`, EXIT.server);
|
|
99
|
+
}
|
|
100
|
+
if (res.status === 401) {
|
|
101
|
+
const refreshed = await refreshLoginToken();
|
|
102
|
+
if (refreshed) {
|
|
103
|
+
headers.set("authorization", `Bearer ${refreshed}`);
|
|
104
|
+
const orgId = orgIdFromToken(refreshed);
|
|
105
|
+
if (orgId)
|
|
106
|
+
headers.set("X-Organization-Id", orgId);
|
|
107
|
+
res = await fetch(`${config.apiUrl}${pathname}`, { ...init, headers });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return res;
|
|
111
|
+
}
|
|
112
|
+
/** api() + JSON body + fail-on-error, the shape most commands want. */
|
|
113
|
+
export async function apiJson(pathname, init = {}) {
|
|
114
|
+
const res = await api(pathname, init);
|
|
115
|
+
const body = (await res.json().catch(() => null));
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
fail(body?.error ?? `request failed (${res.status})`, exitCodeFor(res.status));
|
|
118
|
+
}
|
|
119
|
+
return body;
|
|
120
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { initContext } from "./http.js";
|
|
5
|
+
import { registerToolCommands } from "./commands/tool.js";
|
|
6
|
+
import { registerAssetCommands } from "./commands/assets.js";
|
|
7
|
+
import { registerKbCommands } from "./commands/kb.js";
|
|
8
|
+
import { registerAppCommands } from "./commands/apps.js";
|
|
9
|
+
import { registerAutomationCommands } from "./commands/automations.js";
|
|
10
|
+
import { registerTaskCommands } from "./commands/tasks.js";
|
|
11
|
+
import { registerConnectorCommands } from "./commands/connectors.js";
|
|
12
|
+
import { registerAuthCommands } from "./commands/auth.js";
|
|
13
|
+
/**
|
|
14
|
+
* The Fieldwork CLI (ADR 20260804): a plain REST client of the org-scoped
|
|
15
|
+
* API. One artifact, two environments — inside the compute container
|
|
16
|
+
* (credentials injected at ~/.fieldwork/credentials, conversation-scoped
|
|
17
|
+
* token) and on a laptop (`fieldwork login`, org-bound OAuth token). stdout
|
|
18
|
+
* is data, stderr is messages; --json for machines; mutations prompt unless
|
|
19
|
+
* --yes.
|
|
20
|
+
*/
|
|
21
|
+
// One version source: package.json sits beside src/ and dist/ alike, so the
|
|
22
|
+
// relative require resolves in both the tsx dev path and the built package.
|
|
23
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
24
|
+
const program = new Command("fieldwork")
|
|
25
|
+
.description("Drive Fieldwork: org assets, tools, and credentials")
|
|
26
|
+
.version(version)
|
|
27
|
+
.option("--json", "print raw API responses (compact JSON)")
|
|
28
|
+
.option("--yes", "skip confirmation prompts (mutations)")
|
|
29
|
+
.option("--api-url <url>", "override the API base URL")
|
|
30
|
+
.option("--token <token>", "override the bearer token")
|
|
31
|
+
.hook("preAction", (thisCommand) => {
|
|
32
|
+
const opts = thisCommand.optsWithGlobals();
|
|
33
|
+
initContext(opts);
|
|
34
|
+
});
|
|
35
|
+
registerAuthCommands(program);
|
|
36
|
+
registerToolCommands(program);
|
|
37
|
+
registerAssetCommands(program);
|
|
38
|
+
registerKbCommands(program);
|
|
39
|
+
registerAppCommands(program);
|
|
40
|
+
registerAutomationCommands(program);
|
|
41
|
+
registerTaskCommands(program);
|
|
42
|
+
registerConnectorCommands(program);
|
|
43
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
44
|
+
process.stderr.write(`fieldwork: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
package/dist/oauth.d.ts
ADDED
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loginConfigPath, orgIdFromToken } from "./config.js";
|
|
7
|
+
import { EXIT, fail, note } from "./output.js";
|
|
8
|
+
/**
|
|
9
|
+
* `fieldwork login`: OAuth 2.1 authorization-code + PKCE against the
|
|
10
|
+
* Fieldwork provider, as the seeded public `fieldwork-cli` client. Loopback
|
|
11
|
+
* redirect on an ephemeral 127.0.0.1 port — the registered port is ignored
|
|
12
|
+
* for loopback IPs (RFC 8252 §7.3). Zero dependencies: node:http catches the
|
|
13
|
+
* callback, node:crypto does PKCE S256.
|
|
14
|
+
*/
|
|
15
|
+
const CLIENT_ID = "fieldwork-cli";
|
|
16
|
+
const SCOPE = "openid profile email offline_access fieldwork:api";
|
|
17
|
+
async function discover(apiUrl) {
|
|
18
|
+
const res = await fetch(`${apiUrl}/.well-known/oauth-authorization-server/api/auth`).catch(() => null);
|
|
19
|
+
if (!res?.ok) {
|
|
20
|
+
fail(`could not discover the OAuth server at ${apiUrl}`, EXIT.server);
|
|
21
|
+
}
|
|
22
|
+
const meta = (await res.json());
|
|
23
|
+
if (!meta.authorization_endpoint || !meta.token_endpoint) {
|
|
24
|
+
fail("the OAuth server metadata is missing its endpoints", EXIT.server);
|
|
25
|
+
}
|
|
26
|
+
return meta;
|
|
27
|
+
}
|
|
28
|
+
function openBrowser(url) {
|
|
29
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
30
|
+
try {
|
|
31
|
+
const child = spawn(command, [url], { stdio: "ignore", detached: true });
|
|
32
|
+
child.on("error", () => note(`Open this URL to continue:\n\n ${url}\n`));
|
|
33
|
+
child.unref();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
note(`Open this URL to continue:\n\n ${url}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function login(apiUrl) {
|
|
40
|
+
const discovery = await discover(apiUrl);
|
|
41
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
42
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
43
|
+
const state = randomBytes(16).toString("base64url");
|
|
44
|
+
// Bind first so the redirect_uri carries the real port.
|
|
45
|
+
const server = createServer();
|
|
46
|
+
await new Promise((resolve, reject) => {
|
|
47
|
+
server.once("error", reject);
|
|
48
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
49
|
+
});
|
|
50
|
+
const address = server.address();
|
|
51
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
52
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
53
|
+
const authorizeUrl = new URL(discovery.authorization_endpoint);
|
|
54
|
+
authorizeUrl.search = new URLSearchParams({
|
|
55
|
+
response_type: "code",
|
|
56
|
+
client_id: CLIENT_ID,
|
|
57
|
+
redirect_uri: redirectUri,
|
|
58
|
+
scope: SCOPE,
|
|
59
|
+
state,
|
|
60
|
+
code_challenge: challenge,
|
|
61
|
+
code_challenge_method: "S256",
|
|
62
|
+
}).toString();
|
|
63
|
+
const callback = new Promise((resolve, reject) => {
|
|
64
|
+
server.on("request", (req, res) => {
|
|
65
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
66
|
+
if (url.pathname !== "/callback") {
|
|
67
|
+
res.writeHead(404).end();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const error = url.searchParams.get("error");
|
|
71
|
+
const code = url.searchParams.get("code");
|
|
72
|
+
const gotState = url.searchParams.get("state");
|
|
73
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
74
|
+
res.end(error
|
|
75
|
+
? "<p>Authorization failed — you can close this tab.</p>"
|
|
76
|
+
: "<p>Signed in — you can close this tab and return to the terminal.</p>");
|
|
77
|
+
server.close();
|
|
78
|
+
if (error)
|
|
79
|
+
reject(new Error(`authorization failed: ${error}`));
|
|
80
|
+
else if (!code || gotState !== state)
|
|
81
|
+
reject(new Error("invalid callback"));
|
|
82
|
+
else
|
|
83
|
+
resolve(code);
|
|
84
|
+
});
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
server.close();
|
|
87
|
+
reject(new Error("timed out waiting for the browser (5 minutes)"));
|
|
88
|
+
}, 5 * 60 * 1000).unref();
|
|
89
|
+
});
|
|
90
|
+
note("Opening your browser to sign in to Fieldwork…");
|
|
91
|
+
openBrowser(authorizeUrl.toString());
|
|
92
|
+
const code = await callback.catch((err) => fail(err.message, EXIT.auth));
|
|
93
|
+
const tokenRes = await fetch(discovery.token_endpoint, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
96
|
+
body: new URLSearchParams({
|
|
97
|
+
grant_type: "authorization_code",
|
|
98
|
+
code,
|
|
99
|
+
client_id: CLIENT_ID,
|
|
100
|
+
code_verifier: verifier,
|
|
101
|
+
redirect_uri: redirectUri,
|
|
102
|
+
// RFC 8707: makes the access token a JWT bound to the API audience.
|
|
103
|
+
resource: apiUrl,
|
|
104
|
+
}).toString(),
|
|
105
|
+
});
|
|
106
|
+
const tokens = (await tokenRes.json().catch(() => null));
|
|
107
|
+
if (!tokenRes.ok || !tokens?.access_token) {
|
|
108
|
+
fail(tokens?.error_description ?? "token exchange failed", EXIT.auth);
|
|
109
|
+
}
|
|
110
|
+
const config = {
|
|
111
|
+
api_url: apiUrl,
|
|
112
|
+
access_token: tokens.access_token,
|
|
113
|
+
refresh_token: tokens.refresh_token,
|
|
114
|
+
expires_at: tokens.expires_in
|
|
115
|
+
? Math.floor(Date.now() / 1000) + tokens.expires_in
|
|
116
|
+
: undefined,
|
|
117
|
+
organization_id: orgIdFromToken(tokens.access_token) ?? undefined,
|
|
118
|
+
};
|
|
119
|
+
const file = loginConfigPath();
|
|
120
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
121
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
122
|
+
return config;
|
|
123
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output conventions (ADR 20260804): stdout carries data only, every message
|
|
3
|
+
* and prompt goes to stderr, --json prints the raw API payload compact —
|
|
4
|
+
* every verb honors it with a stable shape (mutations included: writes print
|
|
5
|
+
* what happened, pulls print what landed where).
|
|
6
|
+
* Exit codes: 0 ok · 1 server/network · 2 usage or 400 · 3 auth (401) ·
|
|
7
|
+
* 4 forbidden (403) · 5 not found (404) · 6 conflict (409) · 7 rate limited.
|
|
8
|
+
* Failures exit non-zero via fail()/exitCodeFor. The one deliberate
|
|
9
|
+
* exception: `tool run` prints tool-error strings as data and exits 0 (chat
|
|
10
|
+
* semantics — a tool error is a result, not a CLI failure); purpose verbs
|
|
11
|
+
* never do that, which is one reason they are REST-backed.
|
|
12
|
+
*/
|
|
13
|
+
export declare const EXIT: {
|
|
14
|
+
readonly ok: 0;
|
|
15
|
+
readonly server: 1;
|
|
16
|
+
readonly usage: 2;
|
|
17
|
+
readonly auth: 3;
|
|
18
|
+
readonly forbidden: 4;
|
|
19
|
+
readonly notFound: 5;
|
|
20
|
+
readonly conflict: 6;
|
|
21
|
+
readonly rateLimited: 7;
|
|
22
|
+
};
|
|
23
|
+
export declare function exitCodeFor(status: number): number;
|
|
24
|
+
export declare function printData(value: unknown, json: boolean): void;
|
|
25
|
+
export declare function note(message: string): void;
|
|
26
|
+
export interface TabularResult {
|
|
27
|
+
columns: string[];
|
|
28
|
+
rows: Record<string, unknown>[];
|
|
29
|
+
truncated?: boolean;
|
|
30
|
+
truncatedBy?: "rows" | "bytes" | null;
|
|
31
|
+
rowsReturned?: number;
|
|
32
|
+
}
|
|
33
|
+
/** Query results: --json prints the raw payload; human mode prints TSV (a
|
|
34
|
+
* header row, then rows) with any truncation noted on stderr. Truncation is
|
|
35
|
+
* a successful result — no exit-code change. */
|
|
36
|
+
export declare function printTabular(result: TabularResult, json: boolean): void;
|
|
37
|
+
export declare function fail(message: string, code: number): never;
|
|
38
|
+
/** Confirm a mutation: prompt on a TTY, --yes skips, non-TTY without --yes
|
|
39
|
+
* is a usage error (unattended callers must opt in explicitly). */
|
|
40
|
+
export declare function confirm(message: string, yes: boolean): Promise<void>;
|