@akanjs/devkit 3.0.0-beta.2 → 3.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/README.ko.md +2 -2
  2. package/README.md +3 -2
  3. package/abstractDoc.test.ts +6 -15
  4. package/abstractDoc.ts +6 -8
  5. package/agentsIndex.test.ts +4 -7
  6. package/agentsIndex.ts +6 -22
  7. package/akanApp/BackendImportGraph.test.ts +8 -22
  8. package/akanApp/BackendImportGraph.ts +1 -20
  9. package/akanApp/akanApp.host.test.ts +1 -3
  10. package/akanApp/akanApp.host.ts +82 -254
  11. package/akanApp/devHostPolicy.test.ts +10 -34
  12. package/akanApp/devHostPolicy.ts +12 -98
  13. package/akanConfig/akanConfig.test.ts +142 -106
  14. package/akanConfig/akanConfig.ts +147 -191
  15. package/akanConfig/types.ts +4 -3
  16. package/akanContext.ts +110 -160
  17. package/akanMcpContract.ts +28 -73
  18. package/appSelectionMemory.ts +1 -4
  19. package/applicationBuildReporter.ts +37 -5
  20. package/applicationBuildRunner.test.ts +5 -3
  21. package/applicationBuildRunner.ts +54 -61
  22. package/applicationTestPreload.ts +3 -12
  23. package/artifact/implicitRootLayout.test.ts +32 -30
  24. package/artifact/implicitRootLayout.ts +58 -106
  25. package/artifact/routeSeedIndex.test.ts +7 -6
  26. package/artifact/routeSeedIndex.ts +11 -44
  27. package/biome.base.json +32 -1
  28. package/biomeBase.ts +2 -5
  29. package/cloud/cloudApi.test.ts +202 -0
  30. package/cloud/cloudApi.ts +77 -39
  31. package/cloud/configLock.test.ts +64 -0
  32. package/cloud/configLock.ts +96 -0
  33. package/cloud/constants.ts +26 -5
  34. package/cloud/globalConfig.ts +70 -32
  35. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  36. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  37. package/codeAgent/agent/CodeAgent.ts +551 -0
  38. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  39. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  40. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  41. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  42. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  43. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  44. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  45. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  46. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  47. package/codeAgent/agent/CodeMailbox.ts +148 -0
  48. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  49. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  50. package/codeAgent/agent/SubagentPool.ts +214 -0
  51. package/codeAgent/agent/akanCodeModel.ts +47 -0
  52. package/codeAgent/agent/akanCodePaths.ts +31 -0
  53. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  54. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  55. package/codeAgent/agent/codeAgent.test.ts +178 -0
  56. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  57. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  58. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  59. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  60. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  61. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  62. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  63. package/codeAgent/agent/consoleToStderr.ts +8 -0
  64. package/codeAgent/agent/jsonLines.ts +29 -0
  65. package/codeAgent/agent/stringArg.ts +5 -0
  66. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  67. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  68. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  69. package/codeAgent/feedback/PreviewView.ts +95 -0
  70. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  71. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  72. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  73. package/codeAgent/index.ts +34 -0
  74. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  75. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  76. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  77. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  78. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  79. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  80. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  81. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  82. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  83. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  84. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  85. package/codeAgent/tools/AkanCli.ts +50 -0
  86. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  87. package/codeAgent/tools/AkanEditScope.ts +48 -0
  88. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  89. package/codeAgent/tools/AkanToolPack.ts +92 -0
  90. package/codeAgent/tools/AkanVerifier.ts +48 -0
  91. package/codeAgent/tools/AskToolPack.ts +99 -0
  92. package/codeAgent/tools/MailToolPack.ts +76 -0
  93. package/codeAgent/tools/McpClient.ts +187 -0
  94. package/codeAgent/tools/McpOAuth.ts +233 -0
  95. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  96. package/codeAgent/tools/McpServerConfig.ts +129 -0
  97. package/codeAgent/tools/McpSignIn.ts +158 -0
  98. package/codeAgent/tools/McpTokenStore.ts +81 -0
  99. package/codeAgent/tools/McpToolPack.ts +199 -0
  100. package/codeAgent/tools/SessionToolPack.ts +91 -0
  101. package/codeAgent/tools/WebToolPack.ts +147 -0
  102. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  103. package/codeAgent/tools/askToolPack.test.ts +116 -0
  104. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  105. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  106. package/codegenLock.test.ts +4 -13
  107. package/codegenLock.ts +3 -17
  108. package/commandDecorators/argMeta.ts +1 -5
  109. package/commandDecorators/camelToKebabCase.ts +1 -0
  110. package/commandDecorators/command.ts +46 -102
  111. package/commandDecorators/commandBuilder.ts +30 -61
  112. package/commandDecorators/commandDecorators.test.ts +2 -10
  113. package/commandDecorators/dependencyBuilder.ts +10 -1
  114. package/commandDecorators/helpFormatter.ts +68 -145
  115. package/commandDecorators/index.ts +0 -2
  116. package/commandDecorators/targetMeta.ts +9 -7
  117. package/createTunnel.ts +7 -14
  118. package/dependencyScanner.test.ts +4 -16
  119. package/dependencyScanner.ts +31 -80
  120. package/devkitUtils.test.ts +48 -20
  121. package/executors.linkFallback.test.ts +103 -0
  122. package/executors.test.ts +132 -106
  123. package/executors.ts +195 -496
  124. package/fileEditor.ts +9 -37
  125. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  126. package/fileSys.ts +6 -8
  127. package/formSetterScanner.ts +3 -16
  128. package/frontendBuild/allRoutesBuilder.ts +7 -21
  129. package/frontendBuild/autoImportSync.test.ts +27 -38
  130. package/frontendBuild/autoImportSync.ts +31 -65
  131. package/frontendBuild/buildRouteClient.test.ts +62 -37
  132. package/frontendBuild/bundleDefine.ts +12 -0
  133. package/frontendBuild/clientBuildTypes.ts +5 -36
  134. package/frontendBuild/clientEntriesBundler.ts +9 -40
  135. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  136. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  137. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  138. package/frontendBuild/cssCandidateCache.ts +6 -41
  139. package/frontendBuild/cssCompiler.ts +29 -78
  140. package/frontendBuild/cssImportResolver.ts +6 -19
  141. package/frontendBuild/devChangePlanner.ts +7 -10
  142. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  143. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  144. package/frontendBuild/fontOptimizer.test.ts +6 -14
  145. package/frontendBuild/fontOptimizer.ts +15 -72
  146. package/frontendBuild/fontPruner.test.ts +5 -10
  147. package/frontendBuild/fontPruner.ts +5 -20
  148. package/frontendBuild/frontendBuild.test.ts +46 -69
  149. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  150. package/frontendBuild/hmrWatcher.test.ts +8 -32
  151. package/frontendBuild/hmrWatcher.ts +14 -71
  152. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  153. package/frontendBuild/precompressArtifacts.ts +5 -18
  154. package/frontendBuild/routeClientBuilder.ts +23 -46
  155. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  156. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  157. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  158. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  159. package/frontendBuild/styleContract.ts +1 -8
  160. package/frontendBuild/themeValidator.ts +4 -26
  161. package/frontendBuild/watchRootResolver.test.ts +23 -42
  162. package/frontendBuild/watchRootResolver.ts +5 -27
  163. package/getRelatedCnsts.ts +12 -92
  164. package/incrementalBuilder/buildBatch.proc.ts +29 -58
  165. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  166. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  167. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  168. package/incrementalBuilder/builderChannel.test.ts +24 -49
  169. package/incrementalBuilder/builderChannel.ts +6 -37
  170. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  171. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  172. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  173. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  174. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  175. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  176. package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
  177. package/index.ts +5 -19
  178. package/integration/devResourceProbe.ts +50 -56
  179. package/integration/devStability.integration.test.ts +47 -194
  180. package/integration/devStabilityHarness.test.ts +13 -28
  181. package/integration/devStabilityHarness.ts +65 -330
  182. package/integration/ssrMemoryProbe.ts +41 -167
  183. package/libSource.test.ts +4 -15
  184. package/libSource.ts +19 -31
  185. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  186. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  187. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  188. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  189. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  190. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  191. package/lint/gritRules.test.ts +8 -24
  192. package/lint/no-deep-internal-import.grit +2 -2
  193. package/lint/no-document-cookie.grit +29 -0
  194. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  195. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  196. package/lint/no-web-storage.grit +30 -0
  197. package/linter.test.ts +1 -2
  198. package/linter.ts +26 -116
  199. package/mobile/index.ts +6 -0
  200. package/mobile/mobileTarget.ts +16 -13
  201. package/mobile/nativeApi.test.ts +30 -0
  202. package/mobile/nativeApi.ts +40 -0
  203. package/mobile/nativeApp.test.ts +85 -0
  204. package/mobile/nativeApp.ts +210 -0
  205. package/mobile/nativeConfig.test.ts +199 -0
  206. package/mobile/nativeConfig.ts +184 -0
  207. package/mobile/nativeWebDir.test.ts +80 -0
  208. package/mobile/nativeWebDir.ts +62 -0
  209. package/mobile/placeholderAppId.test.ts +13 -0
  210. package/mobile/placeholderAppId.ts +16 -0
  211. package/mobile/usageDescriptions.test.ts +27 -0
  212. package/mobile/usageDescriptions.ts +24 -0
  213. package/package.json +10 -10
  214. package/packageExports.test.ts +3 -18
  215. package/packageExportsMap.ts +3 -19
  216. package/piDependencyPins.test.ts +46 -0
  217. package/platformTest/BunTestReport.test.ts +60 -0
  218. package/platformTest/BunTestReport.ts +65 -0
  219. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  220. package/platformTest/IdleWatchedProcess.ts +73 -0
  221. package/platformTest/LinuxDockerTarget.ts +105 -0
  222. package/platformTest/PlatformTestRun.test.ts +64 -0
  223. package/platformTest/PlatformTestRun.ts +141 -0
  224. package/platformTest/PlatformTestTarget.ts +142 -0
  225. package/platformTest/TestSnapshot.test.ts +72 -0
  226. package/platformTest/TestSnapshot.ts +103 -0
  227. package/platformTest/WindowsSshTarget.ts +151 -0
  228. package/prompter.ts +3 -13
  229. package/qualityScanner.test.ts +9 -26
  230. package/qualityScanner.ts +200 -286
  231. package/recipeScanner.test.ts +7 -12
  232. package/recipeScanner.ts +9 -38
  233. package/repoIdentity.ts +2 -9
  234. package/routeSourceValidator.test.ts +0 -1
  235. package/routeSourceValidator.ts +7 -42
  236. package/scanInfo.ts +91 -225
  237. package/selectModel.ts +1 -2
  238. package/semver.ts +3 -5
  239. package/slicePlanner.test.ts +6 -24
  240. package/slicePlanner.ts +11 -32
  241. package/spinner.test.ts +3 -8
  242. package/spinner.ts +4 -16
  243. package/sshTunnel.ts +1 -6
  244. package/ssrScanner.test.ts +1 -6
  245. package/ssrScanner.ts +103 -113
  246. package/stripAnsi.ts +3 -0
  247. package/subspace.test.ts +30 -25
  248. package/subspace.ts +81 -115
  249. package/subspaceConfig.ts +11 -9
  250. package/testHelpers.ts +194 -0
  251. package/transforms/asyncDefaultExportDetector.ts +3 -11
  252. package/transforms/barrelAnalyzer.ts +43 -135
  253. package/transforms/barrelImportsPlugin.ts +38 -175
  254. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  255. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  256. package/transforms/moduleSyntax.ts +32 -0
  257. package/transforms/rscUseClientTransform.ts +35 -20
  258. package/transforms/transforms.test.ts +153 -44
  259. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  260. package/transforms/useClientBundlePlugin.ts +2 -22
  261. package/typeChecker.ts +7 -63
  262. package/types.ts +1 -0
  263. package/uploadRelease.ts +10 -25
  264. package/workflow/artifacts.ts +18 -0
  265. package/workflow/executor.test.ts +1 -12
  266. package/workflow/executor.ts +101 -110
  267. package/workflow/index.ts +0 -1
  268. package/workflow/moduleIndex.test.ts +4 -15
  269. package/workflow/moduleIndex.ts +9 -49
  270. package/workflow/plan.ts +18 -25
  271. package/workflow/render.ts +80 -96
  272. package/workflow/rolloutGate.test.ts +1 -1
  273. package/workflow/source.ts +52 -69
  274. package/workflow/sourceAst.ts +50 -0
  275. package/workspaceLayout.test.ts +13 -3
  276. package/workspaceLayout.ts +20 -23
  277. package/abstractCompactor.ts +0 -112
  278. package/aiEditor.test.ts +0 -60
  279. package/aiEditor.ts +0 -387
  280. package/applicationReleasePackager.ts +0 -209
  281. package/capacitorApp.test.ts +0 -372
  282. package/capacitorApp.ts +0 -1648
  283. package/commandDecorators/commandMeta.ts +0 -7
  284. package/commandDecorators/types.ts +0 -10
  285. package/getDirname.ts +0 -4
  286. package/guideline.ts +0 -19
  287. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  288. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  289. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,210 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AkanNativeConfig } from "@akanjs/native/config";
4
+ import type { App } from "../commandDecorators";
5
+ import {
6
+ type DesktopPlatform,
7
+ type MobilePlatform,
8
+ type NativePlatform,
9
+ type ResolvedMobileTarget,
10
+ targetHtmlFilename,
11
+ } from "./mobileTarget";
12
+ import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
13
+ import { NativeConfig } from "./nativeConfig";
14
+ import { NativeWebDir } from "./nativeWebDir";
15
+
16
+ type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
17
+ type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
18
+ type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
19
+ type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
20
+
21
+ export interface NativeRunOptions {
22
+ device?: DeviceSelector;
23
+ /** Narrows the iPhone signing to one Apple team. */
24
+ teamId?: string;
25
+ }
26
+
27
+ export interface NativeDevOptions extends NativeRunOptions {
28
+ /** The akan dev server the gateway proxies. */
29
+ upstream: string;
30
+ lang: string;
31
+ }
32
+
33
+ /** One mobile target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
34
+ * what it ships, and the calls into the API. */
35
+ export class NativeApp {
36
+ readonly targetRoot: string;
37
+ readonly web: NativeWebDir;
38
+
39
+ constructor(
40
+ readonly app: App,
41
+ readonly target: ResolvedMobileTarget,
42
+ ) {
43
+ this.targetRoot = path.join(app.cwdPath, ".akan", "mobile", target.name);
44
+ this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
45
+ }
46
+
47
+ outDir(platform: NativePlatform) {
48
+ return path.join(this.targetRoot, "native", platform);
49
+ }
50
+
51
+ /** The web root, from the production build the mobile commands run first. */
52
+ async assembleWeb() {
53
+ const dist = this.app.dist.cwdPath;
54
+ return await this.web.assemble(this.target.config, {
55
+ html: path.join(dist, "csr", targetHtmlFilename(this.target.config)),
56
+ publicDir: path.join(dist, "public"),
57
+ fontsDir: path.join(dist, ".akan", "artifact", "fonts"),
58
+ });
59
+ }
60
+
61
+ async config() {
62
+ const [appConfig, plugins] = await Promise.all([this.app.getConfig(), this.app.collectPlugins()]);
63
+ return NativeConfig.build({
64
+ appPath: this.app.cwdPath,
65
+ target: this.target.config,
66
+ webDir: this.web.dir,
67
+ contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
68
+ locales: appConfig.i18n.locales,
69
+ });
70
+ }
71
+
72
+ /** The API and the config it is about to build, refused here when the runtime would refuse it later. */
73
+ async prepare() {
74
+ const [api, { config, warnings }] = await Promise.all([NativeApi.load(this.app.cwdPath), this.config()]);
75
+ for (const warning of warnings) this.app.logger.warn(warning);
76
+ const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
77
+ if (problems.length)
78
+ throw new Error(
79
+ `Mobile target '${this.target.name}' makes an invalid native config:\n- ${problems.join("\n- ")}`,
80
+ );
81
+ return { api, config };
82
+ }
83
+
84
+ //? The app's own output: its console (mirrored by a dev build), the simulator log stream or logcat.
85
+ #appLine = (line: string) => {
86
+ this.app.logger.info(`[${this.target.name}] ${line}`);
87
+ };
88
+
89
+ //* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
90
+ #task(platform: NativePlatform, config: AkanNativeConfig): TaskOptions {
91
+ return {
92
+ appDir: this.app.cwdPath,
93
+ config,
94
+ platform,
95
+ outDir: this.outDir(platform),
96
+ envFiles: false,
97
+ skipWebBuild: true,
98
+ log: (event) => {
99
+ if (event.level === "error") this.app.logger.error(event.message);
100
+ else if (event.level === "warn") this.app.logger.warn(event.message);
101
+ else if (event.level === "tool") this.app.logger.verbose(event.message);
102
+ else this.app.logger.info(event.message);
103
+ },
104
+ };
105
+ }
106
+
107
+ async build(platform: NativePlatform, { profile = "release" }: { profile?: "debug" | "release" } = {}) {
108
+ await this.assembleWeb();
109
+ const { api, config } = await this.prepare();
110
+ return await api.build({ ...this.#task(platform, config), profile });
111
+ }
112
+
113
+ async run(
114
+ platform: NativePlatform,
115
+ { device, teamId, profile = "debug" }: NativeRunOptions & { profile?: "debug" | "release" } = {},
116
+ ) {
117
+ await this.assembleWeb();
118
+ const { api, config } = await this.prepare();
119
+ return await api.run({
120
+ ...this.#task(platform, config),
121
+ profile,
122
+ onLine: this.#appLine,
123
+ ...(device ? { device } : {}),
124
+ ...(teamId ? { ios: { signing: { teamId } } } : {}),
125
+ });
126
+ }
127
+
128
+ //* The gateway serves the page and its HMR socket on the app origin; API calls go to the dev server itself (baseEnv),
129
+ //* which an Android device reaches through the reversed port.
130
+ async dev(platform: NativePlatform, { upstream, lang, device, teamId }: NativeDevOptions) {
131
+ await mkdir(this.web.dir, { recursive: true });
132
+ const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(), this.app.getConfig()]);
133
+ return await api.dev({
134
+ ...this.#task(platform, config),
135
+ upstream,
136
+ hmrPath: "/_akan/hmr",
137
+ //? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
138
+ wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
139
+ onLine: this.#appLine,
140
+ startPath: this.startPath(lang),
141
+ ...(device ? { device } : {}),
142
+ ...(teamId ? { ios: { signing: { teamId } } } : {}),
143
+ });
144
+ }
145
+
146
+ async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
147
+ await this.assembleWeb();
148
+ const { api, config } = await this.prepare();
149
+ return await api.release({
150
+ ...this.#task("ios", config),
151
+ platform: "ios",
152
+ signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
153
+ });
154
+ }
155
+
156
+ async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
157
+ const signing = NativeApp.androidSigning();
158
+ await this.assembleWeb();
159
+ const { api, config } = await this.prepare();
160
+ return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
161
+ }
162
+
163
+ //* The names the Gradle build read, so a CI that already holds these secrets keeps working.
164
+ static androidSigning(env: Record<string, string | undefined> = process.env): AndroidSigning {
165
+ const keystore = env.MYAPP_RELEASE_STORE_FILE;
166
+ const storePassword = env.MYAPP_RELEASE_STORE_PASSWORD;
167
+ const alias = env.MYAPP_RELEASE_KEY_ALIAS;
168
+ const keyPassword = env.MYAPP_RELEASE_KEY_PASSWORD;
169
+ if (!keystore || !storePassword || !alias) {
170
+ const missing = Object.entries({
171
+ MYAPP_RELEASE_STORE_FILE: keystore,
172
+ MYAPP_RELEASE_STORE_PASSWORD: storePassword,
173
+ MYAPP_RELEASE_KEY_ALIAS: alias,
174
+ }).flatMap(([key, value]) => (value ? [] : [key]));
175
+ throw new Error(
176
+ `An Android release is signed with the upload key; set ${missing.join(", ")} in the environment.`,
177
+ );
178
+ }
179
+ return { keystore, storePassword, alias, ...(keyPassword ? { keyPassword } : {}) };
180
+ }
181
+
182
+ /** The first page of a dev build, the target's home as a release bundle opens it; the dev server answers the CSR
183
+ * shell only for `?csr=true`. */
184
+ startPath(lang: string) {
185
+ const basePath = this.target.config.basePath?.replace(/^\/+|\/+$/g, "");
186
+ const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
187
+ if (basePath) params.set("akanMobileBasePath", basePath);
188
+ if (this.target.config.indexPath) params.set("akanMobileIndexPath", this.target.config.indexPath);
189
+ const home = [lang, basePath, this.target.config.indexPath]
190
+ .flatMap((part) => (part ?? "").split("/"))
191
+ .filter((segment) => segment.length > 0);
192
+ return `/${home.join("/")}?${params}`;
193
+ }
194
+
195
+ //* A desktop app builds only on its own OS, so the platform is this computer's.
196
+ static desktopPlatform(host: NodeJS.Platform = process.platform): DesktopPlatform {
197
+ if (host === "darwin") return "macos";
198
+ if (host === "win32") return "windows";
199
+ if (host === "linux") return "linux";
200
+ throw new Error(`A desktop app builds on macOS, Windows or Linux, not on ${host}.`);
201
+ }
202
+
203
+ static async devices(appDir: string, platform: MobilePlatform) {
204
+ return await (await NativeApi.load(appDir)).devices({ platform });
205
+ }
206
+
207
+ static async doctor(appDir: string, platforms: NativePlatform[]) {
208
+ return await (await NativeApi.load(appDir)).doctor({ platforms });
209
+ }
210
+ }
@@ -0,0 +1,199 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { AkanMobileTargetConfig } from "../akanConfig";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { NativeApi } from "./nativeApi";
7
+ import { NativeConfig } from "./nativeConfig";
8
+
9
+ const makeTempRoot = tempDirs("akan-native-config-");
10
+ const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
11
+
12
+ const minimalTarget: AkanMobileTargetConfig = {
13
+ name: "default",
14
+ indexPath: "/explore",
15
+ appName: "minimal",
16
+ appId: "com.minimal.dev.app",
17
+ version: "0.0.1",
18
+ buildNum: 1,
19
+ permissions: ["push"],
20
+ native: { android: { googleServices: "secrets/google-services.json" } },
21
+ deepLinks: {
22
+ schemes: ["minimal"],
23
+ domains: ["example.com"],
24
+ ios: { teamId: "TEAMID" },
25
+ android: { sha256CertFingerprints: ["00:11"] },
26
+ },
27
+ };
28
+
29
+ const adminTarget: AkanMobileTargetConfig = {
30
+ name: "admin",
31
+ basePath: "admin",
32
+ appName: "Portal Admin",
33
+ appId: "com.portal.admin",
34
+ fileName: "portal-admin",
35
+ version: "2.1.0",
36
+ buildNum: 42,
37
+ assets: { icon: "assets/icon.png", splash: "assets/splash.png" },
38
+ permissions: ["camera", "location", "contacts"],
39
+ deepLinks: { domains: ["portal.example"] },
40
+ files: { "android/res/raw/chime.mp3": "assets/chime.mp3" },
41
+ native: {
42
+ plugins: ["iap"],
43
+ ios: { infoPlist: { ITSAppUsesNonExemptEncryption: false } },
44
+ android: { manifest: ['<queries><package android:name="com.kakao.talk" /></queries>'] },
45
+ },
46
+ };
47
+
48
+ describe("NativeConfig.build", () => {
49
+ test("turns the minimal app's push target into the runtime's config", () => {
50
+ const { config, warnings } = NativeConfig.build({
51
+ appPath: "/repo/apps/minimal",
52
+ target: minimalTarget,
53
+ webDir: "/repo/apps/minimal/.akan/mobile/default/web",
54
+ contributions: [{ permission: "push", plugins: ["push"] }],
55
+ locales: ["en", "ko"],
56
+ });
57
+
58
+ expect(warnings).toEqual([]);
59
+ expect(config).toEqual({
60
+ app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
61
+ web: { dir: "/repo/apps/minimal/.akan/mobile/default/web" },
62
+ plugins: [...NativeConfig.basePlugins, "push"],
63
+ capabilities: [
64
+ {
65
+ identifier: "app",
66
+ description: "The plugins minimal ships, each with its default permissions",
67
+ permissions: [...NativeConfig.basePlugins, "push"].map((plugin) => `${plugin}:default`),
68
+ },
69
+ ],
70
+ deepLinks: { schemes: ["minimal"], domains: ["example.com"] },
71
+ android: {
72
+ debugAppIdSuffix: ".debug",
73
+ googleServices: path.resolve("/repo/apps/minimal", "secrets/google-services.json"),
74
+ },
75
+ keyboard: { resize: "none" },
76
+ });
77
+ });
78
+
79
+ test("falls back to the builtin permission plugins, names the app in their texts, and lists basePath links per locale", () => {
80
+ const { config, warnings } = NativeConfig.build({
81
+ appPath: "/repo/apps/portal",
82
+ target: adminTarget,
83
+ webDir: "/repo/apps/portal/.akan/mobile/admin/web",
84
+ contributions: [],
85
+ locales: ["en", "ko"],
86
+ });
87
+
88
+ expect(warnings).toEqual(["Permission 'contacts' has no native plugin yet; the app ships without it."]);
89
+ expect(config).toEqual({
90
+ app: { id: "com.portal.admin", name: "Portal Admin", fileName: "portal-admin", version: "2.1.0", build: 42 },
91
+ web: { dir: "/repo/apps/portal/.akan/mobile/admin/web" },
92
+ plugins: [...NativeConfig.basePlugins, "camera", "geolocation", "iap"],
93
+ capabilities: [
94
+ {
95
+ identifier: "app",
96
+ description: "The plugins Portal Admin ships, each with its default permissions",
97
+ permissions: [...NativeConfig.basePlugins, "camera", "geolocation", "iap"].map(
98
+ (plugin) => `${plugin}:default`,
99
+ ),
100
+ },
101
+ ],
102
+ usageDescriptions: {
103
+ NSCameraUsageDescription: "Portal Admin requires access to the camera to take photos.",
104
+ NSPhotoLibraryAddUsageDescription: "Portal Admin requires access to the photo library to take photos.",
105
+ NSPhotoLibraryUsageDescription: "Portal Admin requires access to the photo library to take photos.",
106
+ NSLocationAlwaysAndWhenInUseUsageDescription:
107
+ "Portal Admin requires access to the location to get the user's location.",
108
+ NSLocationAlwaysUsageDescription: "Portal Admin requires access to the location to get the user's location.",
109
+ NSLocationWhenInUseUsageDescription: "Portal Admin requires access to the location to get the user's location.",
110
+ },
111
+ deepLinks: { domains: [{ host: "portal.example", pathPrefixes: ["/en/admin", "/ko/admin"] }] },
112
+ native: {
113
+ ios: { infoPlist: { ITSAppUsesNonExemptEncryption: false } },
114
+ android: { manifest: ['<queries><package android:name="com.kakao.talk" /></queries>'] },
115
+ resources: [{ from: path.resolve("/repo/apps/portal", "assets/chime.mp3"), to: "android/res/raw/chime.mp3" }],
116
+ },
117
+ android: { debugAppIdSuffix: ".debug" },
118
+ keyboard: { resize: "none" },
119
+ icon: path.resolve("/repo/apps/portal", "assets/icon.png"),
120
+ splash: { image: path.resolve("/repo/apps/portal", "assets/splash.png") },
121
+ });
122
+ });
123
+
124
+ test("a plugin that claims a permission replaces the builtin one and adds its Android entries", () => {
125
+ const { config } = NativeConfig.build({
126
+ appPath: "/repo/apps/portal",
127
+ target: { ...adminTarget, permissions: ["camera"], native: undefined, files: undefined, assets: undefined },
128
+ webDir: "/web",
129
+ contributions: [
130
+ {
131
+ permission: "camera",
132
+ plugins: ["camera"],
133
+ usageDescriptions: { cameraUsageDescription: "Scan receipts with $(PRODUCT_NAME)." },
134
+ androidPermissions: ["CAMERA"],
135
+ androidFeatures: ["android.hardware.camera"],
136
+ },
137
+ { permission: "push", plugins: ["push"] },
138
+ ],
139
+ locales: ["en"],
140
+ });
141
+
142
+ expect(config.plugins).toEqual([...NativeConfig.basePlugins, "camera"]);
143
+ expect(config.usageDescriptions).toEqual({ NSCameraUsageDescription: "Scan receipts with Portal Admin." });
144
+ expect(config.native).toEqual({
145
+ android: {
146
+ manifest: [
147
+ '<uses-permission android:name="android.permission.CAMERA" />',
148
+ '<uses-feature android:name="android.hardware.camera" android:required="false" />',
149
+ ],
150
+ },
151
+ });
152
+ });
153
+
154
+ test("the runtime accepts both configs as they are", async () => {
155
+ const root = await makeTempRoot();
156
+ for (const file of ["secrets/google-services.json", "assets/chime.mp3"])
157
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true }).then(() =>
158
+ writeFile(path.join(root, file), file.endsWith(".json") ? googleServicesJson : "x"),
159
+ );
160
+ for (const image of ["assets/icon.png", "assets/splash.png"])
161
+ await writeFile(path.join(root, image), Buffer.from(onePixelPng, "base64"));
162
+ await mkdir(path.join(root, "web"), { recursive: true });
163
+ await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
164
+ const api = await NativeApi.load(repoApp);
165
+
166
+ for (const target of [minimalTarget, adminTarget]) {
167
+ const { config } = NativeConfig.build({
168
+ appPath: root,
169
+ target,
170
+ webDir: path.join(root, "web"),
171
+ contributions: [],
172
+ locales: ["en", "ko"],
173
+ });
174
+ expect(api.validateConfig(config, { appDir: root })).toEqual([]);
175
+ }
176
+ });
177
+
178
+ test("keeps a file name the runtime accepts", () => {
179
+ expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
180
+ expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
181
+ expect(NativeConfig.fileNameOf("...")).toBe("app");
182
+ });
183
+ });
184
+
185
+ const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
186
+
187
+ const googleServicesJson = JSON.stringify({
188
+ project_info: { project_number: "1", project_id: "demo", storage_bucket: "demo.appspot.com" },
189
+ client: [
190
+ {
191
+ client_info: { mobilesdk_app_id: "1:1:android:1", android_client_info: { package_name: "com.minimal.dev.app" } },
192
+ api_key: [{ current_key: "demo-key" }],
193
+ },
194
+ {
195
+ client_info: { mobilesdk_app_id: "1:1:android:2", android_client_info: { package_name: "com.portal.admin" } },
196
+ api_key: [{ current_key: "demo-key" }],
197
+ },
198
+ ],
199
+ });
@@ -0,0 +1,184 @@
1
+ import path from "node:path";
2
+ import type { AkanNativeConfig } from "@akanjs/native/config";
3
+ import type { AkanMobileTargetConfig, AkanNativeValue, AkanPluginNativeConfig, MobilePermission } from "akanjs";
4
+ import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
5
+
6
+ export interface NativeConfigInput {
7
+ /** The app folder; every relative path in the target resolves against it. */
8
+ appPath: string;
9
+ target: AkanMobileTargetConfig;
10
+ /** The assembled web root the app loads. */
11
+ webDir: string;
12
+ /** What the app's and its libs' plugins declare. */
13
+ contributions: AkanPluginNativeConfig[];
14
+ locales: readonly string[];
15
+ }
16
+
17
+ export interface NativeConfigResult {
18
+ config: AkanNativeConfig;
19
+ warnings: string[];
20
+ }
21
+
22
+ type NativeIos = NonNullable<NonNullable<AkanNativeConfig["native"]>["ios"]>;
23
+
24
+ export class NativeConfig {
25
+ //* What every akanjs page may call: routing, lifecycle, links, storage, the device facts the runtime reads, and the
26
+ //* system sheet useCamera asks camera-or-library with.
27
+ static readonly basePlugins = [
28
+ "app",
29
+ "app-state",
30
+ "browser",
31
+ "opener",
32
+ "auth-session",
33
+ "device",
34
+ "dialog",
35
+ "keyboard",
36
+ "preferences",
37
+ "secure-storage",
38
+ "haptics",
39
+ ] as const;
40
+ //* Used when no plugin of the app or its libs claims the permission; contacts and speech have no native plugin yet.
41
+ static readonly builtinContributions: { [permission in MobilePermission]: AkanPluginNativeConfig } = {
42
+ camera: {
43
+ permission: "camera",
44
+ plugins: ["camera"],
45
+ usageDescriptions: {
46
+ cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
47
+ photoAddUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
48
+ photoUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
49
+ },
50
+ },
51
+ contacts: { permission: "contacts" },
52
+ location: {
53
+ permission: "location",
54
+ plugins: ["geolocation"],
55
+ usageDescriptions: {
56
+ locationAlwaysUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
57
+ locationWhenInUseUsageDescription:
58
+ "$(PRODUCT_NAME) requires access to the location to get the user's location.",
59
+ },
60
+ },
61
+ push: { permission: "push", plugins: ["push"] },
62
+ speech: { permission: "speech" },
63
+ };
64
+
65
+ static build({ appPath, target, webDir, contributions, locales }: NativeConfigInput): NativeConfigResult {
66
+ const warnings: string[] = [];
67
+ const applied = (target.permissions ?? []).flatMap((permission) => {
68
+ const claimed = contributions.filter((contribution) => contribution.permission === permission);
69
+ const used = claimed.length ? claimed : [NativeConfig.builtinContributions[permission]];
70
+ if (!used.some((contribution) => contribution.plugins?.length))
71
+ warnings.push(`Permission '${permission}' has no native plugin yet; the app ships without it.`);
72
+ return used;
73
+ });
74
+ const abs = (relative: string) => path.resolve(appPath, relative);
75
+ const plugins = [
76
+ ...new Set([
77
+ ...NativeConfig.basePlugins,
78
+ ...applied.flatMap((contribution) => contribution.plugins ?? []),
79
+ ...(target.native?.plugins ?? []),
80
+ ]),
81
+ ];
82
+ const usageDescriptions = Object.fromEntries(
83
+ Object.entries(
84
+ toIosInfoPlistUsageDescriptions(
85
+ Object.assign({}, ...applied.map((contribution) => contribution.usageDescriptions ?? {})),
86
+ ),
87
+ ).map(([key, text]) => [key, text.replaceAll("$(PRODUCT_NAME)", target.appName)]),
88
+ );
89
+ const ios = NativeConfig.#compact<NativeIos>({
90
+ infoPlist: NativeConfig.#merged(
91
+ applied.map((c) => c.infoPlist),
92
+ target.native?.ios?.infoPlist,
93
+ ),
94
+ entitlements: NativeConfig.#merged(
95
+ applied.map((c) => c.entitlements),
96
+ target.native?.ios?.entitlements,
97
+ ),
98
+ });
99
+ const manifest = [
100
+ ...[...new Set(applied.flatMap((c) => c.androidPermissions ?? []))].map(
101
+ (name) => `<uses-permission android:name="android.permission.${name}" />`,
102
+ ),
103
+ ...[...new Set(applied.flatMap((c) => c.androidFeatures ?? []))].map(
104
+ (name) => `<uses-feature android:name="${name}" android:required="false" />`,
105
+ ),
106
+ ...(target.native?.android?.manifest ?? []),
107
+ ];
108
+ const android = NativeConfig.#compact({
109
+ manifest,
110
+ application: target.native?.android?.application ?? [],
111
+ activity: target.native?.android?.activity ?? [],
112
+ });
113
+ const resources = Object.entries(target.files ?? {}).map(([to, from]) => ({ from: abs(from), to }));
114
+ const native = NativeConfig.#compact({ ios, android, resources });
115
+ const googleServices = target.native?.android?.googleServices;
116
+ const config: AkanNativeConfig = {
117
+ app: {
118
+ id: target.appId,
119
+ name: target.appName,
120
+ fileName: target.fileName ?? NativeConfig.fileNameOf(path.basename(appPath)),
121
+ version: target.version,
122
+ build: target.buildNum,
123
+ },
124
+ web: { dir: webDir },
125
+ plugins,
126
+ //? PL-11: named, not implied: every plugin with its own default permissions, which is what a build without
127
+ //? capabilities grants too, so a release states what it allows.
128
+ capabilities: [
129
+ {
130
+ identifier: "app",
131
+ description: `The plugins ${target.appName} ships, each with its default permissions`,
132
+ permissions: plugins.map((plugin) => `${plugin}:default`),
133
+ },
134
+ ],
135
+ ...(Object.keys(usageDescriptions).length ? { usageDescriptions } : {}),
136
+ ...(target.deepLinks?.schemes?.length || target.deepLinks?.domains?.length
137
+ ? { deepLinks: NativeConfig.#deepLinks(target, locales) }
138
+ : {}),
139
+ ...(native ? { native } : {}),
140
+ //? assetlinks.json vouches for `<appId>.debug` outside main, the suffix a debug build installs under.
141
+ android: { debugAppIdSuffix: ".debug", ...(googleServices ? { googleServices: abs(googleServices) } : {}) },
142
+ keyboard: { resize: "none" },
143
+ ...(target.assets?.icon ? { icon: abs(target.assets.icon) } : {}),
144
+ ...(target.assets?.splash ? { splash: { image: abs(target.assets.splash) } } : {}),
145
+ };
146
+ return { config, warnings };
147
+ }
148
+
149
+ /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
150
+ static fileNameOf(name: string) {
151
+ return name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "app";
152
+ }
153
+
154
+ /** Drops empty arrays and objects, and answers undefined when nothing is left. */
155
+ static #compact<T extends object>(value: { [key: string]: unknown }): T | undefined {
156
+ const kept = Object.entries(value).filter(([, entry]) =>
157
+ Array.isArray(entry)
158
+ ? entry.length > 0
159
+ : entry && typeof entry === "object"
160
+ ? Object.keys(entry).length > 0
161
+ : entry !== undefined,
162
+ );
163
+ return kept.length ? (Object.fromEntries(kept) as T) : undefined;
164
+ }
165
+
166
+ static #merged(
167
+ contributed: (Record<string, AkanNativeValue> | undefined)[],
168
+ own: Record<string, AkanNativeValue> | undefined,
169
+ ): Record<string, AkanNativeValue> {
170
+ return Object.assign({}, ...contributed, own);
171
+ }
172
+
173
+ //* Every route sits under /:lang, so an app link limited to a basePath lists it once per locale.
174
+ static #deepLinks(target: AkanMobileTargetConfig, locales: readonly string[]) {
175
+ const basePath = target.basePath?.replace(/^\/+|\/+$/g, "");
176
+ const pathPrefixes = basePath ? locales.map((locale) => `/${locale}/${basePath}`) : undefined;
177
+ return {
178
+ ...(target.deepLinks?.schemes?.length ? { schemes: target.deepLinks.schemes } : {}),
179
+ ...(target.deepLinks?.domains?.length
180
+ ? { domains: target.deepLinks.domains.map((host) => (pathPrefixes ? { host, pathPrefixes } : host)) }
181
+ : {}),
182
+ };
183
+ }
184
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { AkanMobileTargetConfig } from "../akanConfig";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { NativeWebDir } from "./nativeWebDir";
7
+
8
+ const makeTempRoot = tempDirs("akan-native-web-");
9
+
10
+ const target: AkanMobileTargetConfig = {
11
+ name: "admin",
12
+ basePath: "/admin/",
13
+ indexPath: "/home",
14
+ appName: "Portal",
15
+ appId: "com.portal.admin",
16
+ version: "1.0.0",
17
+ buildNum: 1,
18
+ };
19
+
20
+ const write = async (file: string, content: string) => {
21
+ await mkdir(path.dirname(file), { recursive: true });
22
+ await writeFile(file, content);
23
+ };
24
+
25
+ describe("NativeWebDir", () => {
26
+ test("injects the target global once, first in <head>, even when the inlined bundle names it", () => {
27
+ const bundle = '<script type="module">if(!window.__AKAN_MOBILE_TARGET__)boot("</head>")</script>';
28
+ const html = NativeWebDir.injectTarget(`<html><HEAD lang="en">${bundle}</head><body></body></html>`, target);
29
+
30
+ expect(html).toBe(
31
+ `<html><HEAD lang="en">\n<script>window.__AKAN_MOBILE_TARGET__={"name":"admin","basePath":"admin","indexPath":"/home"};</script>${bundle}</head><body></body></html>`,
32
+ );
33
+ expect(NativeWebDir.injectTarget(html, target)).toBe(html);
34
+ });
35
+
36
+ test("assembles the page, the pruned public folder and the fonts, from scratch every time", async () => {
37
+ const root = await makeTempRoot();
38
+ await write(path.join(root, "dist/csr/admin.html"), "<html><head></head><body>app</body></html>");
39
+ await write(path.join(root, "dist/public/images/logo.png"), "png");
40
+ await write(path.join(root, "dist/public/index.html"), "stale page from public");
41
+ await write(path.join(root, "dist/.akan/artifact/fonts/pretendard/400.woff2"), "font");
42
+ const web = new NativeWebDir(path.join(root, "web"));
43
+ await write(path.join(root, "web/leftover.txt"), "from the last build");
44
+
45
+ const files = await web.assemble(target, {
46
+ html: path.join(root, "dist/csr/admin.html"),
47
+ publicDir: path.join(root, "dist/public"),
48
+ fontsDir: path.join(root, "dist/.akan/artifact/fonts"),
49
+ });
50
+
51
+ expect(files).toEqual(["_akan/fonts/pretendard/400.woff2", "images/logo.png", "index.html"]);
52
+ expect(await Bun.file(path.join(root, "web/index.html")).text()).toContain("window.__AKAN_MOBILE_TARGET__");
53
+ });
54
+
55
+ test("builds without a public folder or fonts", async () => {
56
+ const root = await makeTempRoot();
57
+ await write(path.join(root, "index.html"), "<html><head></head></html>");
58
+
59
+ const files = await new NativeWebDir(path.join(root, "web")).assemble(target, {
60
+ html: path.join(root, "index.html"),
61
+ publicDir: path.join(root, "missing-public"),
62
+ });
63
+
64
+ expect(files).toEqual(["index.html"]);
65
+ });
66
+
67
+ test("refuses a missing bundle and a public folder that takes the runtime's reserved path", async () => {
68
+ const root = await makeTempRoot();
69
+ const web = new NativeWebDir(path.join(root, "web"));
70
+ await expect(web.assemble(target, { html: path.join(root, "none.html") })).rejects.toThrow(
71
+ "CSR html for mobile target 'admin' not found",
72
+ );
73
+
74
+ await write(path.join(root, "index.html"), "<html><head></head></html>");
75
+ await write(path.join(root, "public/__akan_native/x.js"), "x");
76
+ await expect(
77
+ web.assemble(target, { html: path.join(root, "index.html"), publicDir: path.join(root, "public") }),
78
+ ).rejects.toThrow("public/__akan_native is reserved for the native runtime");
79
+ });
80
+ });