@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20
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/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +1 -4
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +1 -3
- package/akanApp/akanApp.host.ts +64 -250
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +30 -39
- package/akanConfig/akanConfig.ts +40 -136
- package/akanContext.ts +106 -159
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.ts +25 -59
- package/applicationReleasePackager.ts +24 -44
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +3 -19
- package/artifact/implicitRootLayout.ts +34 -91
- package/artifact/routeSeedIndex.ts +11 -44
- package/biomeBase.ts +2 -5
- package/capacitorApp.test.ts +4 -14
- package/capacitorApp.ts +86 -235
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +33 -57
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +3 -2
- package/cloud/globalConfig.ts +64 -31
- package/codeAgent/agent/AkanCodeServices.ts +15 -57
- package/codeAgent/agent/AkanContextFiles.ts +5 -28
- package/codeAgent/agent/CodeAgent.ts +33 -108
- package/codeAgent/agent/CodeAgentAsks.ts +7 -30
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
- package/codeAgent/agent/CodeAgentGate.ts +8 -34
- package/codeAgent/agent/CodeAgentProxy.ts +4 -13
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
- package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
- package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
- package/codeAgent/agent/CodeAgentUi.ts +2 -7
- package/codeAgent/agent/CodeMailbox.ts +7 -34
- package/codeAgent/agent/CodeSessionFork.ts +4 -19
- package/codeAgent/agent/CodeSessionIndex.ts +5 -31
- package/codeAgent/agent/SubagentPool.ts +48 -58
- package/codeAgent/agent/akanCodeModel.ts +5 -39
- package/codeAgent/agent/akanCodePaths.ts +4 -24
- package/codeAgent/agent/akanContextFiles.test.ts +0 -2
- package/codeAgent/agent/akanSystemPrompt.ts +1 -9
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +5 -28
- package/codeAgent/agent/codeMailbox.test.ts +0 -4
- package/codeAgent/agent/codeSessionFork.test.ts +0 -2
- package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
- package/codeAgent/agent/consoleToStderr.ts +2 -12
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +3 -18
- package/codeAgent/feedback/PreviewChrome.ts +5 -29
- package/codeAgent/feedback/PreviewFeedback.ts +2 -9
- package/codeAgent/feedback/PreviewView.ts +3 -15
- package/codeAgent/feedback/TurnFeedback.ts +2 -17
- package/codeAgent/feedback/VerifyFeedback.ts +1 -7
- package/codeAgent/feedback/previewChrome.test.ts +1 -1
- package/codeAgent/index.ts +2 -10
- package/codeAgent/skills/akan-code/SKILL.md +12 -4
- package/codeAgent/tools/AkanCli.ts +4 -16
- package/codeAgent/tools/AkanCodePlugins.ts +21 -43
- package/codeAgent/tools/AkanEditScope.ts +2 -12
- package/codeAgent/tools/AkanEnvKeys.ts +3 -11
- package/codeAgent/tools/AkanToolPack.ts +6 -28
- package/codeAgent/tools/AkanVerifier.ts +1 -8
- package/codeAgent/tools/AskToolPack.ts +3 -15
- package/codeAgent/tools/MailToolPack.ts +0 -9
- package/codeAgent/tools/McpClient.ts +3 -24
- package/codeAgent/tools/McpOAuth.ts +6 -41
- package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
- package/codeAgent/tools/McpServerConfig.ts +7 -26
- package/codeAgent/tools/McpSignIn.ts +46 -49
- package/codeAgent/tools/McpTokenStore.ts +6 -17
- package/codeAgent/tools/McpToolPack.ts +107 -35
- package/codeAgent/tools/SessionToolPack.ts +1 -11
- package/codeAgent/tools/WebToolPack.ts +1 -7
- package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
- package/codeAgent/tools/askToolPack.test.ts +0 -3
- package/codeAgent/tools/mcpOAuth.test.ts +315 -27
- package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +44 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +1 -9
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +66 -144
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +27 -78
- package/devkitUtils.test.ts +47 -19
- package/executors.test.ts +114 -60
- package/executors.ts +165 -484
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +1 -1
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +14 -38
- package/frontendBuild/autoImportSync.ts +15 -67
- package/frontendBuild/buildRouteClient.test.ts +10 -36
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +5 -18
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +5 -13
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +27 -52
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +7 -31
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +7 -47
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +22 -57
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/devWatchBatch.test.ts +14 -18
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
- package/index.ts +3 -15
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +9 -29
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/gritRules.test.ts +6 -23
- package/linter.test.ts +1 -2
- package/linter.ts +22 -115
- package/mobile/mobileTarget.ts +14 -13
- package/package.json +2 -2
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +3 -14
- package/platformTest/IdleWatchedProcess.test.ts +5 -13
- package/platformTest/IdleWatchedProcess.ts +1 -5
- package/platformTest/LinuxDockerTarget.ts +20 -35
- package/platformTest/PlatformTestRun.test.ts +5 -9
- package/platformTest/PlatformTestTarget.ts +24 -2
- package/platformTest/TestSnapshot.test.ts +6 -14
- package/platformTest/TestSnapshot.ts +1 -5
- package/platformTest/WindowsSshTarget.ts +6 -22
- package/prompter.ts +3 -6
- package/qualityScanner.test.ts +6 -24
- package/qualityScanner.ts +196 -284
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.ts +7 -30
- package/scanInfo.ts +82 -214
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/subspace.test.ts +6 -25
- package/subspace.ts +31 -109
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +49 -34
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +85 -146
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/source.ts +33 -56
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.ts +4 -19
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs, writeJson, writeText } from "../testHelpers";
|
|
5
|
+
|
|
6
|
+
// The cloud rotates a refresh token on every use and treats a second use as theft, revoking every session.
|
|
7
|
+
class FakeCloud {
|
|
8
|
+
readonly server: ReturnType<typeof Bun.serve>;
|
|
9
|
+
readonly refreshes: string[] = [];
|
|
10
|
+
reused = false;
|
|
11
|
+
delayMs = 0;
|
|
12
|
+
hang: boolean = false;
|
|
13
|
+
readonly #refreshTokens = new Set(["refresh-0"]);
|
|
14
|
+
readonly #spent = new Set<string>();
|
|
15
|
+
readonly #jwts = new Set(["jwt-0"]);
|
|
16
|
+
#issued = 0;
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get host() {
|
|
23
|
+
return `http://127.0.0.1:${this.server.port}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async #route(request: Request) {
|
|
27
|
+
const url = new URL(request.url);
|
|
28
|
+
if (url.pathname === "/api/getRemoteSelf") {
|
|
29
|
+
const jwt = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
|
|
30
|
+
return Response.json(this.#jwts.has(jwt) ? { id: "u1", nickname: "akan" } : null);
|
|
31
|
+
}
|
|
32
|
+
if (url.pathname !== "/api/refreshAuthToken") return new Response("not found", { status: 404 });
|
|
33
|
+
const { refreshToken } = (await request.json()) as { refreshToken: string };
|
|
34
|
+
this.refreshes.push(refreshToken);
|
|
35
|
+
if (this.#spent.has(refreshToken)) this.reused = true;
|
|
36
|
+
if (!this.#refreshTokens.delete(refreshToken)) return Response.json({ error: "invalid" }, { status: 401 });
|
|
37
|
+
this.#spent.add(refreshToken);
|
|
38
|
+
this.#issued += 1;
|
|
39
|
+
const next = { jwt: `jwt-${this.#issued}`, refreshToken: `refresh-${this.#issued}` };
|
|
40
|
+
this.#refreshTokens.add(next.refreshToken);
|
|
41
|
+
this.#jwts.add(next.jwt);
|
|
42
|
+
if (this.hang) await new Promise(() => undefined);
|
|
43
|
+
await Bun.sleep(this.delayMs);
|
|
44
|
+
return Response.json({ ...next, expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const makeHome = tempDirs("akan-cloud-home-");
|
|
49
|
+
const cloudIndex = path.resolve(import.meta.dir, "index.ts");
|
|
50
|
+
let cloud: FakeCloud;
|
|
51
|
+
|
|
52
|
+
afterEach(() => cloud.server.stop(true));
|
|
53
|
+
|
|
54
|
+
// A separate `bun` per CLI process, with HOME pointing at a temp dir: the config path is fixed when the module loads.
|
|
55
|
+
const cliRun = async (home: string, { callers = 1 } = {}) => {
|
|
56
|
+
const script = path.join(home, `cli-${crypto.randomUUID()}.ts`);
|
|
57
|
+
await writeText(
|
|
58
|
+
script,
|
|
59
|
+
`import { CloudApi, configPath } from ${JSON.stringify(cloudIndex)};
|
|
60
|
+
if (!configPath.startsWith(${JSON.stringify(home)})) throw new Error("refusing to touch " + configPath);
|
|
61
|
+
const workspace = { workspaceRoot: ${JSON.stringify(home)} };
|
|
62
|
+
const selves = await Promise.all(
|
|
63
|
+
Array.from({ length: ${callers} }, async () => (await (await CloudApi.fromHost(workspace, ${JSON.stringify(cloud.host)})).getRemoteSelf())?.nickname ?? null),
|
|
64
|
+
);
|
|
65
|
+
process.stdout.write(JSON.stringify(selves));
|
|
66
|
+
`,
|
|
67
|
+
);
|
|
68
|
+
return Bun.spawn(["bun", script], { env: { ...process.env, HOME: home }, stdout: "pipe", stderr: "pipe" });
|
|
69
|
+
};
|
|
70
|
+
const output = async (proc: Bun.Subprocess<"ignore", "pipe", "pipe">) => {
|
|
71
|
+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
72
|
+
expect(await proc.exited, stderr).toBe(0);
|
|
73
|
+
return JSON.parse(stdout) as (string | null)[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const writerRun = async (home: string, waitTimeoutMs: number) => {
|
|
77
|
+
const script = path.join(home, `writer-${crypto.randomUUID()}.ts`);
|
|
78
|
+
await writeText(
|
|
79
|
+
script,
|
|
80
|
+
`import { GlobalConfig, configPath } from ${JSON.stringify(cloudIndex)};
|
|
81
|
+
import { ConfigLock } from ${JSON.stringify(path.resolve(import.meta.dir, "configLock.ts"))};
|
|
82
|
+
if (!configPath.startsWith(${JSON.stringify(home)})) throw new Error("refusing to touch " + configPath);
|
|
83
|
+
Object.defineProperty(ConfigLock, "waitTimeoutMs", { value: ${waitTimeoutMs} });
|
|
84
|
+
await GlobalConfig.setTestTargets({ linux: { cpus: 2 } });
|
|
85
|
+
`,
|
|
86
|
+
);
|
|
87
|
+
const proc = Bun.spawn(["bun", script], { env: { ...process.env, HOME: home }, stdout: "pipe", stderr: "pipe" });
|
|
88
|
+
return { code: await proc.exited, stderr: await new Response(proc.stderr).text() };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const storeSession = async (home: string, { expiresInMs = 30 * 60_000, refreshToken = "refresh-0" } = {}) =>
|
|
92
|
+
await writeJson(path.join(home, ".akan", "config.json"), {
|
|
93
|
+
cloudHost: {
|
|
94
|
+
[cloud.host]: {
|
|
95
|
+
host: cloud.host,
|
|
96
|
+
auth: {
|
|
97
|
+
accessToken: { jwt: "jwt-0", refreshToken, expiresAt: new Date(Date.now() + expiresInMs).toISOString() },
|
|
98
|
+
self: { id: "u1", nickname: "akan" },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const storedToken = async (home: string) => {
|
|
104
|
+
const config = JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8"));
|
|
105
|
+
return config.cloudHost[cloud.host].auth.accessToken as { jwt: string; refreshToken: string | null };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
describe("CloudApi.fromHost token refresh", () => {
|
|
109
|
+
test("two CLI processes with a session in its last hour make exactly one refresh, and both use its result", async () => {
|
|
110
|
+
cloud = new FakeCloud();
|
|
111
|
+
cloud.delayMs = 300;
|
|
112
|
+
const home = await makeHome();
|
|
113
|
+
await storeSession(home);
|
|
114
|
+
|
|
115
|
+
const [first, second] = await Promise.all([
|
|
116
|
+
cliRun(home, { callers: 2 }).then(output),
|
|
117
|
+
cliRun(home, { callers: 2 }).then(output),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
expect(cloud.refreshes).toEqual(["refresh-0"]);
|
|
121
|
+
expect(cloud.reused).toBe(false);
|
|
122
|
+
expect([...first, ...second]).toEqual(["akan", "akan", "akan", "akan"]);
|
|
123
|
+
expect(await storedToken(home)).toMatchObject({ jwt: "jwt-1", refreshToken: "refresh-1" });
|
|
124
|
+
}, 30_000);
|
|
125
|
+
|
|
126
|
+
test("a CLI started while another is mid-refresh waits for that refresh and uses its session", async () => {
|
|
127
|
+
cloud = new FakeCloud();
|
|
128
|
+
cloud.delayMs = 1_500;
|
|
129
|
+
const home = await makeHome();
|
|
130
|
+
await storeSession(home);
|
|
131
|
+
|
|
132
|
+
const first = cliRun(home).then(output);
|
|
133
|
+
for (let tries = 0; tries < 300 && (await storedToken(home)).refreshToken !== null; tries++) await Bun.sleep(10);
|
|
134
|
+
expect((await storedToken(home)).refreshToken).toBeNull();
|
|
135
|
+
const second = await cliRun(home).then(output);
|
|
136
|
+
|
|
137
|
+
expect(await first).toEqual(["akan"]);
|
|
138
|
+
expect(second).toEqual(["akan"]);
|
|
139
|
+
expect(cloud.refreshes).toEqual(["refresh-0"]);
|
|
140
|
+
}, 30_000);
|
|
141
|
+
|
|
142
|
+
test("a CLI killed while its refresh is in flight never lets the next one present that refresh token", async () => {
|
|
143
|
+
cloud = new FakeCloud();
|
|
144
|
+
cloud.hang = true;
|
|
145
|
+
const home = await makeHome();
|
|
146
|
+
await storeSession(home);
|
|
147
|
+
|
|
148
|
+
const killed = await cliRun(home);
|
|
149
|
+
for (let waited = 0; !cloud.refreshes.length && waited < 10_000; waited += 25) await Bun.sleep(25);
|
|
150
|
+
expect(cloud.refreshes).toEqual(["refresh-0"]);
|
|
151
|
+
killed.kill("SIGKILL");
|
|
152
|
+
await killed.exited;
|
|
153
|
+
cloud.hang = false;
|
|
154
|
+
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
expect(await output(await cliRun(home))).toEqual([null]);
|
|
157
|
+
expect(Date.now() - started).toBeLessThan(10_000);
|
|
158
|
+
expect(cloud.refreshes).toEqual(["refresh-0"]);
|
|
159
|
+
expect(cloud.reused).toBe(false);
|
|
160
|
+
expect((await storedToken(home)).refreshToken).toBeNull();
|
|
161
|
+
}, 30_000);
|
|
162
|
+
|
|
163
|
+
test("a refresh the cloud refuses is not presented again", async () => {
|
|
164
|
+
cloud = new FakeCloud();
|
|
165
|
+
const home = await makeHome();
|
|
166
|
+
await storeSession(home, { refreshToken: "refresh-unknown" });
|
|
167
|
+
|
|
168
|
+
expect(await output(await cliRun(home))).toEqual([null]);
|
|
169
|
+
expect(await output(await cliRun(home))).toEqual([null]);
|
|
170
|
+
expect(cloud.refreshes).toEqual(["refresh-unknown"]);
|
|
171
|
+
expect((await storedToken(home)).refreshToken).toBeNull();
|
|
172
|
+
}, 30_000);
|
|
173
|
+
|
|
174
|
+
test("any other config write waits for the lock instead of writing around it", async () => {
|
|
175
|
+
cloud = new FakeCloud();
|
|
176
|
+
const home = await makeHome();
|
|
177
|
+
await storeSession(home);
|
|
178
|
+
const lockPath = path.join(home, ".akan", "config.json.lock");
|
|
179
|
+
await writeJson(lockPath, { pid: process.pid, at: Date.now() });
|
|
180
|
+
|
|
181
|
+
const blocked = await writerRun(home, 300);
|
|
182
|
+
expect(blocked.code).not.toBe(0);
|
|
183
|
+
expect(blocked.stderr).toContain(lockPath);
|
|
184
|
+
expect(JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8")).testTargets).toBeUndefined();
|
|
185
|
+
|
|
186
|
+
await rm(lockPath);
|
|
187
|
+
expect((await writerRun(home, 300)).code).toBe(0);
|
|
188
|
+
const config = JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8"));
|
|
189
|
+
expect(config.testTargets).toEqual({ linux: { cpus: 2 } });
|
|
190
|
+
expect(config.cloudHost[cloud.host].auth.accessToken.refreshToken).toBe("refresh-0");
|
|
191
|
+
}, 30_000);
|
|
192
|
+
|
|
193
|
+
test("a session with more than an hour left is used as it is", async () => {
|
|
194
|
+
cloud = new FakeCloud();
|
|
195
|
+
const home = await makeHome();
|
|
196
|
+
await storeSession(home, { expiresInMs: 2 * 86_400_000 });
|
|
197
|
+
|
|
198
|
+
expect(await output(await cliRun(home))).toEqual(["akan"]);
|
|
199
|
+
expect(cloud.refreshes).toEqual([]);
|
|
200
|
+
expect(await storedToken(home)).toMatchObject({ jwt: "jwt-0", refreshToken: "refresh-0" });
|
|
201
|
+
}, 30_000);
|
|
202
|
+
});
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -16,11 +16,7 @@ class HttpClient {
|
|
|
16
16
|
}
|
|
17
17
|
async get<T>(url: string, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
|
|
18
18
|
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
19
|
-
headers: {
|
|
20
|
-
"Content-Type": "application/json",
|
|
21
|
-
...this.headers,
|
|
22
|
-
...headers,
|
|
23
|
-
},
|
|
19
|
+
headers: { "Content-Type": "application/json", ...this.headers, ...headers },
|
|
24
20
|
...(signal ? { signal } : {}),
|
|
25
21
|
});
|
|
26
22
|
return await HttpClient.#body<T>(response, url);
|
|
@@ -44,10 +40,7 @@ class HttpClient {
|
|
|
44
40
|
});
|
|
45
41
|
return await HttpClient.#body<T>(response, url);
|
|
46
42
|
}
|
|
47
|
-
|
|
48
|
-
* A route that does not exist answers the error *page*, not JSON, so parsing first reports a syntax error at
|
|
49
|
-
* `<` and buries the status that says what is actually wrong. Read the status before the body.
|
|
50
|
-
*/
|
|
43
|
+
// A missing route answers an HTML error page, so the status is read before the body is parsed as JSON.
|
|
51
44
|
static async #body<T>(response: Response, url: string): Promise<T> {
|
|
52
45
|
const text = await response.text();
|
|
53
46
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText} from ${url}: ${text.slice(0, 200)}`);
|
|
@@ -59,16 +52,13 @@ class HttpClient {
|
|
|
59
52
|
}
|
|
60
53
|
}
|
|
61
54
|
|
|
62
|
-
/**
|
|
63
|
-
* A share the control plane just issued. `token` is the connector credential — whoever holds it can become the
|
|
64
|
-
* origin behind `hostname`, so it is never written to disk or logged, only handed to the agent in memory.
|
|
65
|
-
*/
|
|
66
55
|
export interface TunnelGrant {
|
|
67
56
|
code: string;
|
|
68
57
|
hostname: string;
|
|
69
58
|
url: string;
|
|
70
|
-
/**
|
|
59
|
+
/** Named by the control plane (e.g. `wss://tunnel.akanjs.com`); the CLI never assumes one. */
|
|
71
60
|
gatewayUrl: string;
|
|
61
|
+
/** Whoever holds it becomes the origin behind `hostname`: never written to disk or logged. */
|
|
72
62
|
token: string;
|
|
73
63
|
expiresAt: string | null;
|
|
74
64
|
}
|
|
@@ -92,7 +82,14 @@ export class CloudApi {
|
|
|
92
82
|
|
|
93
83
|
static async fromHost(workspace: Workspace, host?: string) {
|
|
94
84
|
const hostConfig = await GlobalConfig.getHostConfig(host);
|
|
95
|
-
|
|
85
|
+
const accessToken = hostConfig.auth?.accessToken;
|
|
86
|
+
// No refresh token on a session in its last hour can be another process's refresh in flight: wait on its lock.
|
|
87
|
+
if (!accessToken || !GlobalConfig.needRefreshToken(accessToken)) return new CloudApi(workspace, hostConfig);
|
|
88
|
+
const tokenless = new CloudApi(workspace, hostConfig);
|
|
89
|
+
const refreshed = await GlobalConfig.refreshHostAuth(hostConfig.host, (refreshToken) =>
|
|
90
|
+
tokenless.refreshAuthToken(refreshToken),
|
|
91
|
+
);
|
|
92
|
+
return new CloudApi(workspace, refreshed);
|
|
96
93
|
}
|
|
97
94
|
constructor(workspace: Workspace, hostConfig: HostConfig) {
|
|
98
95
|
this.#workspace = workspace;
|
|
@@ -101,17 +98,20 @@ export class CloudApi {
|
|
|
101
98
|
this.url = `${this.host}/api`;
|
|
102
99
|
this.#api = new HttpClient(this.url);
|
|
103
100
|
if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
|
|
104
|
-
this.#api.setHeaders({
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#authorize(accessTokenDto: AccessTokenDto) {
|
|
105
|
+
this.#accessToken = GlobalConfig.toAccessToken(accessTokenDto);
|
|
106
|
+
this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
|
|
107
|
+
return this.#accessToken;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
async uploadEnv(devProjectId: string, file: File): Promise<boolean> {
|
|
110
111
|
const formData = new FormData();
|
|
111
112
|
formData.append("devProjectId", devProjectId);
|
|
112
113
|
formData.append("file", file);
|
|
113
|
-
|
|
114
|
-
return data;
|
|
114
|
+
return await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
|
|
115
115
|
}
|
|
116
116
|
async downloadEnv(devProjectId: string): Promise<unknown> {
|
|
117
117
|
const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
|
|
@@ -120,39 +120,21 @@ export class CloudApi {
|
|
|
120
120
|
}
|
|
121
121
|
async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
|
|
122
122
|
try {
|
|
123
|
-
|
|
124
|
-
this.#accessToken = GlobalConfig.toAccessToken(accessToken);
|
|
125
|
-
this.#api.setHeaders({
|
|
126
|
-
Authorization: `Bearer ${this.#accessToken.jwt}`,
|
|
127
|
-
});
|
|
128
|
-
return this.#accessToken;
|
|
123
|
+
return this.#authorize(await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`));
|
|
129
124
|
} catch (_) {
|
|
130
125
|
return null;
|
|
131
126
|
}
|
|
132
127
|
}
|
|
133
|
-
async #ensureAccessTokenLive({
|
|
134
|
-
allowUnauthorized = false,
|
|
135
|
-
}: {
|
|
136
|
-
allowUnauthorized?: boolean;
|
|
137
|
-
} = {}): Promise<AccessToken> {
|
|
138
|
-
if (!this.#accessToken) throw new Error("No access token");
|
|
139
|
-
const needRefresh = GlobalConfig.needRefreshToken(this.#accessToken);
|
|
140
|
-
if (!needRefresh) return this.#accessToken;
|
|
141
|
-
const refreshToken = this.#accessToken?.refreshToken;
|
|
142
|
-
if (!refreshToken) throw new Error("No refresh token");
|
|
143
|
-
return await this.refreshAuthToken(refreshToken);
|
|
144
|
-
}
|
|
145
128
|
async refreshAuthToken(refreshToken: string): Promise<AccessToken> {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
*/
|
|
129
|
+
return this.#authorize(
|
|
130
|
+
await this.#api.post<AccessTokenDto>(
|
|
131
|
+
`/refreshAuthToken`,
|
|
132
|
+
{ refreshToken },
|
|
133
|
+
{ signal: AbortSignal.timeout(20_000) },
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// `/tunnel` is the model's refName, which the `_cloud` service routes above lack; dropping it answers 404.
|
|
156
138
|
async requestTunnel(input: { name: string; ttlMinutes?: number }): Promise<TunnelGrant> {
|
|
157
139
|
return await this.#api.post<TunnelGrant>(`/tunnel/requestTunnel`, input);
|
|
158
140
|
}
|
|
@@ -160,20 +142,14 @@ export class CloudApi {
|
|
|
160
142
|
async tunnelListInSelf(): Promise<TunnelSummary[]> {
|
|
161
143
|
return await this.#api.get<TunnelSummary[]>(`/tunnel/tunnelListInSelf`);
|
|
162
144
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
*
|
|
166
|
-
* Bounded because every caller is on the way out: an unreachable control plane is exactly the state a session
|
|
167
|
-
* ends in, and an unbounded `fetch` there holds the event loop open long after the work is done. Losing the
|
|
168
|
-
* release costs nothing — the share expires on its own TTL.
|
|
169
|
-
*/
|
|
145
|
+
// Bounded: callers are on the way out and an unreachable control plane would hold the event loop open; a lost
|
|
146
|
+
// release costs nothing, since the share expires on its own TTL.
|
|
170
147
|
async revokeTunnel(code: string): Promise<boolean> {
|
|
171
148
|
return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
|
|
172
149
|
}
|
|
173
150
|
async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
|
|
174
151
|
try {
|
|
175
|
-
|
|
176
|
-
return data;
|
|
152
|
+
return await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
|
|
177
153
|
} catch {
|
|
178
154
|
return null;
|
|
179
155
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs } from "../testHelpers";
|
|
5
|
+
import { ConfigLock } from "./configLock";
|
|
6
|
+
|
|
7
|
+
const makeRoot = tempDirs("akan-config-lock-");
|
|
8
|
+
/** A pid that cannot be alive: `kill(0)` on it is ESRCH on every platform this runs on. */
|
|
9
|
+
const deadPid = 0x7ffffff;
|
|
10
|
+
const lockIn = async () => path.join(await makeRoot(), ".akan", "config.json.lock");
|
|
11
|
+
|
|
12
|
+
const withWaitTimeout = async <T>(ms: number, fn: () => Promise<T>) => {
|
|
13
|
+
const waitTimeoutMs = ConfigLock.waitTimeoutMs;
|
|
14
|
+
Object.defineProperty(ConfigLock, "waitTimeoutMs", { value: ms, configurable: true });
|
|
15
|
+
try {
|
|
16
|
+
return await fn();
|
|
17
|
+
} finally {
|
|
18
|
+
Object.defineProperty(ConfigLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("ConfigLock", () => {
|
|
23
|
+
test("runs one caller at a time, in the same process too", async () => {
|
|
24
|
+
const lockPath = await lockIn();
|
|
25
|
+
const order: string[] = [];
|
|
26
|
+
const body = async (name: string) => {
|
|
27
|
+
order.push(`${name}:in`);
|
|
28
|
+
await Bun.sleep(30);
|
|
29
|
+
order.push(`${name}:out`);
|
|
30
|
+
};
|
|
31
|
+
await Promise.all(["a", "b", "c"].map((name) => ConfigLock.run(lockPath, () => body(name))));
|
|
32
|
+
for (const name of ["a", "b", "c"]) expect(order.indexOf(`${name}:out`)).toBe(order.indexOf(`${name}:in`) + 1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("releases the lock when the body throws", async () => {
|
|
36
|
+
const lockPath = await lockIn();
|
|
37
|
+
await expect(ConfigLock.run(lockPath, async () => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
|
|
38
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("reclaims a lock whose holder died", async () => {
|
|
42
|
+
const lockPath = await lockIn();
|
|
43
|
+
await ConfigLock.run(lockPath, async () => undefined);
|
|
44
|
+
await writeFile(lockPath, JSON.stringify({ pid: deadPid, at: Date.now() }));
|
|
45
|
+
const holder = await ConfigLock.run(lockPath, async () => JSON.parse(await readFile(lockPath, "utf8")));
|
|
46
|
+
expect(holder.pid).toBe(process.pid);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("waits out a live holder and then fails, rather than going ahead without the lock", async () => {
|
|
50
|
+
const lockPath = await lockIn();
|
|
51
|
+
await ConfigLock.run(lockPath, async () => undefined);
|
|
52
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
53
|
+
let ran = false;
|
|
54
|
+
await withWaitTimeout(150, async () => {
|
|
55
|
+
await expect(
|
|
56
|
+
ConfigLock.run(lockPath, async () => {
|
|
57
|
+
ran = true;
|
|
58
|
+
}),
|
|
59
|
+
).rejects.toThrow(lockPath);
|
|
60
|
+
});
|
|
61
|
+
expect(ran).toBe(false);
|
|
62
|
+
expect(JSON.parse(await readFile(lockPath, "utf8")).pid).toBe(process.pid);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
interface LockHolder {
|
|
5
|
+
pid: number;
|
|
6
|
+
at: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Across processes, not only calls: two `akan` commands started together share one `~/.akan/config.json`. A live
|
|
10
|
+
// holder is never reclaimed, so a wait that runs out fails rather than proceeding without the lock.
|
|
11
|
+
export class ConfigLock {
|
|
12
|
+
static readonly waitTimeoutMs = 30_000;
|
|
13
|
+
// A holder-less lock file younger than this is someone between the exclusive create and the write.
|
|
14
|
+
static readonly #unknownHolderStaleMs = 10_000;
|
|
15
|
+
static readonly #pollMs = 50;
|
|
16
|
+
|
|
17
|
+
static async run<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
|
|
18
|
+
await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
19
|
+
const deadline = Date.now() + ConfigLock.waitTimeoutMs;
|
|
20
|
+
while (!(await ConfigLock.#take(lockPath))) {
|
|
21
|
+
if (Date.now() >= deadline) {
|
|
22
|
+
const holder = (await ConfigLock.#holder(lockPath))?.pid ?? "unknown";
|
|
23
|
+
throw new Error(
|
|
24
|
+
`${lockPath} is held by process ${holder}; if no akan command is running, delete it and retry.`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
await ConfigLock.#reclaimIfAbandoned(lockPath);
|
|
28
|
+
await Bun.sleep(ConfigLock.#pollMs);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} finally {
|
|
33
|
+
await rm(lockPath, { force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static async #take(lockPath: string) {
|
|
38
|
+
const handle = await open(lockPath, "wx", 0o600).catch((error: NodeJS.ErrnoException) => {
|
|
39
|
+
if (error.code === "EEXIST") return null;
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
if (!handle) return false;
|
|
43
|
+
try {
|
|
44
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() } satisfies LockHolder));
|
|
45
|
+
} finally {
|
|
46
|
+
await handle.close();
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Exclusive too: a waiter acting on its own stale read could delete the lock another waiter took a moment later.
|
|
52
|
+
static async #reclaimIfAbandoned(lockPath: string) {
|
|
53
|
+
if (!(await ConfigLock.#abandoned(lockPath))) return;
|
|
54
|
+
const guard = `${lockPath}.reclaim`;
|
|
55
|
+
if (!(await ConfigLock.#take(guard))) {
|
|
56
|
+
if (await ConfigLock.#abandoned(guard)) await rm(guard, { force: true });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if (await ConfigLock.#abandoned(lockPath)) await rm(lockPath, { force: true });
|
|
61
|
+
} finally {
|
|
62
|
+
await rm(guard, { force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async #abandoned(lockPath: string) {
|
|
67
|
+
const info = await stat(lockPath).catch(() => null);
|
|
68
|
+
if (!info) return false;
|
|
69
|
+
const holder = await ConfigLock.#holder(lockPath);
|
|
70
|
+
if (!holder) return Date.now() - info.mtimeMs > ConfigLock.#unknownHolderStaleMs;
|
|
71
|
+
return !ConfigLock.#isAlive(holder.pid);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static async #holder(lockPath: string): Promise<LockHolder | null> {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(await readFile(lockPath, "utf8")) as Partial<LockHolder>;
|
|
77
|
+
return typeof parsed.pid === "number" && typeof parsed.at === "number"
|
|
78
|
+
? { pid: parsed.pid, at: parsed.at }
|
|
79
|
+
: null;
|
|
80
|
+
} catch {
|
|
81
|
+
// Missing, or caught between the create and the write; either way it names no holder.
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static #isAlive(pid: number) {
|
|
87
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// EPERM is a pid that exists under another user, which still counts as holding the lock.
|
|
93
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
package/cloud/constants.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import type { Dayjs } from "dayjs";
|
|
2
3
|
import { GlobalConfig } from "./globalConfig";
|
|
3
4
|
|
|
4
|
-
export const basePath =
|
|
5
|
-
export const configPath =
|
|
5
|
+
export const basePath = path.join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".akan");
|
|
6
|
+
export const configPath = path.join(basePath, "config.json");
|
|
6
7
|
|
|
7
8
|
export interface HostConfig {
|
|
8
9
|
host: string;
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import dayjs from "dayjs";
|
|
3
3
|
import { FileSys } from "../fileSys";
|
|
4
|
+
import { ConfigLock } from "./configLock";
|
|
4
5
|
import {
|
|
5
6
|
type AccessToken,
|
|
6
7
|
type AccessTokenDto,
|
|
@@ -31,58 +32,90 @@ export class GlobalConfig {
|
|
|
31
32
|
testTargets: akanConfig.testTargets ?? defaultAkanGlobalConfig.testTargets,
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* written owner-only — the same `0600` the runtime gives its control socket. `Bun.write` takes no mode
|
|
37
|
-
* and lands on `0666 & ~umask` (0644 on a default shell), so the mode is applied after the write; an
|
|
38
|
-
* existing world-readable file is tightened by the next write rather than left as it was found.
|
|
39
|
-
*/
|
|
35
|
+
// Holds the cloud jwt and refresh token, so owner-only 0600: written to a 0600 sibling and renamed over the file,
|
|
36
|
+
// which also replaces a world-readable one and never shows another CLI process a half-written file.
|
|
40
37
|
static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
|
|
41
38
|
await mkdir(basePath, { recursive: true, mode: 0o700 });
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });
|
|
42
|
+
await rename(temp, configPath);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
await rm(temp, { force: true });
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
static readonly #lockPath = `${configPath}.lock`;
|
|
49
|
+
// Every write re-reads under the lock: one built on an earlier read could restore a rotated-away refresh token.
|
|
50
|
+
static async #update(mutate: (akanConfig: AkanGlobalConfig) => AkanGlobalConfig) {
|
|
51
|
+
await ConfigLock.run(GlobalConfig.#lockPath, async () => {
|
|
52
|
+
await GlobalConfig.#setAkanGlobalConfig(mutate(await GlobalConfig.#getAkanGlobalConfig()));
|
|
53
|
+
});
|
|
44
54
|
}
|
|
45
55
|
static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
|
|
46
56
|
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
47
57
|
return GlobalConfig.toHostConfig(akanConfig.cloudHost[host] ?? getDefaultHostConfig(host));
|
|
48
58
|
}
|
|
49
59
|
static async setHostConfig(config: HostConfig = getDefaultHostConfig()) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
await GlobalConfig.#update((akanConfig) => GlobalConfig.#withHost(akanConfig, config));
|
|
61
|
+
}
|
|
62
|
+
static #withHost(akanConfig: AkanGlobalConfig, config: HostConfig): AkanGlobalConfig {
|
|
63
|
+
return {
|
|
64
|
+
...akanConfig,
|
|
65
|
+
cloudHost: { ...akanConfig.cloudHost, [config.host]: GlobalConfig.toHostConfigDto(config) },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// The cloud rotates the refresh token and revokes every session of the account when one is presented twice, so it
|
|
69
|
+
// leaves the disk before it is sent: a refresh killed in flight leaves nothing to replay, only a sign-in to redo.
|
|
70
|
+
static async refreshHostAuth(host: string, refresh: (refreshToken: string) => Promise<AccessToken>) {
|
|
71
|
+
try {
|
|
72
|
+
return await ConfigLock.run(GlobalConfig.#lockPath, async () => {
|
|
73
|
+
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
74
|
+
const current = GlobalConfig.toHostConfig(akanConfig.cloudHost[host] ?? getDefaultHostConfig(host));
|
|
75
|
+
const accessToken = current.auth?.accessToken;
|
|
76
|
+
const refreshToken = accessToken?.refreshToken;
|
|
77
|
+
if (!accessToken || !refreshToken || !GlobalConfig.needRefreshToken(accessToken)) return current;
|
|
78
|
+
const claimed: HostConfig = {
|
|
79
|
+
...current,
|
|
80
|
+
auth: { ...current.auth, accessToken: { ...accessToken, refreshToken: null } },
|
|
81
|
+
};
|
|
82
|
+
await GlobalConfig.#setAkanGlobalConfig(GlobalConfig.#withHost(akanConfig, claimed));
|
|
83
|
+
const next = await refresh(refreshToken).catch(() => null);
|
|
84
|
+
if (!next?.jwt) return claimed;
|
|
85
|
+
const refreshed: HostConfig = { ...current, auth: { ...current.auth, accessToken: next } };
|
|
86
|
+
await GlobalConfig.#setAkanGlobalConfig(
|
|
87
|
+
GlobalConfig.#withHost(await GlobalConfig.#getAkanGlobalConfig(), refreshed),
|
|
88
|
+
);
|
|
89
|
+
return refreshed;
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// No lock, or a write that failed: what is stored is used as it is, and nothing is refreshed outside the lock.
|
|
93
|
+
return await GlobalConfig.getHostConfig(host);
|
|
94
|
+
}
|
|
53
95
|
}
|
|
54
96
|
static async getRemoteEnvServers(): Promise<AkanGlobalConfig["remoteEnvServers"]> {
|
|
55
|
-
|
|
56
|
-
return akanConfig.remoteEnvServers;
|
|
97
|
+
return (await GlobalConfig.#getAkanGlobalConfig()).remoteEnvServers;
|
|
57
98
|
}
|
|
58
99
|
static async setRemoteEnvServer(name: string, config: RemoteEnvServerConfig) {
|
|
59
|
-
|
|
60
|
-
await GlobalConfig.#setAkanGlobalConfig({
|
|
100
|
+
await GlobalConfig.#update((akanConfig) => ({
|
|
61
101
|
...akanConfig,
|
|
62
|
-
remoteEnvServers: {
|
|
63
|
-
|
|
64
|
-
[name]: config,
|
|
65
|
-
},
|
|
66
|
-
});
|
|
102
|
+
remoteEnvServers: { ...akanConfig.remoteEnvServers, [name]: config },
|
|
103
|
+
}));
|
|
67
104
|
}
|
|
68
105
|
static async removeRemoteEnvServer(name: string) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
...akanConfig,
|
|
73
|
-
remoteEnvServers,
|
|
106
|
+
await GlobalConfig.#update((akanConfig) => {
|
|
107
|
+
const { [name]: _, ...remoteEnvServers } = akanConfig.remoteEnvServers;
|
|
108
|
+
return { ...akanConfig, remoteEnvServers };
|
|
74
109
|
});
|
|
75
110
|
}
|
|
76
111
|
static async getTestTargets(): Promise<TestTargetsConfig> {
|
|
77
|
-
|
|
78
|
-
return akanConfig.testTargets;
|
|
112
|
+
return (await GlobalConfig.#getAkanGlobalConfig()).testTargets;
|
|
79
113
|
}
|
|
80
114
|
static async setTestTargets(testTargets: TestTargetsConfig) {
|
|
81
|
-
|
|
82
|
-
await GlobalConfig.#setAkanGlobalConfig({
|
|
115
|
+
await GlobalConfig.#update((akanConfig) => ({
|
|
83
116
|
...akanConfig,
|
|
84
117
|
testTargets: { ...akanConfig.testTargets, ...testTargets },
|
|
85
|
-
});
|
|
118
|
+
}));
|
|
86
119
|
}
|
|
87
120
|
static needRefreshToken(accessToken: AccessToken): boolean {
|
|
88
121
|
return !!accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));
|