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

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 (38) hide show
  1. package/akanApp/akanApp.host.ts +22 -2
  2. package/akanConfig/akanBin.test.ts +62 -0
  3. package/akanConfig/akanBin.ts +78 -0
  4. package/akanConfig/akanConfig.test.ts +212 -2
  5. package/akanConfig/akanConfig.ts +93 -26
  6. package/akanConfig/index.ts +1 -0
  7. package/akanConfig/types.ts +7 -1
  8. package/applicationBuildRunner.test.ts +36 -2
  9. package/applicationBuildRunner.ts +21 -6
  10. package/biome.base.json +3 -1
  11. package/csrE2e/csrPageStack.e2e.test.ts +65 -0
  12. package/executors.test.ts +43 -0
  13. package/executors.ts +17 -2
  14. package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
  15. package/frontendBuild/csrDevModuleCompiler.ts +45 -2
  16. package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
  17. package/integration/devStability.integration.test.ts +35 -0
  18. package/mobile/desktopBin.test.ts +177 -0
  19. package/mobile/desktopBin.ts +143 -0
  20. package/mobile/desktopServerStage.test.ts +129 -0
  21. package/mobile/desktopServerStage.ts +120 -0
  22. package/mobile/index.ts +3 -0
  23. package/mobile/mobileTarget.test.ts +50 -1
  24. package/mobile/mobileTarget.ts +20 -4
  25. package/mobile/nativeApp.test.ts +22 -1
  26. package/mobile/nativeApp.ts +123 -14
  27. package/mobile/nativeAppLine.test.ts +7 -0
  28. package/mobile/nativeAppLine.ts +8 -0
  29. package/mobile/nativeConfig.test.ts +215 -4
  30. package/mobile/nativeConfig.ts +117 -8
  31. package/mobile/nativePluginFolders.test.ts +81 -0
  32. package/mobile/nativePluginFolders.ts +60 -0
  33. package/package.json +2 -2
  34. package/transforms/serverEnvPlugin.test.ts +100 -0
  35. package/transforms/serverEnvPlugin.ts +41 -0
  36. package/types.ts +1 -0
  37. package/workspaceLayout.test.ts +5 -0
  38. package/workspaceLayout.ts +2 -0
@@ -104,6 +104,9 @@ export class AkanAppHost {
104
104
  #backendGaveUp = false;
105
105
  #backendLifecycleState: BackendLifecycleState = "stopped";
106
106
  #pendingRestartReason: BackendRestartReason | null = null;
107
+ //? One replacement at a time: `#stopBackend` clears `#backend` only once the old process exits, so a second restart
108
+ //? inside that wait signals the same pid and spawns a second backend, and the one the host stops tracking keeps the port.
109
+ #backendRestart: Promise<void> | null = null;
107
110
  #pendingRecycle: {
108
111
  message: Extract<BuilderMessage, { type: "invalidate" }>;
109
112
  refreshConfig: boolean;
@@ -181,8 +184,10 @@ export class AkanAppHost {
181
184
  this.#cancelIdleSuspend();
182
185
  this.#stopIdleWatcher();
183
186
  this.#clearRestartTimers();
187
+ this.#pendingRestartReason = null;
184
188
  // Before the backend goes away, while it can still receive the answer.
185
189
  this.#failPendingBuilderMessages("dev server is shutting down");
190
+ await this.#backendRestart;
186
191
  await this.#stopBackend();
187
192
  this.#stopBuilder();
188
193
  return this;
@@ -202,6 +207,11 @@ export class AkanAppHost {
202
207
  return await createTunnel(type, { app: this.app, environment });
203
208
  }
204
209
  #startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
210
+ if (this.#stopping) return;
211
+ if (this.#backend) {
212
+ this.logger.warn(`backend pid=${this.#backend.pid} is still running; not starting a second one`);
213
+ return;
214
+ }
205
215
  // Before the spawn: the new backend numbers its requests from 1 again, so old answers must not reach it.
206
216
  this.#builderRequests.startGeneration();
207
217
  this.#discardPendingBuilderMessages("the backend restarted while the builder was away");
@@ -386,10 +396,18 @@ export class AkanAppHost {
386
396
  if (this.#restartTimer) clearTimeout(this.#restartTimer);
387
397
  this.#restartTimer = setTimeout(() => {
388
398
  this.#restartTimer = null;
399
+ this.#backendRestart ??= this.#drainBackendRestarts().finally(() => {
400
+ this.#backendRestart = null;
401
+ });
402
+ }, BACKEND_RESTART_DEBOUNCE_MS);
403
+ }
404
+ //? A reason that arrived during a restart runs after it; one whose debounce is still pending waits for its timer.
405
+ async #drainBackendRestarts() {
406
+ while (this.#pendingRestartReason && !this.#restartTimer && !this.#stopping) {
389
407
  const next = this.#pendingRestartReason;
390
408
  this.#pendingRestartReason = null;
391
- if (next) void this.#restartBackend(next);
392
- }, BACKEND_RESTART_DEBOUNCE_MS);
409
+ await this.#restartBackend(next);
410
+ }
393
411
  }
394
412
  async #restartBackend(reason: BackendRestartReason) {
395
413
  this.logger.verbose(
@@ -719,6 +737,7 @@ export class AkanAppHost {
719
737
  return !!(
720
738
  this.#pendingRecycle ||
721
739
  this.#restartTimer ||
740
+ this.#backendRestart ||
722
741
  this.#backendRecoveryTimer ||
723
742
  this.#builderRecoveryTimer ||
724
743
  this.#rssRecycleReason
@@ -1053,6 +1072,7 @@ export class AkanAppHost {
1053
1072
  this.#clearRestartTimers();
1054
1073
  this.#builderRecoveryAttempts = 0;
1055
1074
  this.#pendingRestartReason = null;
1075
+ await this.#backendRestart;
1056
1076
  this.#lastGoodFrontend = {};
1057
1077
  this.#buildStatusByPhase.clear();
1058
1078
  this.#pendingBuildStatusReplay = [];
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { AkanBin } from "./akanBin";
4
+
5
+ const owner = "apps/portal/akan.config.ts";
6
+ const sha256 = "A".repeat(64);
7
+
8
+ describe("AkanBin.parse", () => {
9
+ test("takes a download checked by its hash, or a file relative to the declaring folder", () => {
10
+ expect(
11
+ AkanBin.parse(
12
+ {
13
+ ffmpeg: {
14
+ "linux-x64": { url: "https://files.test/ffmpeg-linux64-lgpl.tar.xz", sha256, file: "./ffmpeg\\bin/ffmpeg" },
15
+ "darwin-arm64": { path: "tools/ffmpeg" },
16
+ },
17
+ },
18
+ owner,
19
+ "/repo/apps/portal",
20
+ ),
21
+ ).toEqual({
22
+ ffmpeg: {
23
+ "linux-x64": {
24
+ url: "https://files.test/ffmpeg-linux64-lgpl.tar.xz",
25
+ sha256: "a".repeat(64),
26
+ file: "ffmpeg/bin/ffmpeg",
27
+ },
28
+ "darwin-arm64": { path: path.resolve("/repo/apps/portal/tools/ffmpeg") },
29
+ },
30
+ });
31
+ expect(AkanBin.parse(undefined, owner, "/repo/apps/portal")).toEqual({});
32
+ });
33
+
34
+ test("names the declaration that is wrong", () => {
35
+ const parse = (bin: unknown) => () => AkanBin.parse(bin, owner, "/repo/apps/portal");
36
+ expect(parse(["ffmpeg"])).toThrow(`${owner}: bin maps each executable's name`);
37
+ expect(parse({ "../ffmpeg": {} })).toThrow("bin.../ffmpeg is not a file name");
38
+ expect(parse({ ffmpeg: { "darwin-universal": { path: "x" } } })).toThrow(
39
+ "bin.ffmpeg.darwin-universal is not one of darwin-arm64",
40
+ );
41
+ expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg", sha256, path: "x" } } })).toThrow(
42
+ "takes either url and sha256, or path",
43
+ );
44
+ expect(parse({ ffmpeg: { "linux-x64": { url: "ftp://x.test/ffmpeg", sha256 } } })).toThrow(
45
+ "url must be an http(s) address",
46
+ );
47
+ expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg", sha256: "abc" } } })).toThrow(
48
+ "sha256 must be the downloaded file's 64-digit hex digest",
49
+ );
50
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", sha256 } } })).toThrow("a path source downloads nothing");
51
+ expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg.zip", sha256 } } })).toThrow(
52
+ "names an archive: file picks the executable inside it",
53
+ );
54
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", file: "bin/ffmpeg" } } })).toThrow(
55
+ "file picks the executable out of an archive",
56
+ );
57
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg.tar.gz", file: "../ffmpeg" } } })).toThrow(
58
+ "file must be a path inside the archive",
59
+ );
60
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", mode: 755 } } })).toThrow("unknown key mode");
61
+ });
62
+ });
@@ -0,0 +1,78 @@
1
+ import path from "node:path";
2
+ import {
3
+ type AkanBinConfig,
4
+ type AkanBinPathSource,
5
+ type AkanBinSource,
6
+ type AkanBinUrlSource,
7
+ type BinPlatform,
8
+ binPlatforms,
9
+ } from "./types";
10
+
11
+ //* Checked when the config loads, and a `path` source is made absolute against the folder that declares it: a lib's
12
+ //* relative path has to find its file from every app that carries it.
13
+ export class AkanBin {
14
+ static readonly archive = /\.(zip|tar\.gz|tgz|tar\.xz|txz|tar\.bz2|tbz2|tar)$/i;
15
+ static readonly fileName = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
16
+
17
+ static parse(declared: unknown, owner: string, baseDir: string): AkanBinConfig {
18
+ if (declared === undefined) return {};
19
+ if (!declared || typeof declared !== "object" || Array.isArray(declared))
20
+ throw new Error(`${owner}: bin maps each executable's name to its platforms`);
21
+ return Object.fromEntries(
22
+ Object.entries(declared).map(([name, platforms]) => [name, AkanBin.#platforms(name, platforms, owner, baseDir)]),
23
+ );
24
+ }
25
+
26
+ static isArchive(source: AkanBinSource): boolean {
27
+ return AkanBin.archive.test("url" in source ? new URL(source.url).pathname : source.path);
28
+ }
29
+
30
+ static #platforms(name: string, platforms: unknown, owner: string, baseDir: string) {
31
+ if (!AkanBin.fileName.test(name)) throw new Error(`${owner}: bin.${name} is not a file name`);
32
+ if (!platforms || typeof platforms !== "object" || Array.isArray(platforms))
33
+ throw new Error(`${owner}: bin.${name} maps platforms (${binPlatforms.join(", ")}) to where the file comes from`);
34
+ const resolved: { [platform in BinPlatform]?: AkanBinSource } = {};
35
+ for (const [platform, source] of Object.entries(platforms)) {
36
+ if (!(binPlatforms as readonly string[]).includes(platform))
37
+ throw new Error(`${owner}: bin.${name}.${platform} is not one of ${binPlatforms.join(", ")}`);
38
+ resolved[platform as BinPlatform] = AkanBin.#source(`${owner}: bin.${name}.${platform}`, source, baseDir);
39
+ }
40
+ return resolved;
41
+ }
42
+
43
+ static #source(at: string, source: unknown, baseDir: string): AkanBinSource {
44
+ if (!source || typeof source !== "object" || Array.isArray(source))
45
+ throw new Error(`${at} must be { url, sha256 } or { path }`);
46
+ const { url, sha256, path: localPath, file, ...rest } = source as Record<string, unknown>;
47
+ const extra = Object.keys(rest);
48
+ if (extra.length) throw new Error(`${at}: unknown key ${extra.join(", ")}`);
49
+ if ((url === undefined) === (localPath === undefined))
50
+ throw new Error(`${at} takes either url and sha256, or path`);
51
+ const resolved =
52
+ url !== undefined ? AkanBin.#urlSource(at, url, sha256) : AkanBin.#pathSource(at, localPath, sha256, baseDir);
53
+ const archive = AkanBin.isArchive(resolved);
54
+ if (file === undefined) {
55
+ if (archive) throw new Error(`${at} names an archive: file picks the executable inside it`);
56
+ return resolved;
57
+ }
58
+ if (!archive) throw new Error(`${at}: file picks the executable out of an archive, and this source is none`);
59
+ const parts = typeof file === "string" ? file.split(/[\\/]/).filter((part) => part && part !== ".") : [];
60
+ if (!parts.length || path.isAbsolute(file as string) || parts.includes(".."))
61
+ throw new Error(`${at}: file must be a path inside the archive (got ${JSON.stringify(file)})`);
62
+ return { ...resolved, file: parts.join("/") };
63
+ }
64
+
65
+ static #urlSource(at: string, url: unknown, sha256: unknown): AkanBinUrlSource {
66
+ if (typeof url !== "string" || !URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol))
67
+ throw new Error(`${at}: url must be an http(s) address`);
68
+ if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(sha256))
69
+ throw new Error(`${at}: sha256 must be the downloaded file's 64-digit hex digest`);
70
+ return { url, sha256: sha256.toLowerCase() };
71
+ }
72
+
73
+ static #pathSource(at: string, localPath: unknown, sha256: unknown, baseDir: string): AkanBinPathSource {
74
+ if (typeof localPath !== "string" || !localPath) throw new Error(`${at}: path must name a file`);
75
+ if (sha256 !== undefined) throw new Error(`${at}: sha256 checks a download, and a path source downloads nothing`);
76
+ return { path: path.resolve(baseDir, localPath) };
77
+ }
78
+ }
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { AppExecutor, WorkspaceExecutor } from "../executors";
7
7
  import type { PackageJson } from "../types";
8
8
  import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
9
- import type { DeepPartial, LibConfigResult } from "./types";
9
+ import type { LibConfigInput } from "./types";
10
10
 
11
11
  const akanPackageJson = JSON.parse(
12
12
  fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
@@ -172,6 +172,75 @@ describe("AkanAppConfig", () => {
172
172
  expect(new AkanAppConfig(app, [], packageJson, { web: true }, baseDevEnv).web).toEqual({ ssr: true, csr: true });
173
173
  });
174
174
 
175
+ test("writes the image env from getProductionEnv, one ENV line per key in its order", () => {
176
+ const config = new AkanAppConfig(
177
+ app,
178
+ [],
179
+ packageJson,
180
+ { routes: [{ basePath: "admin", domains: {} }], web: false, database: { modes: ["single", "cluster"] } },
181
+ baseDevEnv,
182
+ );
183
+
184
+ expect(config.getProductionEnv()).toEqual({
185
+ PORT: "8282",
186
+ NODE_ENV: "production",
187
+ AKAN_PUBLIC_REPO_NAME: "akanjs",
188
+ AKAN_PUBLIC_SERVE_DOMAIN: "akanjs.com",
189
+ AKAN_PUBLIC_APP_NAME: "portal",
190
+ AKAN_PUBLIC_ENV: "debug",
191
+ AKAN_PUBLIC_BASE_PATHS: "admin",
192
+ AKAN_PUBLIC_DEFAULT_LOCALE: "en",
193
+ AKAN_PUBLIC_LOCALES: config.i18n.locales.join(","),
194
+ AKAN_PUBLIC_API_PREFIX: "/api",
195
+ AKAN_PUBLIC_WS_PREFIX: "/ws",
196
+ AKAN_PUBLIC_OPERATION_MODE: "cloud",
197
+ AKAN_DATABASE_MODES: "single,cluster",
198
+ AKAN_LOG_TO_FILE: "0",
199
+ AKAN_SSR: "false",
200
+ AKAN_CSR: "false",
201
+ });
202
+ const envBlock = Object.entries(config.getProductionEnv())
203
+ .map(([key, value]) => `ENV ${key}=${value}`)
204
+ .join("\n");
205
+ expect(config.dockerfile).toContain(`COPY . .\n${envBlock}\nCMD ["bun","main.js"]`);
206
+ });
207
+
208
+ test("writes the Dockerfile instructions it wrote before its env came from getProductionEnv", () => {
209
+ //? 9adfb95c's output for this config; only the blank lines its empty interpolations left are gone since.
210
+ const before = [
211
+ "FROM oven/bun:1-slim",
212
+ "RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*",
213
+ "RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime",
214
+ "ARG TARGETARCH",
215
+ "",
216
+ "RUN mkdir -p /workspace",
217
+ "WORKDIR /workspace",
218
+ "COPY ./package.json ./package.json",
219
+ "RUN bun install --production",
220
+ "",
221
+ "COPY . .",
222
+ "ENV PORT=8282",
223
+ "ENV NODE_ENV=production",
224
+ "ENV AKAN_PUBLIC_REPO_NAME=akanjs",
225
+ "ENV AKAN_PUBLIC_SERVE_DOMAIN=akanjs.com",
226
+ "ENV AKAN_PUBLIC_APP_NAME=portal",
227
+ "ENV AKAN_PUBLIC_ENV=debug",
228
+ "",
229
+ "ENV AKAN_PUBLIC_DEFAULT_LOCALE=en",
230
+ "ENV AKAN_PUBLIC_LOCALES=en,ko",
231
+ "ENV AKAN_PUBLIC_API_PREFIX=/api",
232
+ "ENV AKAN_PUBLIC_WS_PREFIX=/ws",
233
+ "ENV AKAN_PUBLIC_OPERATION_MODE=cloud",
234
+ "ENV AKAN_DATABASE_MODES=single",
235
+ "ENV AKAN_LOG_TO_FILE=0",
236
+ "",
237
+ 'CMD ["bun","main.js"]',
238
+ ];
239
+ const lines = (text: string) => text.split("\n").filter((line) => line !== "");
240
+
241
+ expect(lines(new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).dockerfile)).toEqual(lines(before.join("\n")));
242
+ });
243
+
175
244
  test("refuses a csr-less build that ships a mobile app", () => {
176
245
  expect(
177
246
  () => new AkanAppConfig(app, [], packageJson, { web: { csr: false }, mobile: { appName: "portal" } }, baseDevEnv),
@@ -458,6 +527,84 @@ describe("AkanAppConfig", () => {
458
527
  });
459
528
  });
460
529
 
530
+ test("gives every target the mobile-wide updates, a target overriding a field", () => {
531
+ const updates = { url: "https://releases.example.com/portal", publicKey: "key=" };
532
+ const config = new AkanAppConfig(
533
+ app,
534
+ [],
535
+ packageJson,
536
+ {
537
+ mobile: {
538
+ updates,
539
+ targets: { default: {}, pilot: { updates: { channel: "pilot" } } },
540
+ },
541
+ },
542
+ baseDevEnv,
543
+ );
544
+
545
+ expect(config.mobile.targets.default?.updates).toEqual(updates);
546
+ expect(config.mobile.targets.pilot?.updates).toEqual({ ...updates, channel: "pilot" });
547
+ expect(
548
+ () =>
549
+ new AkanAppConfig(
550
+ app,
551
+ [],
552
+ packageJson,
553
+ { mobile: { targets: { pilot: { updates: { channel: "pilot" } } } } },
554
+ baseDevEnv,
555
+ ),
556
+ ).toThrow("mobile.targets.pilot.updates in apps/portal/akan.config.ts has no url or publicKey");
557
+ });
558
+
559
+ test("merges the desktop settings field by field, the target winning", () => {
560
+ const config = new AkanAppConfig(
561
+ app,
562
+ [],
563
+ packageJson,
564
+ {
565
+ mobile: {
566
+ native: { desktop: { recovery: "reload", window: { fullscreen: true } } },
567
+ targets: { default: { native: { desktop: { window: { skipTaskbar: true } } } } },
568
+ },
569
+ },
570
+ baseDevEnv,
571
+ );
572
+
573
+ expect(config.mobile.targets.default?.native?.desktop).toEqual({
574
+ recovery: "reload",
575
+ window: { fullscreen: true, skipTaskbar: true },
576
+ });
577
+ });
578
+
579
+ test("merges the mobile-wide updates, push and privacy into each target field by field, the target winning", () => {
580
+ const publicKey = Buffer.alloc(32, 7).toString("base64");
581
+ const config = new AkanAppConfig(
582
+ app,
583
+ [],
584
+ packageJson,
585
+ {
586
+ mobile: {
587
+ updates: { url: "https://updates.example.com/portal", publicKey },
588
+ native: { push: { android: { color: "#ff5a5f" } }, privacy: { tracking: false } },
589
+ targets: {
590
+ default: {},
591
+ beta: {
592
+ updates: { channel: "beta" },
593
+ native: { push: { android: { smallIcon: "assets/noti.png" } } },
594
+ },
595
+ },
596
+ },
597
+ } as never,
598
+ baseDevEnv,
599
+ );
600
+
601
+ expect(config.mobile.targets.default?.updates).toEqual({ url: "https://updates.example.com/portal", publicKey });
602
+ expect(config.mobile.targets.beta).toMatchObject({
603
+ updates: { url: "https://updates.example.com/portal", publicKey, channel: "beta" },
604
+ native: { push: { android: { color: "#ff5a5f", smallIcon: "assets/noti.png" } }, privacy: { tracking: false } },
605
+ });
606
+ });
607
+
461
608
  test("refuses the Capacitor-era keys with the setting that replaces them", () => {
462
609
  const make = (mobile: Record<string, unknown>) => () =>
463
610
  new AkanAppConfig(app, [], packageJson, { mobile } as never, baseDevEnv);
@@ -587,12 +734,75 @@ describe("AkanAppConfig lib docker runs", () => {
587
734
  });
588
735
  });
589
736
 
737
+ describe("AkanAppConfig trustedDependencies and bin", () => {
738
+ const sha256 = "b".repeat(64);
739
+ const libBin = { ffmpeg: { "linux-x64": { url: "https://files.test/ffmpeg.tar.xz", sha256, file: "bin/ffmpeg" } } };
740
+
741
+ test("the production package.json trusts the app's and its libs' packages, and nothing when none are named", () => {
742
+ const withTrusted = new AkanAppConfig(
743
+ app,
744
+ [],
745
+ packageJson,
746
+ { trustedDependencies: [" rclnodejs ", "sharp"] },
747
+ baseDevEnv,
748
+ [],
749
+ {
750
+ externalLibs: [],
751
+ trustedDependencies: ["sharp", "@serialport/bindings-cpp"],
752
+ docker: { preRuns: [], postRuns: [] },
753
+ },
754
+ );
755
+ expect(withTrusted.getProductionPackageJson().trustedDependencies).toEqual([
756
+ "rclnodejs",
757
+ "sharp",
758
+ "@serialport/bindings-cpp",
759
+ ]);
760
+ expect(new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).getProductionPackageJson()).not.toHaveProperty(
761
+ "trustedDependencies",
762
+ );
763
+ expect(() => new AkanAppConfig(app, [], packageJson, { trustedDependencies: [""] }, baseDevEnv)).toThrow(
764
+ "apps/portal/akan.config.ts: trustedDependencies lists package names",
765
+ );
766
+ });
767
+
768
+ test("keeps the app's bin apart from each lib's, with paths made absolute where they were declared", () => {
769
+ const config = new AkanAppConfig(
770
+ { name: "portal", cwdPath: "/repo/apps/portal" } as never,
771
+ [],
772
+ packageJson,
773
+ { bin: { ffmpeg: { "darwin-arm64": { path: "tools/ffmpeg" } } } },
774
+ baseDevEnv,
775
+ [],
776
+ { externalLibs: [], docker: { preRuns: [], postRuns: [] }, bin: [{ lib: "media", bin: libBin }] },
777
+ );
778
+ expect(config.bin).toEqual({
779
+ ffmpeg: { "darwin-arm64": { path: path.resolve("/repo/apps/portal/tools/ffmpeg") } },
780
+ });
781
+ expect(config.libBins).toEqual([{ lib: "media", bin: libBin }]);
782
+ expect(
783
+ new AkanLibConfig({ name: "media", cwdPath: "/repo/libs/media" } as never, {
784
+ bin: { ffprobe: { "linux-x64": { path: "../../tools/ffprobe" } } },
785
+ }).bin,
786
+ ).toEqual({ ffprobe: { "linux-x64": { path: path.resolve("/repo/tools/ffprobe") } } });
787
+ });
788
+
789
+ test("reads them off every workspace lib config on load", async () => {
790
+ const config = await loadExtAppConfig(
791
+ "akan-config-libbin-",
792
+ "export default { trustedDependencies: ['sharp'] };\n",
793
+ `export default { trustedDependencies: ['rclnodejs'], bin: ${JSON.stringify(libBin)} };\n`,
794
+ );
795
+ expect(config.trustedDependencies).toEqual(["sharp", "rclnodejs"]);
796
+ expect(config.libBins).toEqual([{ lib: "extlib", bin: libBin }]);
797
+ });
798
+ });
799
+
590
800
  describe("AkanLibConfig", () => {
591
801
  test("uses empty external libs by default and preserves explicit libs", () => {
592
802
  const lib = { name: "shared" } as never;
593
803
  expect(new AkanLibConfig(lib, {}).externalLibs).toEqual([]);
594
804
 
595
- const config: DeepPartial<LibConfigResult> = {
805
+ const config: LibConfigInput = {
596
806
  externalLibs: ["firebase-admin"],
597
807
  };
598
808
  expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);