@fargowealth/fargo-ai-cli 0.7.2 → 0.8.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 +19 -0
- package/README.md +31 -24
- package/package.json +1 -1
- package/src/agent-install.mjs +241 -0
- package/src/cli.mjs +26 -17
- package/src/constants.mjs +1 -1
- package/src/lego-migrations.mjs +1 -163
- package/src/migration-sql.mjs +476 -0
- package/src/workbuddy-install.mjs +9 -126
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- Replaced regex-based D1 migration checks with token-structured parsing that
|
|
6
|
+
handles comments, literals, identifiers, parentheses, and statement shape.
|
|
7
|
+
- Require `--table-prefix` for every schema and controlled data migration plan
|
|
8
|
+
and apply; table names, index names, index targets, and foreign-key references
|
|
9
|
+
must all remain under that App prefix.
|
|
10
|
+
- Preserve the separate approval requirement for controlled data mutations;
|
|
11
|
+
the hosted service independently derives and verifies the authoritative App
|
|
12
|
+
prefix before executing the migration as an atomic D1 batch.
|
|
13
|
+
|
|
14
|
+
## 0.7.3
|
|
15
|
+
|
|
16
|
+
- Added one-command Agent installation through `fargo agent install --agent`
|
|
17
|
+
for WorkBuddy, Claude Code, Codex, and Cursor.
|
|
18
|
+
- The installer completes CLI-managed OAuth + PKCE, writes a local stdio MCP
|
|
19
|
+
connection, and reuses the same private rotating credential on later runs.
|
|
20
|
+
- Kept `fargo workbuddy install` as a compatibility alias.
|
|
21
|
+
|
|
3
22
|
## 0.7.2
|
|
4
23
|
|
|
5
24
|
- Added `fargo lego deploy --help` with the complete Worker Static Assets
|
package/README.md
CHANGED
|
@@ -9,23 +9,25 @@ Requires Node.js 20.19 or newer.
|
|
|
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.8.0
|
|
13
13
|
fargo version
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
only when an Agent first needs local files:
|
|
16
|
+
Install the Fargo MCP connection with one command for the selected Agent:
|
|
18
17
|
|
|
19
18
|
```sh
|
|
20
|
-
fargo
|
|
21
|
-
fargo
|
|
22
|
-
fargo
|
|
19
|
+
npx -y @fargowealth/fargo-ai-cli@latest agent install --agent=codex --open-browser=always --output=jsonl
|
|
20
|
+
npx -y @fargowealth/fargo-ai-cli@latest agent install --agent=claude-code --open-browser=always --output=jsonl
|
|
21
|
+
npx -y @fargowealth/fargo-ai-cli@latest agent install --agent=cursor --open-browser=always --output=jsonl
|
|
22
|
+
npx -y @fargowealth/fargo-ai-cli@latest agent install --agent=workbuddy --open-browser=always --output=jsonl
|
|
23
23
|
fargo auth status
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
Authorization Code + PKCE over an ephemeral IPv4 loopback callback
|
|
28
|
-
|
|
26
|
+
Run only the line for the Agent being configured. The command completes OAuth
|
|
27
|
+
Authorization Code + PKCE over an ephemeral IPv4 loopback callback, writes the
|
|
28
|
+
Agent's user-level local stdio MCP entry, and reports `installed=true`. It is
|
|
29
|
+
normally run once; the rotating credential is then refreshed automatically and
|
|
30
|
+
reused by both MCP and local CLI operations.
|
|
29
31
|
|
|
30
32
|
## MCP boundary
|
|
31
33
|
|
|
@@ -34,9 +36,10 @@ validates state, uses PKCE S256, closes the listener, and never prints tokens.
|
|
|
34
36
|
- CLI owns local walking, exclusions, ZIP creation, hashing, binary transfer,
|
|
35
37
|
deploy composition, and migration-file ordering.
|
|
36
38
|
- CLI has no generic R2 or Cloudflare administration commands.
|
|
37
|
-
- `fargo mcp serve` is the local stdio bridge for WorkBuddy
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
- `fargo mcp serve` is the local stdio bridge for WorkBuddy, Claude Code,
|
|
40
|
+
Codex, and Cursor.
|
|
41
|
+
- `fargo mcp tools` and `fargo mcp call` support scripts and one-shot
|
|
42
|
+
diagnostics without starting an MCP host.
|
|
40
43
|
|
|
41
44
|
```sh
|
|
42
45
|
fargo mcp tools
|
|
@@ -143,9 +146,9 @@ Deployment artifacts preserve binary bytes and include
|
|
|
143
146
|
## LEGO D1 migrations
|
|
144
147
|
|
|
145
148
|
```sh
|
|
146
|
-
# Schema-only migrations
|
|
147
|
-
fargo lego d1 migrate plan --app app_xxx --dir migrations
|
|
148
|
-
fargo lego d1 migrate apply --app app_xxx --dir migrations
|
|
149
|
+
# Schema-only migrations also require the authoritative local prefix assertion
|
|
150
|
+
fargo lego d1 migrate plan --app app_xxx --dir migrations --table-prefix app_code_
|
|
151
|
+
fargo lego d1 migrate apply --app app_xxx --dir migrations --table-prefix app_code_
|
|
149
152
|
|
|
150
153
|
# Controlled data migrations: review the reported impact first
|
|
151
154
|
fargo lego d1 migrate plan \
|
|
@@ -167,22 +170,26 @@ production registry; `apply` stops on content drift and submits only pending
|
|
|
167
170
|
files. The plan reports statement types, data-mutation types, target tables,
|
|
168
171
|
and whether separate data-migration approval is required.
|
|
169
172
|
|
|
170
|
-
Schema DDL remains the default.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
Schema DDL remains the default. Every migration command requires
|
|
174
|
+
`--table-prefix`; the token parser applies it to table names, index names, index
|
|
175
|
+
targets, and foreign-key references before MCP is contacted. The opt-in data
|
|
176
|
+
mode accepts `INSERT ... VALUES` with optional `ON CONFLICT`, plus `UPDATE` and
|
|
177
|
+
`DELETE` only when they contain a `WHERE` clause. Data targets must use the server-derived App table prefix;
|
|
173
178
|
system tables, cross-table queries, `PRAGMA`, `ATTACH`, and `DROP` remain
|
|
174
|
-
blocked.
|
|
175
|
-
|
|
176
|
-
server-side and
|
|
179
|
+
blocked. The service independently derives the authoritative prefix and rejects
|
|
180
|
+
a mismatched client assertion. Each migration file executes as one transactional
|
|
181
|
+
D1 batch, server-side and independently from deploy.
|
|
177
182
|
|
|
178
|
-
##
|
|
183
|
+
## Compatibility and repair
|
|
179
184
|
|
|
180
185
|
```sh
|
|
181
186
|
npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always --output=jsonl
|
|
182
187
|
```
|
|
183
188
|
|
|
184
|
-
The
|
|
185
|
-
|
|
189
|
+
The legacy WorkBuddy command is an alias for the unified Agent installer.
|
|
190
|
+
Installers preserve unrelated MCP servers. Rerun the unified command only to
|
|
191
|
+
upgrade or repair a Fargo entry. Device Authorization Grant remains an explicit
|
|
192
|
+
fallback:
|
|
186
193
|
|
|
187
194
|
```sh
|
|
188
195
|
fargo auth device --open-browser=always --output=jsonl
|
package/package.json
CHANGED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { normalizeAgentType } from "./auth.mjs";
|
|
7
|
+
import { CLI_VERSION } from "./constants.mjs";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_NAME = "@fargowealth/fargo-ai-cli";
|
|
10
|
+
const SERVER_NAME = "fargo-ai";
|
|
11
|
+
|
|
12
|
+
export async function installAgentMcp(rawAgent, options = {}) {
|
|
13
|
+
const agent = normalizeAgentType(rawAgent);
|
|
14
|
+
const platform = options.platform ?? process.platform;
|
|
15
|
+
const env = options.env ?? process.env;
|
|
16
|
+
const homeDirectory = options.home ?? homedir();
|
|
17
|
+
const packageSpec = `${PACKAGE_NAME}@${options.packageVersion ?? CLI_VERSION}`;
|
|
18
|
+
const npxCommand = options.npxCommand ?? (platform === "darwin"
|
|
19
|
+
? await resolveNpxCommand({ env, execPath: options.execPath, platform })
|
|
20
|
+
: "npx");
|
|
21
|
+
const server = mcpServerConfig({ agent, env, homeDirectory, npxCommand, packageSpec, platform });
|
|
22
|
+
|
|
23
|
+
if (agent === "workbuddy" || agent === "cursor") {
|
|
24
|
+
return installJsonMcp(agent, server, {
|
|
25
|
+
...options,
|
|
26
|
+
configPath: options.configPath ?? defaultAgentConfigPath(agent, homeDirectory)
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return installHostCliMcp(agent, server, options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function installWorkBuddyMcp(options = {}) {
|
|
34
|
+
return installAgentMcp("workbuddy", options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function defaultAgentConfigPath(rawAgent, homeDirectory = homedir()) {
|
|
38
|
+
const agent = normalizeAgentType(rawAgent);
|
|
39
|
+
if (agent === "workbuddy") return join(homeDirectory, ".workbuddy-ai", "mcp.json");
|
|
40
|
+
if (agent === "cursor") return join(homeDirectory, ".cursor", "mcp.json");
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolveNpxCommand(options = {}) {
|
|
45
|
+
const platform = options.platform ?? process.platform;
|
|
46
|
+
const env = options.env ?? process.env;
|
|
47
|
+
const execPath = options.execPath ?? process.execPath;
|
|
48
|
+
const executableName = platform === "win32" ? "npx.cmd" : "npx";
|
|
49
|
+
const pathDelimiter = platform === "win32" ? ";" : ":";
|
|
50
|
+
const candidates = [
|
|
51
|
+
join(dirname(execPath), executableName),
|
|
52
|
+
...String(env.PATH ?? env.Path ?? env.path ?? "").split(pathDelimiter).filter(Boolean).map((entry) => join(entry, executableName))
|
|
53
|
+
];
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
const absoluteCandidate = isAbsolute(candidate) ? candidate : resolve(candidate);
|
|
56
|
+
try {
|
|
57
|
+
await access(absoluteCandidate, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
|
58
|
+
return absoluteCandidate;
|
|
59
|
+
} catch {
|
|
60
|
+
// Continue until an executable visible to the GUI host is found.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new Error("Unable to locate an absolute npx executable for the Agent MCP configuration");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mcpServerConfig({ agent, env, homeDirectory, npxCommand, packageSpec, platform }) {
|
|
67
|
+
const credentialDirectory = platform === "win32"
|
|
68
|
+
? join(env.APPDATA || homeDirectory, "Fargo AI")
|
|
69
|
+
: join(homeDirectory, ".fargo");
|
|
70
|
+
const serverEnv = {
|
|
71
|
+
FARGO_CREDENTIAL_PATH: join(credentialDirectory, "credentials.json"),
|
|
72
|
+
FARGO_CREDENTIAL_STORE: "file",
|
|
73
|
+
FARGO_REFRESH_LOCK_PATH: join(credentialDirectory, "oauth-refresh.lock")
|
|
74
|
+
};
|
|
75
|
+
if (platform === "darwin") serverEnv.PATH = buildMacPath(npxCommand);
|
|
76
|
+
if (platform === "win32") {
|
|
77
|
+
const windowsPath = env.PATH ?? env.Path ?? env.path;
|
|
78
|
+
if (windowsPath) serverEnv.PATH = windowsPath;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
agent,
|
|
82
|
+
args: ["-y", packageSpec, "mcp", "serve"],
|
|
83
|
+
command: npxCommand,
|
|
84
|
+
env: serverEnv,
|
|
85
|
+
packageSpec
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function installJsonMcp(agent, server, options) {
|
|
90
|
+
const config = await readConfig(options.configPath, agent);
|
|
91
|
+
const nextServer = {
|
|
92
|
+
...(agent === "workbuddy" ? { type: "stdio" } : {}),
|
|
93
|
+
command: server.command,
|
|
94
|
+
args: server.args,
|
|
95
|
+
...(agent === "workbuddy" ? { timeout: 60000 } : {}),
|
|
96
|
+
env: server.env
|
|
97
|
+
};
|
|
98
|
+
const previousServer = config.mcpServers?.[SERVER_NAME];
|
|
99
|
+
const changed = JSON.stringify(previousServer) !== JSON.stringify(nextServer);
|
|
100
|
+
|
|
101
|
+
if (changed) {
|
|
102
|
+
config.mcpServers = { ...(config.mcpServers ?? {}), [SERVER_NAME]: nextServer };
|
|
103
|
+
await writeConfig(options.configPath, config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return installationResult(agent, server.packageSpec, changed, {
|
|
107
|
+
config_path: options.configPath,
|
|
108
|
+
trust_required: agent === "workbuddy"
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function installHostCliMcp(agent, server, options) {
|
|
113
|
+
const command = agent === "codex" ? "codex" : "claude";
|
|
114
|
+
const removeArgs = agent === "codex"
|
|
115
|
+
? ["mcp", "remove", SERVER_NAME]
|
|
116
|
+
: ["mcp", "remove", "--scope", "user", SERVER_NAME];
|
|
117
|
+
const removeResult = await runHostCommand(command, removeArgs, options);
|
|
118
|
+
if (removeResult.exitCode !== 0 && !isMissingServer(removeResult)) {
|
|
119
|
+
throw new Error(`${agentLabel(agent)} could not replace the existing ${SERVER_NAME} MCP entry: ${commandError(removeResult)}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const environmentArgs = Object.entries(server.env).flatMap(([key, value]) => agent === "codex"
|
|
123
|
+
? ["--env", `${key}=${value}`]
|
|
124
|
+
: ["-e", `${key}=${value}`]);
|
|
125
|
+
const addArgs = agent === "codex"
|
|
126
|
+
? ["mcp", "add", ...environmentArgs, SERVER_NAME, "--", server.command, ...server.args]
|
|
127
|
+
: ["mcp", "add", "--transport", "stdio", "--scope", "user", SERVER_NAME, ...environmentArgs, "--", server.command, ...server.args];
|
|
128
|
+
const addResult = await runHostCommand(command, addArgs, options);
|
|
129
|
+
if (addResult.exitCode !== 0) {
|
|
130
|
+
throw new Error(`${agentLabel(agent)} could not add the ${SERVER_NAME} CLI MCP entry: ${commandError(addResult)}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return installationResult(agent, server.packageSpec, true, {
|
|
134
|
+
configured_by: command,
|
|
135
|
+
trust_required: false
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function runHostCommand(command, args, options) {
|
|
140
|
+
if (options.runCommand) return normalizeCommandResult(await options.runCommand(command, args));
|
|
141
|
+
return new Promise((resolveResult, reject) => {
|
|
142
|
+
const child = spawn(command, args, { env: options.env ?? process.env, windowsHide: true });
|
|
143
|
+
let stdout = "";
|
|
144
|
+
let stderr = "";
|
|
145
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
146
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
147
|
+
child.on("error", (error) => {
|
|
148
|
+
if (error?.code === "ENOENT") {
|
|
149
|
+
reject(new Error(`Required ${agentCommandLabel(command)} command '${command}' was not found; run the installer from that Agent environment`));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
reject(error);
|
|
153
|
+
});
|
|
154
|
+
child.on("close", (exitCode) => resolveResult({ exitCode: exitCode ?? 1, stderr, stdout }));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeCommandResult(result) {
|
|
159
|
+
return {
|
|
160
|
+
exitCode: Number(result?.exitCode ?? result?.code ?? 0),
|
|
161
|
+
stderr: String(result?.stderr ?? ""),
|
|
162
|
+
stdout: String(result?.stdout ?? "")
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isMissingServer(result) {
|
|
167
|
+
return /not found|no .*mcp server|does not exist|unknown server/i.test(`${result.stdout}\n${result.stderr}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function commandError(result) {
|
|
171
|
+
return (result.stderr || result.stdout || `exit ${result.exitCode}`).trim().slice(0, 300);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function installationResult(agent, packageSpec, changed, extra) {
|
|
175
|
+
return {
|
|
176
|
+
type: "agent_installation_complete",
|
|
177
|
+
agent,
|
|
178
|
+
installed: true,
|
|
179
|
+
changed,
|
|
180
|
+
server_name: SERVER_NAME,
|
|
181
|
+
package: packageSpec,
|
|
182
|
+
...extra,
|
|
183
|
+
next_action: agent === "workbuddy"
|
|
184
|
+
? "Open WorkBuddy Custom Connectors, trust and enable fargo-ai, then start a new task."
|
|
185
|
+
: `Restart ${agentLabel(agent)} or start a new Agent session, then call ping_who_am_i.`
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function agentLabel(agent) {
|
|
190
|
+
if (agent === "claude-code") return "Claude Code";
|
|
191
|
+
if (agent === "codex") return "Codex";
|
|
192
|
+
if (agent === "cursor") return "Cursor";
|
|
193
|
+
return "WorkBuddy";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function agentCommandLabel(command) {
|
|
197
|
+
return command === "claude" ? "Claude Code" : "Codex";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildMacPath(npxCommand) {
|
|
201
|
+
return [...new Set([
|
|
202
|
+
dirname(npxCommand),
|
|
203
|
+
"/opt/homebrew/bin",
|
|
204
|
+
"/usr/local/bin",
|
|
205
|
+
"/usr/bin",
|
|
206
|
+
"/bin",
|
|
207
|
+
"/usr/sbin",
|
|
208
|
+
"/sbin"
|
|
209
|
+
])].join(":");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function readConfig(configPath, agent) {
|
|
213
|
+
let text;
|
|
214
|
+
try {
|
|
215
|
+
text = await readFile(configPath, "utf8");
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error?.code === "ENOENT") return {};
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
if (!text.trim()) return {};
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(text);
|
|
224
|
+
} catch {
|
|
225
|
+
throw new Error(`${agentLabel(agent)} MCP config is not valid JSON: ${configPath}`);
|
|
226
|
+
}
|
|
227
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
228
|
+
throw new Error(`${agentLabel(agent)} MCP config must contain a JSON object: ${configPath}`);
|
|
229
|
+
}
|
|
230
|
+
if (parsed.mcpServers !== undefined && (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers))) {
|
|
231
|
+
throw new Error(`${agentLabel(agent)} mcpServers must be a JSON object: ${configPath}`);
|
|
232
|
+
}
|
|
233
|
+
return parsed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function writeConfig(configPath, config) {
|
|
237
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
238
|
+
const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
239
|
+
await writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
240
|
+
await rename(temporaryPath, configPath);
|
|
241
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { deployLocalLegoApp } from "./lego-deploy.mjs";
|
|
|
8
8
|
import { applyLegoMigrations, planLegoMigrations } from "./lego-migrations.mjs";
|
|
9
9
|
import { callMcpTool, listMcpTools } from "./mcp-client.mjs";
|
|
10
10
|
import { runMcpProxy } from "./mcp-proxy.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import { installAgentMcp } from "./agent-install.mjs";
|
|
12
12
|
|
|
13
13
|
export async function runCli(args, options = {}) {
|
|
14
14
|
const stdout = options.stdout ?? process.stdout;
|
|
@@ -112,26 +112,17 @@ export async function runCli(args, options = {}) {
|
|
|
112
112
|
stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
-
throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId>
|
|
115
|
+
throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> --table-prefix <app_prefix_> [--dir migrations] [--allow-data-migrations]");
|
|
116
|
+
}
|
|
117
|
+
if (args[0] === "agent" && args[1] === "install") {
|
|
118
|
+
await installAgentConnection(args, requiredOption(args, "--agent"), stdout, store, options);
|
|
119
|
+
return;
|
|
116
120
|
}
|
|
117
121
|
if (args[0] === "workbuddy" && args[1] === "install") {
|
|
118
|
-
|
|
119
|
-
if (credential) {
|
|
120
|
-
try {
|
|
121
|
-
credential = await currentCredential(store, options);
|
|
122
|
-
} catch {
|
|
123
|
-
await store.clear();
|
|
124
|
-
credential = undefined;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (credential?.authorization_method !== "authorization_code_pkce") {
|
|
128
|
-
credential = undefined;
|
|
129
|
-
}
|
|
130
|
-
if (!credential?.refresh_token) await authorizeWithPkce(args, stdout, store, { ...options, agentType: "workbuddy" });
|
|
131
|
-
stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
|
|
122
|
+
await installAgentConnection(args, "workbuddy", stdout, store, options);
|
|
132
123
|
return;
|
|
133
124
|
}
|
|
134
|
-
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");
|
|
125
|
+
throw new Error("Usage: fargo auth login|device|status|logout | fargo agent install --agent <agent> | 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");
|
|
135
126
|
} catch (error) {
|
|
136
127
|
stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
137
128
|
if (options.throwOnError) throw error;
|
|
@@ -143,6 +134,7 @@ const ROOT_HELP = `Usage: fargo <command> [options]
|
|
|
143
134
|
|
|
144
135
|
Commands:
|
|
145
136
|
auth login|device|status|logout
|
|
137
|
+
agent install --agent <codex|claude-code|cursor|workbuddy>
|
|
146
138
|
mcp serve|tools|call
|
|
147
139
|
lego snapshot upload|list|download
|
|
148
140
|
lego deploy
|
|
@@ -197,6 +189,23 @@ function isHelpOption(args) {
|
|
|
197
189
|
return args.includes("--help") || args.includes("-h");
|
|
198
190
|
}
|
|
199
191
|
|
|
192
|
+
async function installAgentConnection(args, agentType, stdout, store, options) {
|
|
193
|
+
let credential = await store.load();
|
|
194
|
+
if (credential) {
|
|
195
|
+
try {
|
|
196
|
+
credential = await currentCredential(store, options);
|
|
197
|
+
} catch {
|
|
198
|
+
await store.clear();
|
|
199
|
+
credential = undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (credential?.authorization_method !== "authorization_code_pkce") credential = undefined;
|
|
203
|
+
const authorizationReused = Boolean(credential?.refresh_token);
|
|
204
|
+
if (!authorizationReused) await authorizeWithPkce(args, stdout, store, { ...options, agentType });
|
|
205
|
+
const installation = await installAgentMcp(agentType, options);
|
|
206
|
+
stdout.write(`${JSON.stringify({ ...installation, authorization_reused: authorizationReused })}\n`);
|
|
207
|
+
}
|
|
208
|
+
|
|
200
209
|
function assertAssetsDirectoryProvided(args, assetsDirectory) {
|
|
201
210
|
const assetOptions = [
|
|
202
211
|
"--assets-binding",
|
package/src/constants.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.8.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";
|
package/src/lego-migrations.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { lstat, opendir, readFile } from "node:fs/promises";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { callMcpTool } from "./mcp-client.mjs";
|
|
5
5
|
import { mcpToolPayload } from "./mcp-result.mjs";
|
|
6
|
+
import { analyzeMigrationStatement, parseMigrationStatements } from "./migration-sql.mjs";
|
|
6
7
|
|
|
7
8
|
const MAX_MIGRATION_BYTES = 80_000;
|
|
8
9
|
const MAX_MIGRATION_FILES = 500;
|
|
@@ -163,166 +164,3 @@ export function analyzeLegoMigrationSql(sql, fileName = "migration.sql", options
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
export const validateLegoMigrationSql = analyzeLegoMigrationSql;
|
|
166
|
-
|
|
167
|
-
function parseMigrationStatements(sql) {
|
|
168
|
-
const statements = [];
|
|
169
|
-
let current = "";
|
|
170
|
-
let quote;
|
|
171
|
-
let blockComment = false;
|
|
172
|
-
let lineComment = false;
|
|
173
|
-
|
|
174
|
-
for (let index = 0; index < sql.length; index += 1) {
|
|
175
|
-
const char = sql[index];
|
|
176
|
-
const next = sql[index + 1];
|
|
177
|
-
if (blockComment) {
|
|
178
|
-
if (char === "*" && next === "/") {
|
|
179
|
-
blockComment = false;
|
|
180
|
-
index += 1;
|
|
181
|
-
}
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
if (lineComment) {
|
|
185
|
-
if (char === "\n") lineComment = false;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (!quote && char === "/" && next === "*") {
|
|
189
|
-
blockComment = true;
|
|
190
|
-
current += " ";
|
|
191
|
-
index += 1;
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
if (!quote && char === "-" && next === "-") {
|
|
195
|
-
lineComment = true;
|
|
196
|
-
current += " ";
|
|
197
|
-
index += 1;
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
if ((char === "'" || char === '"') && sql[index - 1] !== "\\") {
|
|
201
|
-
quote = quote === char ? undefined : quote ?? char;
|
|
202
|
-
}
|
|
203
|
-
if (!quote && char === ";") {
|
|
204
|
-
const statement = current.trim();
|
|
205
|
-
if (statement) statements.push(statement);
|
|
206
|
-
current = "";
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
current += char;
|
|
210
|
-
}
|
|
211
|
-
if (blockComment) throw new Error("Migration SQL contains an unterminated block comment");
|
|
212
|
-
|
|
213
|
-
const trailing = current.trim();
|
|
214
|
-
if (trailing) statements.push(trailing);
|
|
215
|
-
return statements;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function analyzeMigrationStatement(statement, options = {}) {
|
|
219
|
-
const normalized = statement.replace(/\s+/g, " ").trim();
|
|
220
|
-
const createTable = normalized.match(/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
|
|
221
|
-
if (createTable?.[1]) {
|
|
222
|
-
assertMigrationIdentifier(createTable[1]);
|
|
223
|
-
assertOwnedReferences(normalized, options.tablePrefix);
|
|
224
|
-
return { category: "schema", table: createTable[1], type: "create_table" };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const createIndex = normalized.match(/^CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ON\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
|
|
228
|
-
if (createIndex?.[1] && createIndex[2]) {
|
|
229
|
-
assertMigrationIdentifier(createIndex[1]);
|
|
230
|
-
assertMigrationIdentifier(createIndex[2]);
|
|
231
|
-
return { category: "schema", table: createIndex[2], type: "create_index" };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const alterTable = normalized.match(/^ALTER\s+TABLE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ADD\s+COLUMN\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
235
|
-
if (alterTable?.[1]) {
|
|
236
|
-
assertMigrationIdentifier(alterTable[1]);
|
|
237
|
-
assertOwnedReferences(normalized, options.tablePrefix);
|
|
238
|
-
return { category: "schema", table: alterTable[1], type: "alter_table" };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const structure = stripQuotedSql(normalized);
|
|
242
|
-
if (/^(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*\./i.test(structure)) {
|
|
243
|
-
throw new Error("Data migrations cannot use qualified table names");
|
|
244
|
-
}
|
|
245
|
-
const insert = structure.match(/^INSERT\s+INTO\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
246
|
-
if (insert?.[1]) {
|
|
247
|
-
assertOwnedDataTable(insert[1], options.tablePrefix);
|
|
248
|
-
assertNoCrossResourceDataQuery(structure, "insert");
|
|
249
|
-
if (!/\bVALUES\s*\(/i.test(structure)) throw new Error("INSERT data migrations must use VALUES");
|
|
250
|
-
if (/\bON\s+CONFLICT\b/i.test(structure) && !/\bDO\s+(?:NOTHING|UPDATE\s+SET)\b/i.test(structure)) {
|
|
251
|
-
throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
252
|
-
}
|
|
253
|
-
return { category: "data", table: insert[1], type: "insert" };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const update = structure.match(/^UPDATE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+SET\s+/i);
|
|
257
|
-
if (update?.[1]) {
|
|
258
|
-
assertOwnedDataTable(update[1], options.tablePrefix);
|
|
259
|
-
if (!/\bWHERE\b/i.test(structure)) throw new Error("UPDATE data migrations must include WHERE");
|
|
260
|
-
assertNoCrossResourceDataQuery(structure, "update");
|
|
261
|
-
return { category: "data", table: update[1], type: "update" };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const deleteFrom = structure.match(/^DELETE\s+FROM\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
265
|
-
if (deleteFrom?.[1]) {
|
|
266
|
-
assertOwnedDataTable(deleteFrom[1], options.tablePrefix);
|
|
267
|
-
if (!/\bWHERE\b/i.test(structure)) throw new Error("DELETE data migrations must include WHERE");
|
|
268
|
-
assertNoCrossResourceDataQuery(structure, "delete");
|
|
269
|
-
return { category: "data", table: deleteFrom[1], type: "delete" };
|
|
270
|
-
}
|
|
271
|
-
throw new Error("Statement type is not allowed");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function assertMigrationIdentifier(identifier) {
|
|
275
|
-
if (identifier.toLowerCase().startsWith("sqlite_")) {
|
|
276
|
-
throw new Error("reserved_migration_identifier");
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function assertOwnedDataTable(identifier, tablePrefix) {
|
|
281
|
-
assertMigrationIdentifier(identifier);
|
|
282
|
-
const prefix = String(tablePrefix ?? "").trim().toLowerCase();
|
|
283
|
-
if (prefix && !identifier.toLowerCase().startsWith(prefix)) {
|
|
284
|
-
throw new Error(`Data table ${identifier} is outside App prefix ${prefix}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function assertOwnedReferences(statement, tablePrefix) {
|
|
289
|
-
for (const match of statement.matchAll(/\bREFERENCES\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/gi)) {
|
|
290
|
-
if (match[1]) assertOwnedDataTable(match[1], tablePrefix);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function assertNoCrossResourceDataQuery(statement, kind) {
|
|
295
|
-
const forbidden = kind === "delete"
|
|
296
|
-
? /\b(?:SELECT|WITH|JOIN|ATTACH|DETACH|PRAGMA|DROP)\b/i
|
|
297
|
-
: /\b(?:SELECT|WITH|JOIN|FROM|ATTACH|DETACH|PRAGMA|DROP)\b/i;
|
|
298
|
-
if (forbidden.test(statement)) {
|
|
299
|
-
throw new Error("Data migrations cannot query other tables or use administrative SQL");
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function stripQuotedSql(value) {
|
|
304
|
-
let result = "";
|
|
305
|
-
let quote;
|
|
306
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
307
|
-
const char = value[index];
|
|
308
|
-
if (quote) {
|
|
309
|
-
if (char === quote) {
|
|
310
|
-
if (value[index + 1] === quote) {
|
|
311
|
-
result += " ";
|
|
312
|
-
index += 1;
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
quote = undefined;
|
|
316
|
-
}
|
|
317
|
-
result += " ";
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
if (char === "'" || char === '"') {
|
|
321
|
-
quote = char;
|
|
322
|
-
result += " ";
|
|
323
|
-
continue;
|
|
324
|
-
}
|
|
325
|
-
result += char;
|
|
326
|
-
}
|
|
327
|
-
return result;
|
|
328
|
-
}
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
const ADMINISTRATIVE_KEYWORDS = new Set([
|
|
2
|
+
"ANALYZE",
|
|
3
|
+
"ATTACH",
|
|
4
|
+
"DETACH",
|
|
5
|
+
"DROP",
|
|
6
|
+
"PRAGMA",
|
|
7
|
+
"REINDEX",
|
|
8
|
+
"VACUUM"
|
|
9
|
+
]);
|
|
10
|
+
const CROSS_TABLE_QUERY_KEYWORDS = new Set(["JOIN", "SELECT", "WITH"]);
|
|
11
|
+
const ALTER_DEFINITION_STATEMENT_KEYWORDS = new Set(["ALTER", "CREATE", "DELETE", "INSERT", "UPDATE"]);
|
|
12
|
+
|
|
13
|
+
export function parseMigrationStatements(sql) {
|
|
14
|
+
const statements = [];
|
|
15
|
+
let current = "";
|
|
16
|
+
let quote;
|
|
17
|
+
let bracketIdentifier = false;
|
|
18
|
+
let blockComment = false;
|
|
19
|
+
let lineComment = false;
|
|
20
|
+
|
|
21
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
22
|
+
const char = sql[index];
|
|
23
|
+
const next = sql[index + 1];
|
|
24
|
+
if (blockComment) {
|
|
25
|
+
if (char === "*" && next === "/") {
|
|
26
|
+
blockComment = false;
|
|
27
|
+
index += 1;
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (lineComment) {
|
|
32
|
+
if (char === "\n") {
|
|
33
|
+
lineComment = false;
|
|
34
|
+
current += "\n";
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!quote && !bracketIdentifier && char === "/" && next === "*") {
|
|
39
|
+
blockComment = true;
|
|
40
|
+
current += " ";
|
|
41
|
+
index += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!quote && !bracketIdentifier && char === "-" && next === "-") {
|
|
45
|
+
lineComment = true;
|
|
46
|
+
current += " ";
|
|
47
|
+
index += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!quote && char === "[") {
|
|
51
|
+
bracketIdentifier = true;
|
|
52
|
+
} else if (bracketIdentifier && char === "]") {
|
|
53
|
+
bracketIdentifier = false;
|
|
54
|
+
} else if (!bracketIdentifier && (char === "'" || char === "\"" || char === "`")) {
|
|
55
|
+
if (quote === char && next === char) {
|
|
56
|
+
current += char + next;
|
|
57
|
+
index += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
quote = quote === char ? undefined : quote ?? char;
|
|
61
|
+
}
|
|
62
|
+
if (!quote && !bracketIdentifier && char === ";") {
|
|
63
|
+
const statement = current.trim();
|
|
64
|
+
if (statement) statements.push(statement);
|
|
65
|
+
current = "";
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
current += char;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (blockComment) throw new Error("Migration SQL contains an unterminated block comment");
|
|
72
|
+
if (quote || bracketIdentifier) throw new Error("Migration SQL contains an unterminated literal or identifier");
|
|
73
|
+
const trailing = current.trim();
|
|
74
|
+
if (trailing) statements.push(trailing);
|
|
75
|
+
return statements;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function analyzeMigrationStatement(statement, options = {}) {
|
|
79
|
+
const tablePrefix = normalizeRequiredPrefix(options.tablePrefix);
|
|
80
|
+
const tokens = tokenizeSql(statement);
|
|
81
|
+
if (tokens.length === 0) throw new Error("Statement type is not allowed");
|
|
82
|
+
rejectAdministrativeKeywords(tokens);
|
|
83
|
+
|
|
84
|
+
if (isKeyword(tokens[0], "CREATE")) return validateCreate(tokens, tablePrefix);
|
|
85
|
+
if (isKeyword(tokens[0], "ALTER")) return validateAlterTable(tokens, tablePrefix);
|
|
86
|
+
if (isKeyword(tokens[0], "INSERT")) return validateInsert(tokens, tablePrefix);
|
|
87
|
+
if (isKeyword(tokens[0], "UPDATE")) return validateUpdate(tokens, tablePrefix);
|
|
88
|
+
if (isKeyword(tokens[0], "DELETE")) return validateDelete(tokens, tablePrefix);
|
|
89
|
+
throw new Error("Statement type is not allowed");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validateCreate(tokens, tablePrefix) {
|
|
93
|
+
const cursor = new TokenCursor(tokens);
|
|
94
|
+
cursor.expectKeyword("CREATE");
|
|
95
|
+
if (cursor.matchKeyword("TABLE")) {
|
|
96
|
+
cursor.expectKeyword("IF");
|
|
97
|
+
cursor.expectKeyword("NOT");
|
|
98
|
+
cursor.expectKeyword("EXISTS");
|
|
99
|
+
const table = cursor.readIdentifier();
|
|
100
|
+
assertOwnedMigrationIdentifier(table, tablePrefix);
|
|
101
|
+
cursor.expectSymbol("(");
|
|
102
|
+
const closing = matchingParenIndex(tokens, cursor.position - 1);
|
|
103
|
+
if (closing !== tokens.length - 1 || closing === cursor.position) throw unsupportedStatement();
|
|
104
|
+
const definition = tokens.slice(cursor.position, closing);
|
|
105
|
+
assertOwnedReferences(definition, tablePrefix);
|
|
106
|
+
assertNoQueryKeywords(definition);
|
|
107
|
+
return { category: "schema", table, type: "create_table" };
|
|
108
|
+
}
|
|
109
|
+
if (cursor.matchKeyword("INDEX")) {
|
|
110
|
+
cursor.expectKeyword("IF");
|
|
111
|
+
cursor.expectKeyword("NOT");
|
|
112
|
+
cursor.expectKeyword("EXISTS");
|
|
113
|
+
const indexName = cursor.readIdentifier();
|
|
114
|
+
assertOwnedMigrationIdentifier(indexName, tablePrefix);
|
|
115
|
+
cursor.expectKeyword("ON");
|
|
116
|
+
const table = cursor.readIdentifier();
|
|
117
|
+
assertOwnedMigrationIdentifier(table, tablePrefix);
|
|
118
|
+
cursor.expectSymbol("(");
|
|
119
|
+
const closing = matchingParenIndex(tokens, cursor.position - 1);
|
|
120
|
+
if (closing === cursor.position) throw unsupportedStatement();
|
|
121
|
+
const trailing = tokens.slice(closing + 1);
|
|
122
|
+
if (trailing.length > 0 && (!isKeyword(trailing[0], "WHERE") || trailing.length === 1)) {
|
|
123
|
+
throw unsupportedStatement();
|
|
124
|
+
}
|
|
125
|
+
assertNoQueryKeywords(tokens.slice(cursor.position, closing));
|
|
126
|
+
assertNoQueryKeywords(trailing);
|
|
127
|
+
return { category: "schema", table, type: "create_index" };
|
|
128
|
+
}
|
|
129
|
+
throw unsupportedStatement();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateAlterTable(tokens, tablePrefix) {
|
|
133
|
+
const cursor = new TokenCursor(tokens);
|
|
134
|
+
cursor.expectKeyword("ALTER");
|
|
135
|
+
cursor.expectKeyword("TABLE");
|
|
136
|
+
const table = cursor.readIdentifier();
|
|
137
|
+
assertOwnedMigrationIdentifier(table, tablePrefix);
|
|
138
|
+
cursor.expectKeyword("ADD");
|
|
139
|
+
cursor.expectKeyword("COLUMN");
|
|
140
|
+
cursor.readIdentifier();
|
|
141
|
+
if (cursor.done()) throw unsupportedStatement();
|
|
142
|
+
const definition = tokens.slice(cursor.position);
|
|
143
|
+
assertOwnedReferences(definition, tablePrefix);
|
|
144
|
+
assertNoQueryKeywords(definition);
|
|
145
|
+
if (definition.some((token) => token.kind === "word" && ALTER_DEFINITION_STATEMENT_KEYWORDS.has(token.upper))) {
|
|
146
|
+
throw unsupportedStatement();
|
|
147
|
+
}
|
|
148
|
+
return { category: "schema", table, type: "alter_table" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateInsert(tokens, tablePrefix) {
|
|
152
|
+
const cursor = new TokenCursor(tokens);
|
|
153
|
+
cursor.expectKeyword("INSERT");
|
|
154
|
+
cursor.expectKeyword("INTO");
|
|
155
|
+
const table = cursor.readIdentifier();
|
|
156
|
+
assertOwnedMigrationIdentifier(table, tablePrefix);
|
|
157
|
+
rejectQualifiedTarget(cursor);
|
|
158
|
+
assertNoDataQueryKeywords(tokens.slice(cursor.position));
|
|
159
|
+
if (cursor.peekSymbol("(")) cursor.skipBalancedGroup();
|
|
160
|
+
cursor.expectKeyword("VALUES", "INSERT data migrations must use VALUES");
|
|
161
|
+
if (!cursor.peekSymbol("(")) throw new Error("INSERT data migrations must use VALUES");
|
|
162
|
+
cursor.skipBalancedGroup();
|
|
163
|
+
while (cursor.matchSymbol(",")) {
|
|
164
|
+
if (!cursor.peekSymbol("(")) throw new Error("INSERT data migrations must use VALUES");
|
|
165
|
+
cursor.skipBalancedGroup();
|
|
166
|
+
}
|
|
167
|
+
if (cursor.done()) return { category: "data", table, type: "insert" };
|
|
168
|
+
cursor.expectKeyword("ON", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
169
|
+
cursor.expectKeyword("CONFLICT", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
170
|
+
if (cursor.peekSymbol("(")) cursor.skipBalancedGroup();
|
|
171
|
+
if (cursor.matchKeyword("WHERE")) {
|
|
172
|
+
const doIndex = findTopLevelKeyword(tokens, "DO", cursor.position);
|
|
173
|
+
if (doIndex <= cursor.position) throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
174
|
+
cursor.position = doIndex;
|
|
175
|
+
}
|
|
176
|
+
cursor.expectKeyword("DO", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
177
|
+
if (cursor.matchKeyword("NOTHING")) {
|
|
178
|
+
if (!cursor.done()) throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
179
|
+
return { category: "data", table, type: "insert" };
|
|
180
|
+
}
|
|
181
|
+
cursor.expectKeyword("UPDATE", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
182
|
+
cursor.expectKeyword("SET", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
183
|
+
if (cursor.done()) throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
184
|
+
return { category: "data", table, type: "insert" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validateUpdate(tokens, tablePrefix) {
|
|
188
|
+
const cursor = new TokenCursor(tokens);
|
|
189
|
+
cursor.expectKeyword("UPDATE");
|
|
190
|
+
const table = cursor.readIdentifier();
|
|
191
|
+
assertOwnedMigrationIdentifier(table, tablePrefix);
|
|
192
|
+
rejectQualifiedTarget(cursor);
|
|
193
|
+
cursor.expectKeyword("SET");
|
|
194
|
+
assertNoDataQueryKeywords(tokens.slice(cursor.position));
|
|
195
|
+
const whereIndex = findTopLevelKeyword(tokens, "WHERE", cursor.position);
|
|
196
|
+
if (whereIndex < 0 || whereIndex === tokens.length - 1) throw new Error("UPDATE data migrations must include WHERE");
|
|
197
|
+
if (whereIndex === cursor.position) throw unsupportedStatement();
|
|
198
|
+
return { category: "data", table, type: "update" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validateDelete(tokens, tablePrefix) {
|
|
202
|
+
const cursor = new TokenCursor(tokens);
|
|
203
|
+
cursor.expectKeyword("DELETE");
|
|
204
|
+
cursor.expectKeyword("FROM");
|
|
205
|
+
const table = cursor.readIdentifier();
|
|
206
|
+
assertOwnedMigrationIdentifier(table, tablePrefix);
|
|
207
|
+
rejectQualifiedTarget(cursor);
|
|
208
|
+
assertNoDataQueryKeywords(tokens.slice(cursor.position));
|
|
209
|
+
const whereIndex = findTopLevelKeyword(tokens, "WHERE", cursor.position);
|
|
210
|
+
if (whereIndex < 0 || whereIndex === tokens.length - 1) throw new Error("DELETE data migrations must include WHERE");
|
|
211
|
+
if (whereIndex !== cursor.position) throw unsupportedStatement();
|
|
212
|
+
return { category: "data", table, type: "delete" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function tokenizeSql(statement) {
|
|
216
|
+
const tokens = [];
|
|
217
|
+
for (let index = 0; index < statement.length;) {
|
|
218
|
+
const char = statement[index];
|
|
219
|
+
if (isWhitespace(char)) {
|
|
220
|
+
index += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (isIdentifierStart(char)) {
|
|
224
|
+
const start = index;
|
|
225
|
+
index += 1;
|
|
226
|
+
while (index < statement.length && isIdentifierPart(statement[index])) index += 1;
|
|
227
|
+
const text = statement.slice(start, index);
|
|
228
|
+
tokens.push({ kind: "word", text, upper: text.toUpperCase() });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (isDigit(char) || (char === "." && isDigit(statement[index + 1]))) {
|
|
232
|
+
const end = readNumberEnd(statement, index);
|
|
233
|
+
tokens.push({ kind: "number", text: statement.slice(index, end) });
|
|
234
|
+
index = end;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (char === "'") {
|
|
238
|
+
const start = index;
|
|
239
|
+
index += 1;
|
|
240
|
+
let terminated = false;
|
|
241
|
+
while (index < statement.length) {
|
|
242
|
+
if (statement[index] === "'" && statement[index + 1] === "'") {
|
|
243
|
+
index += 2;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (statement[index] === "'") {
|
|
247
|
+
index += 1;
|
|
248
|
+
terminated = true;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
index += 1;
|
|
252
|
+
}
|
|
253
|
+
if (!terminated) throw new Error("Migration SQL contains an unterminated literal");
|
|
254
|
+
tokens.push({ kind: "string", text: statement.slice(start, index) });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (char === "\"" || char === "`" || char === "[") {
|
|
258
|
+
throw new Error("Quoted migration identifiers are not allowed");
|
|
259
|
+
}
|
|
260
|
+
if (isSymbolStart(char)) {
|
|
261
|
+
const pair = statement.slice(index, index + 2);
|
|
262
|
+
const symbol = isTwoCharacterSymbol(pair) ? pair : char;
|
|
263
|
+
tokens.push({ kind: "symbol", text: symbol });
|
|
264
|
+
index += symbol.length;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
throw new Error("Migration SQL contains an unsupported token");
|
|
268
|
+
}
|
|
269
|
+
assertBalancedParentheses(tokens);
|
|
270
|
+
return tokens;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
class TokenCursor {
|
|
274
|
+
position = 0;
|
|
275
|
+
|
|
276
|
+
constructor(tokens) {
|
|
277
|
+
this.tokens = tokens;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
done() {
|
|
281
|
+
return this.position >= this.tokens.length;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
expectKeyword(keyword, message = "Statement type is not allowed") {
|
|
285
|
+
if (!this.matchKeyword(keyword)) throw new Error(message);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
expectSymbol(symbol) {
|
|
289
|
+
if (!this.matchSymbol(symbol)) throw unsupportedStatement();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
matchKeyword(keyword) {
|
|
293
|
+
if (!isKeyword(this.tokens[this.position], keyword)) return false;
|
|
294
|
+
this.position += 1;
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
matchSymbol(symbol) {
|
|
299
|
+
if (!this.peekSymbol(symbol)) return false;
|
|
300
|
+
this.position += 1;
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
peekSymbol(symbol) {
|
|
305
|
+
const token = this.tokens[this.position];
|
|
306
|
+
return token?.kind === "symbol" && token.text === symbol;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
readIdentifier() {
|
|
310
|
+
const token = this.tokens[this.position];
|
|
311
|
+
if (token?.kind !== "word") throw unsupportedStatement();
|
|
312
|
+
this.position += 1;
|
|
313
|
+
return token.text;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
skipBalancedGroup() {
|
|
317
|
+
if (!this.peekSymbol("(")) throw unsupportedStatement();
|
|
318
|
+
this.position = matchingParenIndex(this.tokens, this.position) + 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function normalizeRequiredPrefix(value) {
|
|
323
|
+
const prefix = String(value ?? "").trim().toLowerCase();
|
|
324
|
+
if (!prefix) throw new Error("App D1 migration preflight requires --table-prefix");
|
|
325
|
+
return prefix;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function assertOwnedMigrationIdentifier(identifier, tablePrefix) {
|
|
329
|
+
const normalized = identifier.toLowerCase();
|
|
330
|
+
if (normalized.startsWith("sqlite_")) throw new Error("reserved_migration_identifier");
|
|
331
|
+
if (!normalized.startsWith(tablePrefix)) {
|
|
332
|
+
throw new Error(`Migration identifier ${identifier} is outside App prefix ${tablePrefix}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function assertOwnedReferences(tokens, tablePrefix) {
|
|
337
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
338
|
+
if (!isKeyword(tokens[index], "REFERENCES")) continue;
|
|
339
|
+
const referenced = tokens[index + 1];
|
|
340
|
+
if (referenced?.kind !== "word") throw unsupportedStatement();
|
|
341
|
+
assertOwnedMigrationIdentifier(referenced.text, tablePrefix);
|
|
342
|
+
if (tokens[index + 2]?.kind === "symbol" && tokens[index + 2]?.text === ".") {
|
|
343
|
+
throw new Error("Data migrations cannot query other tables or use administrative SQL");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function rejectQualifiedTarget(cursor) {
|
|
349
|
+
if (cursor.peekSymbol(".")) throw new Error("Data migrations cannot use qualified table names");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function rejectAdministrativeKeywords(tokens) {
|
|
353
|
+
if (tokens.some((token) => token.kind === "word" && ADMINISTRATIVE_KEYWORDS.has(token.upper))) {
|
|
354
|
+
throw unsupportedStatement();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function assertNoQueryKeywords(tokens) {
|
|
359
|
+
if (tokens.some((token) => token.kind === "word" && CROSS_TABLE_QUERY_KEYWORDS.has(token.upper))) {
|
|
360
|
+
throw new Error("Migrations cannot query other tables");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function assertNoDataQueryKeywords(tokens) {
|
|
365
|
+
if (tokens.some((token) => token.kind === "word" && (token.upper === "FROM" || CROSS_TABLE_QUERY_KEYWORDS.has(token.upper)))) {
|
|
366
|
+
throw new Error("Data migrations cannot query other tables or use administrative SQL");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function assertBalancedParentheses(tokens) {
|
|
371
|
+
let depth = 0;
|
|
372
|
+
for (const token of tokens) {
|
|
373
|
+
if (token.kind !== "symbol") continue;
|
|
374
|
+
if (token.text === "(") depth += 1;
|
|
375
|
+
if (token.text === ")") depth -= 1;
|
|
376
|
+
if (depth < 0) throw new Error("Migration SQL contains unbalanced parentheses");
|
|
377
|
+
}
|
|
378
|
+
if (depth !== 0) throw new Error("Migration SQL contains unbalanced parentheses");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function matchingParenIndex(tokens, openingIndex) {
|
|
382
|
+
if (tokens[openingIndex]?.kind !== "symbol" || tokens[openingIndex]?.text !== "(") throw unsupportedStatement();
|
|
383
|
+
let depth = 0;
|
|
384
|
+
for (let index = openingIndex; index < tokens.length; index += 1) {
|
|
385
|
+
const token = tokens[index];
|
|
386
|
+
if (token.kind !== "symbol") continue;
|
|
387
|
+
if (token.text === "(") depth += 1;
|
|
388
|
+
if (token.text === ")") depth -= 1;
|
|
389
|
+
if (depth === 0) return index;
|
|
390
|
+
}
|
|
391
|
+
throw new Error("Migration SQL contains unbalanced parentheses");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function findTopLevelKeyword(tokens, keyword, start) {
|
|
395
|
+
let depth = 0;
|
|
396
|
+
for (let index = start; index < tokens.length; index += 1) {
|
|
397
|
+
const token = tokens[index];
|
|
398
|
+
if (token.kind === "symbol" && token.text === "(") {
|
|
399
|
+
depth += 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (token.kind === "symbol" && token.text === ")") {
|
|
403
|
+
depth -= 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (depth === 0 && isKeyword(token, keyword)) return index;
|
|
407
|
+
}
|
|
408
|
+
return -1;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readNumberEnd(value, start) {
|
|
412
|
+
let index = start;
|
|
413
|
+
if (value[index] === "0" && (value[index + 1] === "x" || value[index + 1] === "X") && isHexDigit(value[index + 2])) {
|
|
414
|
+
index += 3;
|
|
415
|
+
while (isHexDigit(value[index])) index += 1;
|
|
416
|
+
return index;
|
|
417
|
+
}
|
|
418
|
+
if (value[index] === ".") index += 1;
|
|
419
|
+
while (isDigit(value[index])) index += 1;
|
|
420
|
+
if (value[index] === ".") {
|
|
421
|
+
index += 1;
|
|
422
|
+
while (isDigit(value[index])) index += 1;
|
|
423
|
+
}
|
|
424
|
+
if (value[index] === "e" || value[index] === "E") {
|
|
425
|
+
let exponent = index + 1;
|
|
426
|
+
if (value[exponent] === "+" || value[exponent] === "-") exponent += 1;
|
|
427
|
+
if (isDigit(value[exponent])) {
|
|
428
|
+
index = exponent + 1;
|
|
429
|
+
while (isDigit(value[index])) index += 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return index;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function isIdentifierStart(char) {
|
|
436
|
+
if (!char) return false;
|
|
437
|
+
const code = char.charCodeAt(0);
|
|
438
|
+
return char === "_" || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isIdentifierPart(char) {
|
|
442
|
+
return isIdentifierStart(char) || isDigit(char);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isDigit(char) {
|
|
446
|
+
if (!char) return false;
|
|
447
|
+
const code = char.charCodeAt(0);
|
|
448
|
+
return code >= 48 && code <= 57;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function isHexDigit(char) {
|
|
452
|
+
if (!char) return false;
|
|
453
|
+
const code = char.charCodeAt(0);
|
|
454
|
+
return isDigit(char) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isWhitespace(char) {
|
|
458
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function isSymbolStart(char) {
|
|
462
|
+
return Boolean(char && "(),.=+-*/%<>!|&~".includes(char));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function isTwoCharacterSymbol(value) {
|
|
466
|
+
return value === "<=" || value === ">=" || value === "!=" || value === "<>" || value === "=="
|
|
467
|
+
|| value === "||" || value === "<<" || value === ">>";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isKeyword(token, keyword) {
|
|
471
|
+
return token?.kind === "word" && token.upper === keyword;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function unsupportedStatement() {
|
|
475
|
+
return new Error("Statement type is not allowed");
|
|
476
|
+
}
|
|
@@ -1,129 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
-
import { CLI_VERSION } from "./constants.mjs";
|
|
1
|
+
import { defaultAgentConfigPath } from "./agent-install.mjs";
|
|
6
2
|
|
|
7
|
-
|
|
3
|
+
export {
|
|
4
|
+
defaultAgentConfigPath,
|
|
5
|
+
installAgentMcp,
|
|
6
|
+
installWorkBuddyMcp,
|
|
7
|
+
resolveNpxCommand
|
|
8
|
+
} from "./agent-install.mjs";
|
|
8
9
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
const env = options.env ?? process.env;
|
|
12
|
-
const homeDirectory = options.home ?? homedir();
|
|
13
|
-
const configPath = options.configPath
|
|
14
|
-
?? env.FARGO_WORKBUDDY_MCP_CONFIG
|
|
15
|
-
?? defaultWorkBuddyConfigPath(homeDirectory);
|
|
16
|
-
const packageSpec = `${PACKAGE_NAME}@${options.packageVersion ?? CLI_VERSION}`;
|
|
17
|
-
const config = await readConfig(configPath);
|
|
18
|
-
const npxCommand = options.npxCommand ?? (platform === "darwin"
|
|
19
|
-
? await resolveNpxCommand({ env, execPath: options.execPath, platform })
|
|
20
|
-
: "npx");
|
|
21
|
-
const nextServer = {
|
|
22
|
-
type: "stdio",
|
|
23
|
-
command: npxCommand,
|
|
24
|
-
args: ["-y", packageSpec, "mcp", "serve"],
|
|
25
|
-
timeout: 60000
|
|
26
|
-
};
|
|
27
|
-
if (platform === "darwin" || platform === "win32") {
|
|
28
|
-
const credentialDirectory = platform === "win32"
|
|
29
|
-
? join(env.APPDATA || homeDirectory, "Fargo AI")
|
|
30
|
-
: join(homeDirectory, ".fargo");
|
|
31
|
-
nextServer.env = {
|
|
32
|
-
FARGO_CREDENTIAL_PATH: join(credentialDirectory, "credentials.json"),
|
|
33
|
-
FARGO_CREDENTIAL_STORE: "file",
|
|
34
|
-
FARGO_REFRESH_LOCK_PATH: join(credentialDirectory, "oauth-refresh.lock")
|
|
35
|
-
};
|
|
36
|
-
if (platform === "darwin") nextServer.env.PATH = buildMacPath(npxCommand);
|
|
37
|
-
if (platform === "win32") {
|
|
38
|
-
const windowsPath = env.PATH ?? env.Path ?? env.path;
|
|
39
|
-
if (windowsPath) nextServer.env.PATH = windowsPath;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
const previousServer = config.mcpServers?.["fargo-ai"];
|
|
43
|
-
const changed = JSON.stringify(previousServer) !== JSON.stringify(nextServer);
|
|
44
|
-
|
|
45
|
-
if (changed) {
|
|
46
|
-
config.mcpServers = { ...(config.mcpServers ?? {}), "fargo-ai": nextServer };
|
|
47
|
-
await writeConfig(configPath, config);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
type: "workbuddy_installation_complete",
|
|
52
|
-
installed: true,
|
|
53
|
-
changed,
|
|
54
|
-
config_path: configPath,
|
|
55
|
-
server_name: "fargo-ai",
|
|
56
|
-
package: packageSpec,
|
|
57
|
-
trust_required: true,
|
|
58
|
-
next_action: "Open WorkBuddy MCP management, trust and enable fargo-ai, then reload MCP or start a new task."
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function resolveNpxCommand(options = {}) {
|
|
63
|
-
const platform = options.platform ?? process.platform;
|
|
64
|
-
const env = options.env ?? process.env;
|
|
65
|
-
const execPath = options.execPath ?? process.execPath;
|
|
66
|
-
const executableName = platform === "win32" ? "npx.cmd" : "npx";
|
|
67
|
-
const pathDelimiter = platform === "win32" ? ";" : ":";
|
|
68
|
-
const candidates = [
|
|
69
|
-
join(dirname(execPath), executableName),
|
|
70
|
-
...String(env.PATH ?? "").split(pathDelimiter).filter(Boolean).map((entry) => join(entry, executableName))
|
|
71
|
-
];
|
|
72
|
-
for (const candidate of candidates) {
|
|
73
|
-
const absoluteCandidate = isAbsolute(candidate) ? candidate : resolve(candidate);
|
|
74
|
-
try {
|
|
75
|
-
await access(absoluteCandidate, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
|
76
|
-
return absoluteCandidate;
|
|
77
|
-
} catch {
|
|
78
|
-
// Keep searching for an executable that will also be visible to the GUI host.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
throw new Error("Unable to locate an absolute npx executable for WorkBuddy");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function buildMacPath(npxCommand) {
|
|
85
|
-
return [...new Set([
|
|
86
|
-
dirname(npxCommand),
|
|
87
|
-
"/opt/homebrew/bin",
|
|
88
|
-
"/usr/local/bin",
|
|
89
|
-
"/usr/bin",
|
|
90
|
-
"/bin",
|
|
91
|
-
"/usr/sbin",
|
|
92
|
-
"/sbin"
|
|
93
|
-
])].join(":");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function defaultWorkBuddyConfigPath(homeDirectory = homedir()) {
|
|
97
|
-
return join(homeDirectory, ".workbuddy-ai", "mcp.json");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function readConfig(configPath) {
|
|
101
|
-
let text;
|
|
102
|
-
try {
|
|
103
|
-
text = await readFile(configPath, "utf8");
|
|
104
|
-
} catch (error) {
|
|
105
|
-
if (error?.code === "ENOENT") return {};
|
|
106
|
-
throw error;
|
|
107
|
-
}
|
|
108
|
-
if (!text.trim()) return {};
|
|
109
|
-
let parsed;
|
|
110
|
-
try {
|
|
111
|
-
parsed = JSON.parse(text);
|
|
112
|
-
} catch {
|
|
113
|
-
throw new Error(`WorkBuddy MCP config is not valid JSON: ${configPath}`);
|
|
114
|
-
}
|
|
115
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
116
|
-
throw new Error(`WorkBuddy MCP config must contain a JSON object: ${configPath}`);
|
|
117
|
-
}
|
|
118
|
-
if (parsed.mcpServers !== undefined && (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers))) {
|
|
119
|
-
throw new Error(`WorkBuddy mcpServers must be a JSON object: ${configPath}`);
|
|
120
|
-
}
|
|
121
|
-
return parsed;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function writeConfig(configPath, config) {
|
|
125
|
-
await mkdir(dirname(configPath), { recursive: true });
|
|
126
|
-
const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
127
|
-
await writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
128
|
-
await rename(temporaryPath, configPath);
|
|
10
|
+
export function defaultWorkBuddyConfigPath(homeDirectory) {
|
|
11
|
+
return defaultAgentConfigPath("workbuddy", homeDirectory);
|
|
129
12
|
}
|