@akanjs/devkit 3.0.0-beta.9 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +21 -7
- package/README.md +17 -8
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- 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 +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +439 -141
- package/akanConfig/akanConfig.ts +359 -275
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +22 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +35 -2
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +71 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- 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 +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/csrE2e/csrE2eHarness.fixture.ts +505 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +252 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +31 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- 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 +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- 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/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +105 -199
- package/integration/devStabilityHarness.test.ts +13 -28
- 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/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +182 -0
- package/mobile/desktopBin.ts +144 -0
- package/mobile/desktopServerStage.test.ts +252 -0
- package/mobile/desktopServerStage.ts +178 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +184 -0
- package/mobile/nativeApp.ts +454 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +438 -0
- package/mobile/nativeConfig.ts +294 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- 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/stripAnsi.ts +3 -0
- package/subspace.test.ts +9 -25
- package/subspace.ts +48 -115
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +194 -0
- 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 +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- 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/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- 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
|
@@ -2,6 +2,11 @@ import type { Workspace } from "../commandDecorators";
|
|
|
2
2
|
import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
|
|
3
3
|
import { GlobalConfig } from "./globalConfig";
|
|
4
4
|
|
|
5
|
+
interface HttpRequestOptions {
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
class HttpClient {
|
|
6
11
|
readonly baseUrl: string;
|
|
7
12
|
readonly headers: Record<string, string> = {};
|
|
@@ -9,15 +14,12 @@ class HttpClient {
|
|
|
9
14
|
this.baseUrl = baseUrl;
|
|
10
15
|
this.headers = headers;
|
|
11
16
|
}
|
|
12
|
-
async get<T>(url: string, { headers }:
|
|
17
|
+
async get<T>(url: string, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
|
|
13
18
|
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
14
|
-
headers: {
|
|
15
|
-
|
|
16
|
-
...this.headers,
|
|
17
|
-
...headers,
|
|
18
|
-
},
|
|
19
|
+
headers: { "Content-Type": "application/json", ...this.headers, ...headers },
|
|
20
|
+
...(signal ? { signal } : {}),
|
|
19
21
|
});
|
|
20
|
-
return await response
|
|
22
|
+
return await HttpClient.#body<T>(response, url);
|
|
21
23
|
}
|
|
22
24
|
async getFile(url: string, localPath: string, headers?: Record<string, string>): Promise<void> {
|
|
23
25
|
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
@@ -26,7 +28,7 @@ class HttpClient {
|
|
|
26
28
|
if (!response.ok) throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
|
|
27
29
|
await Bun.write(localPath, response);
|
|
28
30
|
}
|
|
29
|
-
async post<T>(url: string, data: unknown, { headers }:
|
|
31
|
+
async post<T>(url: string, data: unknown, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
|
|
30
32
|
const isFormData = data instanceof FormData;
|
|
31
33
|
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
32
34
|
method: "POST",
|
|
@@ -34,8 +36,15 @@ class HttpClient {
|
|
|
34
36
|
headers: isFormData
|
|
35
37
|
? { ...this.headers, ...headers }
|
|
36
38
|
: { "Content-Type": "application/json", ...this.headers, ...headers },
|
|
39
|
+
...(signal ? { signal } : {}),
|
|
37
40
|
});
|
|
38
|
-
return await response
|
|
41
|
+
return await HttpClient.#body<T>(response, url);
|
|
42
|
+
}
|
|
43
|
+
// A missing route answers an HTML error page, so the status is read before the body is parsed as JSON.
|
|
44
|
+
static async #body<T>(response: Response, url: string): Promise<T> {
|
|
45
|
+
const text = await response.text();
|
|
46
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText} from ${url}: ${text.slice(0, 200)}`);
|
|
47
|
+
return JSON.parse(text) as T;
|
|
39
48
|
}
|
|
40
49
|
setHeaders(headers: Record<string, string>) {
|
|
41
50
|
Object.assign(this.headers, headers);
|
|
@@ -43,6 +52,27 @@ class HttpClient {
|
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
export interface TunnelGrant {
|
|
56
|
+
code: string;
|
|
57
|
+
hostname: string;
|
|
58
|
+
url: string;
|
|
59
|
+
/** Named by the control plane (e.g. `wss://tunnel.akanjs.com`); the CLI never assumes one. */
|
|
60
|
+
gatewayUrl: string;
|
|
61
|
+
/** Whoever holds it becomes the origin behind `hostname`: never written to disk or logged. */
|
|
62
|
+
token: string;
|
|
63
|
+
expiresAt: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TunnelSummary {
|
|
67
|
+
code: string;
|
|
68
|
+
hostname: string;
|
|
69
|
+
url: string;
|
|
70
|
+
name: string | null;
|
|
71
|
+
createdAt: string;
|
|
72
|
+
expiresAt: string | null;
|
|
73
|
+
connected: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
46
76
|
export class CloudApi {
|
|
47
77
|
readonly #api: HttpClient;
|
|
48
78
|
#accessToken: AccessToken | null = null;
|
|
@@ -52,7 +82,14 @@ export class CloudApi {
|
|
|
52
82
|
|
|
53
83
|
static async fromHost(workspace: Workspace, host?: string) {
|
|
54
84
|
const hostConfig = await GlobalConfig.getHostConfig(host);
|
|
55
|
-
|
|
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);
|
|
56
93
|
}
|
|
57
94
|
constructor(workspace: Workspace, hostConfig: HostConfig) {
|
|
58
95
|
this.#workspace = workspace;
|
|
@@ -61,17 +98,20 @@ export class CloudApi {
|
|
|
61
98
|
this.url = `${this.host}/api`;
|
|
62
99
|
this.#api = new HttpClient(this.url);
|
|
63
100
|
if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
|
|
64
|
-
this.#api.setHeaders({
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
67
108
|
}
|
|
68
109
|
|
|
69
110
|
async uploadEnv(devProjectId: string, file: File): Promise<boolean> {
|
|
70
111
|
const formData = new FormData();
|
|
71
112
|
formData.append("devProjectId", devProjectId);
|
|
72
113
|
formData.append("file", file);
|
|
73
|
-
|
|
74
|
-
return data;
|
|
114
|
+
return await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
|
|
75
115
|
}
|
|
76
116
|
async downloadEnv(devProjectId: string): Promise<unknown> {
|
|
77
117
|
const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
|
|
@@ -80,38 +120,36 @@ export class CloudApi {
|
|
|
80
120
|
}
|
|
81
121
|
async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
|
|
82
122
|
try {
|
|
83
|
-
|
|
84
|
-
this.#accessToken = GlobalConfig.toAccessToken(accessToken);
|
|
85
|
-
this.#api.setHeaders({
|
|
86
|
-
Authorization: `Bearer ${this.#accessToken.jwt}`,
|
|
87
|
-
});
|
|
88
|
-
return this.#accessToken;
|
|
123
|
+
return this.#authorize(await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`));
|
|
89
124
|
} catch (_) {
|
|
90
125
|
return null;
|
|
91
126
|
}
|
|
92
127
|
}
|
|
93
|
-
async #ensureAccessTokenLive({
|
|
94
|
-
allowUnauthorized = false,
|
|
95
|
-
}: {
|
|
96
|
-
allowUnauthorized?: boolean;
|
|
97
|
-
} = {}): Promise<AccessToken> {
|
|
98
|
-
if (!this.#accessToken) throw new Error("No access token");
|
|
99
|
-
const needRefresh = GlobalConfig.needRefreshToken(this.#accessToken);
|
|
100
|
-
if (!needRefresh) return this.#accessToken;
|
|
101
|
-
const refreshToken = this.#accessToken?.refreshToken;
|
|
102
|
-
if (!refreshToken) throw new Error("No refresh token");
|
|
103
|
-
return await this.refreshAuthToken(refreshToken);
|
|
104
|
-
}
|
|
105
128
|
async refreshAuthToken(refreshToken: string): Promise<AccessToken> {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
138
|
+
async requestTunnel(input: { name: string; ttlMinutes?: number }): Promise<TunnelGrant> {
|
|
139
|
+
return await this.#api.post<TunnelGrant>(`/tunnel/requestTunnel`, input);
|
|
140
|
+
}
|
|
141
|
+
/** The generated name of the model's own `inSelf` slice: a hand-written `listTunnels` would collide with it. */
|
|
142
|
+
async tunnelListInSelf(): Promise<TunnelSummary[]> {
|
|
143
|
+
return await this.#api.get<TunnelSummary[]>(`/tunnel/tunnelListInSelf`);
|
|
144
|
+
}
|
|
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.
|
|
147
|
+
async revokeTunnel(code: string): Promise<boolean> {
|
|
148
|
+
return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
|
|
110
149
|
}
|
|
111
150
|
async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
|
|
112
151
|
try {
|
|
113
|
-
|
|
114
|
-
return data;
|
|
152
|
+
return await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
|
|
115
153
|
} catch {
|
|
116
154
|
return null;
|
|
117
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,9 +1,9 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import type { Dayjs } from "dayjs";
|
|
2
|
-
import type { SupportedLlmModel } from "../aiEditor";
|
|
3
3
|
import { GlobalConfig } from "./globalConfig";
|
|
4
4
|
|
|
5
|
-
export const basePath =
|
|
6
|
-
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");
|
|
7
7
|
|
|
8
8
|
export interface HostConfig {
|
|
9
9
|
host: string;
|
|
@@ -25,15 +25,36 @@ export interface RemoteEnvServerConfig {
|
|
|
25
25
|
username?: string;
|
|
26
26
|
port?: number;
|
|
27
27
|
}
|
|
28
|
+
export type PlatformGatePolicy = "gate" | "warn";
|
|
29
|
+
export interface LinuxTestTargetConfig {
|
|
30
|
+
cpus?: number;
|
|
31
|
+
memory?: string;
|
|
32
|
+
policy?: PlatformGatePolicy;
|
|
33
|
+
}
|
|
34
|
+
export interface WindowsTestTargetConfig {
|
|
35
|
+
host: string;
|
|
36
|
+
user: string;
|
|
37
|
+
identityFile: string;
|
|
38
|
+
knownHostsFile?: string;
|
|
39
|
+
utmVm?: string;
|
|
40
|
+
workRoot?: string;
|
|
41
|
+
tolerateScriptFailures?: string[];
|
|
42
|
+
policy?: PlatformGatePolicy;
|
|
43
|
+
greenStreak?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface TestTargetsConfig {
|
|
46
|
+
linux?: LinuxTestTargetConfig;
|
|
47
|
+
windows?: WindowsTestTargetConfig;
|
|
48
|
+
}
|
|
28
49
|
export interface AkanGlobalConfig {
|
|
29
50
|
cloudHost: { [key: string]: HostConfigDto };
|
|
30
51
|
remoteEnvServers: Record<string, RemoteEnvServerConfig>;
|
|
31
|
-
|
|
52
|
+
testTargets: TestTargetsConfig;
|
|
32
53
|
}
|
|
33
54
|
export const defaultAkanGlobalConfig: AkanGlobalConfig = {
|
|
34
55
|
cloudHost: {},
|
|
35
56
|
remoteEnvServers: {},
|
|
36
|
-
|
|
57
|
+
testTargets: {},
|
|
37
58
|
};
|
|
38
59
|
|
|
39
60
|
export interface AccessTokenDto {
|