@akanjs/devkit 3.0.0-beta.27 → 3.0.0-beta.29

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 (40) hide show
  1. package/akanApp/akanApp.host.test.ts +55 -0
  2. package/akanApp/akanApp.host.ts +24 -2
  3. package/akanConfig/akanBin.test.ts +62 -0
  4. package/akanConfig/akanBin.ts +78 -0
  5. package/akanConfig/akanConfig.test.ts +212 -2
  6. package/akanConfig/akanConfig.ts +93 -26
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +7 -1
  9. package/applicationBuildRunner.test.ts +36 -2
  10. package/applicationBuildRunner.ts +21 -6
  11. package/biome.base.json +3 -1
  12. package/csrE2e/csrPageStack.e2e.test.ts +65 -0
  13. package/executors.test.ts +43 -0
  14. package/executors.ts +17 -2
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +45 -2
  17. package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
  18. package/integration/devStability.integration.test.ts +35 -0
  19. package/mobile/desktopBin.test.ts +177 -0
  20. package/mobile/desktopBin.ts +143 -0
  21. package/mobile/desktopServerStage.test.ts +204 -0
  22. package/mobile/desktopServerStage.ts +138 -0
  23. package/mobile/index.ts +3 -0
  24. package/mobile/mobileTarget.test.ts +50 -1
  25. package/mobile/mobileTarget.ts +20 -4
  26. package/mobile/nativeApp.test.ts +52 -1
  27. package/mobile/nativeApp.ts +175 -20
  28. package/mobile/nativeAppLine.test.ts +7 -0
  29. package/mobile/nativeAppLine.ts +8 -0
  30. package/mobile/nativeConfig.test.ts +215 -4
  31. package/mobile/nativeConfig.ts +117 -8
  32. package/mobile/nativePluginFolders.test.ts +81 -0
  33. package/mobile/nativePluginFolders.ts +60 -0
  34. package/mobile/nativeWebDir.ts +6 -2
  35. package/package.json +2 -2
  36. package/transforms/serverEnvPlugin.test.ts +103 -0
  37. package/transforms/serverEnvPlugin.ts +41 -0
  38. package/types.ts +1 -0
  39. package/workspaceLayout.test.ts +5 -0
  40. package/workspaceLayout.ts +2 -0
@@ -0,0 +1,204 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { AkanAppConfig } from "../akanConfig";
5
+ import type { App } from "../commandDecorators";
6
+ import { tempDirs, writeText as write } from "../testHelpers";
7
+ import type { PackageJson } from "../types";
8
+ import { DesktopServerStage } from "./desktopServerStage";
9
+ import { NativeApi } from "./nativeApi";
10
+ import { NativeApp } from "./nativeApp";
11
+ import { NativeConfig } from "./nativeConfig";
12
+
13
+ const makeTempRoot = tempDirs("akan-desktop-server-");
14
+ const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
15
+ const baseDevEnv = {
16
+ repoName: "akanjs",
17
+ serveDomain: "akanjs.com",
18
+ env: "debug" as const,
19
+ portOffset: 0,
20
+ workspaceRoot: "/w",
21
+ };
22
+ const rootPackageJson: PackageJson = {
23
+ name: "repo",
24
+ version: "1.0.0",
25
+ description: "repo",
26
+ dependencies: { "@external/runtime": "2.0.0", pg: "8.0.0" },
27
+ };
28
+ const appConfig = (config: object = {}) =>
29
+ new AkanAppConfig({ name: "portal" } as never, [], rootPackageJson, config, baseDevEnv);
30
+
31
+ describe("DesktopServerStage", () => {
32
+ test("starts from the image's env, as an API-only edge server on SQLite that the launcher gives a port", () => {
33
+ const env = DesktopServerStage.env(appConfig({ database: { modes: ["single", "cluster"] } }), "main");
34
+
35
+ expect(env).toMatchObject({
36
+ NODE_ENV: "production",
37
+ AKAN_PUBLIC_APP_NAME: "portal",
38
+ AKAN_PUBLIC_ENV: "main",
39
+ AKAN_PUBLIC_OPERATION_MODE: "edge",
40
+ AKAN_DATABASE_MODE: "single",
41
+ AKAN_DATABASE_MODES: "single",
42
+ AKAN_SSR: "false",
43
+ AKAN_CSR: "false",
44
+ AKAN_MCP: "false",
45
+ AKAN_SHUTDOWN_TIMEOUT_MS: "1000",
46
+ });
47
+ expect(env.PORT).toBeUndefined();
48
+ expect(env.AKAN_LOG_TO_FILE).toBeUndefined();
49
+ });
50
+
51
+ test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
52
+ const config = appConfig({ database: { modes: ["single", "cluster"] }, externalLibs: ["@external/runtime"] });
53
+ const built = config.getProductionPackageJson();
54
+ const drivers = config.getDatabaseModeRuntimePackages("cluster");
55
+
56
+ const { dependencies } = DesktopServerStage.packageJson(config, built);
57
+
58
+ expect(Object.keys(dependencies ?? {}).sort()).toEqual(["@external/runtime", "react", "react-dom"]);
59
+ for (const driver of drivers) expect(Object.keys(built.dependencies ?? {})).toContain(driver);
60
+ });
61
+
62
+ test("keeps the image's trusted packages, so their install scripts run in the app's server too", () => {
63
+ const config = appConfig({ trustedDependencies: ["@external/runtime"], externalLibs: ["@external/runtime"] });
64
+ expect(DesktopServerStage.packageJson(config, config.getProductionPackageJson()).trustedDependencies).toEqual([
65
+ "@external/runtime",
66
+ ]);
67
+ });
68
+
69
+ test("says what the image installs that the desktop app's server goes without", () => {
70
+ const notice = (docker: unknown, carried: string[] = []) =>
71
+ DesktopServerStage.imageStepsNotice({ app: { name: "portal" }, docker } as never, carried);
72
+ const steps = { image: "oven/bun", preRuns: ["apt-get install -y ffmpeg"], postRuns: [], command: [] };
73
+ expect(notice({ ...steps, preRuns: [] })).toBeNull();
74
+ expect(notice(steps)).toEqual({
75
+ level: "warn",
76
+ message: expect.stringContaining(
77
+ "The image runs 1 docker step from the app and its libs, and a desktop app's server runs none of them",
78
+ ),
79
+ });
80
+ expect(notice("FROM ros:humble-ros-core")?.message).toStartWith(
81
+ "apps/portal/akan.config.ts writes its own Dockerfile",
82
+ );
83
+ expect(notice(steps, ["ffmpeg"])).toEqual({
84
+ level: "info",
85
+ message: expect.stringContaining("carries only bin: ffmpeg"),
86
+ });
87
+ });
88
+
89
+ const stageApp = async (spawn: (...args: unknown[]) => Promise<string> = async () => "") => {
90
+ const root = await makeTempRoot();
91
+ const dist = path.join(root, "dist/apps/portal");
92
+ const config = appConfig();
93
+ const app = {
94
+ cwdPath: path.join(root, "apps/portal"),
95
+ dist: { cwdPath: dist },
96
+ getConfig: async () => config,
97
+ getScanInfo: () => ({ getLibs: () => [] }),
98
+ logger: { info: () => undefined, warn: () => undefined },
99
+ spawn,
100
+ } as unknown as App;
101
+ return { dist, app, stage: new DesktopServerStage(app) };
102
+ };
103
+
104
+ test("carries everything the backend build wrote beside main.js but what only the image reads", async () => {
105
+ const { dist, stage } = await stageApp();
106
+ const carried = [
107
+ "main.js",
108
+ "server.js",
109
+ "chunk-5eap2n9b.js",
110
+ "akan.build.json",
111
+ "better-sqlite3-a1b2c3d4.node",
112
+ "engine-e5f6a7b8.wasm",
113
+ "model-c9d0e1f2.bin",
114
+ "private/service-account.json",
115
+ ];
116
+ const imageOnly = [
117
+ "Dockerfile",
118
+ "package.json",
119
+ "rscWorker.js",
120
+ "console.js",
121
+ "console-runtime.js",
122
+ "csr/index.html",
123
+ "public/favicon.ico",
124
+ ".akan/artifact/base-artifact.json",
125
+ ];
126
+ for (const file of [...carried, ...imageOnly]) await write(path.join(dist, file), file);
127
+
128
+ await stage.prepare("main");
129
+
130
+ const staged = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: stage.dir, dot: true }));
131
+ expect(staged.map((file) => file.split(path.sep).join("/")).sort()).toEqual(carried.sort());
132
+ expect(await Bun.file(path.join(stage.dir, "better-sqlite3-a1b2c3d4.node")).text()).toBe(
133
+ "better-sqlite3-a1b2c3d4.node",
134
+ );
135
+ });
136
+
137
+ test("installs from the config alone, offline when Bun's cache holds the packages", async () => {
138
+ const spawned: unknown[][] = [];
139
+ const { stage } = await stageApp(async (...args) => {
140
+ spawned.push(args);
141
+ return "";
142
+ });
143
+
144
+ await stage.install();
145
+
146
+ expect(spawned).toEqual([[process.execPath, ["install", "--production", "--prefer-offline"], { cwd: stage.dir }]]);
147
+ const installed = (await Bun.file(path.join(stage.dir, "package.json")).json()) as PackageJson;
148
+ expect(Object.keys(installed.dependencies ?? {}).sort()).toEqual(["react", "react-dom"]);
149
+ });
150
+
151
+ test("an install that fails says it needs the registry or a Bun cache that holds the packages", async () => {
152
+ const { stage } = await stageApp(async () => {
153
+ throw new Error("error: GET https://registry.npmjs.org/scheduler - ConnectionRefused");
154
+ });
155
+
156
+ const failed = stage.install();
157
+
158
+ await expect(failed).rejects.toThrow(
159
+ "it needs the npm registry, or a Bun cache that already holds every one of them.\nerror: GET https://registry.npmjs.org/scheduler",
160
+ );
161
+ });
162
+
163
+ test("refuses an app whose database modes leave out single", () => {
164
+ expect(() => DesktopServerStage.assertCarriable(appConfig({ database: { modes: ["cluster"] } }))).toThrow(
165
+ "only database mode single runs",
166
+ );
167
+ expect(() =>
168
+ DesktopServerStage.assertCarriable(appConfig({ database: { modes: ["single", "cluster"] } })),
169
+ ).not.toThrow();
170
+ });
171
+
172
+ test("the runtime accepts a desktop config that carries the server, with single-instance on", async () => {
173
+ const root = await makeTempRoot();
174
+ await mkdir(path.join(root, "web"), { recursive: true });
175
+ await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
176
+ await mkdir(path.join(root, "server"), { recursive: true });
177
+ await writeFile(path.join(root, "server/main.js"), "");
178
+ const server = { dir: path.join(root, "server"), entry: "main.js", env: { AKAN_PUBLIC_ENV: "main" } };
179
+
180
+ const { config } = NativeConfig.build({
181
+ appPath: root,
182
+ target: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
183
+ webDir: path.join(root, "web"),
184
+ contributions: [],
185
+ locales: ["en"],
186
+ platform: "macos",
187
+ desktopServer: server,
188
+ });
189
+
190
+ expect(config.plugins).toContain("single-instance");
191
+ expect(config.desktop).toEqual({ server });
192
+ expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
193
+ });
194
+
195
+ test("a phone build cannot carry the server", async () => {
196
+ const app = new NativeApp({ cwdPath: "/repo/apps/portal" } as never, {
197
+ name: "default",
198
+ config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
199
+ });
200
+ const server = { dir: "/tmp/server", entry: "main.js", env: {} };
201
+ await expect(app.build("ios", { server })).rejects.toThrow("Only a desktop app carries its server");
202
+ await expect(app.run("android", { server })).rejects.toThrow("Only a desktop app carries its server");
203
+ });
204
+ });
@@ -0,0 +1,138 @@
1
+ import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AkanAppConfig, MobileEnv } from "../akanConfig";
4
+ import type { App } from "../commandDecorators";
5
+ import type { PackageJson } from "../types";
6
+ import { DesktopBin } from "./desktopBin";
7
+
8
+ /** What a desktop build hands the native runtime as `desktop.server`. */
9
+ export interface DesktopServerBundle {
10
+ dir: string;
11
+ entry: string;
12
+ env: Record<string, string>;
13
+ }
14
+
15
+ //* A desktop app's carried server is the backend `akan build` wrote into dist, installed on its own as the image's
16
+ //* `bun install --production` does. It is staged outside dist: installing there would put host packages into the
17
+ //* image's `COPY . .`.
18
+ export class DesktopServerStage {
19
+ //? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR artifact,
20
+ //? and `install` wrote its own package.json. The rest of dist is the server's: its chunks, a `.node`, a `.wasm`.
21
+ static readonly uncarried = new Set([
22
+ "Dockerfile",
23
+ "package.json",
24
+ "rscWorker.js",
25
+ "console.js",
26
+ "console-runtime.js",
27
+ "csr",
28
+ "public",
29
+ ".akan",
30
+ ]);
31
+ //? Only the RSC worker loads it, and its peer webpack makes 35 of the 43 MB an install would add.
32
+ static readonly rscRenderer = "react-server-dom-webpack";
33
+
34
+ readonly dir: string;
35
+
36
+ constructor(readonly app: App) {
37
+ this.dir = path.join(app.cwdPath, ".akan", "desktop", "server");
38
+ }
39
+
40
+ static assertCarriable(config: AkanAppConfig) {
41
+ if (!config.database.modes.includes("single"))
42
+ throw new Error(
43
+ `native.desktop.server puts the server in the app, where only database mode single runs (no Redis or Postgres); apps/${config.app.name}/akan.config.ts declares ${config.database.modes.join(", ")}.`,
44
+ );
45
+ }
46
+
47
+ static env(config: AkanAppConfig, environment: MobileEnv): Record<string, string> {
48
+ //? The launcher picks the port, and the log file stays on: a user's computer has no log collector.
49
+ const { PORT: _port, AKAN_LOG_TO_FILE: _fileLog, ...image } = config.getProductionEnv(environment);
50
+ return {
51
+ ...image,
52
+ //? Neither the developer's machine (local, which derives a forgeable JWT secret) nor a deployment (cloud).
53
+ AKAN_PUBLIC_OPERATION_MODE: "edge",
54
+ AKAN_DATABASE_MODE: "single",
55
+ AKAN_DATABASE_MODES: "single",
56
+ //? API only: the shell serves the CSR bundle, and an RSC worker would need a bun on the user's PATH.
57
+ AKAN_SSR: "false",
58
+ AKAN_CSR: "false",
59
+ AKAN_MCP: "false",
60
+ //? Ahead of the launcher's 1.5 s grace before it signals, which sits inside the shell's 2 s quit budget.
61
+ AKAN_SHUTDOWN_TIMEOUT_MS: "1000",
62
+ };
63
+ }
64
+
65
+ //? The image's steps install into a Linux image the desktop app never runs in, so its server has none of it.
66
+ static imageStepsNotice(
67
+ config: Pick<AkanAppConfig, "app" | "docker">,
68
+ carried: string[],
69
+ ): { level: "warn" | "info"; message: string } | null {
70
+ const steps =
71
+ typeof config.docker === "string" ? null : config.docker.preRuns.length + config.docker.postRuns.length;
72
+ if (steps === 0) return null;
73
+ const image =
74
+ steps === null
75
+ ? `apps/${config.app.name}/akan.config.ts writes its own Dockerfile`
76
+ : `The image runs ${steps} docker step${steps === 1 ? "" : "s"} from the app and its libs`;
77
+ return carried.length
78
+ ? {
79
+ level: "info",
80
+ message: `${image}; the desktop app's server runs none of them and carries only bin: ${carried.join(", ")}.`,
81
+ }
82
+ : {
83
+ level: "warn",
84
+ message: `${image}, and a desktop app's server runs none of them: declare in bin the executables its code spawns, or those calls fail on the user's computer.`,
85
+ };
86
+ }
87
+
88
+ static packageJson(config: AkanAppConfig, built: PackageJson): PackageJson {
89
+ const otherDrivers = new Set(
90
+ config.database.modes
91
+ .filter((mode) => mode !== "single")
92
+ .flatMap((mode) => config.getDatabaseModeRuntimePackages(mode)),
93
+ );
94
+ const dependencies = Object.entries(built.dependencies ?? {}).filter(
95
+ ([name]) =>
96
+ config.externalLibs.includes(name) || (name !== DesktopServerStage.rscRenderer && !otherDrivers.has(name)),
97
+ );
98
+ return { ...built, dependencies: Object.fromEntries(dependencies) };
99
+ }
100
+
101
+ //* The packages come from the config alone, so they install before `akan build` runs: a machine that cannot install
102
+ //* them stops at once instead of after the whole build.
103
+ async install() {
104
+ const config = await this.app.getConfig();
105
+ DesktopServerStage.assertCarriable(config);
106
+ await rm(this.dir, { recursive: true, force: true });
107
+ await mkdir(this.dir, { recursive: true });
108
+ await writeFile(
109
+ path.join(this.dir, "package.json"),
110
+ JSON.stringify(DesktopServerStage.packageJson(config, config.getProductionPackageJson()), null, 2),
111
+ );
112
+ try {
113
+ await this.app.spawn(process.execPath, ["install", "--production", "--prefer-offline"], { cwd: this.dir });
114
+ } catch (error) {
115
+ throw new Error(
116
+ `The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${error instanceof Error ? error.message : String(error)}`,
117
+ { cause: error },
118
+ );
119
+ }
120
+ //? Launcher links only: a link out of the app bundle breaks its signature, and the server runs none of them.
121
+ await rm(path.join(this.dir, "node_modules", ".bin"), { recursive: true, force: true });
122
+ }
123
+
124
+ //? Copies the build beside what `install` put in the stage, so it runs after both.
125
+ async prepare(environment: MobileEnv): Promise<DesktopServerBundle> {
126
+ const config = await this.app.getConfig();
127
+ const dist = this.app.dist.cwdPath;
128
+ await mkdir(this.dir, { recursive: true });
129
+ for (const name of await readdir(dist))
130
+ if (!DesktopServerStage.uncarried.has(name))
131
+ await cp(path.join(dist, name), path.join(this.dir, name), { recursive: true });
132
+ const scanInfo = this.app.getScanInfo({ allowEmpty: true }) ?? (await this.app.scan({ write: false }));
133
+ const carried = [...DesktopBin.select(config, scanInfo.getLibs()).keys()];
134
+ const notice = DesktopServerStage.imageStepsNotice(config, carried);
135
+ if (notice) this.app.logger[notice.level](notice.message);
136
+ return { dir: this.dir, entry: "main.js", env: DesktopServerStage.env(config, environment) };
137
+ }
138
+ }
package/mobile/index.ts CHANGED
@@ -1,7 +1,10 @@
1
+ export * from "./desktopBin";
2
+ export * from "./desktopServerStage";
1
3
  export * from "./mobileTarget";
2
4
  export * from "./nativeApi";
3
5
  export * from "./nativeApp";
4
6
  export * from "./nativeConfig";
7
+ export * from "./nativePluginFolders";
5
8
  export * from "./nativeWebDir";
6
9
  export * from "./placeholderAppId";
7
10
  export * from "./usageDescriptions";
@@ -1,7 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { AkanMobileTargetConfig } from "../akanConfig";
3
3
  import type { App } from "../commandDecorators";
4
- import { getMobileTargetChoices, resolveMobilePath, resolveMobileTargets, targetHtmlFilename } from "./mobileTarget";
4
+ import {
5
+ appIdsOf,
6
+ getMobileTargetChoices,
7
+ resolveAppId,
8
+ resolveMobilePath,
9
+ resolveMobileTargets,
10
+ targetHtmlFilename,
11
+ } from "./mobileTarget";
5
12
 
6
13
  const target = {
7
14
  name: "akanjs",
@@ -19,6 +26,17 @@ describe("mobile target helpers", () => {
19
26
  expect(resolveMobilePath({ ...target, basePath: undefined }, "/order/123")).toBe("/order/123");
20
27
  });
21
28
 
29
+ test("an app id per platform falls back to default, and a platform with neither is refused", () => {
30
+ const appId = { default: "com.yeollege", ios: "com.puffinplanet.yeollege" };
31
+ expect(resolveAppId(appId, "ios")).toBe("com.puffinplanet.yeollege");
32
+ expect(resolveAppId(appId, "android")).toBe("com.yeollege");
33
+ expect(resolveAppId("com.one.app", "macos")).toBe("com.one.app");
34
+ expect(() => resolveAppId({ ios: "com.puffinplanet.yeollege" }, "android")).toThrow(
35
+ "mobile.appId names no id for android",
36
+ );
37
+ expect(appIdsOf(appId)).toEqual(["com.yeollege", "com.puffinplanet.yeollege"]);
38
+ });
39
+
22
40
  test("selects the self-contained html file for a target", () => {
23
41
  expect(targetHtmlFilename(target)).toBe("akanjs.html");
24
42
  expect(targetHtmlFilename({ ...target, basePath: undefined })).toBe("index.html");
@@ -36,6 +54,37 @@ describe("mobile target helpers", () => {
36
54
  await expect(resolveMobileTargets(app, undefined)).resolves.toEqual([{ name: "akanjs", config: target }]);
37
55
  });
38
56
 
57
+ test("a target naming no basePath is a template in an app with basePaths, never a page of its own", async () => {
58
+ const template = { ...target, name: "default", basePath: undefined };
59
+ const appWith = (basePaths: string[]) =>
60
+ ({
61
+ name: "angelo",
62
+ getConfig: async () => ({ basePaths: new Set(basePaths), mobile: { targets: { default: template } } }),
63
+ }) as unknown as App;
64
+
65
+ const two = appWith(["soft", "office"]);
66
+ await expect(getMobileTargetChoices(two)).resolves.toEqual(["soft", "office"]);
67
+ await expect(resolveMobileTargets(two, undefined)).rejects.toThrow(
68
+ "Multiple mobile targets found for angelo. Pass --target <soft|office|all>.",
69
+ );
70
+ await expect(resolveMobileTargets(two, "office")).resolves.toEqual([
71
+ { name: "office", config: { ...template, name: "office", basePath: "office" } },
72
+ ]);
73
+ await expect(resolveMobileTargets(two, "all")).resolves.toEqual([
74
+ { name: "soft", config: { ...template, name: "soft", basePath: "soft" } },
75
+ { name: "office", config: { ...template, name: "office", basePath: "office" } },
76
+ ]);
77
+
78
+ const one = appWith(["soft"]);
79
+ const soft = [{ name: "soft", config: { ...template, name: "soft", basePath: "soft" } }];
80
+ await expect(resolveMobileTargets(one, undefined)).resolves.toEqual(soft);
81
+ await expect(resolveMobileTargets(one, "all")).resolves.toEqual(soft);
82
+
83
+ await expect(resolveMobileTargets(appWith([]), undefined)).resolves.toEqual([
84
+ { name: "default", config: template },
85
+ ]);
86
+ });
87
+
39
88
  test("resolves a route base path onto the default mobile target config", async () => {
40
89
  const app = {
41
90
  getConfig: async () => ({
@@ -1,4 +1,5 @@
1
- import type { AkanMobileTargetConfig, MobileEnv } from "../akanConfig";
1
+ import type { AkanMobileAppId } from "akanjs";
2
+ import type { AkanAppConfig, AkanMobileTargetConfig, MobileEnv } from "../akanConfig";
2
3
  import type { App } from "../commandDecorators";
3
4
 
4
5
  export type MobilePlatform = "ios" | "android";
@@ -20,10 +21,15 @@ export const getMobileTargets = async (app: App): Promise<ResolvedMobileTarget[]
20
21
  return Object.entries(config.mobile.targets).map(([name, target]) => ({ name, config: target }));
21
22
  };
22
23
 
24
+ //* An app with basePaths builds a CSR page per basePath and none at its root, so a target naming no basePath opens
25
+ //* nothing on its own there; it is the template each basePath's target is made from.
26
+ const isTemplateOnly = ({ basePaths, mobile }: Pick<AkanAppConfig, "basePaths" | "mobile">) =>
27
+ basePaths.size > 0 && Object.values(mobile.targets).every((target) => !target.basePath);
28
+
23
29
  export const getMobileTargetChoices = async (app: App): Promise<string[]> => {
24
30
  const config = await app.getConfig();
25
31
  const targetNames = Object.keys(config.mobile.targets);
26
- if (targetNames.length > 0) return targetNames;
32
+ if (targetNames.length > 0 && !isTemplateOnly(config)) return targetNames;
27
33
  return [...config.basePaths];
28
34
  };
29
35
 
@@ -51,7 +57,7 @@ export const resolveMobileTargets = async (
51
57
  const config = await app.getConfig();
52
58
  const targets = await getMobileTargets(app);
53
59
  if (targets.length === 0) throw new Error(`No mobile targets configured for ${app.name}`);
54
- if (!selection && targets.length === 1) return targets;
60
+ if (!selection && targets.length === 1 && !isTemplateOnly(config)) return targets;
55
61
  if (!selection) {
56
62
  const choices = await getMobileTargetChoices(app);
57
63
  if (choices.length === 1) return resolveMobileTargets(app, choices[0]);
@@ -60,7 +66,7 @@ export const resolveMobileTargets = async (
60
66
  if (selection === "all") {
61
67
  if (Object.keys(config.mobile.targets).length > 1) return targets;
62
68
  const basePaths = [...config.basePaths];
63
- if (basePaths.length > 1) {
69
+ if (basePaths.length > 1 || isTemplateOnly(config)) {
64
70
  return basePaths.flatMap((basePath) => {
65
71
  const resolved = resolveMobileTargetByBasePath(targets, basePath);
66
72
  return resolved ? [resolved] : [];
@@ -76,6 +82,16 @@ export const resolveMobileTargets = async (
76
82
  throw new Error(`Mobile target '${selection}' was not found. Available: ${choices.join(", ")}`);
77
83
  };
78
84
 
85
+ export const resolveAppId = (appId: AkanMobileAppId, platform: NativePlatform): string => {
86
+ if (typeof appId === "string") return appId;
87
+ const id = appId[platform] ?? appId.default;
88
+ if (!id) throw new Error(`mobile.appId names no id for ${platform}; give it ${platform} or default.`);
89
+ return id;
90
+ };
91
+
92
+ export const appIdsOf = (appId: AkanMobileAppId): string[] =>
93
+ typeof appId === "string" ? [appId] : Object.values(appId).filter((id): id is string => !!id);
94
+
79
95
  export const resolveMobilePath = (target: AkanMobileTargetConfig, pathname: string) => {
80
96
  const basePath = trimSlashes(target.basePath ?? "");
81
97
  const normalizedPath = `/${pathname.replace(/^\/+/, "")}`;
@@ -3,9 +3,12 @@ import path from "node:path";
3
3
  import type { AkanPlugin } from "akanjs";
4
4
  import type { AkanMobileTargetConfig } from "../akanConfig";
5
5
  import type { App } from "../commandDecorators";
6
+ import { tempDirs, writeText } from "../testHelpers";
6
7
  import { NativeApp } from "./nativeApp";
7
8
  import { NativeConfig } from "./nativeConfig";
8
9
 
10
+ const makeTempRoot = tempDirs("akan-native-app-");
11
+
9
12
  const target = (config: Partial<AkanMobileTargetConfig> = {}) => ({
10
13
  name: config.name ?? "default",
11
14
  config: {
@@ -22,7 +25,10 @@ const appDir = "/repo/apps/portal";
22
25
 
23
26
  const fakeApp = (plugins: AkanPlugin[] = []) =>
24
27
  ({
28
+ name: "portal",
25
29
  cwdPath: appDir,
30
+ workspace: { workspaceRoot: "/repo" },
31
+ getScanInfo: () => ({ getLibs: () => [] }),
26
32
  dist: { cwdPath: "/repo/dist/apps/portal" },
27
33
  getConfig: async () => ({ i18n: { locales: ["en", "ko"] }, api: { prefix: "/api", websocketPrefix: "/ws" } }),
28
34
  collectPlugins: async () => plugins,
@@ -36,6 +42,33 @@ describe("NativeApp", () => {
36
42
  expect(admin.web.dir).toBe(path.join(appDir, ".akan/mobile/admin/web"));
37
43
  expect(admin.outDir("ios")).toBe(path.join(appDir, ".akan/mobile/admin/native/ios"));
38
44
  expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/mobile/admin/native/android"));
45
+ expect(admin.devOutDir("macos")).toBe(path.join(appDir, ".akan/mobile/admin/dev/macos"));
46
+ });
47
+
48
+ test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
49
+ const app = {
50
+ ...fakeApp(),
51
+ cwdPath: await makeTempRoot(),
52
+ logger: { info: () => undefined, debug: () => undefined, warn: () => undefined },
53
+ } as unknown as App;
54
+ const nativeApp = new NativeApp(app, target());
55
+ await writeText(path.join(nativeApp.web.dir, "index.html"), "<html>the last release</html>");
56
+ const seen: { outDir?: string; leftover: boolean }[] = [];
57
+ nativeApp.prepare = async () =>
58
+ ({
59
+ config: {},
60
+ api: {
61
+ dev: async ({ outDir }: { outDir?: string }) => {
62
+ seen.push({ outDir, leftover: await Bun.file(path.join(nativeApp.web.dir, "index.html")).exists() });
63
+ return { gateway: "http://127.0.0.1:9", exited: Promise.resolve(0), stop: async () => undefined };
64
+ },
65
+ },
66
+ }) as never;
67
+
68
+ await nativeApp.dev("windows", { upstream: "http://localhost:8282", lang: "en" });
69
+
70
+ expect(seen).toEqual([{ outDir: nativeApp.devOutDir("windows"), leftover: false }]);
71
+ expect(path.relative(nativeApp.outDir("windows"), nativeApp.devOutDir("windows"))).toStartWith("..");
39
72
  });
40
73
 
41
74
  test("a dev boot that came up says so in one line: platform, device, dev server, time", () => {
@@ -69,13 +102,31 @@ describe("NativeApp", () => {
69
102
  target({ basePath: undefined, permissions: ["push"] }),
70
103
  );
71
104
 
72
- const { config, warnings } = await app.config();
105
+ const { config, warnings } = await app.config({ platform: "android" });
73
106
 
74
107
  expect(warnings).toEqual([]);
75
108
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
76
109
  expect(config.web.dir).toBe(path.join(appDir, ".akan/mobile/default/web"));
77
110
  });
78
111
 
112
+ test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
113
+ const { config } = await new NativeApp(fakeApp(), target({ basePath: undefined })).config({
114
+ platform: "macos",
115
+ stageBin: false,
116
+ });
117
+ expect(config.desktop?.bin).toBeUndefined();
118
+ });
119
+
120
+ test("follows the updates channel of the backend env it is built for", async () => {
121
+ const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
122
+ const built = async (env?: "debug" | "main") =>
123
+ (await new NativeApp(fakeApp(), target({ basePath: undefined, updates }), env).config({ platform: "android" }))
124
+ .config.updates;
125
+
126
+ expect(await built("main")).toEqual({ ...updates, channel: "main" });
127
+ expect(await built("debug")).toEqual({ ...updates, channel: "debug" });
128
+ });
129
+
79
130
  test("a desktop app is this computer's platform", () => {
80
131
  expect(["darwin", "win32", "linux"].map((host) => NativeApp.desktopPlatform(host as NodeJS.Platform))).toEqual([
81
132
  "macos",