@forcefield/mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/build/index.js +9145 -0
- package/build/index.js.map +1 -0
- package/build/setup/index.js +1044 -0
- package/build/setup/index.js.map +1 -0
- package/package.json +63 -0
- package/workflows/ff-cap-table.md +354 -0
- package/workflows/ff-doc-gen.md +296 -0
- package/workflows/ff-file.md +262 -0
- package/workflows/ff-gap-check.md +189 -0
- package/workflows/ff-help.md +122 -0
- package/workflows/ff-onboard.md +292 -0
- package/workflows/ff-remediate.md +256 -0
- package/workflows/ff-start.md +76 -0
- package/workflows/ff-status.md +178 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// setup/wizard.ts
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import figlet from "figlet";
|
|
7
|
+
import gradient from "gradient-string";
|
|
8
|
+
import { readFile as readFile5, writeFile as writeFile5, access as access3 } from "fs/promises";
|
|
9
|
+
import { join as join7, dirname as dirname2 } from "path";
|
|
10
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
11
|
+
|
|
12
|
+
// setup/ide-detect.ts
|
|
13
|
+
import { access } from "fs/promises";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
var IDE_MARKERS = [
|
|
16
|
+
{ ide: "claude-code", marker: ".claude", isDir: true },
|
|
17
|
+
{ ide: "cursor", marker: ".cursor", isDir: true },
|
|
18
|
+
{ ide: "codex", marker: "AGENTS.md", isDir: false },
|
|
19
|
+
{ ide: "windsurf", marker: ".windsurfrules", isDir: false }
|
|
20
|
+
];
|
|
21
|
+
async function detectIde(projectDir) {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const { ide, marker } of IDE_MARKERS) {
|
|
24
|
+
const markerPath = join(projectDir, marker);
|
|
25
|
+
try {
|
|
26
|
+
await access(markerPath);
|
|
27
|
+
found.push({ ide, marker });
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
detected: found.length === 1 ? found[0].ide : null,
|
|
33
|
+
markers: found
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// setup/ide-adapters/claude-code.ts
|
|
38
|
+
import { mkdir, readdir, copyFile, rm, readFile, writeFile } from "fs/promises";
|
|
39
|
+
import { join as join3 } from "path";
|
|
40
|
+
|
|
41
|
+
// setup/ide-adapters/mcp-runtime.ts
|
|
42
|
+
import { access as access2 } from "fs/promises";
|
|
43
|
+
import { dirname, join as join2 } from "path";
|
|
44
|
+
import { fileURLToPath } from "url";
|
|
45
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
var LOCAL_ENTRY_CANDIDATES = [
|
|
47
|
+
join2(__dirname, "..", "..", "build", "index.js"),
|
|
48
|
+
join2(__dirname, "..", "..", "index.js")
|
|
49
|
+
];
|
|
50
|
+
var FORWARDED_ENV_KEYS = [
|
|
51
|
+
"SUPABASE_URL",
|
|
52
|
+
"SUPABASE_ANON_KEY",
|
|
53
|
+
"FORCEFIELD_LOCAL_DEV",
|
|
54
|
+
"FORCEFIELD_DISABLE_GATING",
|
|
55
|
+
"FORCEFIELD_LOCAL_DATA_FALLBACK"
|
|
56
|
+
];
|
|
57
|
+
function isTruthy(value) {
|
|
58
|
+
if (!value) return false;
|
|
59
|
+
const normalized = value.trim().toLowerCase();
|
|
60
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
61
|
+
}
|
|
62
|
+
async function resolveLocalEntry() {
|
|
63
|
+
for (const candidate of LOCAL_ENTRY_CANDIDATES) {
|
|
64
|
+
try {
|
|
65
|
+
await access2(candidate);
|
|
66
|
+
return candidate;
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
async function getMcpLaunchConfig() {
|
|
73
|
+
if (isTruthy(process.env.FORCEFIELD_LOCAL_DEV) || isTruthy(process.env.FORCEFIELD_SETUP_USE_LOCAL_BUILD)) {
|
|
74
|
+
const localEntry = await resolveLocalEntry();
|
|
75
|
+
if (localEntry) {
|
|
76
|
+
return {
|
|
77
|
+
command: process.execPath,
|
|
78
|
+
args: [localEntry]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
command: "npx",
|
|
84
|
+
args: ["-y", "@forcefield/mcp-server", "forcefield-mcp"]
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function buildMcpEnv(apiKey) {
|
|
88
|
+
const env = {
|
|
89
|
+
FORCEFIELD_API_KEY: apiKey
|
|
90
|
+
};
|
|
91
|
+
for (const key of FORWARDED_ENV_KEYS) {
|
|
92
|
+
const value = process.env[key];
|
|
93
|
+
if (value && value.trim().length > 0) {
|
|
94
|
+
env[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return env;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// setup/ide-adapters/claude-code.ts
|
|
101
|
+
var claudeCodeAdapter = {
|
|
102
|
+
name: "Claude Code",
|
|
103
|
+
async configureMcp(projectDir, apiKey) {
|
|
104
|
+
const claudeDir = join3(projectDir, ".claude");
|
|
105
|
+
await mkdir(claudeDir, { recursive: true });
|
|
106
|
+
const launch = await getMcpLaunchConfig();
|
|
107
|
+
const mcpPath = join3(projectDir, ".mcp.json");
|
|
108
|
+
let existing = {};
|
|
109
|
+
try {
|
|
110
|
+
existing = JSON.parse(await readFile(mcpPath, "utf-8"));
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
const existingServers = typeof existing.mcpServers === "object" && existing.mcpServers != null ? existing.mcpServers : {};
|
|
114
|
+
existingServers.forcefield = {
|
|
115
|
+
command: launch.command,
|
|
116
|
+
args: launch.args,
|
|
117
|
+
env: buildMcpEnv(apiKey)
|
|
118
|
+
};
|
|
119
|
+
const nextConfig = {
|
|
120
|
+
...existing,
|
|
121
|
+
mcpServers: existingServers
|
|
122
|
+
};
|
|
123
|
+
await writeFile(mcpPath, JSON.stringify(nextConfig, null, 2) + "\n", "utf-8");
|
|
124
|
+
},
|
|
125
|
+
async installWorkflows(projectDir, workflowsDir) {
|
|
126
|
+
const commandsDir = join3(projectDir, ".claude", "commands");
|
|
127
|
+
await mkdir(commandsDir, { recursive: true });
|
|
128
|
+
const files = await readdir(workflowsDir);
|
|
129
|
+
const mdFiles = files.filter((f) => f.startsWith("ff-") && f.endsWith(".md"));
|
|
130
|
+
for (const file of mdFiles) {
|
|
131
|
+
await copyFile(join3(workflowsDir, file), join3(commandsDir, file));
|
|
132
|
+
}
|
|
133
|
+
return mdFiles.length;
|
|
134
|
+
},
|
|
135
|
+
async removeWorkflows(projectDir) {
|
|
136
|
+
const commandsDir = join3(projectDir, ".claude", "commands");
|
|
137
|
+
try {
|
|
138
|
+
const files = await readdir(commandsDir);
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
if (file.startsWith("ff-") && file.endsWith(".md")) {
|
|
141
|
+
await rm(join3(commandsDir, file));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// setup/ide-adapters/cursor.ts
|
|
150
|
+
import { mkdir as mkdir2, readdir as readdir2, copyFile as copyFile2, rm as rm2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
151
|
+
import { join as join4 } from "path";
|
|
152
|
+
var cursorAdapter = {
|
|
153
|
+
name: "Cursor",
|
|
154
|
+
async configureMcp(projectDir, apiKey) {
|
|
155
|
+
const cursorDir = join4(projectDir, ".cursor");
|
|
156
|
+
await mkdir2(cursorDir, { recursive: true });
|
|
157
|
+
const configPath = join4(cursorDir, "mcp.json");
|
|
158
|
+
const launch = await getMcpLaunchConfig();
|
|
159
|
+
let existing = {};
|
|
160
|
+
try {
|
|
161
|
+
existing = JSON.parse(await readFile2(configPath, "utf-8"));
|
|
162
|
+
} catch {
|
|
163
|
+
}
|
|
164
|
+
const existingServers = typeof existing.mcpServers === "object" && existing.mcpServers != null ? existing.mcpServers : {};
|
|
165
|
+
existingServers.forcefield = {
|
|
166
|
+
command: launch.command,
|
|
167
|
+
args: launch.args,
|
|
168
|
+
env: buildMcpEnv(apiKey)
|
|
169
|
+
};
|
|
170
|
+
const merged = {
|
|
171
|
+
...existing,
|
|
172
|
+
mcpServers: existingServers
|
|
173
|
+
};
|
|
174
|
+
await writeFile2(configPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
175
|
+
},
|
|
176
|
+
async installWorkflows(projectDir, workflowsDir) {
|
|
177
|
+
const rulesDir = join4(projectDir, ".cursor", "rules");
|
|
178
|
+
await mkdir2(rulesDir, { recursive: true });
|
|
179
|
+
const files = await readdir2(workflowsDir);
|
|
180
|
+
const mdFiles = files.filter((f) => f.startsWith("ff-") && f.endsWith(".md"));
|
|
181
|
+
for (const file of mdFiles) {
|
|
182
|
+
await copyFile2(join4(workflowsDir, file), join4(rulesDir, file));
|
|
183
|
+
}
|
|
184
|
+
return mdFiles.length;
|
|
185
|
+
},
|
|
186
|
+
async removeWorkflows(projectDir) {
|
|
187
|
+
const rulesDir = join4(projectDir, ".cursor", "rules");
|
|
188
|
+
try {
|
|
189
|
+
const files = await readdir2(rulesDir);
|
|
190
|
+
for (const file of files) {
|
|
191
|
+
if (file.startsWith("ff-") && file.endsWith(".md")) {
|
|
192
|
+
await rm2(join4(rulesDir, file));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// setup/ide-adapters/codex.ts
|
|
201
|
+
import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
202
|
+
import { join as join5 } from "path";
|
|
203
|
+
var START_MARKER = "<!-- forcefield:start -->";
|
|
204
|
+
var END_MARKER = "<!-- forcefield:end -->";
|
|
205
|
+
var MCP_START_MARKER = "# forcefield:mcp:start";
|
|
206
|
+
var MCP_END_MARKER = "# forcefield:mcp:end";
|
|
207
|
+
var codexAdapter = {
|
|
208
|
+
name: "Codex",
|
|
209
|
+
async configureMcp(projectDir, apiKey) {
|
|
210
|
+
const codexDir = join5(projectDir, ".codex");
|
|
211
|
+
await mkdir3(codexDir, { recursive: true });
|
|
212
|
+
const configPath = join5(codexDir, "config.toml");
|
|
213
|
+
const launch = await getMcpLaunchConfig();
|
|
214
|
+
const env = buildMcpEnv(apiKey);
|
|
215
|
+
const forcefieldBlock = buildForcefieldTomlBlock(launch.command, launch.args, env);
|
|
216
|
+
let existing = "";
|
|
217
|
+
try {
|
|
218
|
+
existing = await readFile3(configPath, "utf-8");
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
const cleaned = removeTomlForcefieldSection(existing).trim();
|
|
222
|
+
const final = cleaned ? `${cleaned}
|
|
223
|
+
|
|
224
|
+
${forcefieldBlock}
|
|
225
|
+
` : `${forcefieldBlock}
|
|
226
|
+
`;
|
|
227
|
+
await writeFile3(configPath, final, "utf-8");
|
|
228
|
+
},
|
|
229
|
+
async installWorkflows(projectDir, workflowsDir) {
|
|
230
|
+
const agentsPath = join5(projectDir, "AGENTS.md");
|
|
231
|
+
const { readdir: readdir3 } = await import("fs/promises");
|
|
232
|
+
const files = await readdir3(workflowsDir);
|
|
233
|
+
const mdFiles = files.filter((f) => f.startsWith("ff-") && f.endsWith(".md"));
|
|
234
|
+
const workflowContents = [];
|
|
235
|
+
for (const file of mdFiles) {
|
|
236
|
+
const content = await readFile3(join5(workflowsDir, file), "utf-8");
|
|
237
|
+
workflowContents.push(content);
|
|
238
|
+
}
|
|
239
|
+
const forcefieldSection = [
|
|
240
|
+
START_MARKER,
|
|
241
|
+
"",
|
|
242
|
+
"# Forcefield Workflows",
|
|
243
|
+
"",
|
|
244
|
+
"The following workflows are available for compliance management:",
|
|
245
|
+
"",
|
|
246
|
+
...workflowContents.map((c) => c + "\n\n---\n"),
|
|
247
|
+
END_MARKER
|
|
248
|
+
].join("\n");
|
|
249
|
+
let existing = "";
|
|
250
|
+
try {
|
|
251
|
+
existing = await readFile3(agentsPath, "utf-8");
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
const cleaned = removeSection(existing);
|
|
255
|
+
const final = cleaned.trim() ? cleaned.trim() + "\n\n" + forcefieldSection : forcefieldSection;
|
|
256
|
+
await writeFile3(agentsPath, final, "utf-8");
|
|
257
|
+
return mdFiles.length;
|
|
258
|
+
},
|
|
259
|
+
async removeWorkflows(projectDir) {
|
|
260
|
+
const agentsPath = join5(projectDir, "AGENTS.md");
|
|
261
|
+
try {
|
|
262
|
+
const content = await readFile3(agentsPath, "utf-8");
|
|
263
|
+
const cleaned = removeSection(content);
|
|
264
|
+
await writeFile3(agentsPath, cleaned, "utf-8");
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
function removeSection(content) {
|
|
270
|
+
const startIdx = content.indexOf(START_MARKER);
|
|
271
|
+
const endIdx = content.indexOf(END_MARKER);
|
|
272
|
+
if (startIdx === -1 || endIdx === -1) return content;
|
|
273
|
+
const before = content.slice(0, startIdx).trimEnd();
|
|
274
|
+
const after = content.slice(endIdx + END_MARKER.length).trimStart();
|
|
275
|
+
return before + (after ? "\n\n" + after : "");
|
|
276
|
+
}
|
|
277
|
+
function removeTomlForcefieldSection(content) {
|
|
278
|
+
const markerStart = content.indexOf(MCP_START_MARKER);
|
|
279
|
+
const markerEnd = content.indexOf(MCP_END_MARKER);
|
|
280
|
+
if (markerStart !== -1 && markerEnd !== -1) {
|
|
281
|
+
const before = content.slice(0, markerStart).trimEnd();
|
|
282
|
+
const after = content.slice(markerEnd + MCP_END_MARKER.length).trimStart();
|
|
283
|
+
return before + (after ? "\n\n" + after : "");
|
|
284
|
+
}
|
|
285
|
+
return content.replace(/^\[mcp_servers\.forcefield\]\n[\s\S]*?(?=^\[|$)/gm, "").replace(/^\[mcp_servers\.forcefield\.env\]\n[\s\S]*?(?=^\[|$)/gm, "").trim();
|
|
286
|
+
}
|
|
287
|
+
function buildForcefieldTomlBlock(command, args, env) {
|
|
288
|
+
const envLines = Object.entries(env).map(([key, value]) => `${key} = "${escapeTomlString(value)}"`);
|
|
289
|
+
return [
|
|
290
|
+
MCP_START_MARKER,
|
|
291
|
+
"[mcp_servers.forcefield]",
|
|
292
|
+
`command = "${escapeTomlString(command)}"`,
|
|
293
|
+
`args = [${args.map((arg) => `"${escapeTomlString(arg)}"`).join(", ")}]`,
|
|
294
|
+
"[mcp_servers.forcefield.env]",
|
|
295
|
+
...envLines,
|
|
296
|
+
MCP_END_MARKER
|
|
297
|
+
].join("\n");
|
|
298
|
+
}
|
|
299
|
+
function escapeTomlString(value) {
|
|
300
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// setup/ide-adapters/windsurf.ts
|
|
304
|
+
import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
|
|
305
|
+
import { join as join6 } from "path";
|
|
306
|
+
var START_MARKER2 = "<!-- forcefield:start -->";
|
|
307
|
+
var END_MARKER2 = "<!-- forcefield:end -->";
|
|
308
|
+
var windsurfAdapter = {
|
|
309
|
+
name: "Windsurf",
|
|
310
|
+
async configureMcp(projectDir, apiKey) {
|
|
311
|
+
const configDir = join6(projectDir, ".windsurf");
|
|
312
|
+
await mkdir4(configDir, { recursive: true });
|
|
313
|
+
const configPath = join6(configDir, "mcp_config.json");
|
|
314
|
+
const launch = await getMcpLaunchConfig();
|
|
315
|
+
let existing = {};
|
|
316
|
+
try {
|
|
317
|
+
existing = JSON.parse(await readFile4(configPath, "utf-8"));
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
const existingServers = typeof existing.mcpServers === "object" && existing.mcpServers != null ? existing.mcpServers : {};
|
|
321
|
+
existingServers.forcefield = {
|
|
322
|
+
command: launch.command,
|
|
323
|
+
args: launch.args,
|
|
324
|
+
env: buildMcpEnv(apiKey)
|
|
325
|
+
};
|
|
326
|
+
const merged = {
|
|
327
|
+
...existing,
|
|
328
|
+
mcpServers: existingServers
|
|
329
|
+
};
|
|
330
|
+
await writeFile4(configPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
331
|
+
},
|
|
332
|
+
async installWorkflows(projectDir, workflowsDir) {
|
|
333
|
+
const rulesPath = join6(projectDir, ".windsurfrules");
|
|
334
|
+
const { readdir: readdir3 } = await import("fs/promises");
|
|
335
|
+
const files = await readdir3(workflowsDir);
|
|
336
|
+
const mdFiles = files.filter((f) => f.startsWith("ff-") && f.endsWith(".md"));
|
|
337
|
+
const workflowContents = [];
|
|
338
|
+
for (const file of mdFiles) {
|
|
339
|
+
const content = await readFile4(join6(workflowsDir, file), "utf-8");
|
|
340
|
+
workflowContents.push(content);
|
|
341
|
+
}
|
|
342
|
+
const forcefieldSection = [
|
|
343
|
+
START_MARKER2,
|
|
344
|
+
"",
|
|
345
|
+
"# Forcefield Workflows",
|
|
346
|
+
"",
|
|
347
|
+
...workflowContents.map((c) => c + "\n\n---\n"),
|
|
348
|
+
END_MARKER2
|
|
349
|
+
].join("\n");
|
|
350
|
+
let existing = "";
|
|
351
|
+
try {
|
|
352
|
+
existing = await readFile4(rulesPath, "utf-8");
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
const cleaned = removeSection2(existing);
|
|
356
|
+
const final = cleaned.trim() ? cleaned.trim() + "\n\n" + forcefieldSection : forcefieldSection;
|
|
357
|
+
await writeFile4(rulesPath, final, "utf-8");
|
|
358
|
+
return mdFiles.length;
|
|
359
|
+
},
|
|
360
|
+
async removeWorkflows(projectDir) {
|
|
361
|
+
const rulesPath = join6(projectDir, ".windsurfrules");
|
|
362
|
+
try {
|
|
363
|
+
const content = await readFile4(rulesPath, "utf-8");
|
|
364
|
+
const cleaned = removeSection2(content);
|
|
365
|
+
await writeFile4(rulesPath, cleaned, "utf-8");
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
function removeSection2(content) {
|
|
371
|
+
const startIdx = content.indexOf(START_MARKER2);
|
|
372
|
+
const endIdx = content.indexOf(END_MARKER2);
|
|
373
|
+
if (startIdx === -1 || endIdx === -1) return content;
|
|
374
|
+
const before = content.slice(0, startIdx).trimEnd();
|
|
375
|
+
const after = content.slice(endIdx + END_MARKER2.length).trimStart();
|
|
376
|
+
return before + (after ? "\n\n" + after : "");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// setup/ide-adapters/index.ts
|
|
380
|
+
var adapters = {
|
|
381
|
+
"claude-code": claudeCodeAdapter,
|
|
382
|
+
"cursor": cursorAdapter,
|
|
383
|
+
"codex": codexAdapter,
|
|
384
|
+
"windsurf": windsurfAdapter
|
|
385
|
+
};
|
|
386
|
+
function getAdapter(ide) {
|
|
387
|
+
return adapters[ide];
|
|
388
|
+
}
|
|
389
|
+
var IDE_CHOICES = [
|
|
390
|
+
{ value: "claude-code", label: "Claude Code", hint: ".claude/commands/" },
|
|
391
|
+
{ value: "cursor", label: "Cursor", hint: ".cursor/rules/" },
|
|
392
|
+
{ value: "codex", label: "Codex (OpenAI)", hint: "AGENTS.md" },
|
|
393
|
+
{ value: "windsurf", label: "Windsurf", hint: ".windsurfrules" }
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
// setup/auth.ts
|
|
397
|
+
import { createHash, randomBytes } from "crypto";
|
|
398
|
+
import { createClient } from "@supabase/supabase-js";
|
|
399
|
+
var API_KEY_PREFIX = "ff_live_";
|
|
400
|
+
var MAX_RETRIES = 3;
|
|
401
|
+
var LOCAL_SUPABASE_URL = "http://127.0.0.1:54321";
|
|
402
|
+
var LOCAL_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0";
|
|
403
|
+
var LOCAL_SUPABASE_SERVICE_ROLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU";
|
|
404
|
+
function isTruthy2(value) {
|
|
405
|
+
if (!value) return false;
|
|
406
|
+
const normalized = value.trim().toLowerCase();
|
|
407
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
408
|
+
}
|
|
409
|
+
function getSupabaseUrl(options) {
|
|
410
|
+
return options?.supabaseUrl ?? process.env.SUPABASE_URL ?? LOCAL_SUPABASE_URL;
|
|
411
|
+
}
|
|
412
|
+
function isLocalSupabaseUrl(supabaseUrl) {
|
|
413
|
+
try {
|
|
414
|
+
const parsed = new URL(supabaseUrl);
|
|
415
|
+
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function isLocalMode(options) {
|
|
421
|
+
if (options?.localDev !== void 0) return options.localDev;
|
|
422
|
+
if (isTruthy2(process.env.FORCEFIELD_LOCAL_DEV)) return true;
|
|
423
|
+
return isLocalSupabaseUrl(getSupabaseUrl(options));
|
|
424
|
+
}
|
|
425
|
+
function getAnonKey(supabaseUrl, options) {
|
|
426
|
+
const explicit = options?.anonKey ?? process.env.SUPABASE_ANON_KEY;
|
|
427
|
+
if (explicit && explicit.trim().length > 0) return explicit;
|
|
428
|
+
if (isLocalSupabaseUrl(supabaseUrl)) {
|
|
429
|
+
return LOCAL_SUPABASE_ANON_KEY;
|
|
430
|
+
}
|
|
431
|
+
throw new Error("Missing SUPABASE_ANON_KEY for hosted setup authentication.");
|
|
432
|
+
}
|
|
433
|
+
function getServiceRoleKey(supabaseUrl) {
|
|
434
|
+
const explicit = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
435
|
+
if (explicit && explicit.trim().length > 0) return explicit;
|
|
436
|
+
if (isLocalSupabaseUrl(supabaseUrl)) {
|
|
437
|
+
return LOCAL_SUPABASE_SERVICE_ROLE_KEY;
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
function validateApiKeyFormat(key) {
|
|
442
|
+
return key.startsWith(API_KEY_PREFIX) && key.length > API_KEY_PREFIX.length + 8;
|
|
443
|
+
}
|
|
444
|
+
async function verifyApiKey(apiKey, options) {
|
|
445
|
+
if (!validateApiKeyFormat(apiKey)) {
|
|
446
|
+
return {
|
|
447
|
+
valid: false,
|
|
448
|
+
apiKey: "",
|
|
449
|
+
error: 'Invalid API key format. Keys must start with "ff_live_".'
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const supabaseUrl = getSupabaseUrl(options);
|
|
453
|
+
const endpoint = `${supabaseUrl}/functions/v1/key-exchange`;
|
|
454
|
+
try {
|
|
455
|
+
const response = await fetch(endpoint, {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: {
|
|
458
|
+
Authorization: `Bearer ${apiKey}`,
|
|
459
|
+
"Content-Type": "application/json"
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
const payload = await response.json().catch(() => ({}));
|
|
464
|
+
const detail = payload.error ?? "API key invalid or revoked.";
|
|
465
|
+
return {
|
|
466
|
+
valid: false,
|
|
467
|
+
apiKey: "",
|
|
468
|
+
error: `Authentication failed (${response.status}): ${detail}`
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
return { valid: true, apiKey };
|
|
472
|
+
} catch {
|
|
473
|
+
return {
|
|
474
|
+
valid: false,
|
|
475
|
+
apiKey: "",
|
|
476
|
+
error: `Could not reach Forcefield backend at ${supabaseUrl}. Check SUPABASE_URL/network and retry.`
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async function authenticateWithRetry(getKey, options) {
|
|
481
|
+
let lastError;
|
|
482
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
483
|
+
const key = await getKey();
|
|
484
|
+
if (!key) {
|
|
485
|
+
return { valid: false, apiKey: "", error: "No API key provided." };
|
|
486
|
+
}
|
|
487
|
+
const verified = await verifyApiKey(key, options);
|
|
488
|
+
if (verified.valid) {
|
|
489
|
+
return verified;
|
|
490
|
+
}
|
|
491
|
+
lastError = verified.error;
|
|
492
|
+
if (attempt < MAX_RETRIES) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
valid: false,
|
|
498
|
+
apiKey: "",
|
|
499
|
+
error: lastError ?? `Authentication failed after ${MAX_RETRIES} attempts.`
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function generateApiKey() {
|
|
503
|
+
const raw = randomBytes(32).toString("hex");
|
|
504
|
+
const plaintext = `${API_KEY_PREFIX}${raw}`;
|
|
505
|
+
const hash = createHash("sha256").update(plaintext).digest("hex");
|
|
506
|
+
const prefix = plaintext.slice(0, 12);
|
|
507
|
+
return { plaintext, hash, prefix };
|
|
508
|
+
}
|
|
509
|
+
async function authenticateAccountAndCreateApiKey(email, password, options) {
|
|
510
|
+
const supabaseUrl = getSupabaseUrl(options);
|
|
511
|
+
const anonKey = getAnonKey(supabaseUrl, options);
|
|
512
|
+
const supabase = createClient(supabaseUrl, anonKey, {
|
|
513
|
+
auth: {
|
|
514
|
+
persistSession: false,
|
|
515
|
+
autoRefreshToken: false
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
let signIn = await supabase.auth.signInWithPassword({ email, password });
|
|
519
|
+
if (signIn.error || !signIn.data.user) {
|
|
520
|
+
await supabase.auth.signUp({ email, password });
|
|
521
|
+
signIn = await supabase.auth.signInWithPassword({ email, password });
|
|
522
|
+
}
|
|
523
|
+
if (signIn.error || !signIn.data.user) {
|
|
524
|
+
const message = signIn.error?.message ?? "Invalid login credentials";
|
|
525
|
+
throw new Error(`Authentication failed: ${message}`);
|
|
526
|
+
}
|
|
527
|
+
const userId = signIn.data.user.id;
|
|
528
|
+
const { plaintext, hash, prefix } = generateApiKey();
|
|
529
|
+
const { error: insertError } = await supabase.from("api_keys").insert({
|
|
530
|
+
user_id: userId,
|
|
531
|
+
key_hash: hash,
|
|
532
|
+
key_prefix: prefix,
|
|
533
|
+
label: options?.label ?? "Setup Wizard Key",
|
|
534
|
+
tier: "free"
|
|
535
|
+
});
|
|
536
|
+
if (insertError) {
|
|
537
|
+
if (isLocalMode(options)) {
|
|
538
|
+
const serviceRoleKey = getServiceRoleKey(supabaseUrl);
|
|
539
|
+
if (serviceRoleKey) {
|
|
540
|
+
return createApiKeyViaServiceRole({
|
|
541
|
+
supabaseUrl,
|
|
542
|
+
serviceRoleKey,
|
|
543
|
+
email,
|
|
544
|
+
password,
|
|
545
|
+
label: options?.label ?? "Setup Wizard Key"
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
throw new Error(`Failed to create API key: ${insertError.message}`);
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
apiKey: plaintext,
|
|
553
|
+
userId,
|
|
554
|
+
email
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async function authenticateLocalAndCreateApiKey(email, password, options) {
|
|
558
|
+
return authenticateAccountAndCreateApiKey(email, password, {
|
|
559
|
+
...options,
|
|
560
|
+
localDev: true,
|
|
561
|
+
label: options?.label ?? "Local Setup Key"
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
async function createApiKeyViaServiceRole(params) {
|
|
565
|
+
const admin = createClient(params.supabaseUrl, params.serviceRoleKey, {
|
|
566
|
+
auth: {
|
|
567
|
+
persistSession: false,
|
|
568
|
+
autoRefreshToken: false
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
let userId;
|
|
572
|
+
const createUser = await admin.auth.admin.createUser({
|
|
573
|
+
email: params.email,
|
|
574
|
+
password: params.password,
|
|
575
|
+
email_confirm: true
|
|
576
|
+
});
|
|
577
|
+
if (createUser.data.user?.id) {
|
|
578
|
+
userId = createUser.data.user.id;
|
|
579
|
+
} else if (createUser.error) {
|
|
580
|
+
const authDb = createClient(params.supabaseUrl, params.serviceRoleKey, {
|
|
581
|
+
db: { schema: "auth" },
|
|
582
|
+
auth: {
|
|
583
|
+
persistSession: false,
|
|
584
|
+
autoRefreshToken: false
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
const existingUser = await authDb.from("users").select("id,email").eq("email", params.email).limit(1).maybeSingle();
|
|
588
|
+
if (existingUser.error) {
|
|
589
|
+
throw new Error(`Local login failed: ${createUser.error.message}`);
|
|
590
|
+
}
|
|
591
|
+
userId = existingUser.data?.id;
|
|
592
|
+
}
|
|
593
|
+
if (!userId) {
|
|
594
|
+
throw new Error("Local login failed: could not resolve user account");
|
|
595
|
+
}
|
|
596
|
+
const { plaintext, hash, prefix } = generateApiKey();
|
|
597
|
+
const { error: insertError } = await admin.from("api_keys").insert({
|
|
598
|
+
user_id: userId,
|
|
599
|
+
key_hash: hash,
|
|
600
|
+
key_prefix: prefix,
|
|
601
|
+
label: params.label,
|
|
602
|
+
tier: "free"
|
|
603
|
+
});
|
|
604
|
+
if (insertError) {
|
|
605
|
+
throw new Error(`Failed to create local API key: ${insertError.message}`);
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
apiKey: plaintext,
|
|
609
|
+
userId,
|
|
610
|
+
email: params.email
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// setup/wizard.ts
|
|
615
|
+
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
616
|
+
var WORKFLOW_DIR_CANDIDATES = [
|
|
617
|
+
join7(__dirname2, "..", "workflows"),
|
|
618
|
+
join7(__dirname2, "..", "..", "workflows")
|
|
619
|
+
];
|
|
620
|
+
var CONFIG_FILE = ".forcefield.json";
|
|
621
|
+
var VERSION = "0.1.0";
|
|
622
|
+
var BANNER_TEXT = "Forcefield";
|
|
623
|
+
var BANNER_FONT = "Rowan Cap";
|
|
624
|
+
var BANNER_MIN_COLUMNS = 80;
|
|
625
|
+
var BANNER_FALLBACK_TEXT = " Forcefield";
|
|
626
|
+
var BANNER_SUBTITLE = " Corporate compliance copilot\n";
|
|
627
|
+
var BANNER_GRADIENT_STOPS = ["#ff3b3b", "#ff9f1a", "#ffe74c", "#2ed573", "#1e90ff", "#7d5fff", "#e056fd"];
|
|
628
|
+
var bannerGradient = gradient(BANNER_GRADIENT_STOPS);
|
|
629
|
+
async function resolveWorkflowsDir() {
|
|
630
|
+
for (const dir of WORKFLOW_DIR_CANDIDATES) {
|
|
631
|
+
try {
|
|
632
|
+
await access3(dir);
|
|
633
|
+
return dir;
|
|
634
|
+
} catch {
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
throw new Error(
|
|
638
|
+
`Unable to locate workflow templates. Checked: ${WORKFLOW_DIR_CANDIDATES.join(", ")}`
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
async function runWizard(projectDir, args) {
|
|
642
|
+
if (args.status) {
|
|
643
|
+
await showStatus(projectDir);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (args.ide && args.mode && (args.token || args.localAuth || args.email && args.password)) {
|
|
647
|
+
await runNonInteractive(projectDir, args);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
await runInteractive(projectDir, args);
|
|
651
|
+
}
|
|
652
|
+
function showBanner() {
|
|
653
|
+
const cols = process.stdout.columns || 80;
|
|
654
|
+
if (cols >= BANNER_MIN_COLUMNS) {
|
|
655
|
+
try {
|
|
656
|
+
const banner = figlet.textSync(BANNER_TEXT, { font: BANNER_FONT });
|
|
657
|
+
const colored = applyBannerGradient(banner);
|
|
658
|
+
console.log(colored);
|
|
659
|
+
} catch {
|
|
660
|
+
console.log(pc.bold(applyBannerGradient(BANNER_FALLBACK_TEXT)));
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
console.log(pc.bold(applyBannerGradient(BANNER_FALLBACK_TEXT)));
|
|
664
|
+
}
|
|
665
|
+
console.log(pc.dim(BANNER_SUBTITLE));
|
|
666
|
+
}
|
|
667
|
+
function applyBannerGradient(text2) {
|
|
668
|
+
return bannerGradient.multiline(text2);
|
|
669
|
+
}
|
|
670
|
+
async function showStatus(projectDir) {
|
|
671
|
+
const config = await readConfig(projectDir);
|
|
672
|
+
if (!config) {
|
|
673
|
+
console.log(pc.yellow("No Forcefield setup found in this directory."));
|
|
674
|
+
console.log(`Run ${pc.cyan("forcefield-setup")} to get started.`);
|
|
675
|
+
process.exit(1);
|
|
676
|
+
}
|
|
677
|
+
console.log(pc.bold("Forcefield Setup Status"));
|
|
678
|
+
console.log(` IDE: ${pc.cyan(config.ide)}`);
|
|
679
|
+
console.log(` Mode: ${pc.cyan(config.mode)}`);
|
|
680
|
+
console.log(` Version: ${pc.cyan(config.version)}`);
|
|
681
|
+
console.log(` Installed: ${pc.dim(config.installed_at)}`);
|
|
682
|
+
}
|
|
683
|
+
async function runNonInteractive(projectDir, args) {
|
|
684
|
+
const ide = args.ide;
|
|
685
|
+
const mode = args.mode;
|
|
686
|
+
let apiKey = args.token;
|
|
687
|
+
if (args.localAuth) {
|
|
688
|
+
if (!args.email || !args.password) {
|
|
689
|
+
console.error(pc.red("Non-interactive local auth requires --email and --password."));
|
|
690
|
+
process.exit(1);
|
|
691
|
+
}
|
|
692
|
+
const auth = await authenticateLocalAndCreateApiKey(args.email, args.password, {
|
|
693
|
+
label: "Setup Wizard (Local)"
|
|
694
|
+
});
|
|
695
|
+
apiKey = auth.apiKey;
|
|
696
|
+
console.log(`Created local API key for ${auth.email}.`);
|
|
697
|
+
} else if (!apiKey && args.email && args.password) {
|
|
698
|
+
const auth = await authenticateAccountAndCreateApiKey(args.email, args.password, {
|
|
699
|
+
label: "Setup Wizard Key",
|
|
700
|
+
localDev: isLocalSetupMode(args)
|
|
701
|
+
});
|
|
702
|
+
apiKey = auth.apiKey;
|
|
703
|
+
console.log(`Created API key for ${auth.email}.`);
|
|
704
|
+
} else if (apiKey) {
|
|
705
|
+
const verified = await authenticateWithRetry(async () => apiKey ?? null, {
|
|
706
|
+
localDev: isLocalSetupMode(args)
|
|
707
|
+
});
|
|
708
|
+
if (!verified.valid) {
|
|
709
|
+
console.error(pc.red(verified.error ?? "API key validation failed."));
|
|
710
|
+
process.exit(1);
|
|
711
|
+
}
|
|
712
|
+
apiKey = verified.apiKey;
|
|
713
|
+
}
|
|
714
|
+
if (!apiKey) {
|
|
715
|
+
console.error(pc.red("Missing auth input. Pass --token, --local-auth, or --email/--password."));
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
console.log(`Setting up Forcefield for ${ide} in ${mode} mode...`);
|
|
719
|
+
await configure(projectDir, ide, mode, apiKey);
|
|
720
|
+
console.log(pc.green("Setup complete!"));
|
|
721
|
+
for (const line of getPostSetupInstructions(ide, mode, projectDir)) {
|
|
722
|
+
console.log(line);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function runInteractive(projectDir, preArgs) {
|
|
726
|
+
showBanner();
|
|
727
|
+
p.intro(pc.bgCyan(pc.black(" Forcefield Setup ")));
|
|
728
|
+
const existingConfig = await readConfig(projectDir);
|
|
729
|
+
if (existingConfig) {
|
|
730
|
+
const updateChoice = await p.select({
|
|
731
|
+
message: `Forcefield is already set up (${existingConfig.ide}, ${existingConfig.mode} mode).`,
|
|
732
|
+
options: [
|
|
733
|
+
{ value: "update", label: "Update existing setup", hint: "Refresh workflows + auth" },
|
|
734
|
+
{ value: "fresh", label: "Start fresh", hint: "Remove existing config and reconfigure" }
|
|
735
|
+
]
|
|
736
|
+
});
|
|
737
|
+
if (p.isCancel(updateChoice)) {
|
|
738
|
+
p.cancel("Setup cancelled.");
|
|
739
|
+
process.exit(0);
|
|
740
|
+
}
|
|
741
|
+
if (updateChoice === "update") {
|
|
742
|
+
const apiKey2 = await promptApiKey();
|
|
743
|
+
if (!apiKey2) return;
|
|
744
|
+
await configure(projectDir, existingConfig.ide, existingConfig.mode, apiKey2);
|
|
745
|
+
showSuccess(existingConfig.ide, existingConfig.mode, projectDir);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const ide = preArgs.ide ?? await promptIde(projectDir);
|
|
750
|
+
if (!ide) return;
|
|
751
|
+
const mode = preArgs.mode ?? await promptMode();
|
|
752
|
+
if (!mode) return;
|
|
753
|
+
const apiKey = preArgs.token ?? await promptApiKey(preArgs);
|
|
754
|
+
if (!apiKey) return;
|
|
755
|
+
const s = p.spinner();
|
|
756
|
+
s.start("Configuring Forcefield...");
|
|
757
|
+
try {
|
|
758
|
+
await configure(projectDir, ide, mode, apiKey);
|
|
759
|
+
s.stop("Configuration complete.");
|
|
760
|
+
} catch (err) {
|
|
761
|
+
s.stop("Configuration failed.");
|
|
762
|
+
p.cancel(err instanceof Error ? err.message : "Unknown error");
|
|
763
|
+
process.exit(1);
|
|
764
|
+
}
|
|
765
|
+
showSuccess(ide, mode, projectDir);
|
|
766
|
+
}
|
|
767
|
+
async function promptIde(projectDir) {
|
|
768
|
+
const detection = await detectIde(projectDir);
|
|
769
|
+
if (detection.detected) {
|
|
770
|
+
const adapter = getAdapter(detection.detected);
|
|
771
|
+
const confirm2 = await p.confirm({
|
|
772
|
+
message: `Detected ${adapter.name} (${detection.markers[0].marker} found). Use ${adapter.name}?`
|
|
773
|
+
});
|
|
774
|
+
if (p.isCancel(confirm2)) {
|
|
775
|
+
p.cancel("Setup cancelled.");
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
if (confirm2) return detection.detected;
|
|
779
|
+
}
|
|
780
|
+
const choice = await p.select({
|
|
781
|
+
message: "Which IDE do you use?",
|
|
782
|
+
options: IDE_CHOICES
|
|
783
|
+
});
|
|
784
|
+
if (p.isCancel(choice)) {
|
|
785
|
+
p.cancel("Setup cancelled.");
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
return choice;
|
|
789
|
+
}
|
|
790
|
+
async function promptMode() {
|
|
791
|
+
const choice = await p.select({
|
|
792
|
+
message: "Installation type:",
|
|
793
|
+
options: [
|
|
794
|
+
{ value: "full", label: "Full \u2014 MCP server + guided workflows", hint: "Recommended" },
|
|
795
|
+
{ value: "core", label: "Core \u2014 MCP server only", hint: "Minimal" }
|
|
796
|
+
]
|
|
797
|
+
});
|
|
798
|
+
if (p.isCancel(choice)) {
|
|
799
|
+
p.cancel("Setup cancelled.");
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
return choice;
|
|
803
|
+
}
|
|
804
|
+
async function promptApiKey(preArgs) {
|
|
805
|
+
if (preArgs?.localAuth) {
|
|
806
|
+
const preEmail = preArgs.email ?? await promptTextValue("Local test email:", "alice@test.local");
|
|
807
|
+
const prePassword = preArgs.password ?? await promptTextValue("Local test password:", "password123", true);
|
|
808
|
+
if (!preEmail || !prePassword) return null;
|
|
809
|
+
try {
|
|
810
|
+
const result = await authenticateLocalAndCreateApiKey(preEmail, prePassword, {
|
|
811
|
+
label: "Setup Wizard (Local)"
|
|
812
|
+
});
|
|
813
|
+
p.log.success(`Created local API key for ${result.email}.`);
|
|
814
|
+
return result.apiKey;
|
|
815
|
+
} catch (err) {
|
|
816
|
+
p.log.error(err instanceof Error ? err.message : "Failed local auth");
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const localMode = isLocalSetupMode(preArgs);
|
|
821
|
+
const methodOptions = [
|
|
822
|
+
{ value: "account", label: "Sign in / create account + API key", hint: "Recommended" },
|
|
823
|
+
{ value: "paste", label: "Paste existing API key", hint: "Use an existing ff_live_... key" }
|
|
824
|
+
];
|
|
825
|
+
if (localMode) {
|
|
826
|
+
methodOptions.push({
|
|
827
|
+
value: "local",
|
|
828
|
+
label: "Create local account + API key",
|
|
829
|
+
hint: "For local Supabase testing"
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
const method = await p.select({
|
|
833
|
+
message: "How do you want to authenticate?",
|
|
834
|
+
options: methodOptions
|
|
835
|
+
});
|
|
836
|
+
if (p.isCancel(method)) {
|
|
837
|
+
p.cancel("Setup cancelled.");
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
if (method === "account") {
|
|
841
|
+
const email = await promptTextValue("Account email:", preArgs?.email ?? "you@company.com");
|
|
842
|
+
const password = await promptTextValue("Account password:", "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", true);
|
|
843
|
+
if (!email || !password) return null;
|
|
844
|
+
try {
|
|
845
|
+
const result = await authenticateAccountAndCreateApiKey(email, password, {
|
|
846
|
+
label: "Setup Wizard Key",
|
|
847
|
+
localDev: localMode
|
|
848
|
+
});
|
|
849
|
+
p.log.success(`Created API key for ${result.email}.`);
|
|
850
|
+
return result.apiKey;
|
|
851
|
+
} catch (err) {
|
|
852
|
+
p.log.error(err instanceof Error ? err.message : "Account authentication failed");
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (method === "local") {
|
|
857
|
+
const email = await promptTextValue("Local test email:", "alice@test.local");
|
|
858
|
+
const password = await promptTextValue("Local test password:", "password123", true);
|
|
859
|
+
if (!email || !password) return null;
|
|
860
|
+
try {
|
|
861
|
+
const result = await authenticateLocalAndCreateApiKey(email, password, {
|
|
862
|
+
label: "Setup Wizard (Local)"
|
|
863
|
+
});
|
|
864
|
+
p.log.success(`Created local API key for ${result.email}.`);
|
|
865
|
+
return result.apiKey;
|
|
866
|
+
} catch (err) {
|
|
867
|
+
p.log.error(err instanceof Error ? err.message : "Failed local auth");
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const auth = await authenticateWithRetry(
|
|
872
|
+
async () => {
|
|
873
|
+
const key = await p.text({
|
|
874
|
+
message: "Enter your Forcefield API key:",
|
|
875
|
+
placeholder: "ff_live_...",
|
|
876
|
+
validate: (value) => {
|
|
877
|
+
if (!value) return "API key is required.";
|
|
878
|
+
if (!value.startsWith("ff_live_")) return 'Key must start with "ff_live_".';
|
|
879
|
+
if (value.length < 20) return "Key seems too short.";
|
|
880
|
+
return void 0;
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
if (p.isCancel(key)) {
|
|
884
|
+
p.cancel("Setup cancelled.");
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
return key;
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
localDev: localMode
|
|
891
|
+
}
|
|
892
|
+
);
|
|
893
|
+
if (!auth.valid) {
|
|
894
|
+
p.log.error(auth.error ?? "Authentication failed. Check your credentials and retry.");
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
return auth.apiKey;
|
|
898
|
+
}
|
|
899
|
+
function isLocalSetupMode(preArgs) {
|
|
900
|
+
if (preArgs?.localAuth) return true;
|
|
901
|
+
const localValue = process.env.FORCEFIELD_LOCAL_DEV;
|
|
902
|
+
if (localValue) {
|
|
903
|
+
const normalized = localValue.trim().toLowerCase();
|
|
904
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const supabaseUrl = process.env.SUPABASE_URL;
|
|
909
|
+
if (!supabaseUrl) return false;
|
|
910
|
+
try {
|
|
911
|
+
const parsed = new URL(supabaseUrl);
|
|
912
|
+
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
|
|
913
|
+
} catch {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function promptTextValue(message, placeholder, secret = false) {
|
|
918
|
+
const value = await p.text({
|
|
919
|
+
message,
|
|
920
|
+
placeholder,
|
|
921
|
+
...secret ? { placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" } : {},
|
|
922
|
+
validate: (input) => {
|
|
923
|
+
if (!input) return "This field is required.";
|
|
924
|
+
return void 0;
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
if (p.isCancel(value)) {
|
|
928
|
+
p.cancel("Setup cancelled.");
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
return value;
|
|
932
|
+
}
|
|
933
|
+
async function configure(projectDir, ide, mode, apiKey) {
|
|
934
|
+
const adapter = getAdapter(ide);
|
|
935
|
+
await adapter.configureMcp(projectDir, apiKey);
|
|
936
|
+
let workflowCount = 0;
|
|
937
|
+
if (mode === "full") {
|
|
938
|
+
const workflowsDir = await resolveWorkflowsDir();
|
|
939
|
+
workflowCount = await adapter.installWorkflows(projectDir, workflowsDir);
|
|
940
|
+
}
|
|
941
|
+
const config = {
|
|
942
|
+
ide,
|
|
943
|
+
mode,
|
|
944
|
+
installed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
945
|
+
version: VERSION
|
|
946
|
+
};
|
|
947
|
+
await writeConfig(projectDir, config);
|
|
948
|
+
void workflowCount;
|
|
949
|
+
}
|
|
950
|
+
function getPostSetupInstructions(ide, mode, projectDir) {
|
|
951
|
+
const repo = pc.cyan(projectDir);
|
|
952
|
+
const dashboardHint = `Optional beta companion: run local dashboard in the Forcefield repo via ${pc.cyan("pnpm run dev:dashboard")} and sign in at ${pc.cyan("http://localhost:3000")}.`;
|
|
953
|
+
if (mode === "core") {
|
|
954
|
+
return [
|
|
955
|
+
`Next: open your coding agent in ${repo} and use Forcefield MCP tools directly (e.g. ${pc.cyan('ff_system(action: "health")')}).`,
|
|
956
|
+
dashboardHint
|
|
957
|
+
];
|
|
958
|
+
}
|
|
959
|
+
const common = [
|
|
960
|
+
`Next: start your coding agent in ${repo}.`,
|
|
961
|
+
`Then in IDE chat (not terminal), run ${pc.cyan("/ff-onboard")} to set up your company profile and deadlines.`,
|
|
962
|
+
`Optional: run ${pc.cyan("/ff-start")} only if you want a command primer or connection troubleshooting.`,
|
|
963
|
+
dashboardHint
|
|
964
|
+
];
|
|
965
|
+
if (ide === "claude-code") {
|
|
966
|
+
return [
|
|
967
|
+
...common,
|
|
968
|
+
`If tools are missing, run ${pc.cyan("/mcp")} and confirm ${pc.cyan("forcefield")} is connected.`
|
|
969
|
+
];
|
|
970
|
+
}
|
|
971
|
+
return common;
|
|
972
|
+
}
|
|
973
|
+
function showSuccess(ide, mode, projectDir) {
|
|
974
|
+
const adapter = getAdapter(ide);
|
|
975
|
+
const workflowStatus = mode === "full" ? `${pc.green("\u2713")} Project workflow commands installed` : `${pc.yellow("\u2022")} Core mode selected (no slash-command workflows installed)`;
|
|
976
|
+
p.note(
|
|
977
|
+
[
|
|
978
|
+
`${pc.green("\u2713")} MCP server configured`,
|
|
979
|
+
workflowStatus,
|
|
980
|
+
`${pc.green("\u2713")} IDE: ${adapter.name}`
|
|
981
|
+
].join("\n"),
|
|
982
|
+
"Setup Complete"
|
|
983
|
+
);
|
|
984
|
+
p.outro(getPostSetupInstructions(ide, mode, projectDir).join("\n"));
|
|
985
|
+
}
|
|
986
|
+
async function readConfig(projectDir) {
|
|
987
|
+
const configPath = join7(projectDir, CONFIG_FILE);
|
|
988
|
+
try {
|
|
989
|
+
await access3(configPath);
|
|
990
|
+
const raw = await readFile5(configPath, "utf-8");
|
|
991
|
+
return JSON.parse(raw);
|
|
992
|
+
} catch {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
async function writeConfig(projectDir, config) {
|
|
997
|
+
const configPath = join7(projectDir, CONFIG_FILE);
|
|
998
|
+
await writeFile5(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// setup/index.ts
|
|
1002
|
+
function parseArgs(argv) {
|
|
1003
|
+
const args = {};
|
|
1004
|
+
for (let i = 2; i < argv.length; i++) {
|
|
1005
|
+
const arg = argv[i];
|
|
1006
|
+
if (arg === "--status") {
|
|
1007
|
+
args.status = true;
|
|
1008
|
+
} else if (arg === "--ide" && argv[i + 1]) {
|
|
1009
|
+
const ide = argv[++i];
|
|
1010
|
+
if (["claude-code", "cursor", "codex", "windsurf"].includes(ide)) {
|
|
1011
|
+
args.ide = ide;
|
|
1012
|
+
}
|
|
1013
|
+
} else if (arg === "--mode" && argv[i + 1]) {
|
|
1014
|
+
const mode = argv[++i];
|
|
1015
|
+
if (["full", "core"].includes(mode)) {
|
|
1016
|
+
args.mode = mode;
|
|
1017
|
+
}
|
|
1018
|
+
} else if (arg === "--token" && argv[i + 1]) {
|
|
1019
|
+
args.token = argv[++i];
|
|
1020
|
+
} else if (arg === "--email" && argv[i + 1]) {
|
|
1021
|
+
args.email = argv[++i];
|
|
1022
|
+
} else if (arg === "--password" && argv[i + 1]) {
|
|
1023
|
+
args.password = argv[++i];
|
|
1024
|
+
} else if (arg === "--local-auth") {
|
|
1025
|
+
args.localAuth = true;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return args;
|
|
1029
|
+
}
|
|
1030
|
+
async function main() {
|
|
1031
|
+
const args = parseArgs(process.argv);
|
|
1032
|
+
const projectDir = process.cwd();
|
|
1033
|
+
try {
|
|
1034
|
+
await runWizard(projectDir, args);
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.error("Setup failed:", err instanceof Error ? err.message : err);
|
|
1037
|
+
process.exit(1);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
main();
|
|
1041
|
+
export {
|
|
1042
|
+
parseArgs
|
|
1043
|
+
};
|
|
1044
|
+
//# sourceMappingURL=index.js.map
|