@fargowealth/fargo-ai-cli 0.4.2 → 0.5.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/CHANGELOG.md +15 -0
- package/README.md +87 -14
- package/package.json +6 -2
- package/src/api-client.mjs +42 -0
- package/src/auth.mjs +42 -9
- package/src/cli.mjs +88 -3
- package/src/constants.mjs +3 -1
- package/src/credential-store.mjs +2 -2
- package/src/lego-snapshots.mjs +75 -0
- package/src/loopback-receiver.mjs +1 -1
- package/src/mcp-client.mjs +34 -0
- package/src/mcp-proxy.mjs +1 -1
- package/src/source-snapshot.mjs +185 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
- Exclude the conventional `.build` generated-output directory from LEGO
|
|
6
|
+
source snapshots by default.
|
|
7
|
+
|
|
8
|
+
## 0.5.0
|
|
9
|
+
|
|
10
|
+
- Fargo CLI is now Agent-neutral; PKCE login records Codex, Claude Code,
|
|
11
|
+
Cursor, or WorkBuddy through `--agent`.
|
|
12
|
+
- Added one-shot `fargo mcp tools` and `fargo mcp call` commands for any
|
|
13
|
+
shell-capable Agent.
|
|
14
|
+
- Added deterministic LEGO source snapshot upload, list, and download commands
|
|
15
|
+
with `.legoignore`, secure defaults, SHA-256 manifests, and a 50 MB ZIP limit.
|
|
16
|
+
- WorkBuddy installation remains supported as an adapter over the common CLI.
|
|
17
|
+
|
|
3
18
|
## 0.4.2
|
|
4
19
|
|
|
5
20
|
- Windows no longer calls `chmod` while reading or writing credentials; `%APPDATA%` continues to provide the inherited NTFS user ACL without a sandbox-blocked metadata write.
|
package/README.md
CHANGED
|
@@ -1,27 +1,100 @@
|
|
|
1
|
-
# Fargo CLI
|
|
1
|
+
# Fargo AI CLI
|
|
2
2
|
|
|
3
|
-
`fargo
|
|
3
|
+
`fargo` is the Agent-neutral command-line client for Fargo AI. Codex, Claude
|
|
4
|
+
Code, Cursor, WorkBuddy, and any Agent that can execute shell commands share
|
|
5
|
+
the same Fargo identity, hosted MCP contracts, and LEGO project operations.
|
|
6
|
+
|
|
7
|
+
Requires Node.js 20.19 or newer.
|
|
8
|
+
|
|
9
|
+
## Install or upgrade
|
|
4
10
|
|
|
5
11
|
```sh
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
npx -y @fargowealth/fargo-ai-cli@latest auth logout --output=json
|
|
12
|
+
npm install -g @fargowealth/fargo-ai-cli@0.5.1
|
|
13
|
+
fargo version
|
|
9
14
|
```
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
Use the native remote Fargo MCP connection as the default online tool surface.
|
|
17
|
+
Authenticate the CLI separately only when an Agent needs to package or upload
|
|
18
|
+
files from its local workspace:
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
```sh
|
|
21
|
+
fargo auth login --agent codex
|
|
22
|
+
fargo auth login --agent claude-code
|
|
23
|
+
fargo auth login --agent cursor
|
|
24
|
+
fargo auth status
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Aliases `cc` and `claude` resolve to `claude-code`. Login uses OAuth
|
|
28
|
+
Authorization Code + PKCE over an ephemeral IPv4 loopback callback. The CLI
|
|
29
|
+
validates OAuth state, uses PKCE S256, closes the listener after callback, and
|
|
30
|
+
never prints access or refresh credentials.
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
- **WorkBuddy on Windows:** `%APPDATA%\Fargo AI\credentials.json`. WorkBuddy may also restrict DPAPI access, so the CLI uses a user-profile credential file and does not depend on DPAPI.
|
|
17
|
-
- **WorkBuddy on Linux:** `~/.fargo/credentials.json`.
|
|
32
|
+
## MCP boundary
|
|
18
33
|
|
|
19
|
-
|
|
34
|
+
- MCP is the default for online Fargo resources, status, deployment,
|
|
35
|
+
publication, rollback, and other business operations.
|
|
36
|
+
- CLI is used when the operation needs the Agent's local filesystem or binary
|
|
37
|
+
transfer: source walking, exclusions, ZIP creation, hashing, upload, and
|
|
38
|
+
download.
|
|
39
|
+
- A future `fargo lego deploy --source .` command may compose the two, but it
|
|
40
|
+
will remain a client-side workflow over the same server deployment contract.
|
|
20
41
|
|
|
21
|
-
|
|
42
|
+
Agents without native MCP support can use the CLI fallback:
|
|
22
43
|
|
|
23
|
-
|
|
44
|
+
```sh
|
|
45
|
+
fargo mcp tools
|
|
46
|
+
fargo mcp call lego_get_app --args '{"appId":"app_xxx"}'
|
|
47
|
+
fargo mcp call lego_get_app --args-file ./arguments.json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`fargo mcp serve` remains the local stdio bridge for hosts such as WorkBuddy
|
|
51
|
+
that require one. Codex, Claude Code, and Cursor should prefer the remote MCP
|
|
52
|
+
URL and its native OAuth support.
|
|
53
|
+
|
|
54
|
+
## LEGO source snapshots
|
|
24
55
|
|
|
25
56
|
```sh
|
|
26
|
-
|
|
57
|
+
fargo lego snapshot upload --app app_xxx --source . --dry-run
|
|
58
|
+
fargo lego snapshot upload --app app_xxx --source . --version v1
|
|
59
|
+
fargo lego snapshot list --app app_xxx
|
|
60
|
+
fargo lego snapshot download --app app_xxx --snapshot snap_xxx
|
|
27
61
|
```
|
|
62
|
+
|
|
63
|
+
The CLI applies secure defaults plus `.legoignore`, skips symbolic links,
|
|
64
|
+
creates a deterministic ZIP, calculates SHA-256 digests, and uploads the bytes
|
|
65
|
+
over an authenticated HTTP data path. Archive bytes are not embedded in MCP
|
|
66
|
+
JSON-RPC. The default archive limit is 50 MB.
|
|
67
|
+
|
|
68
|
+
The ZIP includes `.fargo-snapshot/manifest.json`. Dependencies, VCS data,
|
|
69
|
+
generated output, caches, secrets, previous archives, large video/audio, and
|
|
70
|
+
design-source files are excluded by default. Add project-specific gitignore
|
|
71
|
+
syntax to `.legoignore`.
|
|
72
|
+
|
|
73
|
+
## WorkBuddy adapter
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always --output=jsonl
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The installer preserves existing MCP servers and adds the local Fargo stdio
|
|
80
|
+
bridge. Device Authorization Grant remains available only as an explicit
|
|
81
|
+
WorkBuddy fallback:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
fargo auth device --open-browser=always --output=jsonl
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Credential storage
|
|
88
|
+
|
|
89
|
+
- macOS and Linux: `~/.fargo/credentials.json`
|
|
90
|
+
- Windows: `%APPDATA%\Fargo AI\credentials.json`
|
|
91
|
+
|
|
92
|
+
On macOS and Linux, the directory is forced to `0700` and the credential file
|
|
93
|
+
to `0600`. Windows inherits the current user's NTFS ACL from `%APPDATA%`.
|
|
94
|
+
Writes use an `fsync`-backed temporary file followed by atomic replacement,
|
|
95
|
+
symbolic-link reads are rejected, and refresh-token rotation is serialized
|
|
96
|
+
across local CLI processes.
|
|
97
|
+
|
|
98
|
+
The file contains an OAuth refresh credential. Never copy, upload, commit, or
|
|
99
|
+
print it. Use `fargo auth logout` to remove it and revoke the Fargo connection
|
|
100
|
+
when a device is no longer trusted.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fargowealth/fargo-ai-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Fargo AI
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Agent-neutral Fargo AI CLI, MCP client, and LEGO project tooling",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"fargo": "bin/fargo.mjs",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=20.19.0"
|
|
18
18
|
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"fflate": "^0.8.2",
|
|
21
|
+
"ignore": "^7.0.6"
|
|
22
|
+
},
|
|
19
23
|
"license": "UNLICENSED",
|
|
20
24
|
"publishConfig": {
|
|
21
25
|
"access": "public"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CLI_VERSION } from "./constants.mjs";
|
|
2
|
+
import { currentCredential, refreshCurrentCredential } from "./auth.mjs";
|
|
3
|
+
|
|
4
|
+
export async function authenticatedRequest(store, url, init = {}, options = {}) {
|
|
5
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
6
|
+
let credential = await currentCredential(store, { ...options, fetchImpl });
|
|
7
|
+
if (!credential?.access_token) {
|
|
8
|
+
throw new Error("Fargo CLI is not authenticated. Run 'fargo auth login' first");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let response = await send(credential);
|
|
12
|
+
if (response.status === 401) {
|
|
13
|
+
credential = await refreshCurrentCredential(store, credential, { ...options, fetchImpl });
|
|
14
|
+
if (!credential?.access_token) {
|
|
15
|
+
throw new Error("Fargo CLI authentication expired. Run 'fargo auth login' again");
|
|
16
|
+
}
|
|
17
|
+
response = await send(credential);
|
|
18
|
+
}
|
|
19
|
+
return response;
|
|
20
|
+
|
|
21
|
+
function send(activeCredential) {
|
|
22
|
+
const headers = new Headers(init.headers);
|
|
23
|
+
headers.set("Authorization", `Bearer ${activeCredential.access_token}`);
|
|
24
|
+
headers.set("User-Agent", `fargo-cli/${CLI_VERSION}`);
|
|
25
|
+
return fetchImpl(url, { ...init, headers });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function responsePayload(response) {
|
|
30
|
+
const text = await response.text();
|
|
31
|
+
let payload;
|
|
32
|
+
try {
|
|
33
|
+
payload = text.trim() ? JSON.parse(text) : {};
|
|
34
|
+
} catch {
|
|
35
|
+
payload = { message: text.trim() };
|
|
36
|
+
}
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const message = payload?.message || payload?.error || `Fargo API returned HTTP ${response.status}`;
|
|
39
|
+
throw new Error(message);
|
|
40
|
+
}
|
|
41
|
+
return payload;
|
|
42
|
+
}
|
package/src/auth.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL, DEVICE_CODE_GRANT_TYPE, OAUTH_CLIENT_ID, OAUTH_SCOPE } from "./constants.mjs";
|
|
3
|
+
import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL, DEVICE_CODE_GRANT_TYPE, OAUTH_CLIENT_ID, OAUTH_SCOPE, SUPPORTED_AGENT_TYPES } from "./constants.mjs";
|
|
4
4
|
import { createLoopbackReceiver } from "./loopback-receiver.mjs";
|
|
5
5
|
|
|
6
6
|
export async function authorizePkce(store, options = {}) {
|
|
@@ -12,6 +12,8 @@ export async function authorizePkce(store, options = {}) {
|
|
|
12
12
|
const verifier = random(32).toString("base64url");
|
|
13
13
|
const state = random(32).toString("base64url");
|
|
14
14
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
15
|
+
const agentType = normalizeAgentType(options.agentType ?? process.env.FARGO_AGENT ?? "codex");
|
|
16
|
+
const clientName = agentClientName(agentType);
|
|
15
17
|
const receiver = await (options.loopbackReceiverFactory ?? createLoopbackReceiver)({
|
|
16
18
|
expectedState: state,
|
|
17
19
|
randomBytes: random,
|
|
@@ -21,10 +23,10 @@ export async function authorizePkce(store, options = {}) {
|
|
|
21
23
|
try {
|
|
22
24
|
const registrationResponse = await fetchImpl(metadata.registration_endpoint, {
|
|
23
25
|
method: "POST",
|
|
24
|
-
headers: { "Content-Type": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION}
|
|
26
|
+
headers: { "Content-Type": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION}` },
|
|
25
27
|
body: JSON.stringify({
|
|
26
|
-
agent_type:
|
|
27
|
-
client_name:
|
|
28
|
+
agent_type: agentType,
|
|
29
|
+
client_name: clientName,
|
|
28
30
|
grant_types: ["authorization_code", "refresh_token"],
|
|
29
31
|
redirect_uris: [receiver.redirectUri],
|
|
30
32
|
response_types: ["code"],
|
|
@@ -63,7 +65,7 @@ export async function authorizePkce(store, options = {}) {
|
|
|
63
65
|
|
|
64
66
|
const tokenResponse = await fetchImpl(metadata.token_endpoint, {
|
|
65
67
|
method: "POST",
|
|
66
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}
|
|
68
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
|
|
67
69
|
body: new URLSearchParams({
|
|
68
70
|
client_id: registration.client_id,
|
|
69
71
|
code,
|
|
@@ -74,6 +76,7 @@ export async function authorizePkce(store, options = {}) {
|
|
|
74
76
|
});
|
|
75
77
|
const payload = await readOAuthResponse(tokenResponse);
|
|
76
78
|
const credential = credentialFromToken(payload, authUrl, {
|
|
79
|
+
agent_type: agentType,
|
|
77
80
|
authorization_method: "authorization_code_pkce",
|
|
78
81
|
client_id: registration.client_id,
|
|
79
82
|
resource_url: mcpUrl,
|
|
@@ -92,7 +95,7 @@ export async function startDeviceAuthorization(options = {}) {
|
|
|
92
95
|
const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
|
|
93
96
|
const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/device_authorization`, {
|
|
94
97
|
method: "POST",
|
|
95
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}
|
|
98
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
|
|
96
99
|
body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, resource: mcpUrl, scope: OAUTH_SCOPE })
|
|
97
100
|
});
|
|
98
101
|
return readOAuthResponse(response);
|
|
@@ -108,12 +111,13 @@ export async function pollDeviceAuthorization(device, store, options = {}) {
|
|
|
108
111
|
await sleep(interval * 1000);
|
|
109
112
|
const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/token`, {
|
|
110
113
|
method: "POST",
|
|
111
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}
|
|
114
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
|
|
112
115
|
body: new URLSearchParams({ grant_type: DEVICE_CODE_GRANT_TYPE, client_id: OAUTH_CLIENT_ID, device_code: device.device_code })
|
|
113
116
|
});
|
|
114
117
|
const payload = await response.json().catch(() => ({}));
|
|
115
118
|
if (response.ok) {
|
|
116
119
|
const credential = credentialFromToken(payload, authUrl, {
|
|
120
|
+
agent_type: "workbuddy",
|
|
117
121
|
authorization_method: "device_flow",
|
|
118
122
|
client_id: OAUTH_CLIENT_ID,
|
|
119
123
|
resource_url: options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL,
|
|
@@ -136,11 +140,12 @@ export async function refreshCredential(store, credential, options = {}) {
|
|
|
136
140
|
const tokenEndpoint = credential.token_endpoint ?? `${authUrl}/v1/mcp/oauth/token`;
|
|
137
141
|
const response = await fetchImpl(tokenEndpoint, {
|
|
138
142
|
method: "POST",
|
|
139
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}
|
|
143
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
|
|
140
144
|
body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: credential.refresh_token })
|
|
141
145
|
});
|
|
142
146
|
const payload = await readOAuthResponse(response);
|
|
143
147
|
const refreshed = credentialFromToken(payload, authUrl, {
|
|
148
|
+
agent_type: credential.agent_type,
|
|
144
149
|
authorization_method: credential.authorization_method,
|
|
145
150
|
client_id: clientId,
|
|
146
151
|
resource_url: credential.resource_url,
|
|
@@ -194,6 +199,34 @@ export function browserLaunchCommand(url, platform = process.platform) {
|
|
|
194
199
|
return { command: platform === "darwin" ? "open" : "xdg-open", args: [url] };
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
export function normalizeAgentType(value) {
|
|
203
|
+
const normalized = String(value ?? "").trim().toLowerCase().replaceAll("_", "-");
|
|
204
|
+
const aliases = {
|
|
205
|
+
cc: "claude-code",
|
|
206
|
+
claude: "claude-code",
|
|
207
|
+
"claude-code": "claude-code",
|
|
208
|
+
codex: "codex",
|
|
209
|
+
cursor: "cursor",
|
|
210
|
+
workbuddy: "workbuddy",
|
|
211
|
+
"work-buddy": "workbuddy"
|
|
212
|
+
};
|
|
213
|
+
const agentType = aliases[normalized];
|
|
214
|
+
if (!agentType || !SUPPORTED_AGENT_TYPES.includes(agentType)) {
|
|
215
|
+
throw new Error(`Unsupported Agent '${value}'. Expected: ${SUPPORTED_AGENT_TYPES.join(", ")}`);
|
|
216
|
+
}
|
|
217
|
+
return agentType;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function agentClientName(agentType) {
|
|
221
|
+
const labels = {
|
|
222
|
+
"claude-code": "Claude Code",
|
|
223
|
+
codex: "Codex",
|
|
224
|
+
cursor: "Cursor",
|
|
225
|
+
workbuddy: "WorkBuddy"
|
|
226
|
+
};
|
|
227
|
+
return `Fargo CLI for ${labels[agentType]}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
197
230
|
function credentialFromToken(payload, authUrl, metadata = {}) {
|
|
198
231
|
if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
|
|
199
232
|
throw new Error("OAuth token response did not contain rotating credentials");
|
|
@@ -209,7 +242,7 @@ async function readOAuthResponse(response) {
|
|
|
209
242
|
|
|
210
243
|
async function discoverOAuthMetadata(authUrl, fetchImpl) {
|
|
211
244
|
const response = await fetchImpl(`${authUrl.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, {
|
|
212
|
-
headers: { "Accept": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION}
|
|
245
|
+
headers: { "Accept": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION}` }
|
|
213
246
|
});
|
|
214
247
|
const metadata = await readOAuthResponse(response);
|
|
215
248
|
for (const field of ["authorization_endpoint", "registration_endpoint", "token_endpoint"]) {
|
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
1
3
|
import { createCredentialStore } from "./credential-store.mjs";
|
|
2
4
|
import { authorizePkce, currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
|
|
3
5
|
import { CLI_VERSION } from "./constants.mjs";
|
|
6
|
+
import { downloadLegoSourceSnapshot, listLegoSourceSnapshots, uploadLegoSourceSnapshot } from "./lego-snapshots.mjs";
|
|
7
|
+
import { callMcpTool, listMcpTools } from "./mcp-client.mjs";
|
|
4
8
|
import { runMcpProxy } from "./mcp-proxy.mjs";
|
|
5
9
|
import { installWorkBuddyMcp } from "./workbuddy-install.mjs";
|
|
6
10
|
|
|
@@ -23,7 +27,7 @@ export async function runCli(args, options = {}) {
|
|
|
23
27
|
}
|
|
24
28
|
if (args[0] === "auth" && args[1] === "status") {
|
|
25
29
|
const credential = await store.load();
|
|
26
|
-
stdout.write(`${JSON.stringify({ logged_in: Boolean(credential?.refresh_token), method: credential?.authorization_method ?? (credential?.refresh_token ? "oauth" : null), credential_store: store.kind, expires_at: credential?.expires_at ?? null })}\n`);
|
|
30
|
+
stdout.write(`${JSON.stringify({ logged_in: Boolean(credential?.refresh_token), method: credential?.authorization_method ?? (credential?.refresh_token ? "oauth" : null), agent_type: credential?.agent_type ?? null, credential_store: store.kind, expires_at: credential?.expires_at ?? null })}\n`);
|
|
27
31
|
return;
|
|
28
32
|
}
|
|
29
33
|
if (args[0] === "auth" && (args[1] === "logout" || args[1] === "revoke")) {
|
|
@@ -36,6 +40,21 @@ export async function runCli(args, options = {}) {
|
|
|
36
40
|
await runMcpProxy(store, options);
|
|
37
41
|
return;
|
|
38
42
|
}
|
|
43
|
+
if (args[0] === "mcp" && args[1] === "tools") {
|
|
44
|
+
stdout.write(`${JSON.stringify(await listMcpTools(store, options))}\n`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (args[0] === "mcp" && args[1] === "call") {
|
|
48
|
+
const toolName = args[2]?.trim();
|
|
49
|
+
if (!toolName || toolName.startsWith("-")) throw new Error("Usage: fargo mcp call <tool> [--args JSON | --args-file PATH]");
|
|
50
|
+
const toolArgs = await readToolArgs(args);
|
|
51
|
+
stdout.write(`${JSON.stringify(await callMcpTool(store, toolName, toolArgs, options))}\n`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (args[0] === "lego" && args[1] === "snapshot") {
|
|
55
|
+
await runLegoSnapshotCommand(args, stdout, store, options);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
39
58
|
if (args[0] === "workbuddy" && args[1] === "install") {
|
|
40
59
|
let credential = await store.load();
|
|
41
60
|
if (credential) {
|
|
@@ -49,11 +68,11 @@ export async function runCli(args, options = {}) {
|
|
|
49
68
|
if (credential?.authorization_method !== "authorization_code_pkce") {
|
|
50
69
|
credential = undefined;
|
|
51
70
|
}
|
|
52
|
-
if (!credential?.refresh_token) await authorizeWithPkce(args, stdout, store, options);
|
|
71
|
+
if (!credential?.refresh_token) await authorizeWithPkce(args, stdout, store, { ...options, agentType: "workbuddy" });
|
|
53
72
|
stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
|
|
54
73
|
return;
|
|
55
74
|
}
|
|
56
|
-
throw new Error("Usage: fargo auth login|device|status|logout | fargo
|
|
75
|
+
throw new Error("Usage: fargo auth login|device|status|logout | fargo mcp serve|tools|call | fargo lego snapshot upload|list|download | fargo workbuddy install | fargo version");
|
|
57
76
|
} catch (error) {
|
|
58
77
|
stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
59
78
|
if (options.throwOnError) throw error;
|
|
@@ -65,6 +84,7 @@ async function authorizeWithPkce(args, stdout, store, options) {
|
|
|
65
84
|
const browserMode = optionValue(args, "--open-browser") ?? "auto";
|
|
66
85
|
await authorizePkce(store, {
|
|
67
86
|
...options,
|
|
87
|
+
agentType: options.agentType ?? optionValue(args, "--agent") ?? process.env.FARGO_AGENT ?? "codex",
|
|
68
88
|
openBrowser: browserMode !== "never" && options.openBrowser !== false,
|
|
69
89
|
async onAuthorization(details) {
|
|
70
90
|
stdout.write(`${JSON.stringify({
|
|
@@ -79,6 +99,71 @@ async function authorizeWithPkce(args, stdout, store, options) {
|
|
|
79
99
|
stdout.write(`${JSON.stringify({ type: "authorization_complete", logged_in: true, method: "authorization_code_pkce" })}\n`);
|
|
80
100
|
}
|
|
81
101
|
|
|
102
|
+
async function runLegoSnapshotCommand(args, stdout, store, options) {
|
|
103
|
+
const action = args[2];
|
|
104
|
+
const appId = requiredOption(args, "--app");
|
|
105
|
+
if (action === "upload") {
|
|
106
|
+
const result = await uploadLegoSourceSnapshot(store, {
|
|
107
|
+
appId,
|
|
108
|
+
dryRun: args.includes("--dry-run"),
|
|
109
|
+
source: optionValue(args, "--source") ?? ".",
|
|
110
|
+
version: optionValue(args, "--version")
|
|
111
|
+
}, options);
|
|
112
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (action === "list") {
|
|
116
|
+
stdout.write(`${JSON.stringify(await listLegoSourceSnapshots(store, appId, options))}\n`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (action === "download") {
|
|
120
|
+
const snapshotId = requiredOption(args, "--snapshot");
|
|
121
|
+
const result = await downloadLegoSourceSnapshot(store, { appId, snapshotId }, options);
|
|
122
|
+
const outputPath = resolve(optionValue(args, "--output") ?? result.fileName);
|
|
123
|
+
if (!args.includes("--force") && await pathExists(outputPath)) {
|
|
124
|
+
throw new Error(`Output file already exists: ${outputPath}. Pass --force to replace it`);
|
|
125
|
+
}
|
|
126
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
127
|
+
await writeFile(outputPath, result.archive);
|
|
128
|
+
stdout.write(`${JSON.stringify({ archiveSha256: result.archiveSha256, outputPath, sizeBytes: result.archive.byteLength, snapshotId })}\n`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
throw new Error("Usage: fargo lego snapshot upload|list|download --app <appId>");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readToolArgs(args) {
|
|
135
|
+
const inline = optionValue(args, "--args");
|
|
136
|
+
const file = optionValue(args, "--args-file");
|
|
137
|
+
if (inline && file) throw new Error("Use only one of --args or --args-file");
|
|
138
|
+
const text = file ? await readFile(resolve(file), "utf8") : inline ?? "{}";
|
|
139
|
+
let value;
|
|
140
|
+
try {
|
|
141
|
+
value = JSON.parse(text);
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error("MCP tool arguments must be valid JSON");
|
|
144
|
+
}
|
|
145
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
146
|
+
throw new Error("MCP tool arguments must be a JSON object");
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function pathExists(path) {
|
|
152
|
+
try {
|
|
153
|
+
await lstat(path);
|
|
154
|
+
return true;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error?.code === "ENOENT") return false;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function requiredOption(args, name) {
|
|
162
|
+
const value = optionValue(args, name)?.trim();
|
|
163
|
+
if (!value) throw new Error(`Missing required option ${name}`);
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
82
167
|
async function authorizeDevice(args, stdout, store, options) {
|
|
83
168
|
const device = await startDeviceAuthorization(options);
|
|
84
169
|
stdout.write(`${JSON.stringify({
|
package/src/constants.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.5.1";
|
|
2
2
|
export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
3
3
|
export const DEFAULT_AUTH_URL = "https://auth.fargowealth.app";
|
|
4
|
+
export const DEFAULT_API_URL = "https://mcp.fargowealth.app";
|
|
4
5
|
export const DEFAULT_MCP_URL = "https://mcp.fargowealth.app/mcp";
|
|
5
6
|
export const OAUTH_CLIENT_ID = "fargo-cli-workbuddy";
|
|
6
7
|
export const OAUTH_SCOPE = "fargo:forum:read fargo:forum:write fargo:hub:read fargo:lego:read fargo:lego:write fargo:knowledge:read fargo:knowledge:write fargo:knowledge:share";
|
|
8
|
+
export const SUPPORTED_AGENT_TYPES = ["codex", "claude-code", "cursor", "workbuddy"];
|
package/src/credential-store.mjs
CHANGED
|
@@ -10,10 +10,10 @@ export function createCredentialStore(options = {}) {
|
|
|
10
10
|
const home = options.home ?? homedir();
|
|
11
11
|
const storeKind = env.FARGO_CREDENTIAL_STORE;
|
|
12
12
|
if (storeKind === "keychain") {
|
|
13
|
-
throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by
|
|
13
|
+
throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by Fargo CLI; use file on macOS");
|
|
14
14
|
}
|
|
15
15
|
if (storeKind === "dpapi") {
|
|
16
|
-
throw new Error("FARGO_CREDENTIAL_STORE=dpapi is not supported by
|
|
16
|
+
throw new Error("FARGO_CREDENTIAL_STORE=dpapi is not supported by Fargo CLI; use file on Windows");
|
|
17
17
|
}
|
|
18
18
|
if (storeKind && storeKind !== "file") {
|
|
19
19
|
throw new Error(`Unsupported FARGO_CREDENTIAL_STORE: ${storeKind}`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { DEFAULT_API_URL } from "./constants.mjs";
|
|
2
|
+
import { authenticatedRequest, responsePayload } from "./api-client.mjs";
|
|
3
|
+
import { createSourceSnapshot } from "./source-snapshot.mjs";
|
|
4
|
+
|
|
5
|
+
export async function uploadLegoSourceSnapshot(store, input, options = {}) {
|
|
6
|
+
const snapshot = await createSourceSnapshot(input.source ?? ".", options.snapshotOptions);
|
|
7
|
+
if (input.dryRun) {
|
|
8
|
+
return { dryRun: true, snapshot: snapshotSummary(snapshot) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const url = snapshotCollectionUrl(input.appId, options);
|
|
12
|
+
if (input.version) url.searchParams.set("version", input.version);
|
|
13
|
+
const response = await authenticatedRequest(store, url, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: {
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
"Content-Type": "application/zip",
|
|
18
|
+
"Content-Length": String(snapshot.archive.byteLength),
|
|
19
|
+
"X-Fargo-Source-Archive-Sha256": snapshot.archiveSha256,
|
|
20
|
+
"X-Fargo-Source-Content-Sha256": snapshot.contentSha256,
|
|
21
|
+
"X-Fargo-Source-File-Count": String(snapshot.fileCount),
|
|
22
|
+
"X-Fargo-Source-Size": String(snapshot.sourceSizeBytes)
|
|
23
|
+
},
|
|
24
|
+
body: snapshot.archive
|
|
25
|
+
}, options);
|
|
26
|
+
const payload = await responsePayload(response);
|
|
27
|
+
return {
|
|
28
|
+
...payload,
|
|
29
|
+
local: snapshotSummary(snapshot)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function listLegoSourceSnapshots(store, appId, options = {}) {
|
|
34
|
+
const response = await authenticatedRequest(store, snapshotCollectionUrl(appId, options), {
|
|
35
|
+
headers: { Accept: "application/json" }
|
|
36
|
+
}, options);
|
|
37
|
+
return responsePayload(response);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function downloadLegoSourceSnapshot(store, input, options = {}) {
|
|
41
|
+
const base = snapshotCollectionUrl(input.appId, options);
|
|
42
|
+
base.pathname = `${base.pathname}/${encodeURIComponent(input.snapshotId)}/download`;
|
|
43
|
+
const response = await authenticatedRequest(store, base, {
|
|
44
|
+
headers: { Accept: "application/zip" }
|
|
45
|
+
}, options);
|
|
46
|
+
if (!response.ok) await responsePayload(response);
|
|
47
|
+
return {
|
|
48
|
+
archive: new Uint8Array(await response.arrayBuffer()),
|
|
49
|
+
archiveSha256: response.headers.get("x-fargo-source-archive-sha256") ?? undefined,
|
|
50
|
+
fileName: responseFileName(response) ?? `${input.appId}-${input.snapshotId}.zip`
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function snapshotCollectionUrl(appId, options) {
|
|
55
|
+
const apiUrl = (options.apiUrl ?? process.env.FARGO_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
|
|
56
|
+
return new URL(`${apiUrl}/api/lego/apps/${encodeURIComponent(appId)}/source-snapshots`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function snapshotSummary(snapshot) {
|
|
60
|
+
return {
|
|
61
|
+
archiveName: snapshot.archiveName,
|
|
62
|
+
archiveSha256: snapshot.archiveSha256,
|
|
63
|
+
archiveSizeBytes: snapshot.archive.byteLength,
|
|
64
|
+
contentSha256: snapshot.contentSha256,
|
|
65
|
+
fileCount: snapshot.fileCount,
|
|
66
|
+
root: snapshot.root,
|
|
67
|
+
skippedSymlinkCount: snapshot.skippedSymlinkCount,
|
|
68
|
+
sourceSizeBytes: snapshot.sourceSizeBytes
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function responseFileName(response) {
|
|
73
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
74
|
+
return disposition.match(/filename="([^"]+)"/i)?.[1];
|
|
75
|
+
}
|
|
@@ -25,7 +25,7 @@ export async function createLoopbackReceiver(options = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
if (settled) {
|
|
27
27
|
response.writeHead(409, secureHtmlHeaders());
|
|
28
|
-
response.end(completionPage(false, "
|
|
28
|
+
response.end(completionPage(false, "这次授权回调已经处理,请返回发起授权的 Agent。"));
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
settled = true;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DEFAULT_MCP_URL } from "./constants.mjs";
|
|
2
|
+
import { authenticatedRequest, responsePayload } from "./api-client.mjs";
|
|
3
|
+
|
|
4
|
+
export async function listMcpTools(store, options = {}) {
|
|
5
|
+
const result = await mcpRequest(store, "tools/list", {}, options);
|
|
6
|
+
return result;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function callMcpTool(store, name, args, options = {}) {
|
|
10
|
+
return mcpRequest(store, "tools/call", { name, arguments: args }, options);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function mcpRequest(store, method, params, options) {
|
|
14
|
+
const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
|
|
15
|
+
const response = await authenticatedRequest(store, mcpUrl, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"MCP-Protocol-Version": "2025-06-18"
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
jsonrpc: "2.0",
|
|
24
|
+
id: crypto.randomUUID(),
|
|
25
|
+
method,
|
|
26
|
+
params
|
|
27
|
+
})
|
|
28
|
+
}, options);
|
|
29
|
+
const rpc = await responsePayload(response);
|
|
30
|
+
if (rpc.error) {
|
|
31
|
+
throw new Error(rpc.error.message || "Fargo MCP request failed");
|
|
32
|
+
}
|
|
33
|
+
return rpc.result ?? {};
|
|
34
|
+
}
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export async function runMcpProxy(store, options = {}) {
|
|
|
34
34
|
"Authorization": `Bearer ${credential.access_token}`,
|
|
35
35
|
"Content-Type": "application/json",
|
|
36
36
|
"MCP-Protocol-Version": "2025-11-25",
|
|
37
|
-
"User-Agent": `fargo-cli/${CLI_VERSION}
|
|
37
|
+
"User-Agent": `fargo-cli/${CLI_VERSION}`
|
|
38
38
|
};
|
|
39
39
|
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
40
40
|
const response = await fetchImpl(mcpUrl, { method: "POST", headers, body: JSON.stringify(message) });
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, opendir, readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
4
|
+
import { strToU8, zipSync } from "fflate";
|
|
5
|
+
import ignore from "ignore";
|
|
6
|
+
|
|
7
|
+
export const MAX_ARCHIVE_BYTES = 50_000_000;
|
|
8
|
+
export const MAX_FILE_BYTES = 20_000_000;
|
|
9
|
+
export const MAX_FILE_COUNT = 10_000;
|
|
10
|
+
export const MAX_SOURCE_BYTES = 100_000_000;
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_IGNORE_RULES = [
|
|
13
|
+
".git/",
|
|
14
|
+
"node_modules/",
|
|
15
|
+
".build/",
|
|
16
|
+
"dist/",
|
|
17
|
+
"build/",
|
|
18
|
+
".next/",
|
|
19
|
+
".nuxt/",
|
|
20
|
+
".cache/",
|
|
21
|
+
".turbo/",
|
|
22
|
+
".wrangler/",
|
|
23
|
+
"coverage/",
|
|
24
|
+
".fargo-snapshot/",
|
|
25
|
+
".DS_Store",
|
|
26
|
+
"Thumbs.db",
|
|
27
|
+
"*.log",
|
|
28
|
+
"*.zip",
|
|
29
|
+
"*.tar",
|
|
30
|
+
"*.tar.gz",
|
|
31
|
+
"*.tgz",
|
|
32
|
+
".env",
|
|
33
|
+
".env.*",
|
|
34
|
+
"!.env.example",
|
|
35
|
+
"!.env.sample",
|
|
36
|
+
"!.env.template",
|
|
37
|
+
".npmrc",
|
|
38
|
+
".pypirc",
|
|
39
|
+
"*.pem",
|
|
40
|
+
"*.key",
|
|
41
|
+
"id_rsa*",
|
|
42
|
+
"credentials*.json",
|
|
43
|
+
"*.mp4",
|
|
44
|
+
"*.mov",
|
|
45
|
+
"*.avi",
|
|
46
|
+
"*.mkv",
|
|
47
|
+
"*.webm",
|
|
48
|
+
"*.mp3",
|
|
49
|
+
"*.wav",
|
|
50
|
+
"*.flac",
|
|
51
|
+
"*.psd",
|
|
52
|
+
"*.sketch"
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0, 0);
|
|
56
|
+
const MANIFEST_PATH = ".fargo-snapshot/manifest.json";
|
|
57
|
+
|
|
58
|
+
export async function createSourceSnapshot(sourceDirectory, options = {}) {
|
|
59
|
+
const root = resolve(sourceDirectory || ".");
|
|
60
|
+
const rootStat = await lstat(root).catch(() => undefined);
|
|
61
|
+
if (!rootStat?.isDirectory()) {
|
|
62
|
+
throw new Error(`Snapshot source is not a directory: ${root}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const matcher = ignore().add(DEFAULT_IGNORE_RULES);
|
|
66
|
+
const customRules = await readOptionalText(join(root, ".legoignore"));
|
|
67
|
+
if (customRules) matcher.add(customRules);
|
|
68
|
+
|
|
69
|
+
const sourceFiles = [];
|
|
70
|
+
const skippedSymlinks = [];
|
|
71
|
+
await walk(root, "");
|
|
72
|
+
sourceFiles.sort((left, right) => compareText(left.path, right.path));
|
|
73
|
+
if (sourceFiles.length === 0) {
|
|
74
|
+
throw new Error("No source files remain after applying snapshot exclusions");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const files = [];
|
|
78
|
+
let sourceSizeBytes = 0;
|
|
79
|
+
const contentHash = createHash("sha256");
|
|
80
|
+
const archiveEntries = {};
|
|
81
|
+
for (const file of sourceFiles) {
|
|
82
|
+
const bytes = await readFile(file.absolutePath);
|
|
83
|
+
const sha256 = digest(bytes);
|
|
84
|
+
sourceSizeBytes += bytes.byteLength;
|
|
85
|
+
if (sourceSizeBytes > (options.maxSourceBytes ?? MAX_SOURCE_BYTES)) {
|
|
86
|
+
throw new Error(`Snapshot source exceeds ${formatBytes(options.maxSourceBytes ?? MAX_SOURCE_BYTES)}; add generated or media files to .legoignore`);
|
|
87
|
+
}
|
|
88
|
+
const item = { path: file.path, sha256, sizeBytes: bytes.byteLength };
|
|
89
|
+
files.push(item);
|
|
90
|
+
contentHash.update(item.path).update("\0").update(String(item.sizeBytes)).update("\0").update(item.sha256).update("\n");
|
|
91
|
+
archiveEntries[item.path] = [bytes, { level: 6, mtime: FIXED_ZIP_DATE }];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const contentSha256 = contentHash.digest("hex");
|
|
95
|
+
const manifest = {
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
kind: "fargo-lego-source-snapshot",
|
|
98
|
+
archiveFormat: "zip",
|
|
99
|
+
contentSha256,
|
|
100
|
+
fileCount: files.length,
|
|
101
|
+
sourceSizeBytes,
|
|
102
|
+
files
|
|
103
|
+
};
|
|
104
|
+
archiveEntries[MANIFEST_PATH] = [strToU8(`${JSON.stringify(manifest, null, 2)}\n`), { level: 6, mtime: FIXED_ZIP_DATE }];
|
|
105
|
+
const archive = zipSync(archiveEntries, { level: 6, mtime: FIXED_ZIP_DATE });
|
|
106
|
+
const maxArchiveBytes = options.maxArchiveBytes ?? MAX_ARCHIVE_BYTES;
|
|
107
|
+
if (archive.byteLength > maxArchiveBytes) {
|
|
108
|
+
throw new Error(`Snapshot archive is ${formatBytes(archive.byteLength)}, above the ${formatBytes(maxArchiveBytes)} limit; update .legoignore`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
archive,
|
|
113
|
+
archiveName: `${safeFileName(basename(root))}-${contentSha256.slice(0, 12)}.zip`,
|
|
114
|
+
archiveSha256: digest(archive),
|
|
115
|
+
contentSha256,
|
|
116
|
+
fileCount: files.length,
|
|
117
|
+
manifest,
|
|
118
|
+
root,
|
|
119
|
+
skippedSymlinkCount: skippedSymlinks.length,
|
|
120
|
+
sourceSizeBytes
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
async function walk(absoluteDirectory, relativeDirectory) {
|
|
124
|
+
const directory = await opendir(absoluteDirectory);
|
|
125
|
+
const entries = [];
|
|
126
|
+
for await (const entry of directory) entries.push(entry);
|
|
127
|
+
entries.sort((left, right) => compareText(left.name, right.name));
|
|
128
|
+
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
const relativePath = normalizePath(relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name);
|
|
131
|
+
const absolutePath = join(absoluteDirectory, entry.name);
|
|
132
|
+
if (entry.isSymbolicLink()) {
|
|
133
|
+
skippedSymlinks.push(relativePath);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (entry.isDirectory()) {
|
|
137
|
+
if (!matcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!entry.isFile() || matcher.ignores(relativePath)) continue;
|
|
141
|
+
if (relativePath === MANIFEST_PATH) continue;
|
|
142
|
+
|
|
143
|
+
const stat = await lstat(absolutePath);
|
|
144
|
+
const maxFileBytes = options.maxFileBytes ?? MAX_FILE_BYTES;
|
|
145
|
+
if (stat.size > maxFileBytes) {
|
|
146
|
+
throw new Error(`Source file '${relativePath}' is ${formatBytes(stat.size)}, above the ${formatBytes(maxFileBytes)} per-file limit; add it to .legoignore or raise the limit explicitly`);
|
|
147
|
+
}
|
|
148
|
+
sourceFiles.push({ absolutePath, path: relativePath });
|
|
149
|
+
if (sourceFiles.length > (options.maxFileCount ?? MAX_FILE_COUNT)) {
|
|
150
|
+
throw new Error(`Snapshot contains more than ${options.maxFileCount ?? MAX_FILE_COUNT} files; check dependency and build-output exclusions`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function digest(value) {
|
|
157
|
+
return createHash("sha256").update(value).digest("hex");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function compareText(left, right) {
|
|
161
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizePath(value) {
|
|
165
|
+
return value.split(sep).join("/").replace(/^\.\//, "");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readOptionalText(path) {
|
|
169
|
+
try {
|
|
170
|
+
return await readFile(path, "utf8");
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error?.code === "ENOENT") return "";
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function safeFileName(value) {
|
|
178
|
+
return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatBytes(value) {
|
|
182
|
+
if (value < 1_000) return `${value} B`;
|
|
183
|
+
if (value < 1_000_000) return `${(value / 1_000).toFixed(1)} KB`;
|
|
184
|
+
return `${(value / 1_000_000).toFixed(1)} MB`;
|
|
185
|
+
}
|