@akanjs/devkit 3.0.0-beta.9 → 3.0.1
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/CHANGELOG.md +1819 -0
- 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 +34 -31
- package/artifact/implicitRootLayout.ts +60 -107
- package/artifact/routeSeedIndex.test.ts +5 -18
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +39 -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 +506 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +265 -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 +37 -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 +111 -202
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +67 -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-static-in-object-light-model/bad.ts +43 -0
- package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
- package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -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-static-in-object-light-model.grit +27 -0
- 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 +19 -26
- 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,182 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { AkanBinConfig, BinPlatform } from "../akanConfig";
|
|
5
|
+
import { tempDirs } from "../testHelpers";
|
|
6
|
+
import { DesktopBin } from "./desktopBin";
|
|
7
|
+
|
|
8
|
+
const makeTempRoot = tempDirs("akan-desktop-bin-");
|
|
9
|
+
const here = DesktopBin.platform() as BinPlatform;
|
|
10
|
+
const elsewhere: BinPlatform = here === "linux-x64" ? "darwin-arm64" : "linux-x64";
|
|
11
|
+
const script = "#!/bin/sh\necho carried\n";
|
|
12
|
+
|
|
13
|
+
const fakeApp = (cwdPath: string, libs: string[] = []) =>
|
|
14
|
+
({
|
|
15
|
+
name: "portal",
|
|
16
|
+
cwdPath,
|
|
17
|
+
getScanInfo: () => ({ getLibs: () => libs }),
|
|
18
|
+
logger: { info: () => {}, warn: () => {} },
|
|
19
|
+
spawn: async (command: string, args: string[], { cwd }: { cwd?: string } = {}) => {
|
|
20
|
+
const proc = Bun.spawnSync([command, ...args], { cwd });
|
|
21
|
+
if (proc.exitCode !== 0) throw new Error(proc.stderr.toString());
|
|
22
|
+
return proc.stdout.toString();
|
|
23
|
+
},
|
|
24
|
+
}) as never;
|
|
25
|
+
|
|
26
|
+
const stage = async (
|
|
27
|
+
root: string,
|
|
28
|
+
bin: AkanBinConfig,
|
|
29
|
+
{ libBins = [], libs = [] }: { libBins?: { lib: string; bin: AkanBinConfig }[]; libs?: string[] } = {},
|
|
30
|
+
) => {
|
|
31
|
+
const binDir = path.join(root, "apps/portal/.akan/native/default/bin");
|
|
32
|
+
const config = { app: { name: "portal" }, bin, libBins } as never;
|
|
33
|
+
const carried = await new DesktopBin(fakeApp(path.join(root, "apps/portal"), libs), config).stage(binDir);
|
|
34
|
+
return { carried, binDir };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let requests = 0;
|
|
38
|
+
const served = new Map<string, Uint8Array<ArrayBuffer>>();
|
|
39
|
+
const files = Bun.serve({
|
|
40
|
+
port: 0,
|
|
41
|
+
fetch(req) {
|
|
42
|
+
requests++;
|
|
43
|
+
const body = served.get(new URL(req.url).pathname);
|
|
44
|
+
return body ? new Response(body) : new Response("missing", { status: 404 });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
afterAll(() => files.stop(true));
|
|
48
|
+
const hash = (bytes: Uint8Array | string) => new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
49
|
+
|
|
50
|
+
describe("DesktopBin", () => {
|
|
51
|
+
test("a build carries the file of the CPU it is for", () => {
|
|
52
|
+
expect(DesktopBin.platform("x64")).toBe(`${process.platform}-x64` as BinPlatform);
|
|
53
|
+
expect(DesktopBin.platform("ia32")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("carries the app's own entries and those of the libs it depends on, the app's first", () => {
|
|
57
|
+
const own = { ffmpeg: { [here]: { path: "/a/ffmpeg" } } };
|
|
58
|
+
const lib = { ffmpeg: { [here]: { path: "/lib/ffmpeg" } }, ffprobe: { [here]: { path: "/lib/ffprobe" } } };
|
|
59
|
+
const other = { gst: { [here]: { path: "/other/gst" } } };
|
|
60
|
+
const chosen = DesktopBin.select(
|
|
61
|
+
{
|
|
62
|
+
app: { name: "portal" } as never,
|
|
63
|
+
bin: own,
|
|
64
|
+
libBins: [
|
|
65
|
+
{ lib: "media", bin: lib },
|
|
66
|
+
{ lib: "robotics", bin: other },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
["media"],
|
|
70
|
+
);
|
|
71
|
+
expect([...chosen.entries()]).toEqual([
|
|
72
|
+
["ffmpeg", { owner: "apps/portal/akan.config.ts", platforms: own.ffmpeg }],
|
|
73
|
+
["ffprobe", { owner: "libs/media/akan.config.ts", platforms: lib.ffprobe }],
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("two libs may repeat an entry but not change it", () => {
|
|
78
|
+
const select = (second: AkanBinConfig) => () =>
|
|
79
|
+
DesktopBin.select(
|
|
80
|
+
{
|
|
81
|
+
app: { name: "portal" } as never,
|
|
82
|
+
bin: {},
|
|
83
|
+
libBins: [
|
|
84
|
+
{ lib: "media", bin: { ffmpeg: { [here]: { path: "/lib/ffmpeg" } } } },
|
|
85
|
+
{ lib: "video", bin: second },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
["media", "video"],
|
|
89
|
+
);
|
|
90
|
+
expect(select({ ffmpeg: { [here]: { path: "/lib/ffmpeg" } } })).not.toThrow();
|
|
91
|
+
expect(select({ ffmpeg: { [here]: { path: "/video/ffmpeg" } } })).toThrow(
|
|
92
|
+
"libs/media/akan.config.ts and libs/video/akan.config.ts declare bin.ffmpeg differently",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("keeps an extension on Windows, where PATH lookup needs one", () => {
|
|
97
|
+
expect(DesktopBin.fileName("ffmpeg", "/x/ffmpeg.exe", "win32")).toBe("ffmpeg.exe");
|
|
98
|
+
expect(DesktopBin.fileName("ffmpeg", "/x/ffmpeg", "win32")).toBe("ffmpeg.exe");
|
|
99
|
+
expect(DesktopBin.fileName("tool", "/x/tool.cmd", "win32")).toBe("tool.cmd");
|
|
100
|
+
expect(DesktopBin.fileName("ffmpeg", "/x/ffmpeg.exe", "darwin")).toBe("ffmpeg");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("names a download by its URL's extension alone, so no URL writes outside the cache folder", () => {
|
|
104
|
+
expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg-7.1-linux64.tar.xz")).toBe("download.tar.xz");
|
|
105
|
+
expect(DesktopBin.downloadName("https://x.dev/r/FFmpeg.ZIP?token=1")).toBe("download.zip");
|
|
106
|
+
expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg.exe")).toBe("download.exe");
|
|
107
|
+
expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg")).toBe("download");
|
|
108
|
+
expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg%E0.zip")).toBe("download.zip");
|
|
109
|
+
expect(DesktopBin.downloadName("https://x.dev/a/..%2F..%2Fevil.zip")).toBe("download.zip");
|
|
110
|
+
expect(DesktopBin.downloadName("https://x.dev/a/..%5C..%5Cevil.zip")).toBe("download.zip");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("unpacks with the OS's own tools", () => {
|
|
114
|
+
expect(DesktopBin.extractCommand("/c/a.zip", "/d", "linux")).toEqual(["unzip", "-q", "-o", "/c/a.zip", "-d", "/d"]);
|
|
115
|
+
expect(DesktopBin.extractCommand("/c/a.zip", "/d", "darwin")).toEqual(["tar", "-xf", "/c/a.zip", "-C", "/d"]);
|
|
116
|
+
expect(DesktopBin.extractCommand("C:\\c\\a.zip", "C:\\d", "win32")[0]).toMatch(/System32[\\/]tar\.exe$/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("copies a file into the app's bin folder, executable", async () => {
|
|
120
|
+
const root = await makeTempRoot();
|
|
121
|
+
writeFileSync(path.join(root, "tool"), script);
|
|
122
|
+
const { carried, binDir } = await stage(root, { tool: { [here]: { path: path.join(root, "tool") } } });
|
|
123
|
+
const staged = path.join(binDir, DesktopBin.fileName("tool", "tool"));
|
|
124
|
+
expect(carried).toEqual(["tool"]);
|
|
125
|
+
expect(readFileSync(staged, "utf8")).toBe(script);
|
|
126
|
+
if (process.platform !== "win32") expect(statSync(staged).mode & 0o111).toBe(0o111);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("empties the folder first, so a bin taken out of the config is not carried again", async () => {
|
|
130
|
+
const root = await makeTempRoot();
|
|
131
|
+
writeFileSync(path.join(root, "tool"), script);
|
|
132
|
+
writeFileSync(path.join(root, "other"), script);
|
|
133
|
+
await stage(root, { tool: { [here]: { path: path.join(root, "tool") } } });
|
|
134
|
+
const { binDir } = await stage(root, { other: { [here]: { path: path.join(root, "other") } } });
|
|
135
|
+
expect(readdirSync(binDir)).toEqual([DesktopBin.fileName("other", "other")]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("downloads once, checks the hash, and reuses the file it kept", async () => {
|
|
139
|
+
const root = await makeTempRoot();
|
|
140
|
+
served.set("/ffmpeg", new TextEncoder().encode(script));
|
|
141
|
+
const bin = { ffmpeg: { [here]: { url: `${files.url}ffmpeg`, sha256: hash(script) } } };
|
|
142
|
+
const before = requests;
|
|
143
|
+
await stage(root, bin);
|
|
144
|
+
await stage(root, bin);
|
|
145
|
+
expect(requests - before).toBe(1);
|
|
146
|
+
await expect(
|
|
147
|
+
stage(await makeTempRoot(), { ffmpeg: { [here]: { url: `${files.url}ffmpeg`, sha256: "0".repeat(64) } } }),
|
|
148
|
+
).rejects.toThrow(`hashes to ${hash(script)}, not the declared`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("takes the executable out of an archive", async () => {
|
|
152
|
+
const root = await makeTempRoot();
|
|
153
|
+
mkdirSync(path.join(root, "pkg/ffmpeg-7.1/bin"), { recursive: true });
|
|
154
|
+
writeFileSync(path.join(root, "pkg/ffmpeg-7.1/bin/ffmpeg"), script);
|
|
155
|
+
Bun.spawnSync(["tar", "-czf", path.join(root, "ffmpeg.tar.gz"), "-C", path.join(root, "pkg"), "ffmpeg-7.1"]);
|
|
156
|
+
served.set("/ffmpeg.tar.gz", new Uint8Array(readFileSync(path.join(root, "ffmpeg.tar.gz"))));
|
|
157
|
+
const { binDir } = await stage(root, {
|
|
158
|
+
ffmpeg: {
|
|
159
|
+
[here]: {
|
|
160
|
+
url: `${files.url}ffmpeg.tar.gz`,
|
|
161
|
+
sha256: hash(readFileSync(path.join(root, "ffmpeg.tar.gz"))),
|
|
162
|
+
file: "ffmpeg-7.1/bin/ffmpeg",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
expect(readFileSync(path.join(binDir, DesktopBin.fileName("ffmpeg", "ffmpeg")), "utf8")).toBe(script);
|
|
167
|
+
await expect(
|
|
168
|
+
stage(await makeTempRoot(), {
|
|
169
|
+
ffmpeg: { [here]: { path: path.join(root, "ffmpeg.tar.gz"), file: "ffmpeg-7.1/bin/ffprobe" } },
|
|
170
|
+
}),
|
|
171
|
+
).rejects.toThrow("the archive holds no ffmpeg-7.1/bin/ffprobe");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("refuses an entry with no file for this computer, and carries nothing for a lib the app does not use", async () => {
|
|
175
|
+
const root = await makeTempRoot();
|
|
176
|
+
await expect(stage(root, { ffmpeg: { [elsewhere]: { path: "/x/ffmpeg" } } })).rejects.toThrow(
|
|
177
|
+
`apps/portal/akan.config.ts: bin.ffmpeg names no ${here} file`,
|
|
178
|
+
);
|
|
179
|
+
const unused = await stage(root, {}, { libBins: [{ lib: "media", bin: { ffmpeg: { [here]: { path: "/x" } } } }] });
|
|
180
|
+
expect(unused.carried).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type AkanAppConfig,
|
|
5
|
+
type AkanBinConfig,
|
|
6
|
+
type AkanBinSource,
|
|
7
|
+
type AkanBinUrlSource,
|
|
8
|
+
type BinPlatform,
|
|
9
|
+
binPlatforms,
|
|
10
|
+
} from "../akanConfig";
|
|
11
|
+
import type { App } from "../commandDecorators";
|
|
12
|
+
|
|
13
|
+
type BinPlatforms = AkanBinConfig[string];
|
|
14
|
+
export type DesktopArch = "arm64" | "x64";
|
|
15
|
+
|
|
16
|
+
//* A desktop app is built on its own OS, for this computer's CPU or the one `--arch` names, so only that platform's file
|
|
17
|
+
//* is fetched. It is copied byte for byte into the folder the app carries as `desktop.bin`: no extended attribute, so
|
|
18
|
+
//* no quarantine flag, travels into the bundle.
|
|
19
|
+
export class DesktopBin {
|
|
20
|
+
constructor(
|
|
21
|
+
readonly app: App,
|
|
22
|
+
readonly config: AkanAppConfig,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
static platform(arch: string = process.arch): BinPlatform | null {
|
|
26
|
+
const platform = `${process.platform}-${arch}`;
|
|
27
|
+
return (binPlatforms as readonly string[]).includes(platform) ? (platform as BinPlatform) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The app's own entries, then those of the libs it depends on: the app's wins a name, two libs may not differ on one. */
|
|
31
|
+
static select(config: Pick<AkanAppConfig, "app" | "bin" | "libBins">, libs: string[]) {
|
|
32
|
+
const chosen = new Map<string, { owner: string; platforms: BinPlatforms }>();
|
|
33
|
+
for (const [name, platforms] of Object.entries(config.bin))
|
|
34
|
+
chosen.set(name, { owner: `apps/${config.app.name}/akan.config.ts`, platforms });
|
|
35
|
+
for (const { lib, bin } of config.libBins) {
|
|
36
|
+
if (!libs.includes(lib)) continue;
|
|
37
|
+
const owner = `libs/${lib}/akan.config.ts`;
|
|
38
|
+
for (const [name, platforms] of Object.entries(bin)) {
|
|
39
|
+
const held = chosen.get(name);
|
|
40
|
+
if (!held) chosen.set(name, { owner, platforms });
|
|
41
|
+
else if (held.owner.startsWith("libs/") && JSON.stringify(held.platforms) !== JSON.stringify(platforms))
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${held.owner} and ${owner} declare bin.${name} differently; declare it in apps/${config.app.name}/akan.config.ts to pick one.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return chosen;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Windows finds an executable on PATH by its extension, so the carried file keeps one. */
|
|
51
|
+
static fileName(name: string, executable: string, platform: NodeJS.Platform = process.platform) {
|
|
52
|
+
if (platform !== "win32" || /\.(exe|cmd|bat|com)$/i.test(name)) return name;
|
|
53
|
+
return `${name}${path.extname(executable).toLowerCase() || ".exe"}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//? Only the extension is taken from the URL: unzip and a Windows PATH lookup read it, and a decoded name could carry
|
|
57
|
+
//? a separator or `..` out of the cache folder.
|
|
58
|
+
static downloadName(url: string) {
|
|
59
|
+
const extension = /(?:\.tar)?\.[A-Za-z0-9]{1,8}$/.exec(path.posix.basename(new URL(url).pathname))?.[0] ?? "";
|
|
60
|
+
return `download${extension.toLowerCase()}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static extractCommand(archive: string, dir: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
64
|
+
if (platform === "linux" && /\.zip$/i.test(archive)) return ["unzip", "-q", "-o", archive, "-d", dir];
|
|
65
|
+
//? Windows' own bsdtar reads zip, and a GNU tar ahead of it on PATH (Git's) takes `C:` for a remote host.
|
|
66
|
+
const tar =
|
|
67
|
+
platform === "win32" ? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe") : "tar";
|
|
68
|
+
return [tar, "-xf", archive, "-C", dir];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static async sha256(file: string) {
|
|
72
|
+
return new Bun.CryptoHasher("sha256").update(await Bun.file(file).arrayBuffer()).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get #cacheDir() {
|
|
76
|
+
return path.join(this.app.cwdPath, ".akan", "cache", "bin");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Copies each chosen executable into `dir`, emptied first, and answers the names it carries. */
|
|
80
|
+
async stage(dir: string, arch: DesktopArch = process.arch as DesktopArch): Promise<string[]> {
|
|
81
|
+
const scanInfo = this.app.getScanInfo({ allowEmpty: true }) ?? (await this.app.scan({ write: false }));
|
|
82
|
+
const chosen = DesktopBin.select(this.config, scanInfo.getLibs());
|
|
83
|
+
if (!chosen.size) return [];
|
|
84
|
+
const platform = DesktopBin.platform(arch);
|
|
85
|
+
if (!platform)
|
|
86
|
+
throw new Error(
|
|
87
|
+
`bin: this desktop app is for ${process.platform}-${arch}, and a bin names only ${binPlatforms.join(", ")}.`,
|
|
88
|
+
);
|
|
89
|
+
await rm(dir, { recursive: true, force: true });
|
|
90
|
+
await mkdir(dir, { recursive: true });
|
|
91
|
+
for (const [name, { owner, platforms }] of chosen) {
|
|
92
|
+
const source = platforms[platform];
|
|
93
|
+
if (!source)
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${owner}: bin.${name} names no ${platform} file, the platform this desktop app is built for (it names ${Object.keys(platforms).join(", ") || "none"}).`,
|
|
96
|
+
);
|
|
97
|
+
const executable = await this.#executable(`${owner}: bin.${name}.${platform}`, source);
|
|
98
|
+
const target = path.join(dir, DesktopBin.fileName(name, executable));
|
|
99
|
+
await Bun.write(target, Bun.file(executable));
|
|
100
|
+
await chmod(target, 0o755);
|
|
101
|
+
this.app.logger.info(`The desktop app carries ${name} from ${"url" in source ? source.url : source.path}`);
|
|
102
|
+
}
|
|
103
|
+
return [...chosen.keys()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async #executable(at: string, source: AkanBinSource): Promise<string> {
|
|
107
|
+
const file = "url" in source ? await this.#download(at, source) : source.path;
|
|
108
|
+
if (!(await Bun.file(file).exists())) throw new Error(`${at}: ${file} does not exist`);
|
|
109
|
+
if (!source.file) return file;
|
|
110
|
+
const key = "url" in source ? source.sha256 : await DesktopBin.sha256(file);
|
|
111
|
+
const executable = path.join(await this.#extract(file, key), ...source.file.split("/"));
|
|
112
|
+
if (!(await Bun.file(executable).exists())) throw new Error(`${at}: the archive holds no ${source.file}`);
|
|
113
|
+
return executable;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async #download(at: string, { url, sha256 }: AkanBinUrlSource): Promise<string> {
|
|
117
|
+
const dir = path.join(this.#cacheDir, sha256);
|
|
118
|
+
const file = path.join(dir, DesktopBin.downloadName(url));
|
|
119
|
+
if (await Bun.file(file).exists()) return file;
|
|
120
|
+
await mkdir(dir, { recursive: true });
|
|
121
|
+
this.app.logger.info(`Downloading ${url}`);
|
|
122
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(600_000) });
|
|
123
|
+
if (!res.ok) throw new Error(`${at}: ${url} answered ${res.status}`);
|
|
124
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
125
|
+
const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
126
|
+
if (digest !== sha256) throw new Error(`${at}: ${url} hashes to ${digest}, not the declared ${sha256}`);
|
|
127
|
+
await Bun.write(`${file}.partial`, bytes);
|
|
128
|
+
await rename(`${file}.partial`, file);
|
|
129
|
+
return file;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #extract(archive: string, key: string): Promise<string> {
|
|
133
|
+
const dir = path.join(this.#cacheDir, key, "extracted");
|
|
134
|
+
if (await Bun.file(`${dir}.done`).exists()) return dir;
|
|
135
|
+
await rm(dir, { recursive: true, force: true });
|
|
136
|
+
await mkdir(dir, { recursive: true });
|
|
137
|
+
const [command = "tar", ...args] = DesktopBin.extractCommand(archive, dir);
|
|
138
|
+
if (command === "unzip" && !Bun.which("unzip"))
|
|
139
|
+
throw new Error(`${archive} is a zip, and this computer has no unzip: install it, or use a .tar.gz or .tar.xz.`);
|
|
140
|
+
await this.app.spawn(command, args, { cwd: dir });
|
|
141
|
+
await writeFile(`${dir}.done`, "");
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AkanAppConfig } from "../akanConfig";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
7
|
+
import type { PackageJson } from "../types";
|
|
8
|
+
import { DesktopServerStage } from "./desktopServerStage";
|
|
9
|
+
import { NativeApi } from "./nativeApi";
|
|
10
|
+
import { NativeApp } from "./nativeApp";
|
|
11
|
+
import { NativeConfig } from "./nativeConfig";
|
|
12
|
+
|
|
13
|
+
const makeTempRoot = tempDirs("akan-desktop-server-");
|
|
14
|
+
const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
|
|
15
|
+
const baseDevEnv = {
|
|
16
|
+
repoName: "akanjs",
|
|
17
|
+
serveDomain: "akanjs.com",
|
|
18
|
+
env: "debug" as const,
|
|
19
|
+
portOffset: 0,
|
|
20
|
+
workspaceRoot: "/w",
|
|
21
|
+
};
|
|
22
|
+
const rootPackageJson: PackageJson = {
|
|
23
|
+
name: "repo",
|
|
24
|
+
version: "1.0.0",
|
|
25
|
+
description: "repo",
|
|
26
|
+
dependencies: { "@external/runtime": "2.0.0", pg: "8.0.0", rclnodejs: "1.0.0" },
|
|
27
|
+
};
|
|
28
|
+
const appConfig = (config: object = {}) =>
|
|
29
|
+
new AkanAppConfig({ name: "portal" } as never, [], rootPackageJson, config, baseDevEnv);
|
|
30
|
+
|
|
31
|
+
describe("DesktopServerStage", () => {
|
|
32
|
+
test("starts from the image's env, as an API-only edge server on SQLite that the launcher gives a port", () => {
|
|
33
|
+
const env = DesktopServerStage.env(appConfig({ database: { modes: ["single", "cluster"] } }), "main");
|
|
34
|
+
|
|
35
|
+
expect(env).toMatchObject({
|
|
36
|
+
NODE_ENV: "production",
|
|
37
|
+
AKAN_PUBLIC_APP_NAME: "portal",
|
|
38
|
+
AKAN_PUBLIC_ENV: "main",
|
|
39
|
+
AKAN_PUBLIC_OPERATION_MODE: "edge",
|
|
40
|
+
AKAN_DATABASE_MODE: "single",
|
|
41
|
+
AKAN_DATABASE_MODES: "single",
|
|
42
|
+
AKAN_SSR: "false",
|
|
43
|
+
AKAN_CSR: "false",
|
|
44
|
+
AKAN_MCP: "false",
|
|
45
|
+
AKAN_SHUTDOWN_TIMEOUT_MS: "1000",
|
|
46
|
+
});
|
|
47
|
+
expect(env.PORT).toBeUndefined();
|
|
48
|
+
expect(env.AKAN_LOG_TO_FILE).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
|
|
52
|
+
const config = appConfig({ database: { modes: ["single", "cluster"] }, externalLibs: ["@external/runtime"] });
|
|
53
|
+
const built = config.getProductionPackageJson();
|
|
54
|
+
const drivers = config.getDatabaseModeRuntimePackages("cluster");
|
|
55
|
+
|
|
56
|
+
const { dependencies } = DesktopServerStage.packageJson(config, built);
|
|
57
|
+
|
|
58
|
+
expect(Object.keys(dependencies ?? {}).sort()).toEqual(["@external/runtime", "react", "react-dom"]);
|
|
59
|
+
for (const driver of drivers) expect(Object.keys(built.dependencies ?? {})).toContain(driver);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("leaves out what desktop.server.omit names, an external lib included, while the image keeps it", () => {
|
|
63
|
+
const config = appConfig({ externalLibs: ["rclnodejs", "@external/runtime"] });
|
|
64
|
+
const built = config.getProductionPackageJson();
|
|
65
|
+
|
|
66
|
+
const { dependencies } = DesktopServerStage.packageJson(config, built, ["rclnodejs"]);
|
|
67
|
+
|
|
68
|
+
expect(Object.keys(dependencies ?? {})).toContain("@external/runtime");
|
|
69
|
+
expect(Object.keys(dependencies ?? {})).not.toContain("rclnodejs");
|
|
70
|
+
expect(Object.keys(config.getProductionPackageJson().dependencies ?? {})).toContain("rclnodejs");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("keeps the image's trusted packages, so their install scripts run in the app's server too", () => {
|
|
74
|
+
const config = appConfig({ trustedDependencies: ["@external/runtime"], externalLibs: ["@external/runtime"] });
|
|
75
|
+
expect(DesktopServerStage.packageJson(config, config.getProductionPackageJson()).trustedDependencies).toEqual([
|
|
76
|
+
"@external/runtime",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("says what the image installs that the desktop app's server goes without", () => {
|
|
81
|
+
const notice = (docker: unknown, carried: string[] = []) =>
|
|
82
|
+
DesktopServerStage.imageStepsNotice({ app: { name: "portal" }, docker } as never, carried);
|
|
83
|
+
const steps = { image: "oven/bun", preRuns: ["apt-get install -y ffmpeg"], postRuns: [], command: [] };
|
|
84
|
+
expect(notice({ ...steps, preRuns: [] })).toBeNull();
|
|
85
|
+
expect(notice(steps)).toEqual({
|
|
86
|
+
level: "warn",
|
|
87
|
+
message: expect.stringContaining(
|
|
88
|
+
"The image runs 1 docker step from the app and its libs, and a desktop app's server runs none of them",
|
|
89
|
+
),
|
|
90
|
+
});
|
|
91
|
+
expect(notice("FROM ros:humble-ros-core")?.message).toStartWith(
|
|
92
|
+
"apps/portal/akan.config.ts writes its own Dockerfile",
|
|
93
|
+
);
|
|
94
|
+
expect(notice(steps, ["ffmpeg"])).toEqual({
|
|
95
|
+
level: "info",
|
|
96
|
+
message: expect.stringContaining("carries only bin: ffmpeg"),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const stageApp = async (spawn: (...args: unknown[]) => Promise<string> = async () => "") => {
|
|
101
|
+
const root = await makeTempRoot();
|
|
102
|
+
const dist = path.join(root, "dist/apps/portal");
|
|
103
|
+
const config = appConfig();
|
|
104
|
+
const app = {
|
|
105
|
+
cwdPath: path.join(root, "apps/portal"),
|
|
106
|
+
dist: { cwdPath: dist },
|
|
107
|
+
getConfig: async () => config,
|
|
108
|
+
getScanInfo: () => ({ getLibs: () => [] }),
|
|
109
|
+
logger: { info: () => undefined, warn: () => undefined },
|
|
110
|
+
spawn,
|
|
111
|
+
} as unknown as App;
|
|
112
|
+
return { dist, app, stage: new DesktopServerStage(app) };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
test("carries everything the backend build wrote beside main.js but what only the image reads", async () => {
|
|
116
|
+
const { dist, stage } = await stageApp();
|
|
117
|
+
const carried = [
|
|
118
|
+
"main.js",
|
|
119
|
+
"server.js",
|
|
120
|
+
"chunk-5eap2n9b.js",
|
|
121
|
+
"akan.build.json",
|
|
122
|
+
"better-sqlite3-a1b2c3d4.node",
|
|
123
|
+
"engine-e5f6a7b8.wasm",
|
|
124
|
+
"model-c9d0e1f2.bin",
|
|
125
|
+
"private/service-account.json",
|
|
126
|
+
];
|
|
127
|
+
const imageOnly = [
|
|
128
|
+
"Dockerfile",
|
|
129
|
+
"package.json",
|
|
130
|
+
"rscWorker.js",
|
|
131
|
+
"console.js",
|
|
132
|
+
"console-runtime.js",
|
|
133
|
+
"csr/index.html",
|
|
134
|
+
"public/favicon.ico",
|
|
135
|
+
".akan/artifact/base-artifact.json",
|
|
136
|
+
];
|
|
137
|
+
for (const file of [...carried, ...imageOnly]) await write(path.join(dist, file), file);
|
|
138
|
+
|
|
139
|
+
await stage.prepare("main");
|
|
140
|
+
|
|
141
|
+
const staged = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: stage.dir, dot: true }));
|
|
142
|
+
expect(staged.map((file) => file.split(path.sep).join("/")).sort()).toEqual(carried.sort());
|
|
143
|
+
expect(await Bun.file(path.join(stage.dir, "better-sqlite3-a1b2c3d4.node")).text()).toBe(
|
|
144
|
+
"better-sqlite3-a1b2c3d4.node",
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("installs from the config alone, offline when Bun's cache holds the packages", async () => {
|
|
149
|
+
const spawned: unknown[][] = [];
|
|
150
|
+
const { stage } = await stageApp(async (...args) => {
|
|
151
|
+
spawned.push(args);
|
|
152
|
+
return "";
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await stage.install();
|
|
156
|
+
|
|
157
|
+
expect(spawned).toEqual([[process.execPath, ["install", "--production", "--prefer-offline"], { cwd: stage.dir }]]);
|
|
158
|
+
const installed = (await Bun.file(path.join(stage.dir, "package.json")).json()) as PackageJson;
|
|
159
|
+
expect(Object.keys(installed.dependencies ?? {}).sort()).toEqual(["react", "react-dom"]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("an omitted package another dependency still installs stops the stage, naming who needs it", async () => {
|
|
163
|
+
const installing =
|
|
164
|
+
(pulled: boolean) =>
|
|
165
|
+
async (...args: unknown[]) => {
|
|
166
|
+
const nodeModules = path.join((args[2] as { cwd: string }).cwd, "node_modules");
|
|
167
|
+
await write(path.join(nodeModules, "react/package.json"), JSON.stringify({ name: "react" }));
|
|
168
|
+
if (pulled) {
|
|
169
|
+
await write(path.join(nodeModules, "rclnodejs/package.json"), JSON.stringify({ name: "rclnodejs" }));
|
|
170
|
+
await write(
|
|
171
|
+
path.join(nodeModules, "@robot/bridge/package.json"),
|
|
172
|
+
JSON.stringify({ name: "@robot/bridge", dependencies: { rclnodejs: "^1" } }),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return "";
|
|
176
|
+
};
|
|
177
|
+
await (await stageApp(installing(false))).stage.install(undefined, ["rclnodejs"]);
|
|
178
|
+
await expect((await stageApp(installing(true))).stage.install(undefined, ["rclnodejs"])).rejects.toThrow(
|
|
179
|
+
"another dependency still installs: rclnodejs (needed by @robot/bridge)",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("installs another CPU's optional packages for that CPU", () => {
|
|
184
|
+
const other = process.arch === "arm64" ? "x64" : "arm64";
|
|
185
|
+
expect(DesktopServerStage.installArgs()).toEqual(["install", "--production", "--prefer-offline"]);
|
|
186
|
+
expect(DesktopServerStage.installArgs(process.arch as "arm64" | "x64")).toEqual([
|
|
187
|
+
"install",
|
|
188
|
+
"--production",
|
|
189
|
+
"--prefer-offline",
|
|
190
|
+
]);
|
|
191
|
+
expect(DesktopServerStage.installArgs(other)).toEqual([
|
|
192
|
+
"install",
|
|
193
|
+
"--production",
|
|
194
|
+
"--prefer-offline",
|
|
195
|
+
`--cpu=${other}`,
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("an install that fails says it needs the registry or a Bun cache that holds the packages", async () => {
|
|
200
|
+
const { stage } = await stageApp(async () => {
|
|
201
|
+
throw new Error("error: GET https://registry.npmjs.org/scheduler - ConnectionRefused");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const failed = stage.install();
|
|
205
|
+
|
|
206
|
+
await expect(failed).rejects.toThrow(
|
|
207
|
+
"it needs the npm registry, or a Bun cache that already holds every one of them.\nerror: GET https://registry.npmjs.org/scheduler",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("refuses an app whose database modes leave out single", () => {
|
|
212
|
+
expect(() => DesktopServerStage.assertCarriable(appConfig({ database: { modes: ["cluster"] } }))).toThrow(
|
|
213
|
+
"only database mode single runs",
|
|
214
|
+
);
|
|
215
|
+
expect(() =>
|
|
216
|
+
DesktopServerStage.assertCarriable(appConfig({ database: { modes: ["single", "cluster"] } })),
|
|
217
|
+
).not.toThrow();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("the runtime accepts a desktop config that carries the server, with single-instance on", async () => {
|
|
221
|
+
const root = await makeTempRoot();
|
|
222
|
+
await mkdir(path.join(root, "web"), { recursive: true });
|
|
223
|
+
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
224
|
+
await mkdir(path.join(root, "server"), { recursive: true });
|
|
225
|
+
await writeFile(path.join(root, "server/main.js"), "");
|
|
226
|
+
const server = { dir: path.join(root, "server"), entry: "main.js", env: { AKAN_PUBLIC_ENV: "main" } };
|
|
227
|
+
|
|
228
|
+
const { config } = NativeConfig.build({
|
|
229
|
+
appPath: root,
|
|
230
|
+
target: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
|
|
231
|
+
webDir: path.join(root, "web"),
|
|
232
|
+
contributions: [],
|
|
233
|
+
locales: ["en"],
|
|
234
|
+
platform: "macos",
|
|
235
|
+
desktopServer: server,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(config.plugins).toContain("single-instance");
|
|
239
|
+
expect(config.desktop).toEqual({ server });
|
|
240
|
+
expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("a phone build cannot carry the server", async () => {
|
|
244
|
+
const app = new NativeApp({ cwdPath: "/repo/apps/portal" } as never, {
|
|
245
|
+
name: "default",
|
|
246
|
+
config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
|
|
247
|
+
});
|
|
248
|
+
const server = { dir: "/tmp/server", entry: "main.js", env: {} };
|
|
249
|
+
await expect(app.build("ios", { server })).rejects.toThrow("Only a desktop app carries its server");
|
|
250
|
+
await expect(app.run("android", { server })).rejects.toThrow("Only a desktop app carries its server");
|
|
251
|
+
});
|
|
252
|
+
});
|