@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,44 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Browser APIs a native shell's WebView lacks: Android's WebView has no Web Share, an app:// page on iOS has no
|
|
5
|
+
// service worker, no WebView has the Notification API, and iOS has no vibration. They stay legal on the web, so the
|
|
6
|
+
// rule does not ban them — it keeps them in `webkit/`, the layer whose hooks branch on the platform (and is
|
|
7
|
+
// excluded from this rule's scope in biome.base.json), and names the native side where one exists.
|
|
8
|
+
//
|
|
9
|
+
// Pinned to member access, so JSX text naming an API does not match; `Notification` only through its static entry
|
|
10
|
+
// points, because the name is also the libs/shared model (`Notification.View`, `cnst.Notification`).
|
|
11
|
+
or {
|
|
12
|
+
JsStaticMemberExpression() as $access where {
|
|
13
|
+
$access <: or { `navigator.share`, `navigator.canShare` },
|
|
14
|
+
register_diagnostic(
|
|
15
|
+
span = $access,
|
|
16
|
+
message = "Android's WebView has no Web Share, so this does nothing in the app. Move it into a `webkit/` hook that branches on `isNativeApp()` from `akanjs/client/native`; the runtime's `share` plugin is the app side.",
|
|
17
|
+
severity = "warn"
|
|
18
|
+
)
|
|
19
|
+
},
|
|
20
|
+
JsStaticMemberExpression() as $access where {
|
|
21
|
+
$access <: or { `navigator.serviceWorker`, `Notification.requestPermission`, `Notification.permission` },
|
|
22
|
+
register_diagnostic(
|
|
23
|
+
span = $access,
|
|
24
|
+
message = "A native app page has no service worker or Notification API. Keep this in a `webkit/` hook behind `isNativeApp()`; native push is `push` from `akanjs/client/native`, and `usePushNotification` in libs/util wraps both.",
|
|
25
|
+
severity = "warn"
|
|
26
|
+
)
|
|
27
|
+
},
|
|
28
|
+
JsStaticMemberExpression() as $access where {
|
|
29
|
+
$access <: `navigator.geolocation`,
|
|
30
|
+
register_diagnostic(
|
|
31
|
+
span = $access,
|
|
32
|
+
message = "`navigator.geolocation` skips the app's location plugin and its permission flow. Use `useGeoLocation` from `akanjs/webkit`.",
|
|
33
|
+
severity = "warn"
|
|
34
|
+
)
|
|
35
|
+
},
|
|
36
|
+
JsStaticMemberExpression() as $access where {
|
|
37
|
+
$access <: `navigator.vibrate`,
|
|
38
|
+
register_diagnostic(
|
|
39
|
+
span = $access,
|
|
40
|
+
message = "iOS has no `navigator.vibrate`. Use `haptics` from `akanjs/client/native` in a `webkit/` hook.",
|
|
41
|
+
severity = "warn"
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Web Storage works inside the native shells, so this is not about a crash: akanjs/client's `storage` is where the
|
|
5
|
+
// backend is chosen per platform (the shell's Preferences — UserDefaults, SharedPreferences, a JSON file on the
|
|
6
|
+
// desktop — in an app, localStorage on the web), `secretStorage` keeps credentials in the Keychain/Keystore and
|
|
7
|
+
// clears them on a reinstall, and both answer nothing on the server where a bare `localStorage` read during SSR
|
|
8
|
+
// throws.
|
|
9
|
+
//
|
|
10
|
+
// Only the global is matched — a reference, or a member of window/globalThis/self — pinned to those node kinds: a
|
|
11
|
+
// bare snippet matches the name in any position, a field, an object key or JSX text included.
|
|
12
|
+
or {
|
|
13
|
+
JsIdentifierExpression() as $access where {
|
|
14
|
+
$access <: or { `localStorage`, `sessionStorage` },
|
|
15
|
+
register_diagnostic(
|
|
16
|
+
span = $access,
|
|
17
|
+
message = "Web Storage bypasses the store akanjs picks per platform (the shell's Preferences in an app) and throws during SSR. Use `storage` from `akanjs/client`, or `secretStorage` for tokens and credentials; a value that may be lost with the tab belongs in component state or the store.",
|
|
18
|
+
severity = "error"
|
|
19
|
+
)
|
|
20
|
+
},
|
|
21
|
+
JsStaticMemberExpression(object = $root, member = $name) as $access where {
|
|
22
|
+
$root <: or { `window`, `globalThis`, `self` },
|
|
23
|
+
$name <: or { `localStorage`, `sessionStorage` },
|
|
24
|
+
register_diagnostic(
|
|
25
|
+
span = $access,
|
|
26
|
+
message = "Web Storage bypasses the store akanjs picks per platform (the shell's Preferences in an app) and throws during SSR. Use `storage` from `akanjs/client`, or `secretStorage` for tokens and credentials; a value that may be lost with the tab belongs in component state or the store.",
|
|
27
|
+
severity = "error"
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
}
|
package/linter.test.ts
CHANGED
|
@@ -27,8 +27,7 @@ describe("parseBiomeReport", () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
test("reads a report behind leading text that carries a brace", () => {
|
|
30
|
-
//
|
|
31
|
-
// diagnostics onto the same stream as the report, and those messages quote source and config.
|
|
30
|
+
// Biome prints configuration and IO diagnostics onto the report's stream, and they quote source and config.
|
|
32
31
|
const output = [
|
|
33
32
|
"configuration/deserialize: unknown key `overrides[0].includes { }`",
|
|
34
33
|
" the file `biome.jsonc` cannot be read",
|
package/linter.ts
CHANGED
|
@@ -58,16 +58,8 @@ interface LintResponse {
|
|
|
58
58
|
warnings: LintMessage[];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
* Biome mixes configuration and IO diagnostics into the same stream as the report, so the output is not
|
|
65
|
-
* always JSON alone. Slicing from the first `{` to the last `}` mis-parses as soon as that leading text
|
|
66
|
-
* carries a brace of its own, which is not hypothetical: a config error printed ahead of the report made
|
|
67
|
-
* one unchanged command alternate between 2 and 10 diagnostics. So every `{` is tried as a start, its
|
|
68
|
-
* match is found by depth while string literals are skipped, and the first candidate that both parses and
|
|
69
|
-
* carries a report field wins — a brace in prose parses as nothing, and `{"a":1}` in prose is not a report.
|
|
70
|
-
*/
|
|
61
|
+
// Biome mixes config and IO diagnostics into the report stream, and that text can carry braces of its own, so every
|
|
62
|
+
// `{` is tried as a start (string literals skipped) and the first candidate that parses and carries a report field wins.
|
|
71
63
|
export const parseBiomeReport = (output: string): BiomeReport => {
|
|
72
64
|
for (let start = output.indexOf("{"); start !== -1; start = output.indexOf("{", start + 1)) {
|
|
73
65
|
const end = objectEndAt(output, start);
|
|
@@ -111,7 +103,19 @@ const asBiomeReport = (candidate: string): BiomeReport | null => {
|
|
|
111
103
|
/** Biome reads `biome.json` first and `biome.jsonc` second; only the latter may carry comments. */
|
|
112
104
|
const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
|
|
113
105
|
|
|
106
|
+
const emptyResult = (filePath: string): LintResult => ({
|
|
107
|
+
filePath,
|
|
108
|
+
messages: [],
|
|
109
|
+
errorCount: 0,
|
|
110
|
+
warningCount: 0,
|
|
111
|
+
fixableErrorCount: 0,
|
|
112
|
+
fixableWarningCount: 0,
|
|
113
|
+
});
|
|
114
|
+
|
|
114
115
|
export class Linter {
|
|
116
|
+
//? Bun links a package bin as `<name>.exe` (plus a `.bunx` stub) on Windows, and a bare `<name>` elsewhere.
|
|
117
|
+
static readonly biomeBinName = process.platform === "win32" ? "biome.exe" : "biome";
|
|
118
|
+
|
|
115
119
|
lintRoot: string;
|
|
116
120
|
configPath: string;
|
|
117
121
|
#biomeBin: string;
|
|
@@ -119,7 +123,7 @@ export class Linter {
|
|
|
119
123
|
constructor(cwdPath: string) {
|
|
120
124
|
this.lintRoot = this.#findBiomeRootPath(cwdPath);
|
|
121
125
|
this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
|
|
122
|
-
const localBiomeBin = path.join(this.lintRoot, "node_modules
|
|
126
|
+
const localBiomeBin = path.join(this.lintRoot, "node_modules", ".bin", Linter.biomeBinName);
|
|
123
127
|
this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
|
|
124
128
|
}
|
|
125
129
|
|
|
@@ -199,16 +203,7 @@ export class Linter {
|
|
|
199
203
|
for (const diagnostic of report.diagnostics ?? []) {
|
|
200
204
|
if (diagnostic.severity !== "error" && diagnostic.severity !== "warning") continue;
|
|
201
205
|
const diagnosticFilePath = this.#diagnosticFilePath(diagnostic, filePath);
|
|
202
|
-
const result =
|
|
203
|
-
resultsByPath.get(diagnosticFilePath) ??
|
|
204
|
-
({
|
|
205
|
-
filePath: diagnosticFilePath,
|
|
206
|
-
messages: [],
|
|
207
|
-
errorCount: 0,
|
|
208
|
-
warningCount: 0,
|
|
209
|
-
fixableErrorCount: 0,
|
|
210
|
-
fixableWarningCount: 0,
|
|
211
|
-
} satisfies LintResult);
|
|
206
|
+
const result = resultsByPath.get(diagnosticFilePath) ?? emptyResult(diagnosticFilePath);
|
|
212
207
|
const message = this.#createLintMessage(diagnostic);
|
|
213
208
|
result.messages.push(message);
|
|
214
209
|
if (message.severity === 2) result.errorCount += 1;
|
|
@@ -217,15 +212,7 @@ export class Linter {
|
|
|
217
212
|
}
|
|
218
213
|
|
|
219
214
|
return [
|
|
220
|
-
resultsByPath.get(filePath) ??
|
|
221
|
-
({
|
|
222
|
-
filePath,
|
|
223
|
-
messages: [],
|
|
224
|
-
errorCount: 0,
|
|
225
|
-
warningCount: 0,
|
|
226
|
-
fixableErrorCount: 0,
|
|
227
|
-
fixableWarningCount: 0,
|
|
228
|
-
} satisfies LintResult),
|
|
215
|
+
resultsByPath.get(filePath) ?? emptyResult(filePath),
|
|
229
216
|
...[...resultsByPath.entries()].filter(([resultPath]) => resultPath !== filePath).map(([, result]) => result),
|
|
230
217
|
];
|
|
231
218
|
}
|
|
@@ -269,22 +256,12 @@ export class Linter {
|
|
|
269
256
|
return await this.lintFile(filePath);
|
|
270
257
|
}
|
|
271
258
|
|
|
272
|
-
/**
|
|
273
|
-
* Lint a single file using Biome.
|
|
274
|
-
* @param filePath - Path to the file to lint
|
|
275
|
-
* @returns Array of Biome results in the legacy lint result shape
|
|
276
|
-
*/
|
|
277
259
|
async lintFile(filePath: string): Promise<LintResponse> {
|
|
278
260
|
const resolvedFilePath = this.#resolveFilePath(filePath);
|
|
279
261
|
if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
|
|
280
262
|
return await this.#checkFile(resolvedFilePath);
|
|
281
263
|
}
|
|
282
264
|
|
|
283
|
-
/**
|
|
284
|
-
* Format lint results for console output
|
|
285
|
-
* @param results - Array of Biome results
|
|
286
|
-
* @returns Formatted string
|
|
287
|
-
*/
|
|
288
265
|
formatLintResults(results: LintResult[]): string {
|
|
289
266
|
if (results.length === 0) return "No files to lint";
|
|
290
267
|
|
|
@@ -305,7 +282,7 @@ export class Linter {
|
|
|
305
282
|
const sourceContent = readFileSync(result.filePath, "utf8");
|
|
306
283
|
sourceLines = sourceContent.split("\n");
|
|
307
284
|
} catch {
|
|
308
|
-
|
|
285
|
+
//? An unreadable source only loses its code frame.
|
|
309
286
|
}
|
|
310
287
|
}
|
|
311
288
|
|
|
@@ -318,14 +295,12 @@ export class Linter {
|
|
|
318
295
|
output.push(`\n ${icon} ${typeColor(type)}: ${message.message}${ruleInfo}`);
|
|
319
296
|
output.push(` ${chalk.gray("at")} ${result.filePath}:${chalk.bold(`${message.line}:${message.column}`)}`);
|
|
320
297
|
|
|
321
|
-
// Show source line with underline
|
|
322
298
|
if (sourceLines.length > 0 && message.line <= sourceLines.length) {
|
|
323
299
|
const sourceLine = sourceLines[message.line - 1];
|
|
324
300
|
const lineNumber = message.line.toString().padStart(5, " ");
|
|
325
301
|
|
|
326
302
|
output.push(`\n${chalk.dim(`${lineNumber} |`)} ${sourceLine}`);
|
|
327
303
|
|
|
328
|
-
// Create underline
|
|
329
304
|
const underlinePrefix = " ".repeat(message.column - 1);
|
|
330
305
|
const underlineLength = message.endColumn ? message.endColumn - message.column : 1;
|
|
331
306
|
const underline = "^".repeat(Math.max(1, underlineLength));
|
|
@@ -345,27 +320,7 @@ export class Linter {
|
|
|
345
320
|
return summary.concat(output).join("\n");
|
|
346
321
|
}
|
|
347
322
|
|
|
348
|
-
|
|
349
|
-
* Get detailed lint information
|
|
350
|
-
* @param filePath - Path to the file to lint
|
|
351
|
-
* @returns Object containing detailed lint information
|
|
352
|
-
*/
|
|
353
|
-
async getDetailedLintInfo(filePath: string): Promise<{
|
|
354
|
-
results: LintResult[];
|
|
355
|
-
details: {
|
|
356
|
-
line: number;
|
|
357
|
-
column: number;
|
|
358
|
-
message: string;
|
|
359
|
-
ruleId: string | null;
|
|
360
|
-
severity: "error" | "warning";
|
|
361
|
-
}[];
|
|
362
|
-
stats: {
|
|
363
|
-
errorCount: number;
|
|
364
|
-
warningCount: number;
|
|
365
|
-
fixableErrorCount: number;
|
|
366
|
-
fixableWarningCount: number;
|
|
367
|
-
};
|
|
368
|
-
}> {
|
|
323
|
+
async getDetailedLintInfo(filePath: string) {
|
|
369
324
|
const { results } = await this.lintFile(filePath);
|
|
370
325
|
|
|
371
326
|
const details = results.flatMap((result) =>
|
|
@@ -385,22 +340,13 @@ export class Linter {
|
|
|
385
340
|
fixableErrorCount: acc.fixableErrorCount + result.fixableErrorCount,
|
|
386
341
|
fixableWarningCount: acc.fixableWarningCount + result.fixableWarningCount,
|
|
387
342
|
}),
|
|
388
|
-
{
|
|
389
|
-
errorCount: 0,
|
|
390
|
-
warningCount: 0,
|
|
391
|
-
fixableErrorCount: 0,
|
|
392
|
-
fixableWarningCount: 0,
|
|
393
|
-
},
|
|
343
|
+
{ errorCount: 0, warningCount: 0, fixableErrorCount: 0, fixableWarningCount: 0 },
|
|
394
344
|
);
|
|
395
345
|
|
|
396
346
|
return { results, details, stats };
|
|
397
347
|
}
|
|
398
348
|
|
|
399
|
-
/**
|
|
400
|
-
* Check if a file has lint errors
|
|
401
|
-
* @param filePath - Path to the file to check
|
|
402
|
-
* @returns true if there are no errors, false otherwise
|
|
403
|
-
*/
|
|
349
|
+
/** Also false when the file cannot be linted at all. */
|
|
404
350
|
async hasNoLintErrors(filePath: string): Promise<boolean> {
|
|
405
351
|
try {
|
|
406
352
|
const { results } = await this.lintFile(filePath);
|
|
@@ -410,32 +356,17 @@ export class Linter {
|
|
|
410
356
|
}
|
|
411
357
|
}
|
|
412
358
|
|
|
413
|
-
/**
|
|
414
|
-
* Get only error messages (excluding warnings)
|
|
415
|
-
* @param filePath - Path to the file to lint
|
|
416
|
-
* @returns Array of error messages
|
|
417
|
-
*/
|
|
418
359
|
async getErrors(filePath: string): Promise<LintMessage[]> {
|
|
419
360
|
const { results } = await this.lintFile(filePath);
|
|
420
361
|
return results.flatMap((result) => result.messages.filter((message) => message.severity === 2));
|
|
421
362
|
}
|
|
422
363
|
|
|
423
|
-
/**
|
|
424
|
-
* Get only warning messages
|
|
425
|
-
* @param filePath - Path to the file to lint
|
|
426
|
-
* @returns Array of warning messages
|
|
427
|
-
*/
|
|
428
364
|
async getWarnings(filePath: string): Promise<LintMessage[]> {
|
|
429
365
|
const { results } = await this.lintFile(filePath);
|
|
430
366
|
return results.flatMap((result) => result.messages.filter((message) => message.severity === 1));
|
|
431
367
|
}
|
|
432
368
|
|
|
433
|
-
/**
|
|
434
|
-
* Fix lint errors automatically
|
|
435
|
-
* @param filePath - Path to the file to fix
|
|
436
|
-
* @param dryRun - If true, returns the fixed content without writing to file
|
|
437
|
-
* @returns Fixed content and remaining issues
|
|
438
|
-
*/
|
|
369
|
+
/** `dryRun` returns the fixed content in `output` without writing the file. */
|
|
439
370
|
async fixFile(filePath: string, dryRun = false): Promise<LintResponse> {
|
|
440
371
|
const resolvedFilePath = this.#resolveFilePath(filePath);
|
|
441
372
|
if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
|
|
@@ -451,19 +382,8 @@ export class Linter {
|
|
|
451
382
|
return { ...lintResult, fixed: stdout !== source, output: stdout };
|
|
452
383
|
}
|
|
453
384
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
* @param filePath - Path to the file
|
|
457
|
-
* @returns Biome configuration object
|
|
458
|
-
*/
|
|
459
|
-
/**
|
|
460
|
-
* Applies Biome's safe fixes to many files in one process.
|
|
461
|
-
*
|
|
462
|
-
* One spawn, not one per file: Biome's startup dominates a small file, so the per-file path turns a
|
|
463
|
-
* fifteen-file scaffold into three seconds of process launches. Paths that do not exist are dropped
|
|
464
|
-
* rather than thrown on, because the caller is usually reporting what a template just wrote and a
|
|
465
|
-
* template is allowed to decline a file.
|
|
466
|
-
*/
|
|
385
|
+
// One spawn for all files: Biome's startup dominates a small file. Missing paths are dropped (a template may
|
|
386
|
+
// decline a file).
|
|
467
387
|
async fixFiles(filePaths: string[]): Promise<{ fixed: string[] }> {
|
|
468
388
|
const resolved = filePaths.map((filePath) => this.#resolveFilePath(filePath)).filter(existsSync);
|
|
469
389
|
if (resolved.length === 0) return { fixed: [] };
|
|
@@ -489,21 +409,11 @@ export class Linter {
|
|
|
489
409
|
return JSON.parse(readFileSync(this.configPath, "utf8")) as unknown;
|
|
490
410
|
}
|
|
491
411
|
|
|
492
|
-
/**
|
|
493
|
-
* Get rules that are causing errors in a file
|
|
494
|
-
* @param filePath - Path to the file to check
|
|
495
|
-
* @returns Object mapping rule IDs to their error counts
|
|
496
|
-
*/
|
|
497
412
|
async getProblematicRules(filePath: string): Promise<Record<string, number>> {
|
|
498
413
|
const { results } = await this.lintFile(filePath);
|
|
499
414
|
const ruleCounts: Record<string, number> = {};
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
result.messages.forEach((message) => {
|
|
503
|
-
if (message.ruleId) ruleCounts[message.ruleId] = (ruleCounts[message.ruleId] || 0) + 1;
|
|
504
|
-
});
|
|
505
|
-
});
|
|
506
|
-
|
|
415
|
+
for (const message of results.flatMap((result) => result.messages))
|
|
416
|
+
if (message.ruleId) ruleCounts[message.ruleId] = (ruleCounts[message.ruleId] || 0) + 1;
|
|
507
417
|
return ruleCounts;
|
|
508
418
|
}
|
|
509
419
|
}
|
|
@@ -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
|
+
}
|