@fargowealth/fargo-ai-cli 0.5.0 → 0.6.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/CHANGELOG.md +18 -0
- package/README.md +68 -38
- package/package.json +1 -1
- package/src/cli.mjs +33 -1
- package/src/constants.mjs +1 -1
- package/src/deployment-artifact.mjs +207 -0
- package/src/lego-deploy-artifacts.mjs +47 -0
- package/src/lego-deploy.mjs +77 -0
- package/src/lego-migrations.mjs +113 -0
- package/src/lego-snapshots.mjs +1 -1
- package/src/mcp-result.mjs +14 -0
- package/src/source-snapshot.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Added `fargo lego deploy` to create a source snapshot and deterministic
|
|
6
|
+
Pages/Worker deployment ZIP, upload both through authenticated binary data
|
|
7
|
+
paths, and create a ready deployment through the hosted MCP contract.
|
|
8
|
+
- Deployment artifacts now preserve text and binary files, carry per-file and
|
|
9
|
+
aggregate SHA-256 provenance, support `.legodeployignore`, and are linked to
|
|
10
|
+
deployment records by `deployArtifactId`.
|
|
11
|
+
- Added `fargo lego d1 migrate plan|apply` for ordered local SQL files with
|
|
12
|
+
server-verified hashes, idempotent retries, and content-drift protection.
|
|
13
|
+
- Kept publication and migrations independent from deployment, and kept R2,
|
|
14
|
+
Cloudflare credentials, permission checks, and D1 execution server-side.
|
|
15
|
+
|
|
16
|
+
## 0.5.1
|
|
17
|
+
|
|
18
|
+
- Exclude the conventional `.build` generated-output directory from LEGO
|
|
19
|
+
source snapshots by default.
|
|
20
|
+
|
|
3
21
|
## 0.5.0
|
|
4
22
|
|
|
5
23
|
- Fargo CLI is now Agent-neutral; PKCE login records Codex, Claude Code,
|
package/README.md
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
# Fargo AI CLI
|
|
2
2
|
|
|
3
3
|
`fargo` is the Agent-neutral command-line client for Fargo AI. Codex, Claude
|
|
4
|
-
Code, Cursor, WorkBuddy, and
|
|
5
|
-
|
|
4
|
+
Code, Cursor, WorkBuddy, and future shell-capable Agents share the same Fargo
|
|
5
|
+
identity, hosted MCP contracts, and deterministic LEGO project operations.
|
|
6
6
|
|
|
7
7
|
Requires Node.js 20.19 or newer.
|
|
8
8
|
|
|
9
9
|
## Install or upgrade
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install -g @fargowealth/fargo-ai-cli@0.
|
|
12
|
+
npm install -g @fargowealth/fargo-ai-cli@0.6.0
|
|
13
13
|
fargo version
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Use
|
|
17
|
-
|
|
18
|
-
files from its local workspace:
|
|
16
|
+
Use native hosted Fargo MCP for ordinary online tools. Authenticate the CLI
|
|
17
|
+
only when an Agent first needs local files:
|
|
19
18
|
|
|
20
19
|
```sh
|
|
21
20
|
fargo auth login --agent codex
|
|
@@ -26,20 +25,18 @@ fargo auth status
|
|
|
26
25
|
|
|
27
26
|
Aliases `cc` and `claude` resolve to `claude-code`. Login uses OAuth
|
|
28
27
|
Authorization Code + PKCE over an ephemeral IPv4 loopback callback. The CLI
|
|
29
|
-
validates
|
|
30
|
-
never prints access or refresh credentials.
|
|
28
|
+
validates state, uses PKCE S256, closes the listener, and never prints tokens.
|
|
31
29
|
|
|
32
30
|
## MCP boundary
|
|
33
31
|
|
|
34
|
-
- MCP
|
|
35
|
-
|
|
36
|
-
- CLI
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Agents without native MCP support can use the CLI fallback:
|
|
32
|
+
- Hosted MCP owns Fargo resources, permission checks, status, publication,
|
|
33
|
+
rollback, Cloudflare writes, and D1 execution.
|
|
34
|
+
- CLI owns local walking, exclusions, ZIP creation, hashing, binary transfer,
|
|
35
|
+
deploy composition, and migration-file ordering.
|
|
36
|
+
- CLI has no generic R2 or Cloudflare administration commands.
|
|
37
|
+
- `fargo mcp serve` is the local stdio bridge for WorkBuddy.
|
|
38
|
+
- `fargo mcp tools` and `fargo mcp call` are fallbacks for Agents without a
|
|
39
|
+
native remote MCP client.
|
|
43
40
|
|
|
44
41
|
```sh
|
|
45
42
|
fargo mcp tools
|
|
@@ -47,10 +44,6 @@ fargo mcp call lego_get_app --args '{"appId":"app_xxx"}'
|
|
|
47
44
|
fargo mcp call lego_get_app --args-file ./arguments.json
|
|
48
45
|
```
|
|
49
46
|
|
|
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
47
|
## LEGO source snapshots
|
|
55
48
|
|
|
56
49
|
```sh
|
|
@@ -61,14 +54,54 @@ fargo lego snapshot download --app app_xxx --snapshot snap_xxx
|
|
|
61
54
|
```
|
|
62
55
|
|
|
63
56
|
The CLI applies secure defaults plus `.legoignore`, skips symbolic links,
|
|
64
|
-
creates a deterministic ZIP, calculates SHA-256 digests, and uploads
|
|
65
|
-
over
|
|
66
|
-
|
|
57
|
+
creates a deterministic ZIP, calculates SHA-256 digests, and uploads raw bytes
|
|
58
|
+
over authenticated HTTP. The ZIP includes
|
|
59
|
+
`.fargo-snapshot/manifest.json`; its default archive limit is 50 MB.
|
|
60
|
+
|
|
61
|
+
## LEGO local deploy
|
|
62
|
+
|
|
63
|
+
Run the project build explicitly, then give Fargo CLI the completed output:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm run build
|
|
67
|
+
fargo lego deploy \
|
|
68
|
+
--app app_xxx \
|
|
69
|
+
--source . \
|
|
70
|
+
--deploy-dir dist \
|
|
71
|
+
--target pages \
|
|
72
|
+
--entrypoint index.html \
|
|
73
|
+
--version v1
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For Workers use `--target worker`; the default entrypoint becomes `index.js`.
|
|
77
|
+
The CLI does not install dependencies, infer a framework, or run the build.
|
|
78
|
+
|
|
79
|
+
The command creates and uploads two immutable archives:
|
|
80
|
+
|
|
81
|
+
1. a filtered source snapshot for recovery and provenance;
|
|
82
|
+
2. a deterministic deployment ZIP from `--deploy-dir` using
|
|
83
|
+
`.legodeployignore`.
|
|
84
|
+
|
|
85
|
+
It then sends only `sourceSnapshotId` and `deployArtifactId` through the hosted
|
|
86
|
+
MCP deployment contract. The result is `ready`, never automatically published.
|
|
87
|
+
Use `--dry-run` to validate both local packages without authentication or
|
|
88
|
+
network writes.
|
|
89
|
+
|
|
90
|
+
Deployment artifacts preserve binary bytes and include
|
|
91
|
+
`.fargo-deploy/manifest.json`. Default limits are 1,000 files, 25 MB total,
|
|
92
|
+
20 MB per file, and a 25 MB ZIP.
|
|
93
|
+
|
|
94
|
+
## LEGO D1 migrations
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
fargo lego d1 migrate plan --app app_xxx --dir migrations
|
|
98
|
+
fargo lego d1 migrate apply --app app_xxx --dir migrations
|
|
99
|
+
```
|
|
67
100
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
Migration files are top-level, lexically ordered `*.sql` files. The filename
|
|
102
|
+
without `.sql` is the version. `plan` compares local SHA-256 values with the
|
|
103
|
+
production registry; `apply` stops on content drift and submits only pending
|
|
104
|
+
files. Migration execution remains server-side and is never coupled to deploy.
|
|
72
105
|
|
|
73
106
|
## WorkBuddy adapter
|
|
74
107
|
|
|
@@ -77,8 +110,7 @@ npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always
|
|
|
77
110
|
```
|
|
78
111
|
|
|
79
112
|
The installer preserves existing MCP servers and adds the local Fargo stdio
|
|
80
|
-
bridge. Device Authorization Grant remains
|
|
81
|
-
WorkBuddy fallback:
|
|
113
|
+
bridge. Device Authorization Grant remains an explicit fallback:
|
|
82
114
|
|
|
83
115
|
```sh
|
|
84
116
|
fargo auth device --open-browser=always --output=jsonl
|
|
@@ -89,12 +121,10 @@ fargo auth device --open-browser=always --output=jsonl
|
|
|
89
121
|
- macOS and Linux: `~/.fargo/credentials.json`
|
|
90
122
|
- Windows: `%APPDATA%\Fargo AI\credentials.json`
|
|
91
123
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
across local CLI processes.
|
|
124
|
+
macOS/Linux use a `0700` directory and `0600` file. Windows inherits the
|
|
125
|
+
current user's NTFS ACL. Writes use an `fsync`-backed temporary file and atomic
|
|
126
|
+
replacement, symbolic-link reads are rejected, and refresh-token rotation is
|
|
127
|
+
serialized across CLI processes.
|
|
97
128
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
when a device is no longer trusted.
|
|
129
|
+
Never copy, upload, commit, or print the credential file. Use
|
|
130
|
+
`fargo auth logout` when a device is no longer trusted.
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { createCredentialStore } from "./credential-store.mjs";
|
|
|
4
4
|
import { authorizePkce, currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
|
|
5
5
|
import { CLI_VERSION } from "./constants.mjs";
|
|
6
6
|
import { downloadLegoSourceSnapshot, listLegoSourceSnapshots, uploadLegoSourceSnapshot } from "./lego-snapshots.mjs";
|
|
7
|
+
import { deployLocalLegoApp } from "./lego-deploy.mjs";
|
|
8
|
+
import { applyLegoMigrations, planLegoMigrations } from "./lego-migrations.mjs";
|
|
7
9
|
import { callMcpTool, listMcpTools } from "./mcp-client.mjs";
|
|
8
10
|
import { runMcpProxy } from "./mcp-proxy.mjs";
|
|
9
11
|
import { installWorkBuddyMcp } from "./workbuddy-install.mjs";
|
|
@@ -55,6 +57,36 @@ export async function runCli(args, options = {}) {
|
|
|
55
57
|
await runLegoSnapshotCommand(args, stdout, store, options);
|
|
56
58
|
return;
|
|
57
59
|
}
|
|
60
|
+
if (args[0] === "lego" && args[1] === "deploy") {
|
|
61
|
+
const result = await deployLocalLegoApp(store, {
|
|
62
|
+
appId: requiredOption(args, "--app"),
|
|
63
|
+
deployDir: requiredOption(args, "--deploy-dir"),
|
|
64
|
+
dryRun: args.includes("--dry-run"),
|
|
65
|
+
entrypoint: optionValue(args, "--entrypoint"),
|
|
66
|
+
source: optionValue(args, "--source") ?? ".",
|
|
67
|
+
target: requiredOption(args, "--target"),
|
|
68
|
+
version: optionValue(args, "--version")
|
|
69
|
+
}, options);
|
|
70
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (args[0] === "lego" && args[1] === "d1" && args[2] === "migrate") {
|
|
74
|
+
const action = args[3];
|
|
75
|
+
const input = {
|
|
76
|
+
appId: requiredOption(args, "--app"),
|
|
77
|
+
directory: optionValue(args, "--dir") ?? "migrations",
|
|
78
|
+
tablePrefix: optionValue(args, "--table-prefix")
|
|
79
|
+
};
|
|
80
|
+
if (action === "plan") {
|
|
81
|
+
stdout.write(`${JSON.stringify(await planLegoMigrations(store, input, options))}\n`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (action === "apply") {
|
|
85
|
+
stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations]");
|
|
89
|
+
}
|
|
58
90
|
if (args[0] === "workbuddy" && args[1] === "install") {
|
|
59
91
|
let credential = await store.load();
|
|
60
92
|
if (credential) {
|
|
@@ -72,7 +104,7 @@ export async function runCli(args, options = {}) {
|
|
|
72
104
|
stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
|
|
73
105
|
return;
|
|
74
106
|
}
|
|
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");
|
|
107
|
+
throw new Error("Usage: fargo auth login|device|status|logout | fargo mcp serve|tools|call | fargo lego snapshot upload|list|download | fargo lego deploy | fargo lego d1 migrate plan|apply | fargo workbuddy install | fargo version");
|
|
76
108
|
} catch (error) {
|
|
77
109
|
stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
78
110
|
if (options.throwOnError) throw error;
|
package/src/constants.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.6.0";
|
|
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
4
|
export const DEFAULT_API_URL = "https://mcp.fargowealth.app";
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, opendir, readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { strToU8, zipSync } from "fflate";
|
|
5
|
+
import ignore from "ignore";
|
|
6
|
+
|
|
7
|
+
export const MAX_DEPLOY_ARCHIVE_BYTES = 25_000_000;
|
|
8
|
+
export const MAX_DEPLOY_FILE_BYTES = 20_000_000;
|
|
9
|
+
export const MAX_DEPLOY_FILE_COUNT = 1_000;
|
|
10
|
+
export const MAX_DEPLOY_CONTENT_BYTES = 25_000_000;
|
|
11
|
+
|
|
12
|
+
const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0, 0);
|
|
13
|
+
const MANIFEST_PATH = ".fargo-deploy/manifest.json";
|
|
14
|
+
const DEFAULT_IGNORE_RULES = [
|
|
15
|
+
".git/",
|
|
16
|
+
"node_modules/",
|
|
17
|
+
".fargo-deploy/",
|
|
18
|
+
".DS_Store",
|
|
19
|
+
"Thumbs.db",
|
|
20
|
+
".env",
|
|
21
|
+
".env.*",
|
|
22
|
+
".npmrc",
|
|
23
|
+
".pypirc",
|
|
24
|
+
"*.pem",
|
|
25
|
+
"*.key",
|
|
26
|
+
"id_rsa*",
|
|
27
|
+
"credentials*.json"
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export async function createDeploymentArtifact(deployDirectory, options = {}) {
|
|
31
|
+
const root = resolve(deployDirectory || ".");
|
|
32
|
+
const rootStat = await lstat(root).catch(() => undefined);
|
|
33
|
+
if (!rootStat?.isDirectory()) {
|
|
34
|
+
throw new Error(`Deployment output is not a directory: ${root}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const target = deploymentTarget(options.target);
|
|
38
|
+
const entrypoint = normalizePath(options.entrypoint || (target === "pages" ? "index.html" : "index.js"));
|
|
39
|
+
const matcher = ignore().add(DEFAULT_IGNORE_RULES);
|
|
40
|
+
const customRules = await readOptionalText(join(root, ".legodeployignore"));
|
|
41
|
+
if (customRules) matcher.add(customRules);
|
|
42
|
+
|
|
43
|
+
const candidates = [];
|
|
44
|
+
const skippedSymlinks = [];
|
|
45
|
+
await walk(root, "");
|
|
46
|
+
candidates.sort((left, right) => compareText(left.path, right.path));
|
|
47
|
+
if (candidates.length === 0) {
|
|
48
|
+
throw new Error("No deployment files remain after applying deployment exclusions");
|
|
49
|
+
}
|
|
50
|
+
if (!candidates.some((file) => file.path === entrypoint)) {
|
|
51
|
+
throw new Error(`Deployment entrypoint is missing from output: ${entrypoint}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const files = [];
|
|
55
|
+
const archiveEntries = {};
|
|
56
|
+
const contentHash = createHash("sha256");
|
|
57
|
+
let contentSizeBytes = 0;
|
|
58
|
+
for (const file of candidates) {
|
|
59
|
+
const bytes = await readFile(file.absolutePath);
|
|
60
|
+
const sha256 = digest(bytes);
|
|
61
|
+
const contentType = contentTypeForPath(file.path);
|
|
62
|
+
contentSizeBytes += bytes.byteLength;
|
|
63
|
+
if (contentSizeBytes > (options.maxContentBytes ?? MAX_DEPLOY_CONTENT_BYTES)) {
|
|
64
|
+
throw new Error(`Deployment output exceeds ${formatBytes(options.maxContentBytes ?? MAX_DEPLOY_CONTENT_BYTES)}`);
|
|
65
|
+
}
|
|
66
|
+
const item = { path: file.path, sha256, sizeBytes: bytes.byteLength, contentType };
|
|
67
|
+
files.push(item);
|
|
68
|
+
contentHash
|
|
69
|
+
.update(item.path).update("\0")
|
|
70
|
+
.update(String(item.sizeBytes)).update("\0")
|
|
71
|
+
.update(item.sha256).update("\0")
|
|
72
|
+
.update(item.contentType).update("\n");
|
|
73
|
+
archiveEntries[item.path] = [bytes, { level: 6, mtime: FIXED_ZIP_DATE }];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const contentSha256 = contentHash.digest("hex");
|
|
77
|
+
const manifest = {
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
kind: "fargo-lego-deployment-artifact",
|
|
80
|
+
archiveFormat: "zip",
|
|
81
|
+
target,
|
|
82
|
+
entrypoint,
|
|
83
|
+
contentSha256,
|
|
84
|
+
fileCount: files.length,
|
|
85
|
+
contentSizeBytes,
|
|
86
|
+
files
|
|
87
|
+
};
|
|
88
|
+
archiveEntries[MANIFEST_PATH] = [strToU8(`${JSON.stringify(manifest, null, 2)}\n`), { level: 6, mtime: FIXED_ZIP_DATE }];
|
|
89
|
+
const archive = zipSync(archiveEntries, { level: 6, mtime: FIXED_ZIP_DATE });
|
|
90
|
+
const maxArchiveBytes = options.maxArchiveBytes ?? MAX_DEPLOY_ARCHIVE_BYTES;
|
|
91
|
+
if (archive.byteLength > maxArchiveBytes) {
|
|
92
|
+
throw new Error(`Deployment artifact is ${formatBytes(archive.byteLength)}, above the ${formatBytes(maxArchiveBytes)} limit`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
archive,
|
|
97
|
+
archiveName: `${safeFileName(basename(root))}-${contentSha256.slice(0, 12)}.zip`,
|
|
98
|
+
archiveSha256: digest(archive),
|
|
99
|
+
contentSha256,
|
|
100
|
+
contentSizeBytes,
|
|
101
|
+
entrypoint,
|
|
102
|
+
fileCount: files.length,
|
|
103
|
+
manifest,
|
|
104
|
+
root,
|
|
105
|
+
skippedSymlinkCount: skippedSymlinks.length,
|
|
106
|
+
target
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
async function walk(absoluteDirectory, relativeDirectory) {
|
|
110
|
+
const directory = await opendir(absoluteDirectory);
|
|
111
|
+
const entries = [];
|
|
112
|
+
for await (const entry of directory) entries.push(entry);
|
|
113
|
+
entries.sort((left, right) => compareText(left.name, right.name));
|
|
114
|
+
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const relativePath = normalizePath(relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name);
|
|
117
|
+
const absolutePath = join(absoluteDirectory, entry.name);
|
|
118
|
+
if (entry.isSymbolicLink()) {
|
|
119
|
+
skippedSymlinks.push(relativePath);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
if (!matcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!entry.isFile() || matcher.ignores(relativePath) || relativePath === ".legodeployignore") continue;
|
|
127
|
+
const stat = await lstat(absolutePath);
|
|
128
|
+
const maxFileBytes = options.maxFileBytes ?? MAX_DEPLOY_FILE_BYTES;
|
|
129
|
+
if (stat.size > maxFileBytes) {
|
|
130
|
+
throw new Error(`Deployment file '${relativePath}' is ${formatBytes(stat.size)}, above the ${formatBytes(maxFileBytes)} per-file limit`);
|
|
131
|
+
}
|
|
132
|
+
candidates.push({ absolutePath, path: relativePath });
|
|
133
|
+
if (candidates.length > (options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT)) {
|
|
134
|
+
throw new Error(`Deployment output contains more than ${options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT} files`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function contentTypeForPath(path) {
|
|
141
|
+
const extension = extname(path).toLowerCase();
|
|
142
|
+
const known = {
|
|
143
|
+
".avif": "image/avif",
|
|
144
|
+
".css": "text/css; charset=utf-8",
|
|
145
|
+
".csv": "text/csv; charset=utf-8",
|
|
146
|
+
".gif": "image/gif",
|
|
147
|
+
".html": "text/html; charset=utf-8",
|
|
148
|
+
".ico": "image/x-icon",
|
|
149
|
+
".jpeg": "image/jpeg",
|
|
150
|
+
".jpg": "image/jpeg",
|
|
151
|
+
".js": "application/javascript; charset=utf-8",
|
|
152
|
+
".json": "application/json; charset=utf-8",
|
|
153
|
+
".map": "application/json; charset=utf-8",
|
|
154
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
155
|
+
".pdf": "application/pdf",
|
|
156
|
+
".png": "image/png",
|
|
157
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
158
|
+
".txt": "text/plain; charset=utf-8",
|
|
159
|
+
".wasm": "application/wasm",
|
|
160
|
+
".webp": "image/webp",
|
|
161
|
+
".woff": "font/woff",
|
|
162
|
+
".woff2": "font/woff2",
|
|
163
|
+
".xml": "application/xml; charset=utf-8"
|
|
164
|
+
};
|
|
165
|
+
return known[extension] ?? "application/octet-stream";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function deploymentTarget(value) {
|
|
169
|
+
const target = String(value || "").trim().toLowerCase();
|
|
170
|
+
if (target === "pages" || target === "worker") return target;
|
|
171
|
+
throw new Error("Deployment target must be 'pages' or 'worker'");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function digest(value) {
|
|
175
|
+
return createHash("sha256").update(value).digest("hex");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function compareText(left, right) {
|
|
179
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizePath(value) {
|
|
183
|
+
const path = String(value || "").split(sep).join("/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
184
|
+
if (!path || path.length > 240 || path.includes("\\") || path.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
185
|
+
throw new Error(`Invalid deployment path: ${value}`);
|
|
186
|
+
}
|
|
187
|
+
return path;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readOptionalText(path) {
|
|
191
|
+
try {
|
|
192
|
+
return await readFile(path, "utf8");
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error?.code === "ENOENT") return "";
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function safeFileName(value) {
|
|
200
|
+
return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "deploy";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatBytes(value) {
|
|
204
|
+
if (value < 1_000) return `${value} B`;
|
|
205
|
+
if (value < 1_000_000) return `${(value / 1_000).toFixed(1)} KB`;
|
|
206
|
+
return `${(value / 1_000_000).toFixed(1)} MB`;
|
|
207
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { authenticatedRequest, responsePayload } from "./api-client.mjs";
|
|
2
|
+
|
|
3
|
+
export async function uploadLegoDeployArtifact(store, input, options = {}) {
|
|
4
|
+
const artifact = input.artifact;
|
|
5
|
+
if (!artifact?.archive) throw new Error("Deployment artifact archive is required");
|
|
6
|
+
const url = deployArtifactCollectionUrl(input.appId, options);
|
|
7
|
+
if (input.version) url.searchParams.set("version", input.version);
|
|
8
|
+
const response = await authenticatedRequest(store, url, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: {
|
|
11
|
+
Accept: "application/json",
|
|
12
|
+
"Content-Type": "application/zip",
|
|
13
|
+
"Content-Length": String(artifact.archive.byteLength),
|
|
14
|
+
"X-Fargo-Deploy-Archive-Sha256": artifact.archiveSha256,
|
|
15
|
+
"X-Fargo-Deploy-Content-Sha256": artifact.contentSha256,
|
|
16
|
+
"X-Fargo-Deploy-Content-Size": String(artifact.contentSizeBytes),
|
|
17
|
+
"X-Fargo-Deploy-Entrypoint": artifact.entrypoint,
|
|
18
|
+
"X-Fargo-Deploy-File-Count": String(artifact.fileCount),
|
|
19
|
+
"X-Fargo-Deploy-Target": artifact.target
|
|
20
|
+
},
|
|
21
|
+
body: artifact.archive
|
|
22
|
+
}, options);
|
|
23
|
+
return {
|
|
24
|
+
...(await responsePayload(response)),
|
|
25
|
+
local: deploymentArtifactSummary(artifact)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function deploymentArtifactSummary(artifact) {
|
|
30
|
+
return {
|
|
31
|
+
archiveName: artifact.archiveName,
|
|
32
|
+
archiveSha256: artifact.archiveSha256,
|
|
33
|
+
archiveSizeBytes: artifact.archive.byteLength,
|
|
34
|
+
contentSha256: artifact.contentSha256,
|
|
35
|
+
contentSizeBytes: artifact.contentSizeBytes,
|
|
36
|
+
entrypoint: artifact.entrypoint,
|
|
37
|
+
fileCount: artifact.fileCount,
|
|
38
|
+
root: artifact.root,
|
|
39
|
+
skippedSymlinkCount: artifact.skippedSymlinkCount,
|
|
40
|
+
target: artifact.target
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deployArtifactCollectionUrl(appId, options) {
|
|
45
|
+
const apiUrl = (options.apiUrl ?? process.env.FARGO_API_URL ?? "https://mcp.fargowealth.app").replace(/\/+$/, "");
|
|
46
|
+
return new URL(`${apiUrl}/api/lego/apps/${encodeURIComponent(appId)}/deploy-artifacts`);
|
|
47
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createDeploymentArtifact } from "./deployment-artifact.mjs";
|
|
2
|
+
import { uploadLegoDeployArtifact, deploymentArtifactSummary } from "./lego-deploy-artifacts.mjs";
|
|
3
|
+
import { uploadLegoSourceSnapshot } from "./lego-snapshots.mjs";
|
|
4
|
+
import { callMcpTool } from "./mcp-client.mjs";
|
|
5
|
+
import { mcpToolPayload } from "./mcp-result.mjs";
|
|
6
|
+
import { createSourceSnapshot } from "./source-snapshot.mjs";
|
|
7
|
+
|
|
8
|
+
export async function deployLocalLegoApp(store, input, options = {}) {
|
|
9
|
+
const target = requiredTarget(input.target);
|
|
10
|
+
const entrypoint = input.entrypoint || (target === "pages" ? "index.html" : "index.js");
|
|
11
|
+
const [snapshot, artifact] = await Promise.all([
|
|
12
|
+
createSourceSnapshot(input.source || ".", options.snapshotOptions),
|
|
13
|
+
createDeploymentArtifact(input.deployDir, { ...options.artifactOptions, target, entrypoint })
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
if (input.dryRun) {
|
|
17
|
+
return {
|
|
18
|
+
dryRun: true,
|
|
19
|
+
sourceSnapshot: sourceSnapshotSummary(snapshot),
|
|
20
|
+
deployArtifact: deploymentArtifactSummary(artifact)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const uploadedSnapshot = await uploadLegoSourceSnapshot(store, {
|
|
25
|
+
appId: input.appId,
|
|
26
|
+
source: input.source || ".",
|
|
27
|
+
version: input.version
|
|
28
|
+
}, { ...options, snapshot });
|
|
29
|
+
const sourceSnapshotId = uploadedSnapshot.sourceSnapshot?.id;
|
|
30
|
+
if (!sourceSnapshotId) throw new Error("Source snapshot upload did not return sourceSnapshot.id");
|
|
31
|
+
|
|
32
|
+
const uploadedArtifact = await uploadLegoDeployArtifact(store, {
|
|
33
|
+
appId: input.appId,
|
|
34
|
+
artifact,
|
|
35
|
+
version: input.version
|
|
36
|
+
}, options);
|
|
37
|
+
const deployArtifactId = uploadedArtifact.deployArtifact?.id;
|
|
38
|
+
if (!deployArtifactId) throw new Error("Deployment artifact upload did not return deployArtifact.id");
|
|
39
|
+
|
|
40
|
+
const tool = target === "pages" ? "lego_pages_deploy" : "lego_worker_deploy";
|
|
41
|
+
const deployment = mcpToolPayload(await callMcpTool(store, tool, {
|
|
42
|
+
appId: input.appId,
|
|
43
|
+
deployArtifactId,
|
|
44
|
+
entrypoint: artifact.entrypoint,
|
|
45
|
+
sourceSnapshotId,
|
|
46
|
+
version: input.version
|
|
47
|
+
}, options));
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
appId: input.appId,
|
|
51
|
+
target,
|
|
52
|
+
sourceSnapshot: uploadedSnapshot.sourceSnapshot,
|
|
53
|
+
deployArtifact: uploadedArtifact.deployArtifact,
|
|
54
|
+
deployment: deployment.deployment,
|
|
55
|
+
sourceVersion: deployment.sourceVersion,
|
|
56
|
+
security: deployment.security
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sourceSnapshotSummary(snapshot) {
|
|
61
|
+
return {
|
|
62
|
+
archiveName: snapshot.archiveName,
|
|
63
|
+
archiveSha256: snapshot.archiveSha256,
|
|
64
|
+
archiveSizeBytes: snapshot.archive.byteLength,
|
|
65
|
+
contentSha256: snapshot.contentSha256,
|
|
66
|
+
fileCount: snapshot.fileCount,
|
|
67
|
+
root: snapshot.root,
|
|
68
|
+
skippedSymlinkCount: snapshot.skippedSymlinkCount,
|
|
69
|
+
sourceSizeBytes: snapshot.sourceSizeBytes
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requiredTarget(value) {
|
|
74
|
+
const target = String(value || "").trim().toLowerCase();
|
|
75
|
+
if (target === "pages" || target === "worker") return target;
|
|
76
|
+
throw new Error("Missing or invalid --target; use pages or worker");
|
|
77
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, opendir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { callMcpTool } from "./mcp-client.mjs";
|
|
5
|
+
import { mcpToolPayload } from "./mcp-result.mjs";
|
|
6
|
+
|
|
7
|
+
const MAX_MIGRATION_BYTES = 80_000;
|
|
8
|
+
const MAX_MIGRATION_FILES = 500;
|
|
9
|
+
|
|
10
|
+
export async function planLegoMigrations(store, input, options = {}) {
|
|
11
|
+
return publicMigrationPlan(await buildMigrationPlan(store, input, options));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function buildMigrationPlan(store, input, options) {
|
|
15
|
+
const local = await readMigrationDirectory(input.directory);
|
|
16
|
+
const remote = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_list", { appId: input.appId }, options));
|
|
17
|
+
const applied = new Map((remote.migrations ?? []).map((migration) => [String(migration.version), migration]));
|
|
18
|
+
const migrations = local.map((migration) => {
|
|
19
|
+
const existing = applied.get(migration.version);
|
|
20
|
+
if (!existing) return { ...migration, status: "pending" };
|
|
21
|
+
if (!existing.contentSha256) return { ...migration, status: "applied_unverified", remote: existing };
|
|
22
|
+
return existing.contentSha256 === migration.contentSha256
|
|
23
|
+
? { ...migration, status: "applied", remote: existing }
|
|
24
|
+
: { ...migration, status: "drift", remote: existing };
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
appId: input.appId,
|
|
28
|
+
directory: resolve(input.directory || "migrations"),
|
|
29
|
+
migrations,
|
|
30
|
+
summary: {
|
|
31
|
+
applied: migrations.filter((item) => item.status === "applied").length,
|
|
32
|
+
appliedUnverified: migrations.filter((item) => item.status === "applied_unverified").length,
|
|
33
|
+
drift: migrations.filter((item) => item.status === "drift").length,
|
|
34
|
+
pending: migrations.filter((item) => item.status === "pending").length
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function applyLegoMigrations(store, input, options = {}) {
|
|
40
|
+
const plan = await buildMigrationPlan(store, input, options);
|
|
41
|
+
const drift = plan.migrations.filter((migration) => migration.status === "drift");
|
|
42
|
+
if (drift.length > 0) {
|
|
43
|
+
throw new Error(`Migration drift detected: ${drift.map((item) => item.fileName).join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const applied = [];
|
|
47
|
+
for (const migration of plan.migrations.filter((item) => item.status === "pending")) {
|
|
48
|
+
const result = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_apply", {
|
|
49
|
+
appId: input.appId,
|
|
50
|
+
contentSha256: migration.contentSha256,
|
|
51
|
+
fileName: migration.fileName,
|
|
52
|
+
sql: migration.sql,
|
|
53
|
+
tablePrefix: input.tablePrefix,
|
|
54
|
+
version: migration.version
|
|
55
|
+
}, options));
|
|
56
|
+
applied.push(result.migration);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
appId: input.appId,
|
|
61
|
+
applied,
|
|
62
|
+
appliedCount: applied.length,
|
|
63
|
+
plan: {
|
|
64
|
+
directory: plan.directory,
|
|
65
|
+
summary: plan.summary
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function publicMigrationPlan(plan) {
|
|
71
|
+
return {
|
|
72
|
+
...plan,
|
|
73
|
+
migrations: plan.migrations.map(({ sql: _sql, ...migration }) => migration)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readMigrationDirectory(directory) {
|
|
78
|
+
const root = resolve(directory || "migrations");
|
|
79
|
+
const stat = await lstat(root).catch(() => undefined);
|
|
80
|
+
if (!stat?.isDirectory()) throw new Error(`Migration directory is not a directory: ${root}`);
|
|
81
|
+
const handle = await opendir(root);
|
|
82
|
+
const entries = [];
|
|
83
|
+
for await (const entry of handle) entries.push(entry);
|
|
84
|
+
const sqlFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"));
|
|
85
|
+
const unexpectedLinks = entries.filter((entry) => entry.isSymbolicLink());
|
|
86
|
+
if (unexpectedLinks.length > 0) throw new Error(`Migration directory contains symbolic links: ${unexpectedLinks.map((entry) => entry.name).join(", ")}`);
|
|
87
|
+
sqlFiles.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
88
|
+
if (sqlFiles.length === 0) throw new Error(`Migration directory contains no .sql files: ${root}`);
|
|
89
|
+
if (sqlFiles.length > MAX_MIGRATION_FILES) throw new Error(`Migration directory contains more than ${MAX_MIGRATION_FILES} SQL files`);
|
|
90
|
+
|
|
91
|
+
const migrations = [];
|
|
92
|
+
const versions = new Set();
|
|
93
|
+
for (const entry of sqlFiles) {
|
|
94
|
+
const version = entry.name.slice(0, -4);
|
|
95
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(version)) {
|
|
96
|
+
throw new Error(`Invalid migration filename: ${entry.name}`);
|
|
97
|
+
}
|
|
98
|
+
if (versions.has(version)) throw new Error(`Duplicate migration version: ${version}`);
|
|
99
|
+
versions.add(version);
|
|
100
|
+
const sql = await readFile(join(root, entry.name), "utf8");
|
|
101
|
+
const sizeBytes = Buffer.byteLength(sql);
|
|
102
|
+
if (sizeBytes === 0) throw new Error(`Migration file is empty: ${entry.name}`);
|
|
103
|
+
if (sizeBytes > MAX_MIGRATION_BYTES) throw new Error(`Migration file exceeds ${MAX_MIGRATION_BYTES} bytes: ${entry.name}`);
|
|
104
|
+
migrations.push({
|
|
105
|
+
contentSha256: createHash("sha256").update(sql).digest("hex"),
|
|
106
|
+
fileName: entry.name,
|
|
107
|
+
sizeBytes,
|
|
108
|
+
sql,
|
|
109
|
+
version
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return migrations;
|
|
113
|
+
}
|
package/src/lego-snapshots.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { authenticatedRequest, responsePayload } from "./api-client.mjs";
|
|
|
3
3
|
import { createSourceSnapshot } from "./source-snapshot.mjs";
|
|
4
4
|
|
|
5
5
|
export async function uploadLegoSourceSnapshot(store, input, options = {}) {
|
|
6
|
-
const snapshot = await createSourceSnapshot(input.source ?? ".", options.snapshotOptions);
|
|
6
|
+
const snapshot = options.snapshot ?? await createSourceSnapshot(input.source ?? ".", options.snapshotOptions);
|
|
7
7
|
if (input.dryRun) {
|
|
8
8
|
return { dryRun: true, snapshot: snapshotSummary(snapshot) };
|
|
9
9
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function mcpToolPayload(result) {
|
|
2
|
+
if (result?.isError) {
|
|
3
|
+
const code = result.structuredContent?.error?.code;
|
|
4
|
+
const text = result.content?.map((item) => item?.text).filter(Boolean).join("\n");
|
|
5
|
+
throw new Error(code || text || "Fargo MCP tool failed");
|
|
6
|
+
}
|
|
7
|
+
const text = result?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;
|
|
8
|
+
if (!text) return result ?? {};
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(text);
|
|
11
|
+
} catch {
|
|
12
|
+
return { text };
|
|
13
|
+
}
|
|
14
|
+
}
|