gsd-pi 2.70.1-dev.bef631a → 2.70.1-dev.ec24142
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/dist/resources/extensions/claude-code-cli/stream-adapter.js +127 -30
- package/dist/resources/extensions/get-secrets-from-user.js +17 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/dist/env-writer.d.ts +39 -0
- package/packages/mcp-server/dist/env-writer.d.ts.map +1 -0
- package/packages/mcp-server/dist/env-writer.js +158 -0
- package/packages/mcp-server/dist/env-writer.js.map +1 -0
- package/packages/mcp-server/dist/server.d.ts +11 -2
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +102 -2
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/src/env-writer.test.ts +280 -0
- package/packages/mcp-server/src/env-writer.ts +183 -0
- package/packages/mcp-server/src/secure-env-collect.test.ts +265 -0
- package/packages/mcp-server/src/server.ts +137 -3
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +187 -0
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +78 -21
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +1 -0
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
- package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +220 -0
- package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
- package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +2 -0
- package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +102 -27
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
- package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +1 -1
- package/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +1 -0
- package/packages/pi-tui/dist/components/__tests__/input.test.js +9 -0
- package/packages/pi-tui/dist/components/__tests__/input.test.js.map +1 -1
- package/packages/pi-tui/dist/components/input.d.ts +2 -0
- package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/input.js +7 -4
- package/packages/pi-tui/dist/components/input.js.map +1 -1
- package/packages/pi-tui/src/components/__tests__/input.test.ts +11 -0
- package/packages/pi-tui/src/components/input.ts +7 -4
- package/src/resources/extensions/claude-code-cli/stream-adapter.ts +164 -31
- package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +112 -0
- package/src/resources/extensions/get-secrets-from-user.ts +24 -1
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +45 -0
- /package/dist/web/standalone/.next/static/{UlX0WGGZ8aBPN0uSZ5Ki4 → 20e8bFnNjxQJflHNodEve}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{UlX0WGGZ8aBPN0uSZ5Ki4 → 20e8bFnNjxQJflHNodEve}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// @gsd-build/mcp-server — Environment variable write utilities
|
|
2
|
+
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
3
|
+
//
|
|
4
|
+
// Shared helpers for writing env vars to .env files, detecting project
|
|
5
|
+
// destinations, and checking existing keys. Used by secure_env_collect
|
|
6
|
+
// MCP tool. No TUI dependencies — pure filesystem + process.env operations.
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { existsSync, statSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// checkExistingEnvKeys
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check which keys already exist in a .env file or process.env.
|
|
18
|
+
* Returns the subset of `keys` that are already set.
|
|
19
|
+
*/
|
|
20
|
+
export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
|
|
21
|
+
let fileContent = "";
|
|
22
|
+
try {
|
|
23
|
+
fileContent = await readFile(envFilePath, "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
// ENOENT or other read error — proceed with empty content
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const existing: string[] = [];
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
const regex = new RegExp(`^${escaped}\\s*=`, "m");
|
|
32
|
+
if (regex.test(fileContent) || key in process.env) {
|
|
33
|
+
existing.push(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return existing;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// detectDestination
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect the write destination based on project files in basePath.
|
|
45
|
+
* Priority: vercel.json → convex/ dir → fallback "dotenv".
|
|
46
|
+
*/
|
|
47
|
+
export function detectDestination(basePath: string): "dotenv" | "vercel" | "convex" {
|
|
48
|
+
if (existsSync(resolve(basePath, "vercel.json"))) {
|
|
49
|
+
return "vercel";
|
|
50
|
+
}
|
|
51
|
+
const convexPath = resolve(basePath, "convex");
|
|
52
|
+
try {
|
|
53
|
+
if (existsSync(convexPath) && statSync(convexPath).isDirectory()) {
|
|
54
|
+
return "convex";
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// stat error — treat as not found
|
|
58
|
+
}
|
|
59
|
+
return "dotenv";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// writeEnvKey
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write a single key=value pair to a .env file.
|
|
68
|
+
* Updates existing keys in-place, appends new ones at the end.
|
|
69
|
+
*/
|
|
70
|
+
export async function writeEnvKey(filePath: string, key: string, value: string): Promise<void> {
|
|
71
|
+
if (typeof value !== "string") {
|
|
72
|
+
throw new TypeError(`writeEnvKey expects a string value for key "${key}", got ${typeof value}`);
|
|
73
|
+
}
|
|
74
|
+
let content = "";
|
|
75
|
+
try {
|
|
76
|
+
content = await readFile(filePath, "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
content = "";
|
|
79
|
+
}
|
|
80
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
81
|
+
const line = `${key}=${escaped}`;
|
|
82
|
+
const regex = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=.*$`, "m");
|
|
83
|
+
if (regex.test(content)) {
|
|
84
|
+
content = content.replace(regex, line);
|
|
85
|
+
} else {
|
|
86
|
+
if (content.length > 0 && !content.endsWith("\n")) content += "\n";
|
|
87
|
+
content += `${line}\n`;
|
|
88
|
+
}
|
|
89
|
+
await writeFile(filePath, content, "utf8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Validation helpers
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export function isSafeEnvVarKey(key: string): boolean {
|
|
97
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isSupportedDeploymentEnvironment(env: string): boolean {
|
|
101
|
+
return env === "development" || env === "preview" || env === "production";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Shell helpers (for vercel/convex CLI)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export function shellEscapeSingle(value: string): string {
|
|
109
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// applySecrets
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
interface ApplyResult {
|
|
117
|
+
applied: string[];
|
|
118
|
+
errors: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Apply collected secrets to the target destination.
|
|
123
|
+
* Dotenv writes are handled directly; vercel/convex shell out via execFn.
|
|
124
|
+
*/
|
|
125
|
+
export async function applySecrets(
|
|
126
|
+
provided: Array<{ key: string; value: string }>,
|
|
127
|
+
destination: "dotenv" | "vercel" | "convex",
|
|
128
|
+
opts: {
|
|
129
|
+
envFilePath: string;
|
|
130
|
+
environment?: string;
|
|
131
|
+
execFn?: (cmd: string, args: string[]) => Promise<{ code: number; stderr: string }>;
|
|
132
|
+
},
|
|
133
|
+
): Promise<ApplyResult> {
|
|
134
|
+
const applied: string[] = [];
|
|
135
|
+
const errors: string[] = [];
|
|
136
|
+
|
|
137
|
+
if (destination === "dotenv") {
|
|
138
|
+
for (const { key, value } of provided) {
|
|
139
|
+
try {
|
|
140
|
+
await writeEnvKey(opts.envFilePath, key, value);
|
|
141
|
+
applied.push(key);
|
|
142
|
+
// Hydrate process.env so the current session sees the new value
|
|
143
|
+
process.env[key] = value;
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
+
errors.push(`${key}: ${msg}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ((destination === "vercel" || destination === "convex") && opts.execFn) {
|
|
152
|
+
const env = opts.environment ?? "development";
|
|
153
|
+
if (!isSupportedDeploymentEnvironment(env)) {
|
|
154
|
+
errors.push(`environment: unsupported target environment "${env}"`);
|
|
155
|
+
return { applied, errors };
|
|
156
|
+
}
|
|
157
|
+
for (const { key, value } of provided) {
|
|
158
|
+
if (!isSafeEnvVarKey(key)) {
|
|
159
|
+
errors.push(`${key}: invalid environment variable name`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const cmd = destination === "vercel"
|
|
163
|
+
? `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`
|
|
164
|
+
: "";
|
|
165
|
+
try {
|
|
166
|
+
const result = destination === "vercel"
|
|
167
|
+
? await opts.execFn("sh", ["-c", cmd])
|
|
168
|
+
: await opts.execFn("npx", ["convex", "env", "set", key, value]);
|
|
169
|
+
if (result.code !== 0) {
|
|
170
|
+
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
171
|
+
} else {
|
|
172
|
+
applied.push(key);
|
|
173
|
+
process.env[key] = value;
|
|
174
|
+
}
|
|
175
|
+
} catch (err: unknown) {
|
|
176
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
177
|
+
errors.push(`${key}: ${msg}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { applied, errors };
|
|
183
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// @gsd-build/mcp-server — Tests for secure_env_collect MCP tool
|
|
2
|
+
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
3
|
+
//
|
|
4
|
+
// Tests the secure_env_collect tool registered in createMcpServer.
|
|
5
|
+
// Uses a mock MCP server to intercept tool registration and elicitInput calls.
|
|
6
|
+
|
|
7
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { createMcpServer } from './server.js';
|
|
14
|
+
import { SessionManager } from './session-manager.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Mock infrastructure
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* We intercept McpServer construction by monkey-patching the dynamic import.
|
|
22
|
+
* Instead, we'll test the tool handler indirectly through the exported
|
|
23
|
+
* createMcpServer function — capturing the registered tool handlers.
|
|
24
|
+
*
|
|
25
|
+
* Since createMcpServer dynamically imports McpServer, we need to test at
|
|
26
|
+
* a level that exercises the tool handler logic. We do this by extracting
|
|
27
|
+
* the tool handler through the server.tool() calls.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
interface RegisteredTool {
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
params: Record<string, unknown>;
|
|
34
|
+
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ToolResult {
|
|
38
|
+
content?: Array<{ type: string; text: string }>;
|
|
39
|
+
isError?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mock McpServer that captures tool registrations and provides
|
|
44
|
+
* a controllable elicitInput response.
|
|
45
|
+
*/
|
|
46
|
+
class MockMcpServer {
|
|
47
|
+
registeredTools: RegisteredTool[] = [];
|
|
48
|
+
elicitResponse: { action: string; content?: Record<string, unknown> } = { action: 'accept', content: {} };
|
|
49
|
+
|
|
50
|
+
server = {
|
|
51
|
+
elicitInput: async (_params: unknown) => {
|
|
52
|
+
return this.elicitResponse;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>) {
|
|
57
|
+
this.registeredTools.push({ name, description, params, handler });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async connect(_transport: unknown) { /* no-op */ }
|
|
61
|
+
async close() { /* no-op */ }
|
|
62
|
+
|
|
63
|
+
getToolHandler(name: string): ((args: Record<string, unknown>) => Promise<unknown>) | undefined {
|
|
64
|
+
return this.registeredTools.find((t) => t.name === name)?.handler;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Helper to create a mock MCP server with secure_env_collect registered
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Since createMcpServer uses dynamic import for McpServer, we can't easily
|
|
74
|
+
* mock it. Instead, we test the env-writer utilities directly (in env-writer.test.ts)
|
|
75
|
+
* and test the tool integration by verifying:
|
|
76
|
+
* 1. The tool exists in the registered tools list
|
|
77
|
+
* 2. The handler produces correct results with mock data
|
|
78
|
+
*
|
|
79
|
+
* For handler-level testing, we create a standalone test that replicates
|
|
80
|
+
* the tool handler logic with a controllable mock.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
function makeTempDir(prefix: string): string {
|
|
84
|
+
return mkdtempSync(join(tmpdir(), `${prefix}-`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Integration test — verify tool is registered
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe('secure_env_collect tool registration', () => {
|
|
92
|
+
it('createMcpServer registers secure_env_collect tool', async () => {
|
|
93
|
+
// This test verifies the tool exists — createMcpServer internally calls
|
|
94
|
+
// server.tool('secure_env_collect', ...) which we can't intercept without
|
|
95
|
+
// module mocking, but we can verify the server creates successfully
|
|
96
|
+
const sm = new SessionManager();
|
|
97
|
+
try {
|
|
98
|
+
const { server } = await createMcpServer(sm);
|
|
99
|
+
assert.ok(server, 'server should be created');
|
|
100
|
+
// The McpServer internally tracks registered tools — we verify no error
|
|
101
|
+
} finally {
|
|
102
|
+
await sm.cleanup();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Handler logic tests — using env-writer directly to test the flow
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe('secure_env_collect handler logic', () => {
|
|
112
|
+
it('skips keys that already exist in .env', async () => {
|
|
113
|
+
const tmp = makeTempDir('sec-collect');
|
|
114
|
+
try {
|
|
115
|
+
const envPath = join(tmp, '.env');
|
|
116
|
+
writeFileSync(envPath, 'ALREADY_SET=existing-value\n');
|
|
117
|
+
|
|
118
|
+
// Import the utility directly to test the pre-check logic
|
|
119
|
+
const { checkExistingEnvKeys } = await import('./env-writer.js');
|
|
120
|
+
const existing = await checkExistingEnvKeys(['ALREADY_SET', 'NEW_KEY'], envPath);
|
|
121
|
+
assert.deepStrictEqual(existing, ['ALREADY_SET']);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('writes collected values to .env without returning secret values', async () => {
|
|
128
|
+
const tmp = makeTempDir('sec-collect');
|
|
129
|
+
try {
|
|
130
|
+
const envPath = join(tmp, '.env');
|
|
131
|
+
const savedKey = process.env.SEC_COLLECT_TEST_KEY;
|
|
132
|
+
|
|
133
|
+
const { applySecrets } = await import('./env-writer.js');
|
|
134
|
+
const { applied, errors } = await applySecrets(
|
|
135
|
+
[{ key: 'SEC_COLLECT_TEST_KEY', value: 'super-secret-value' }],
|
|
136
|
+
'dotenv',
|
|
137
|
+
{ envFilePath: envPath },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
assert.deepStrictEqual(applied, ['SEC_COLLECT_TEST_KEY']);
|
|
141
|
+
assert.deepStrictEqual(errors, []);
|
|
142
|
+
|
|
143
|
+
// Verify the value was written
|
|
144
|
+
const content = readFileSync(envPath, 'utf8');
|
|
145
|
+
assert.ok(content.includes('SEC_COLLECT_TEST_KEY=super-secret-value'));
|
|
146
|
+
|
|
147
|
+
// Verify process.env was hydrated
|
|
148
|
+
assert.equal(process.env.SEC_COLLECT_TEST_KEY, 'super-secret-value');
|
|
149
|
+
|
|
150
|
+
// Cleanup
|
|
151
|
+
if (savedKey === undefined) delete process.env.SEC_COLLECT_TEST_KEY;
|
|
152
|
+
else process.env.SEC_COLLECT_TEST_KEY = savedKey;
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('auto-detects vercel destination from vercel.json', async () => {
|
|
159
|
+
const tmp = makeTempDir('sec-collect');
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(join(tmp, 'vercel.json'), '{}');
|
|
162
|
+
const { detectDestination } = await import('./env-writer.js');
|
|
163
|
+
assert.equal(detectDestination(tmp), 'vercel');
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('handles empty form values as skipped', async () => {
|
|
170
|
+
// Simulate what happens when user leaves a field empty in the form
|
|
171
|
+
const formContent: Record<string, string> = {
|
|
172
|
+
'API_KEY': 'provided-value',
|
|
173
|
+
'OPTIONAL_KEY': '', // empty = skip
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const provided: Array<{ key: string; value: string }> = [];
|
|
177
|
+
const skipped: string[] = [];
|
|
178
|
+
|
|
179
|
+
for (const [key, raw] of Object.entries(formContent)) {
|
|
180
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
181
|
+
if (value.length > 0) {
|
|
182
|
+
provided.push({ key, value });
|
|
183
|
+
} else {
|
|
184
|
+
skipped.push(key);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
assert.deepStrictEqual(provided, [{ key: 'API_KEY', value: 'provided-value' }]);
|
|
189
|
+
assert.deepStrictEqual(skipped, ['OPTIONAL_KEY']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('result text never contains secret values', async () => {
|
|
193
|
+
const tmp = makeTempDir('sec-collect');
|
|
194
|
+
try {
|
|
195
|
+
const envPath = join(tmp, '.env');
|
|
196
|
+
const savedKey = process.env.RESULT_TEXT_TEST;
|
|
197
|
+
|
|
198
|
+
const { applySecrets } = await import('./env-writer.js');
|
|
199
|
+
const { applied } = await applySecrets(
|
|
200
|
+
[{ key: 'RESULT_TEXT_TEST', value: 'sk-super-secret-abc123' }],
|
|
201
|
+
'dotenv',
|
|
202
|
+
{ envFilePath: envPath },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Simulate building result text (same logic as the tool handler)
|
|
206
|
+
const lines: string[] = [
|
|
207
|
+
'destination: dotenv (auto-detected)',
|
|
208
|
+
...applied.map((k) => `✓ ${k}: applied`),
|
|
209
|
+
];
|
|
210
|
+
const resultText = lines.join('\n');
|
|
211
|
+
|
|
212
|
+
// The result MUST NOT contain the secret value
|
|
213
|
+
assert.ok(!resultText.includes('sk-super-secret-abc123'), 'result text must not contain secret value');
|
|
214
|
+
assert.ok(resultText.includes('RESULT_TEXT_TEST'), 'result text should contain key name');
|
|
215
|
+
|
|
216
|
+
// Cleanup
|
|
217
|
+
if (savedKey === undefined) delete process.env.RESULT_TEXT_TEST;
|
|
218
|
+
else process.env.RESULT_TEXT_TEST = savedKey;
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('handles multiple keys with mixed existing/new/skipped', async () => {
|
|
225
|
+
const tmp = makeTempDir('sec-collect');
|
|
226
|
+
try {
|
|
227
|
+
const envPath = join(tmp, '.env');
|
|
228
|
+
writeFileSync(envPath, 'EXISTING_A=already-here\n');
|
|
229
|
+
const savedB = process.env.NEW_B;
|
|
230
|
+
const savedC = process.env.SKIP_C;
|
|
231
|
+
|
|
232
|
+
const { checkExistingEnvKeys, applySecrets } = await import('./env-writer.js');
|
|
233
|
+
|
|
234
|
+
const allKeys = ['EXISTING_A', 'NEW_B', 'SKIP_C'];
|
|
235
|
+
const existing = await checkExistingEnvKeys(allKeys, envPath);
|
|
236
|
+
assert.deepStrictEqual(existing, ['EXISTING_A']);
|
|
237
|
+
|
|
238
|
+
// Simulate form response: NEW_B has value, SKIP_C is empty
|
|
239
|
+
const formContent = { NEW_B: 'new-value', SKIP_C: '' };
|
|
240
|
+
const provided: Array<{ key: string; value: string }> = [];
|
|
241
|
+
const skipped: string[] = [];
|
|
242
|
+
|
|
243
|
+
for (const key of allKeys.filter((k) => !existing.includes(k))) {
|
|
244
|
+
const raw = formContent[key as keyof typeof formContent] ?? '';
|
|
245
|
+
if (raw.trim().length > 0) provided.push({ key, value: raw.trim() });
|
|
246
|
+
else skipped.push(key);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { applied, errors } = await applySecrets(provided, 'dotenv', { envFilePath: envPath });
|
|
250
|
+
|
|
251
|
+
assert.deepStrictEqual(applied, ['NEW_B']);
|
|
252
|
+
assert.deepStrictEqual(skipped, ['SKIP_C']);
|
|
253
|
+
assert.deepStrictEqual(errors, []);
|
|
254
|
+
assert.deepStrictEqual(existing, ['EXISTING_A']);
|
|
255
|
+
|
|
256
|
+
// Cleanup
|
|
257
|
+
if (savedB === undefined) delete process.env.NEW_B;
|
|
258
|
+
else process.env.NEW_B = savedB;
|
|
259
|
+
if (savedC === undefined) delete process.env.SKIP_C;
|
|
260
|
+
else process.env.SKIP_C = savedC;
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* MCP Server — registers GSD orchestration, project-state, and workflow tools.
|
|
3
3
|
*
|
|
4
4
|
* Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker
|
|
5
|
-
* Interactive tools (
|
|
5
|
+
* Interactive tools (2): ask_user_questions, secure_env_collect via MCP form elicitation
|
|
6
6
|
* Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge
|
|
7
7
|
* Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools
|
|
8
8
|
*
|
|
@@ -22,6 +22,7 @@ import { readCaptures } from './readers/captures.js';
|
|
|
22
22
|
import { readKnowledge } from './readers/knowledge.js';
|
|
23
23
|
import { runDoctorLite } from './readers/doctor-lite.js';
|
|
24
24
|
import { registerWorkflowTools } from './workflow-tools.js';
|
|
25
|
+
import { applySecrets, checkExistingEnvKeys, detectDestination } from './env-writer.js';
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// Constants
|
|
@@ -112,11 +113,26 @@ async function fileExists(path: string): Promise<boolean> {
|
|
|
112
113
|
// MCP Server type — minimal interface for the dynamically-imported McpServer
|
|
113
114
|
// ---------------------------------------------------------------------------
|
|
114
115
|
|
|
116
|
+
interface ElicitResult {
|
|
117
|
+
action: 'accept' | 'decline' | 'cancel';
|
|
118
|
+
content?: Record<string, string | number | boolean | string[]>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ElicitRequestFormParams {
|
|
122
|
+
mode?: 'form';
|
|
123
|
+
message: string;
|
|
124
|
+
requestedSchema: {
|
|
125
|
+
type: 'object';
|
|
126
|
+
properties: Record<string, Record<string, unknown>>;
|
|
127
|
+
required?: string[];
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
interface McpServerInstance {
|
|
116
132
|
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>): unknown;
|
|
117
133
|
server: {
|
|
118
134
|
elicitInput(
|
|
119
|
-
params: AskUserQuestionsElicitRequest,
|
|
135
|
+
params: AskUserQuestionsElicitRequest | ElicitRequestFormParams,
|
|
120
136
|
options?: unknown,
|
|
121
137
|
): Promise<AskUserQuestionsElicitResult>;
|
|
122
138
|
};
|
|
@@ -282,7 +298,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
|
|
|
282
298
|
|
|
283
299
|
const server: McpServerInstance = new McpServer(
|
|
284
300
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
285
|
-
{ capabilities: { tools: {} } },
|
|
301
|
+
{ capabilities: { tools: {}, elicitation: {} } },
|
|
286
302
|
);
|
|
287
303
|
|
|
288
304
|
// -----------------------------------------------------------------------
|
|
@@ -472,6 +488,124 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
|
|
|
472
488
|
},
|
|
473
489
|
);
|
|
474
490
|
|
|
491
|
+
// -----------------------------------------------------------------------
|
|
492
|
+
// secure_env_collect — collect secrets via MCP form elicitation
|
|
493
|
+
// -----------------------------------------------------------------------
|
|
494
|
+
server.tool(
|
|
495
|
+
'secure_env_collect',
|
|
496
|
+
'Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.',
|
|
497
|
+
{
|
|
498
|
+
projectDir: z.string().describe('Absolute path to the project directory'),
|
|
499
|
+
keys: z.array(z.object({
|
|
500
|
+
key: z.string().describe('Env var name, e.g. OPENAI_API_KEY'),
|
|
501
|
+
hint: z.string().optional().describe('Format hint shown to user, e.g. "starts with sk-"'),
|
|
502
|
+
guidance: z.array(z.string()).optional().describe('Step-by-step instructions for obtaining this key'),
|
|
503
|
+
})).min(1).describe('Environment variables to collect'),
|
|
504
|
+
destination: z.enum(['dotenv', 'vercel', 'convex']).optional().describe('Where to write secrets. Auto-detected from project files if omitted.'),
|
|
505
|
+
envFilePath: z.string().optional().describe('Path to .env file (dotenv only). Defaults to .env in projectDir.'),
|
|
506
|
+
environment: z.enum(['development', 'preview', 'production']).optional().describe('Target environment (vercel/convex only)'),
|
|
507
|
+
},
|
|
508
|
+
async (args: Record<string, unknown>) => {
|
|
509
|
+
const { projectDir, keys, destination, envFilePath, environment } = args as {
|
|
510
|
+
projectDir: string;
|
|
511
|
+
keys: Array<{ key: string; hint?: string; guidance?: string[] }>;
|
|
512
|
+
destination?: 'dotenv' | 'vercel' | 'convex';
|
|
513
|
+
envFilePath?: string;
|
|
514
|
+
environment?: 'development' | 'preview' | 'production';
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const resolvedProjectDir = resolve(projectDir);
|
|
519
|
+
const resolvedEnvPath = resolve(resolvedProjectDir, envFilePath ?? '.env');
|
|
520
|
+
|
|
521
|
+
// (1) Check which keys already exist
|
|
522
|
+
const allKeyNames = keys.map((k) => k.key);
|
|
523
|
+
const existingKeys = await checkExistingEnvKeys(allKeyNames, resolvedEnvPath);
|
|
524
|
+
const existingSet = new Set(existingKeys);
|
|
525
|
+
const pendingKeys = keys.filter((k) => !existingSet.has(k.key));
|
|
526
|
+
|
|
527
|
+
// If all keys already exist, return immediately
|
|
528
|
+
if (pendingKeys.length === 0) {
|
|
529
|
+
const lines = existingKeys.map((k) => `• ${k}: already set`);
|
|
530
|
+
return textContent(`All ${existingKeys.length} key(s) already set.\n${lines.join('\n')}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// (2) Build elicitation form — one string field per pending key
|
|
534
|
+
const properties: Record<string, Record<string, unknown>> = {};
|
|
535
|
+
const required: string[] = [];
|
|
536
|
+
|
|
537
|
+
for (const item of pendingKeys) {
|
|
538
|
+
const descParts: string[] = [];
|
|
539
|
+
if (item.hint) descParts.push(`Format: ${item.hint}`);
|
|
540
|
+
if (item.guidance && item.guidance.length > 0) {
|
|
541
|
+
descParts.push('How to get this:');
|
|
542
|
+
item.guidance.forEach((step, i) => descParts.push(`${i + 1}. ${step}`));
|
|
543
|
+
}
|
|
544
|
+
descParts.push('Leave empty to skip.');
|
|
545
|
+
|
|
546
|
+
properties[item.key] = {
|
|
547
|
+
type: 'string',
|
|
548
|
+
title: item.key,
|
|
549
|
+
description: descParts.join('\n'),
|
|
550
|
+
};
|
|
551
|
+
// Don't mark as required — empty string = skip
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// (3) Elicit input from the MCP client
|
|
555
|
+
const elicitation = await server.server.elicitInput({
|
|
556
|
+
message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`,
|
|
557
|
+
requestedSchema: {
|
|
558
|
+
type: 'object',
|
|
559
|
+
properties,
|
|
560
|
+
required,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (elicitation.action !== 'accept' || !elicitation.content) {
|
|
565
|
+
return textContent('secure_env_collect was cancelled by user.');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// (4) Separate provided vs skipped from form response
|
|
569
|
+
const provided: Array<{ key: string; value: string }> = [];
|
|
570
|
+
const skipped: string[] = [];
|
|
571
|
+
|
|
572
|
+
for (const item of pendingKeys) {
|
|
573
|
+
const raw = elicitation.content[item.key];
|
|
574
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
575
|
+
if (value.length > 0) {
|
|
576
|
+
provided.push({ key: item.key, value });
|
|
577
|
+
} else {
|
|
578
|
+
skipped.push(item.key);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// (5) Auto-detect destination if not specified
|
|
583
|
+
const resolvedDestination = destination ?? detectDestination(resolvedProjectDir);
|
|
584
|
+
|
|
585
|
+
// (6) Write secrets to destination
|
|
586
|
+
const { applied, errors } = await applySecrets(provided, resolvedDestination, {
|
|
587
|
+
envFilePath: resolvedEnvPath,
|
|
588
|
+
environment,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// (7) Build result — NEVER include secret values
|
|
592
|
+
const lines: string[] = [
|
|
593
|
+
`destination: ${resolvedDestination}${!destination ? ' (auto-detected)' : ''}${environment ? ` (${environment})` : ''}`,
|
|
594
|
+
];
|
|
595
|
+
for (const k of applied) lines.push(`✓ ${k}: applied`);
|
|
596
|
+
for (const k of skipped) lines.push(`• ${k}: skipped`);
|
|
597
|
+
for (const k of existingKeys) lines.push(`• ${k}: already set`);
|
|
598
|
+
for (const e of errors) lines.push(`✗ ${e}`);
|
|
599
|
+
|
|
600
|
+
return errors.length > 0 && applied.length === 0
|
|
601
|
+
? errorContent(lines.join('\n'))
|
|
602
|
+
: textContent(lines.join('\n'));
|
|
603
|
+
} catch (err) {
|
|
604
|
+
return errorContent(err instanceof Error ? err.message : String(err));
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
|
|
475
609
|
// =======================================================================
|
|
476
610
|
// READ-ONLY TOOLS — no session required, pure filesystem reads
|
|
477
611
|
// =======================================================================
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat-controller-ordering.test.d.ts","sourceRoot":"","sources":["../../src/core/chat-controller-ordering.test.ts"],"names":[],"mappings":""}
|