@akanjs/devkit 3.1.1 → 3.1.2-rc.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.
Files changed (80) hide show
  1. package/akanConfig/akanConfig.test.ts +75 -0
  2. package/akanConfig/akanConfig.ts +11 -1
  3. package/akanMcpContract.test.ts +28 -1
  4. package/akanMcpContract.ts +51 -3
  5. package/applicationBuildRunner.test.ts +36 -0
  6. package/applicationBuildRunner.ts +4 -3
  7. package/artifact/implicitRootLayout.ts +4 -2
  8. package/cloud/globalConfig.test.ts +34 -0
  9. package/cloud/globalConfig.ts +2 -2
  10. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  11. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  12. package/codeAgent/agent/CodeAgent.ts +7 -0
  13. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  14. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  15. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  16. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  17. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  18. package/codeAgent/agent/akanCodePaths.ts +8 -1
  19. package/codeAgent/agent/akanSystemPrompt.ts +4 -0
  20. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  21. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  22. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  23. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  24. package/codeAgent/agent/stringArg.ts +1 -1
  25. package/codeAgent/skills/akan-agent/SKILL.md +8 -1
  26. package/codeAgent/skills/akan-code/SKILL.md +37 -11
  27. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  28. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  29. package/codeAgent/skills/akan-query/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
  31. package/codeAgent/tools/McpServerConfig.ts +6 -4
  32. package/codeAgent/tools/McpSignIn.ts +7 -7
  33. package/codeAgent/tools/McpTokenStore.ts +63 -16
  34. package/codeAgent/tools/McpToolPack.ts +8 -1
  35. package/codeAgent/tools/WebToolPack.ts +50 -6
  36. package/codeAgent/tools/akanCodePlugins.test.ts +70 -2
  37. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  38. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  39. package/commandDecorators/command.ts +4 -1
  40. package/createTunnel.test.ts +200 -2
  41. package/createTunnel.ts +80 -6
  42. package/envSeed.test.ts +90 -0
  43. package/envSeed.ts +68 -0
  44. package/executors.test.ts +19 -1
  45. package/executors.ts +41 -2
  46. package/fileSys.test.ts +30 -2
  47. package/fileSys.ts +14 -0
  48. package/formSetterScanner.test.ts +44 -1
  49. package/formSetterScanner.ts +53 -8
  50. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  51. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  52. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  53. package/frontendBuild/optionalPeers.test.ts +132 -0
  54. package/integration/ssrMemoryProbe.ts +6 -0
  55. package/lint/no-unguarded-endpoint.grit +5 -5
  56. package/maintainabilityReport.ts +1 -1
  57. package/mobile/desktopServerStage.test.ts +1 -0
  58. package/mobile/desktopServerStage.ts +3 -1
  59. package/mobile/nativeApp.test.ts +20 -5
  60. package/mobile/nativeApp.ts +4 -1
  61. package/mobile/nativeConfig.test.ts +209 -5
  62. package/mobile/nativeConfig.ts +127 -6
  63. package/mobile/nativePublicEnv.test.ts +2 -0
  64. package/mobile/nativePublicEnv.ts +13 -0
  65. package/package.json +3 -2
  66. package/shellOnlyEnv.test.ts +65 -0
  67. package/shellOnlyEnv.ts +54 -0
  68. package/signalMutationIndex.ts +22 -81
  69. package/sshHostKeys.ts +59 -0
  70. package/storeSource.ts +9 -8
  71. package/subspace.test.ts +10 -0
  72. package/subspaceClone.ts +3 -1
  73. package/subspaceConfig.ts +6 -0
  74. package/transforms/barrelImportsPlugin.ts +10 -4
  75. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  76. package/transforms/mcpAppPageTrimmer.ts +5 -20
  77. package/transforms/pureLazyMarker.ts +1 -1
  78. package/workflow/mutationScaffold.ts +35 -21
  79. package/workflow/plan.ts +28 -0
  80. package/workflow/sourceAst.ts +10 -48
@@ -1,8 +1,19 @@
1
1
  import { afterEach, beforeAll, describe, expect, test } from "bun:test";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ symlinkSync,
10
+ truncateSync,
11
+ writeFileSync,
12
+ } from "node:fs";
3
13
  import { tmpdir } from "node:os";
4
14
  import path from "node:path";
5
15
  import { akanCodePaths } from "../agent/akanCodePaths";
16
+ import { WorkspaceTrust } from "../agent/WorkspaceTrust";
6
17
  import { McpServerConfig, type McpServerScope } from "./McpServerConfig";
7
18
 
8
19
  const roots: string[] = [];
@@ -210,3 +221,121 @@ describe("MCP server config", () => {
210
221
  expect(McpServerConfig.targetOf({ name: "a", transport: "http", url: "https://x" })).toBe("https://x");
211
222
  });
212
223
  });
224
+
225
+ describe("workspace trust", () => {
226
+ const extension = (root: string, name: string, body: string) => {
227
+ const file = path.join(akanCodePaths.extensionsDir(root), name);
228
+ mkdirSync(path.dirname(file), { recursive: true });
229
+ writeFileSync(file, body);
230
+ };
231
+
232
+ afterEach(() => {
233
+ rmSync(akanCodePaths.trustFile(), { force: true });
234
+ });
235
+
236
+ test("a repo with no servers or extensions of its own needs no approval", () => {
237
+ const root = workspace();
238
+ McpServerConfig.add(root, "home", ["https://home.example/mcp"], "global");
239
+ expect(WorkspaceTrust.pending(root)).toEqual([]);
240
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
241
+ });
242
+
243
+ test("the repo's servers and extensions wait for approval, and a remembered one stays approved", () => {
244
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", args: ["x.js"] } } }));
245
+ extension(root, "hello.ts", "export default () => {};");
246
+ expect(WorkspaceTrust.pending(root).map((item) => item.kind)).toEqual(["mcp", "extensions"]);
247
+ expect(WorkspaceTrust.pending(root)[0]?.lines).toEqual(["tool: node x.js"]);
248
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
249
+ expect(WorkspaceTrust.pending(root)).toEqual([]);
250
+ expect(Object.keys(JSON.parse(readFileSync(akanCodePaths.trustFile(), "utf8")).workspaces)).toHaveLength(1);
251
+ });
252
+
253
+ test.skipIf(process.platform === "win32")("the trust file is readable only by its owner", () => {
254
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
255
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
256
+ expect(statSync(akanCodePaths.trustFile()).mode & 0o777).toBe(0o600);
257
+ });
258
+
259
+ test("a change to what was approved is asked about again", () => {
260
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", args: ["x.js"] } } }));
261
+ extension(root, "hello.ts", "export default () => {};");
262
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
263
+ writeFileSync(
264
+ McpServerConfig.file(root),
265
+ JSON.stringify({ mcpServers: { tool: { command: "node", args: ["y.js"] } } }),
266
+ );
267
+ extension(root, "nested/more.ts", "export default () => {};");
268
+ expect(WorkspaceTrust.pending(root).map((item) => item.kind)).toEqual(["mcp", "extensions"]);
269
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(false);
270
+ });
271
+
272
+ test("an approval that is not remembered lasts only for this process", () => {
273
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
274
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), false);
275
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
276
+ expect(existsSync(akanCodePaths.trustFile())).toBe(false);
277
+ });
278
+
279
+ test("an edit made through akan code keeps the approval the content had", () => {
280
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
281
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
282
+ WorkspaceTrust.carry(root, "mcp", () =>
283
+ McpServerConfig.add(root, "more", ["https://more.example/mcp"], "workspace"),
284
+ );
285
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
286
+ const untrusted = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
287
+ WorkspaceTrust.carry(untrusted, "mcp", () => McpServerConfig.add(untrusted, "more", ["x"], "workspace"));
288
+ expect(WorkspaceTrust.isTrusted(untrusted, "mcp")).toBe(false);
289
+ });
290
+
291
+ test("a link that stays in the extensions folder is followed and checked", () => {
292
+ const root = workspace();
293
+ extension(root, "real/index.ts", "export default () => {};");
294
+ symlinkSync(
295
+ path.join(akanCodePaths.extensionsDir(root), "real"),
296
+ path.join(akanCodePaths.extensionsDir(root), "alias"),
297
+ );
298
+ const [item] = WorkspaceTrust.pending(root);
299
+ expect(item?.lines).toEqual(["alias/index.ts"]);
300
+ expect(item?.hash).not.toBe("");
301
+ WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
302
+ extension(root, "real/index.ts", "export default () => 1;");
303
+ expect(WorkspaceTrust.isTrusted(root, "extensions")).toBe(false);
304
+ });
305
+
306
+ test("a link out of the extensions folder is not followed, and an approval of that folder is never remembered", () => {
307
+ const root = workspace();
308
+ const outside = workspace();
309
+ writeFileSync(path.join(outside, "secret-notes.ts"), "export default () => {};");
310
+ extension(root, "hello.ts", "export default () => {};");
311
+ symlinkSync(outside, path.join(akanCodePaths.extensionsDir(root), "linked"));
312
+ const [item] = WorkspaceTrust.pending(root);
313
+ expect(item?.hash).toBe("");
314
+ expect(item?.lines[0]).toContain("linked links out of the folder");
315
+ expect(item?.lines.join("\n")).not.toContain("secret-notes");
316
+ WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
317
+ expect(existsSync(akanCodePaths.trustFile())).toBe(false);
318
+ });
319
+
320
+ test("an extensions folder past the file or byte cap is not checked, and its approval is never remembered", () => {
321
+ const many = workspace();
322
+ const modules = path.join(akanCodePaths.extensionsDir(many), "node_modules", "dep");
323
+ mkdirSync(modules, { recursive: true });
324
+ for (let index = 0; index <= 10_000; index++) writeFileSync(path.join(modules, `f${index}.js`), "");
325
+ const large = workspace();
326
+ extension(large, "big.js", "");
327
+ truncateSync(path.join(akanCodePaths.extensionsDir(large), "big.js"), 64 * 1024 * 1024 + 1);
328
+ for (const root of [many, large]) {
329
+ const [item] = WorkspaceTrust.pending(root);
330
+ expect(item?.hash).toBe("");
331
+ expect(item?.lines[0]).toContain("too many to check");
332
+ WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
333
+ }
334
+ expect(existsSync(akanCodePaths.trustFile())).toBe(false);
335
+ });
336
+
337
+ test("a disabled server needs no approval", () => {
338
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", disabled: true } } }));
339
+ expect(WorkspaceTrust.pending(root)).toEqual([]);
340
+ });
341
+ });
@@ -436,7 +436,10 @@ export const runCommands = async (...commands: CommandCls[]) => {
436
436
  reportFailure = async (failure) =>
437
437
  await onFailure?.(failure, { usage: false, given: givenOptionsOf(allArgMetas, opt) });
438
438
  const commandArgs = [] as unknown[];
439
- if (targetMeta.targetOption.runsOnWorkspaceRoot) await assertCurrentDirectoryIsWorkspaceRoot();
439
+ if (targetMeta.targetOption.runsOnWorkspaceRoot) {
440
+ await assertCurrentDirectoryIsWorkspaceRoot();
441
+ WorkspaceExecutor.defaultCloneToLocalEnv(process.cwd());
442
+ }
440
443
  const workspace = WorkspaceExecutor.fromRoot();
441
444
  const commandContext: CommandContext = { values: {} };
442
445
  for (const argMeta of allArgMetas) {
@@ -1,8 +1,76 @@
1
- import { describe, expect, spyOn, test } from "bun:test";
2
- import { Client } from "ssh2";
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import type { AddressInfo } from "node:net";
4
+ import path from "node:path";
5
+ import { Client, type Connection, Server, utils } from "ssh2";
3
6
  import { createTunnel } from "./createTunnel";
7
+ import { SshHostKeys } from "./sshHostKeys";
4
8
  import { createTempApp, isolateEnv, tempRoots } from "./testHelpers";
5
9
 
10
+ const freePort = () => {
11
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
12
+ const { port } = listener;
13
+ listener.stop(true);
14
+ return port;
15
+ };
16
+
17
+ const publicBlobOf = (publicKey: string) => {
18
+ const parsed = utils.parseKey(publicKey);
19
+ if (parsed instanceof Error) throw parsed;
20
+ return parsed.getPublicSSH();
21
+ };
22
+
23
+ const sshServers: { server: Server; clients: Set<Connection> }[] = [];
24
+
25
+ const startSshServer = async () => {
26
+ const hostKey = utils.generateKeyPairSync("ed25519");
27
+ const methods: string[] = [];
28
+ const clients = new Set<Connection>();
29
+ const server = new Server({ hostKeys: [hostKey.private] }, (client) => {
30
+ clients.add(client);
31
+ client.on("error", () => clients.delete(client));
32
+ client.on("authentication", (context) => {
33
+ methods.push(context.method);
34
+ if (context.method === "password" || context.method === "publickey") context.accept();
35
+ else context.reject(["publickey", "password"]);
36
+ });
37
+ client.on("ready", () => {
38
+ client.on("tcpip", (accept) => {
39
+ const channel = accept();
40
+ channel.on("data", (chunk: Buffer) => channel.write(chunk));
41
+ });
42
+ });
43
+ });
44
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
45
+ sshServers.push({ server, clients });
46
+ const { port } = server.address() as AddressInfo;
47
+ return { port, methods, fingerprint: SshHostKeys.fingerprintOf(publicBlobOf(hostKey.public)) };
48
+ };
49
+
50
+ const echoThrough = async (port: number) =>
51
+ await new Promise<string>((resolve, reject) => {
52
+ void Bun.connect({
53
+ hostname: "127.0.0.1",
54
+ port,
55
+ socket: {
56
+ open: (socket) => void socket.write("ping"),
57
+ data: (socket, chunk) => {
58
+ resolve(Buffer.from(chunk).toString());
59
+ socket.end();
60
+ },
61
+ error: (_socket, error) => reject(error),
62
+ connectError: (_socket, error) => reject(error),
63
+ },
64
+ });
65
+ });
66
+
67
+ afterEach(() => {
68
+ for (const { server, clients } of sshServers.splice(0)) {
69
+ for (const client of clients) client.end();
70
+ server.close();
71
+ }
72
+ });
73
+
6
74
  describe("createTunnel", () => {
7
75
  isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "testing" });
8
76
  const track = tempRoots();
@@ -62,4 +130,134 @@ describe("createTunnel", () => {
62
130
  connect.mockRestore();
63
131
  }
64
132
  });
133
+
134
+ test("forwards on loopback, signs in with the configured key and keeps the host key it first sees", async () => {
135
+ const { root, app } = track(await createTempApp("demo"));
136
+ const ssh = await startSshServer();
137
+ const knownHosts = path.join(root, "known_hosts.json");
138
+ const keyPath = path.join(root, "id_tunnel");
139
+ await writeFile(keyPath, utils.generateKeyPairSync("ed25519").private);
140
+ Object.assign(process.env, {
141
+ SSH_TUNNEL_PORT: String(ssh.port),
142
+ SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
143
+ SSH_TUNNEL_PRIVATE_KEY_PATH: keyPath,
144
+ });
145
+ const localPort = freePort();
146
+ const listen = spyOn(Bun, "listen");
147
+ try {
148
+ expect(await createTunnel("redis", { app, environment: "testing", port: localPort })).toBe(
149
+ `localhost:${localPort}`,
150
+ );
151
+ expect(listen.mock.calls[0]?.[0]).toMatchObject({ hostname: "127.0.0.1", port: localPort });
152
+ } finally {
153
+ listen.mockRestore();
154
+ }
155
+
156
+ expect(await echoThrough(localPort)).toBe("ping");
157
+ expect(ssh.methods).toContain("publickey");
158
+ expect(ssh.methods).not.toContain("password");
159
+ expect(JSON.parse(await readFile(knownHosts, "utf8"))).toEqual({ [`localhost:${ssh.port}`]: ssh.fingerprint });
160
+ if (process.platform !== "win32") expect((await stat(knownHosts)).mode & 0o777).toBe(0o600);
161
+ });
162
+
163
+ test("refuses a host whose key differs from the kept or the pinned one", async () => {
164
+ const { root, app } = track(await createTempApp("demo"));
165
+ const ssh = await startSshServer();
166
+ const knownHosts = path.join(root, "known_hosts.json");
167
+ const other = `SHA256:${Buffer.alloc(32, 1).toString("base64").replace(/=+$/, "")}`;
168
+ Object.assign(process.env, {
169
+ SSH_TUNNEL_PORT: String(ssh.port),
170
+ SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
171
+ SSH_TUNNEL_PASSWORD: "test-password",
172
+ });
173
+ const open = async () => await createTunnel("redis", { app, environment: "testing", port: freePort() });
174
+
175
+ await writeFile(knownHosts, JSON.stringify({ [`localhost:${ssh.port}`]: other }));
176
+ await expect(open()).rejects.toThrow(`differs from the one ${knownHosts} keeps`);
177
+ expect(JSON.parse(await readFile(knownHosts, "utf8"))).toEqual({ [`localhost:${ssh.port}`]: other });
178
+
179
+ process.env.SSH_TUNNEL_HOST_FINGERPRINT = other;
180
+ await expect(open()).rejects.toThrow("is not one SSH_TUNNEL_HOST_FINGERPRINT pins");
181
+ process.env.SSH_TUNNEL_HOST_FINGERPRINT = ssh.fingerprint;
182
+ await expect(open()).resolves.toStartWith("localhost:");
183
+ expect(ssh.methods.filter((method) => method !== "none")).toEqual(["password"]);
184
+ });
185
+
186
+ test("warns when it signs in with the default shared credential", async () => {
187
+ const { root, app } = track(await createTempApp("demo"));
188
+ const ssh = await startSshServer();
189
+ Object.assign(process.env, {
190
+ SSH_TUNNEL_PORT: String(ssh.port),
191
+ SSH_TUNNEL_KNOWN_HOSTS: path.join(root, "known_hosts.json"),
192
+ });
193
+ //? `akan test` runs from the workspace root, whose dotenv may name a password; this case is the one with none.
194
+ delete process.env.SSH_TUNNEL_PASSWORD;
195
+ delete process.env.SSH_TUNNEL_PRIVATE_KEY_PATH;
196
+ const warn = spyOn(app.logger, "warn");
197
+
198
+ await createTunnel("redis", { app, environment: "testing", port: freePort() });
199
+
200
+ expect(warn.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
201
+ "uses the default shared credential",
202
+ );
203
+ });
204
+
205
+ test("listens where SSH_TUNNEL_BIND_HOST says and warns when that is not loopback", async () => {
206
+ const { root, app } = track(await createTempApp("demo"));
207
+ const ssh = await startSshServer();
208
+ Object.assign(process.env, {
209
+ SSH_TUNNEL_PORT: String(ssh.port),
210
+ SSH_TUNNEL_KNOWN_HOSTS: path.join(root, "known_hosts.json"),
211
+ SSH_TUNNEL_PASSWORD: "test-password",
212
+ SSH_TUNNEL_BIND_HOST: "0.0.0.0",
213
+ });
214
+ const warn = spyOn(app.logger, "warn");
215
+ const localPort = freePort();
216
+ const listen = spyOn(Bun, "listen");
217
+ try {
218
+ await createTunnel("redis", { app, environment: "testing", port: localPort });
219
+ expect(listen.mock.calls[0]?.[0]).toMatchObject({ hostname: "0.0.0.0", port: localPort });
220
+ } finally {
221
+ listen.mockRestore();
222
+ }
223
+
224
+ expect(warn.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
225
+ "The redis tunnel listens on 0.0.0.0 (SSH_TUNNEL_BIND_HOST)",
226
+ );
227
+ });
228
+
229
+ test("takes no host check or listener the repo's dotenv file names, and says it ignored them", async () => {
230
+ const { root, app } = track(await createTempApp("demo"));
231
+ const ssh = await startSshServer();
232
+ const knownHosts = path.join(root, "known_hosts.json");
233
+ const other = `SHA256:${Buffer.alloc(32, 1).toString("base64").replace(/=+$/, "")}`;
234
+ await writeFile(path.join(root, ".env"), "SSH_TUNNEL_HOST_FINGERPRINT=*\nexport SSH_TUNNEL_BIND_HOST=0.0.0.0\n");
235
+ Object.assign(process.env, {
236
+ SSH_TUNNEL_PORT: String(ssh.port),
237
+ SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
238
+ SSH_TUNNEL_PASSWORD: "test-password",
239
+ SSH_TUNNEL_HOST_FINGERPRINT: "*",
240
+ SSH_TUNNEL_BIND_HOST: "0.0.0.0",
241
+ });
242
+ const warn = spyOn(app.logger, "warn");
243
+ const listen = spyOn(Bun, "listen");
244
+ const cwd = process.cwd();
245
+ process.chdir(root);
246
+ try {
247
+ const open = async () => await createTunnel("redis", { app, environment: "testing", port: freePort() });
248
+ await writeFile(knownHosts, JSON.stringify({ [`localhost:${ssh.port}`]: other }));
249
+ await expect(open()).rejects.toThrow(`differs from the one ${knownHosts} keeps`);
250
+ await writeFile(knownHosts, JSON.stringify({ [`localhost:${ssh.port}`]: ssh.fingerprint }));
251
+ await expect(open()).resolves.toStartWith("localhost:");
252
+ expect(listen.mock.calls.at(-1)?.[0]).toMatchObject({ hostname: "127.0.0.1" });
253
+ } finally {
254
+ process.chdir(cwd);
255
+ listen.mockRestore();
256
+ }
257
+
258
+ const warnings = warn.mock.calls.map(([message]) => String(message)).join("\n");
259
+ expect(warnings).toContain("SSH_TUNNEL_HOST_FINGERPRINT is set in .env, which the repository controls");
260
+ expect(warnings).toContain("SSH_TUNNEL_BIND_HOST is set in .env");
261
+ expect(warnings).not.toContain("accepts any host key");
262
+ });
65
263
  });
package/createTunnel.ts CHANGED
@@ -1,36 +1,110 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { ConnectConfig } from "ssh2";
1
3
  import { type AppExecutor, WorkspaceExecutor } from "./executors";
4
+ import { ShellOnlyEnv } from "./shellOnlyEnv";
5
+ import { SshHostKeys } from "./sshHostKeys";
2
6
  import { createSshTunnel } from "./sshTunnel";
3
7
 
8
+ //? Loosening the host check or the listener, or picking a key, is the person's call; a repo's dotenv cannot set it.
9
+ const tunnelEnv = new ShellOnlyEnv([
10
+ "SSH_TUNNEL_PRIVATE_KEY_PATH",
11
+ "SSH_TUNNEL_PASSPHRASE",
12
+ "SSH_TUNNEL_HOST_FINGERPRINT",
13
+ "SSH_TUNNEL_KNOWN_HOSTS",
14
+ "SSH_TUNNEL_BIND_HOST",
15
+ ]);
16
+
4
17
  interface TunnelOption {
5
18
  app: AppExecutor;
6
19
  environment: string;
7
20
  port?: number;
8
21
  }
22
+
23
+ const tunnelAuth = (
24
+ app: AppExecutor,
25
+ repoName: string,
26
+ ): Pick<ConnectConfig, "privateKey" | "passphrase" | "password"> => {
27
+ const keyPath = tunnelEnv.get("SSH_TUNNEL_PRIVATE_KEY_PATH");
28
+ const passphrase = tunnelEnv.get("SSH_TUNNEL_PASSPHRASE");
29
+ if (keyPath) return { privateKey: readFileSync(keyPath), ...(passphrase ? { passphrase } : {}) };
30
+ if (process.env.SSH_TUNNEL_PASSWORD) return { password: process.env.SSH_TUNNEL_PASSWORD };
31
+ app.logger.warn(
32
+ "The SSH tunnel uses the default shared credential. " +
33
+ "Set SSH_TUNNEL_PRIVATE_KEY_PATH to a key (or SSH_TUNNEL_PASSWORD) once the cluster's tunnel endpoint accepts it.",
34
+ );
35
+ return { password: repoName };
36
+ };
37
+
38
+ const tunnelHostVerifier = (app: AppExecutor, hostId: string, onRefusal: (reason: string) => void) => {
39
+ const pinned = SshHostKeys.parsePinned(tunnelEnv.get("SSH_TUNNEL_HOST_FINGERPRINT"));
40
+ const hostKeys = new SshHostKeys(tunnelEnv.get("SSH_TUNNEL_KNOWN_HOSTS") || undefined);
41
+ return (key: Buffer, verify: (valid: boolean) => void) => {
42
+ if (pinned.includes("SHA256:*")) {
43
+ app.logger.warn(`SSH_TUNNEL_HOST_FINGERPRINT=* accepts any host key ${hostId} shows.`);
44
+ verify(true);
45
+ return;
46
+ }
47
+ hostKeys.verify(hostId, key, pinned).then(
48
+ ({ trusted, firstUse, fingerprint }) => {
49
+ if (firstUse)
50
+ app.logger.warn(
51
+ `Trusting the SSH host key ${fingerprint} of ${hostId} on first use; kept in ${hostKeys.filePath}.`,
52
+ );
53
+ if (!trusted)
54
+ onRefusal(
55
+ pinned.length
56
+ ? `the host key ${fingerprint} of ${hostId} is not one SSH_TUNNEL_HOST_FINGERPRINT pins`
57
+ : `the host key ${fingerprint} of ${hostId} differs from the one ${hostKeys.filePath} keeps; if the host was rebuilt, remove its entry or pin the new key with SSH_TUNNEL_HOST_FINGERPRINT`,
58
+ );
59
+ verify(trusted);
60
+ },
61
+ (error: unknown) => {
62
+ onRefusal(
63
+ `the host key store ${hostKeys.filePath} could not be read (${error instanceof Error ? error.message : String(error)})`,
64
+ );
65
+ verify(false);
66
+ },
67
+ );
68
+ };
69
+ };
70
+
9
71
  export const createTunnel = async (
10
72
  service: "redis" | "postgres",
11
73
  { app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
12
74
  ) => {
13
75
  const baseDevEnv = WorkspaceExecutor.getBaseDevEnv();
14
76
  WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, `The ${service} tunnel`);
77
+ for (const line of tunnelEnv.ignored()) app.logger.warn(line);
15
78
  const { serveDomain, repoName } = baseDevEnv;
16
79
  const host = `${app.name}-${environment}.${serveDomain}`;
80
+ //? RFC 6761 makes *.localhost loopback, but only macOS resolves it; glibc without systemd-resolved and Windows answer ENOTFOUND
81
+ const sshHost = host.endsWith(".localhost") ? "localhost" : host;
17
82
  const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
18
83
  const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
19
84
  const dstPort = service === "postgres" ? 5432 : 6379;
85
+ //? The forward reaches a cluster database, so it listens on loopback unless SSH_TUNNEL_BIND_HOST widens it.
86
+ const bindHost = tunnelEnv.get("SSH_TUNNEL_BIND_HOST") || "127.0.0.1";
87
+ if (!["127.0.0.1", "localhost", "::1"].includes(bindHost))
88
+ app.logger.warn(
89
+ `The ${service} tunnel listens on ${bindHost} (SSH_TUNNEL_BIND_HOST), so other machines that reach it reach ${dstHost}.`,
90
+ );
91
+ let refusal: string | null = null;
20
92
  try {
21
93
  await createSshTunnel({
22
- localHost: "0.0.0.0",
94
+ localHost: bindHost,
23
95
  localPort: port,
24
- srcHost: "0.0.0.0",
96
+ srcHost: "127.0.0.1",
25
97
  srcPort: port,
26
98
  dstHost,
27
99
  dstPort,
28
100
  sshOptions: {
29
- //? RFC 6761 makes *.localhost loopback, but only macOS resolves it; glibc without systemd-resolved and Windows answer ENOTFOUND
30
- host: host.endsWith(".localhost") ? "localhost" : host,
101
+ host: sshHost,
31
102
  port: sshPort,
32
103
  username: process.env.SSH_TUNNEL_USERNAME ?? "root",
33
- password: process.env.SSH_TUNNEL_PASSWORD ?? repoName,
104
+ ...tunnelAuth(app, repoName),
105
+ hostVerifier: tunnelHostVerifier(app, `${sshHost}:${sshPort}`, (reason) => {
106
+ refusal = reason;
107
+ }),
34
108
  },
35
109
  });
36
110
  } catch (error) {
@@ -41,7 +115,7 @@ export const createTunnel = async (
41
115
  ? error.message
42
116
  : String(error);
43
117
  throw new Error(
44
- `Could not open the ${service} tunnel for ${app.name}-${environment}: SSH ${host}:${sshPort} -> ${dstHost}:${dstPort} failed (${reasons})`,
118
+ `Could not open the ${service} tunnel for ${app.name}-${environment}: SSH ${host}:${sshPort} -> ${dstHost}:${dstPort} failed (${refusal ?? reasons})`,
45
119
  { cause: error },
46
120
  );
47
121
  }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { EnvSeed } from "./envSeed";
5
+ import { tempDirs, writeText } from "./testHelpers";
6
+
7
+ const makeEnvDir = tempDirs("akan-env-seed-");
8
+ const templates = path.resolve(import.meta.dir, "../cli/templates");
9
+ const template = async (file: string) => await readFile(path.join(templates, file), "utf8");
10
+
11
+ const selector = (side: "server" | "client", names: string[]) =>
12
+ `${names.map((name) => `import { env as ${name} } from "./env.${side}.${name}";`).join("\n")}\n\nexport const env = { ${names.join(", ")} };\n`;
13
+
14
+ const seededIn = async (envDir: string) =>
15
+ Object.fromEntries((await new EnvSeed(envDir).missing()).map(({ fileName, content }) => [fileName, content]));
16
+
17
+ describe("EnvSeed", () => {
18
+ test("names each environment file the selectors import and the testing one, of those missing", async () => {
19
+ const envDir = await makeEnvDir();
20
+ await writeText(path.join(envDir, "env.server.ts"), selector("server", ["debug", "local"]));
21
+ await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local", "staging"]));
22
+ await writeText(path.join(envDir, "env.server.debug.ts"), "export const env = { kept: true };\n");
23
+
24
+ expect(Object.keys(await seededIn(envDir))).toEqual([
25
+ "env.client.local.ts",
26
+ "env.client.staging.ts",
27
+ "env.server.local.ts",
28
+ "env.server.testing.ts",
29
+ ]);
30
+ });
31
+
32
+ test("passes over the generated type, the tracked example and a build profile", async () => {
33
+ const envDir = await makeEnvDir();
34
+ await writeText(
35
+ path.join(envDir, "env.server.ts"),
36
+ [
37
+ 'import { libEnv } from "./env.server.type";',
38
+ 'import { env as example } from "./env.server.example";',
39
+ 'import { env as desktop } from "./env.server.debug.desktop";',
40
+ "",
41
+ "export const env = { ...libEnv, ...example, ...desktop };",
42
+ "",
43
+ ].join("\n"),
44
+ );
45
+
46
+ expect(Object.keys(await seededIn(envDir))).toEqual(["env.server.testing.ts"]);
47
+ });
48
+
49
+ test("writes what the app scaffold writes when the folder has no example", async () => {
50
+ const envDir = await makeEnvDir();
51
+ await writeText(path.join(envDir, "env.server.ts"), selector("server", ["local", "testing"]));
52
+ await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local"]));
53
+ await writeText(path.join(envDir, "env.client.type.ts"), "export type AppClientEnv = Record<never, never>;\n");
54
+
55
+ expect(await seededIn(envDir)).toEqual({
56
+ "env.client.local.ts": await template("app/env/env.client.local.ts.template"),
57
+ "env.server.local.ts": await template("app/env/env.server.local.ts.template"),
58
+ "env.server.testing.ts": await template("app/env/env.server.testing.ts.template"),
59
+ });
60
+ });
61
+
62
+ test("copies the side's tracked example into each of its files", async () => {
63
+ const envDir = await makeEnvDir();
64
+ const example =
65
+ 'import type { ModulesOptions } from "../lib/option";\n\nexport const env: ModulesOptions = { port: 1 };\n';
66
+ await writeText(path.join(envDir, "env.server.ts"), selector("server", ["local"]));
67
+ await writeText(path.join(envDir, "env.server.example.ts"), example);
68
+
69
+ expect(await seededIn(envDir)).toEqual({ "env.server.local.ts": example, "env.server.testing.ts": example });
70
+ });
71
+
72
+ test("leaves a client env untyped when the folder has no env.client.type.ts to type it", async () => {
73
+ const envDir = await makeEnvDir();
74
+ await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local"]));
75
+
76
+ expect((await seededIn(envDir))["env.client.local.ts"]).toBe("export const env = {};\n");
77
+ });
78
+
79
+ test("gives a lib its testing env from the example the lib scaffold tracks", async () => {
80
+ const envDir = await makeEnvDir();
81
+ await writeText(
82
+ path.join(envDir, "env.server.example.ts"),
83
+ await template("libRoot/env/env.server.example.ts.template"),
84
+ );
85
+
86
+ expect(await seededIn(envDir)).toEqual({
87
+ "env.server.testing.ts": await template("libRoot/env/env.server.testing.ts.template"),
88
+ });
89
+ });
90
+ });
package/envSeed.ts ADDED
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { FileSys } from "./fileSys";
3
+
4
+ export interface EnvSeedFile {
5
+ fileName: string;
6
+ content: string;
7
+ }
8
+
9
+ //? These files are gitignored for the secrets they hold, so a fresh clone has none of them.
10
+ export class EnvSeed {
11
+ static readonly #server = `import type { ModulesOptions } from "../lib/option";
12
+
13
+ export const env: ModulesOptions = {};
14
+ `;
15
+ static readonly #testingServer = `import type { ModulesOptions } from "../lib/option";
16
+ import { libEnv } from "./env.server.type";
17
+
18
+ export const env: ModulesOptions = {
19
+ ...libEnv,
20
+ };
21
+ `;
22
+ static readonly #client = `import type { AppClientEnv } from "./env.client.type";
23
+
24
+ export const env: AppClientEnv = {};
25
+ `;
26
+ static readonly #untypedClient = "export const env = {};\n";
27
+ static readonly #selectors = ["env.server.ts", "env.client.ts"];
28
+ //? `type` is generated and `example` tracked; a dotted name (`debug.desktop`) is a build profile no selector imports.
29
+ static readonly #selectedPattern = /^\.\/(env\.(?:server|client)\.(?!(?:type|example)$)[\w-]+)$/;
30
+ //? A lib's server.ts and the signal test runtime import it by name, whatever the selector imports.
31
+ static readonly #testing = "env.server.testing.ts";
32
+
33
+ readonly #envDir: string;
34
+ constructor(envDir: string) {
35
+ this.#envDir = envDir;
36
+ }
37
+
38
+ async missing(): Promise<EnvSeedFile[]> {
39
+ const wanted = [...new Set([EnvSeed.#testing, ...(await this.#selected())])].sort();
40
+ const exists = await Promise.all(wanted.map((fileName) => FileSys.fileExists(path.join(this.#envDir, fileName))));
41
+ const missing = wanted.filter((_, idx) => !exists[idx]);
42
+ return await Promise.all(missing.map(async (fileName) => ({ fileName, content: await this.#contentOf(fileName) })));
43
+ }
44
+
45
+ async #selected() {
46
+ const transpiler = new Bun.Transpiler({ loader: "ts" });
47
+ const selected = await Promise.all(
48
+ EnvSeed.#selectors.map(async (selector) => {
49
+ const file = path.join(this.#envDir, selector);
50
+ if (!(await FileSys.fileExists(file))) return [];
51
+ return transpiler
52
+ .scan(await FileSys.readText(file))
53
+ .imports.flatMap(({ path: specifier }) => specifier.match(EnvSeed.#selectedPattern)?.[1] ?? [])
54
+ .map((name) => `${name}.ts`);
55
+ }),
56
+ );
57
+ return selected.flat();
58
+ }
59
+
60
+ async #contentOf(fileName: string) {
61
+ const side = fileName.startsWith("env.client.") ? "client" : "server";
62
+ const example = path.join(this.#envDir, `env.${side}.example.ts`);
63
+ if (await FileSys.fileExists(example)) return await FileSys.readText(example);
64
+ if (side === "server") return fileName === EnvSeed.#testing ? EnvSeed.#testingServer : EnvSeed.#server;
65
+ const typed = await FileSys.fileExists(path.join(this.#envDir, "env.client.type.ts"));
66
+ return typed ? EnvSeed.#client : EnvSeed.#untypedClient;
67
+ }
68
+ }