@akanjs/devkit 3.0.2-rc.0 → 3.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.test.ts +62 -0
  3. package/akanApp/akanApp.host.ts +16 -4
  4. package/akanApp/devHostPolicy.test.ts +14 -0
  5. package/akanApp/devHostPolicy.ts +11 -0
  6. package/akanConfig/akanConfig.test.ts +77 -7
  7. package/akanConfig/akanConfig.ts +111 -13
  8. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  9. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  10. package/akanConfig/index.ts +1 -0
  11. package/akanConfig/types.ts +23 -1
  12. package/akanContext.ts +1 -1
  13. package/applicationBuildRunner.test.ts +1 -1
  14. package/applicationBuildRunner.ts +73 -84
  15. package/artifact/implicitRootLayout.test.ts +6 -2
  16. package/artifact/implicitRootLayout.ts +12 -7
  17. package/biome.base.json +7 -2
  18. package/cloud/cloudApi.ts +82 -0
  19. package/cloud/globalConfig.ts +4 -0
  20. package/cloud/index.ts +1 -0
  21. package/cloud/updatePackUpload.test.ts +195 -0
  22. package/cloud/updatePackUpload.ts +115 -0
  23. package/commandDecorators/command.ts +8 -2
  24. package/commandDecorators/targetMeta.ts +1 -0
  25. package/createTunnel.test.ts +51 -0
  26. package/createTunnel.ts +32 -14
  27. package/executors.test.ts +21 -0
  28. package/executors.ts +9 -2
  29. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  30. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  31. package/frontendBuild/cssCandidateCache.ts +4 -2
  32. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  33. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  34. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  35. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  36. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  37. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  38. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  39. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  40. package/lint/no-import-external-library-in-test.grit +24 -0
  41. package/mobile/desktopServerStage.test.ts +6 -0
  42. package/mobile/desktopServerStage.ts +25 -3
  43. package/mobile/index.ts +1 -0
  44. package/mobile/nativeApp.test.ts +1 -0
  45. package/mobile/nativeApp.ts +96 -34
  46. package/mobile/nativeConfig.test.ts +59 -1
  47. package/mobile/nativeConfig.ts +13 -3
  48. package/mobile/serverBundleSecrets.test.ts +61 -0
  49. package/mobile/serverBundleSecrets.ts +85 -0
  50. package/mobile/updateUploader.test.ts +49 -0
  51. package/mobile/updateUploader.ts +71 -0
  52. package/package.json +3 -2
  53. package/pipeline/PipelineCompiler.test.ts +631 -0
  54. package/pipeline/PipelineCompiler.ts +646 -0
  55. package/pipeline/PipelineDefinition.test.ts +89 -0
  56. package/pipeline/PipelineDefinition.ts +117 -0
  57. package/pipeline/PipelineDryRun.test.ts +93 -0
  58. package/pipeline/PipelineDryRun.ts +89 -0
  59. package/pipeline/PipelineInputReader.ts +739 -0
  60. package/pipeline/index.ts +4 -0
  61. package/qualityScanner.test.ts +9 -3
  62. package/qualityScanner.ts +5 -2
  63. package/scanInfo.ts +13 -6
  64. package/subspace.test.ts +108 -4
  65. package/subspace.ts +162 -10
  66. package/subspaceConfig.ts +11 -14
  67. package/testHelpers.ts +4 -0
  68. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  69. package/transforms/serverEnvPlugin.test.ts +93 -2
  70. package/transforms/serverEnvPlugin.ts +71 -18
  71. package/transforms/serverEnvProfile.test.ts +82 -0
  72. package/transforms/serverEnvProfile.ts +114 -0
  73. package/typecheck/projectTypecheck.test.ts +32 -0
  74. package/typecheck/projectTypecheck.ts +71 -0
  75. package/workspaceLayout.test.ts +80 -3
  76. package/workspaceLayout.ts +15 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 3.0.2
4
+
5
+ ### Minor Changes
6
+
7
+ - be8ed50: New lint rules: `no-unguarded-endpoint` warns on an endpoint, named slice or root slice that declares no guards, and `no-double-brace-placeholder` refuses `{{name}}` in a dictionary error/translate entry. `no-throw-raw-error` also warns on a raw `Error` constructed without `throw`, and `no-inline-color` warns on color literals in SVG color attributes and `el.style` writes.
8
+ - af66669: Native builds moved from `apps/<app>/.akan/native/<target>` to `dist/native/<app>/<target>` (`build/<platform>`, `dev/<platform>`, `web`, `bin`, `updates`). It sits outside `dist/apps/<app>`, which every `akan build` empties, so a signed release waiting for upload survives the next build. Upload what is waiting in an old `.akan/native/<target>/updates` and delete the folder: `akan start` keeps it and warns until then. A CI step that collects installers reads `dist/native/<app>/*/build/*/` now, and `pack-update`'s default output is `dist/native/<app>/<target>/updates/<platform>`.
9
+ - af66669: The macOS dmg `build-desktop --installer true` makes opens on a drag-to-Applications window: no toolbar, sidebar or status bar, 128 pt icons of the app and the Applications link on a background with an arrow between them (1x and 2x in one multi-resolution TIFF), and the app's icon on the mounted disk (`.VolumeIcon.icns`). Its own files (`.background`, `.VolumeIcon.icns`, `.DS_Store`, `.fseventsd`) are hidden. The window is a `.DS_Store` written directly while a read-write image is mounted, never through Finder or AppleScript, so a headless CI builds the same image; the compressed image is made afterwards and is what gets signed, notarized and stapled. `native.desktop.dmg` (a target's too) replaces the background (`background`, `background2x`, or `false`) and moves the window and the icons (`window`, `iconSize`, `textSize`, `app`, `applications`, in points); the default background lines up with the default positions only.
10
+ - be8ed50: `akan sync` prunes a root-declared dependency nothing imports and realigns every root-declared entry to the root's version; `package.json#akan.keepDependencies`, `externalLibs` and `trustedDependencies` are always kept. `akan install-library` keeps the root's versions and only adds what the root lacks.
11
+
12
+ ### Patch Changes
13
+
14
+ - af66669: `akan build-desktop` builds for this computer's CPU without asking; `--arch arm64|x64` picks another one. Any CLI option declared both `enum` and `nullable` is now `null` when left out instead of prompting for one of its choices.
15
+ - af66669: Upgrading may stop `akan sync` (and every command that runs it, `build-desktop` included) with a scan-convention error on a hand-written `index.tsx`, `index.jsx` or `index.mts` at the root of `ui/`, `webkit/`, `srvkit/`, `common/` or `plugin/`. Bun loads that file ahead of the generated `index.ts` while TypeScript checks `index.ts`, so a name only the generated barrel exports typechecks and is undefined at runtime. Delete the file: the generated barrel re-exports every file and folder of the facet. A namespace it built belongs in `<facet>/<Folder>/index.tsx`, which the check leaves alone.
16
+ - be8ed50: Generated barrels, module indexes and `akan.*.json` are written exactly as Biome prints them, so `akan lint --no-fix` passes right after a sync.
17
+ - af66669: A native build's `--env` reaches its CSR bundle. The CSR builds passed `env: "AKAN_PUBLIC_*"` to `Bun.build`, which inlines the `.env` Bun read when the process started and ranks it above `define`, so `build-desktop --env debug` shipped the root `.env`'s `AKAN_PUBLIC_ENV=local` and called `http://localhost:8282`, and `akan build` baked back the operation mode and ports it had just dropped. Every `AKAN_PUBLIC_*` now comes from `process.env` through `define` alone, and `akan build` drops `AKAN_PUBLIC_CLIENT_PORT` / `AKAN_PUBLIC_SERVER_PORT` from `process.env` as well, so a port in `.env` is no longer baked in.
18
+ - af66669: A Linux desktop app's window is named after its app id (X11 `WM_CLASS`, Wayland `app_id`), so a dock or task bar finds its `<app id>.desktop` and shows that entry's name and icon instead of a generic one; the dock plugin's badge and progress name the same entry. The AppImage `build-desktop --installer true` makes carries `<app id>.desktop` with `StartupWMClass=<app id>` and `<app id>.png`, so the entry AppImageLauncher or appimaged installs under another file name still matches the window. The hidden entry that handles the app's deep links now shows the app's icon (installed as `~/.local/share/icons/hicolor/256x256/apps/<app id>.png`), and from an AppImage it starts the AppImage itself (`$APPIMAGE`) rather than the mount point it ran from, which is gone once the app quits, so a link opened after quitting the app no longer fails.
19
+ - af66669: A `--env local` release build (`build-*`, `release-*`, `start-* --release true`, `publish-update`) calls the app's own dev server: its `env.runtime.json` names `PUBLIC_AKAN_SERVER_URL=http://localhost:<dev port>`, the port `akan start` gives the app (`AKAN_DEV_PORT`, else its place among the apps). Before, it always called 8282, which in a workspace with several apps is another app or nothing. A target that carries its server keeps the URL its server hands the page at launch, and an `AKAN_PUBLIC_SERVER_URL` the bundle was built with still wins.
20
+ - be8ed50: `akan quality scan` no longer flags a non-exported interface or type declared beside a class.
21
+ - 73a81da: Let non-local single database mode development servers continue when the Redis SSH tunnel is unavailable, warning with the SSH target and underlying connection errors and setting an explicitly unavailable Redis host instead of falling back to a local Redis instance. Working tunnels remain available to custom Redis adaptors, while multiple and cluster modes still require Redis and local development remains unchanged.
22
+ - Updated dependencies [be8ed50]
23
+ - Updated dependencies [af66669]
24
+ - Updated dependencies [be8ed50]
25
+ - Updated dependencies [be8ed50]
26
+ - Updated dependencies [be8ed50]
27
+ - Updated dependencies [be8ed50]
28
+ - Updated dependencies [be8ed50]
29
+ - Updated dependencies [be8ed50]
30
+ - Updated dependencies [af66669]
31
+ - Updated dependencies [af66669]
32
+ - Updated dependencies [bd55713]
33
+ - Updated dependencies [be8ed50]
34
+ - Updated dependencies [be8ed50]
35
+ - Updated dependencies [be8ed50]
36
+ - Updated dependencies [be8ed50]
37
+ - Updated dependencies [a789952]
38
+ - Updated dependencies [be8ed50]
39
+ - Updated dependencies [be8ed50]
40
+ - akanjs@3.1.0
41
+
3
42
  ## 3.0.1
4
43
 
5
44
  ### Minor Changes
@@ -557,13 +596,13 @@
557
596
  formatter, the rule set, and every grit plugin registration; a workspace `biome.json` is `extends` plus its own
558
597
  `files.includes`. Biome resolves the specifier through node*modules (it does not consult the package `exports`
559
598
  map), and `plugins` paths inside an extended config resolve from the entry config's directory, so the
560
- `./node_modules/@akanjs/devkit/lint/*.grit` form the template already used is correct. Rule changes now reach a
561
- workspace on `bun update` with no command to run. Two merge behaviours decided the split: `overrides` concatenate,
562
- so the generated-file opt-out moved there from `files.includes`, which \_replaces* and would silently drop the base
563
- list the moment a workspace added one exclusion of its own. Because Biome moves rules between groups across minors
564
- (`noUnnecessaryConditions` is `nursery` at 2.4 and `suspicious` at 2.5, and the old position is a hard error), the
565
- base config and the Biome version travel together: `biomeBase.ts` pins the version `createWorkspace` installs
566
- instead of resolving `latest`.
599
+ `./node_modules/@akanjs/devkit/lint/*.grit`form the template already used is correct. Rule changes now reach a
600
+ workspace on`bun update`with no command to run. Two merge behaviours decided the split:`overrides`concatenate,
601
+ so the generated-file opt-out moved there from`files.includes`, which \_replaces* and would silently drop the base
602
+ list the moment a workspace added one exclusion of its own. Because Biome moves rules between groups across minors
603
+ (`noUnnecessaryConditions`is`nursery`at 2.4 and`suspicious`at 2.5, and the old position is a hard error), the
604
+ base config and the Biome version travel together:`biomeBase.ts`pins the version`createWorkspace`installs
605
+ instead of resolving`latest`.
567
606
 
568
607
  **The conventions body moved into the managed block.** It now ships as the `conventions` guideline and renders
569
608
  between the `akan:agent` markers, so `akan agent install` refreshes it. `AGENTS.md.template` carried a _second_,
@@ -19,6 +19,68 @@ describe("readProcessRssBytes", () => {
19
19
  });
20
20
  });
21
21
 
22
+ describe("Redis tunnels during dev startup", () => {
23
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "testing" });
24
+ const track = tempRoots();
25
+
26
+ test.each(["single", "multiple", "cluster", undefined, "local"] as const)(
27
+ "%s mode preserves its Redis startup requirement",
28
+ async (mode) => {
29
+ const { root, app } = track(await createTempApp("demo"));
30
+ await writeText(
31
+ path.join(root, "apps/demo/main.ts"),
32
+ 'process.send?.({ type: "backend-ready", pid: process.pid });\nsetInterval(() => undefined, 1_000);\n',
33
+ );
34
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
35
+ const port = listener.port;
36
+ listener.stop(true);
37
+ process.env.SSH_TUNNEL_PORT = String(port);
38
+ if (mode === "local") process.env.AKAN_PUBLIC_ENV = "local";
39
+ const create = IncrementalBuilderHost.create;
40
+ IncrementalBuilderHost.create = async () =>
41
+ ({
42
+ status: "ready",
43
+ patcherOff: false,
44
+ start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
45
+ send: () => true,
46
+ stop: () => undefined,
47
+ }) as unknown as IncrementalBuilderHost;
48
+ const ready = Promise.withResolvers<void>();
49
+ const env = { ...process.env } as Record<string, string>;
50
+ delete env.AKAN_DATABASE_MODE;
51
+ if (mode !== undefined && mode !== "local") env.AKAN_DATABASE_MODE = mode;
52
+ const host = new AkanAppHost(app, {
53
+ env,
54
+ onDevEvent: (event) => {
55
+ if ("state" in event && event.state === "ready") ready.resolve();
56
+ },
57
+ });
58
+ let deadline: ReturnType<typeof setTimeout> | undefined;
59
+ try {
60
+ if (mode === "single" || mode === "local") {
61
+ await host.start();
62
+ await Promise.race([
63
+ ready.promise,
64
+ new Promise<never>((_, reject) => {
65
+ deadline = setTimeout(() => reject(new Error("backend-ready IPC timed out")), 5_000);
66
+ }),
67
+ ]);
68
+ expect(host.env.REDIS_HOST).toBe(mode === "local" ? "localhost" : "redis-tunnel-unavailable.invalid");
69
+ if (mode === "single") expect(host.env.REDIS_HOST).not.toBe("localhost");
70
+ } else {
71
+ await expect(host.start()).rejects.toThrow(
72
+ new RegExp(`redis.*demo-testing\\.localhost:${port}.*ECONNREFUSED`),
73
+ );
74
+ }
75
+ } finally {
76
+ clearTimeout(deadline);
77
+ await host.stop();
78
+ IncrementalBuilderHost.create = create;
79
+ }
80
+ },
81
+ );
82
+ });
83
+
22
84
  describe("a gateway whose replica crash-loops", () => {
23
85
  isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
24
86
  const track = tempRoots();
@@ -32,6 +32,7 @@ import {
32
32
  mergeBackendRestartReasons,
33
33
  mergeInvalidateMessages,
34
34
  normalizeBackendReportedGeneration,
35
+ redisTunnelPolicy,
35
36
  resolveIdleSuspendMs,
36
37
  type SourceFingerprints,
37
38
  shouldAbandonBackendRecovery,
@@ -46,6 +47,7 @@ import {
46
47
  shouldRestartBuilderByDevPlan,
47
48
  shouldRestartDevHostByDevPlan,
48
49
  shouldWarnBuilderRssCeilingTight,
50
+ UNAVAILABLE_REDIS_HOST,
49
51
  } from "./devHostPolicy";
50
52
 
51
53
  const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
@@ -203,8 +205,17 @@ export class AkanAppHost {
203
205
 
204
206
  async #prepareDatabase(type: "redis") {
205
207
  const environment = WorkspaceExecutor.getBaseDevEnv().env;
206
- if (environment === "local") return "localhost";
207
- return await createTunnel(type, { app: this.app, environment });
208
+ const policy = redisTunnelPolicy({ environment, databaseMode: this.env.AKAN_DATABASE_MODE });
209
+ if (policy === "none") return "localhost";
210
+ if (policy === "required") return await createTunnel(type, { app: this.app, environment });
211
+ try {
212
+ return await createTunnel(type, { app: this.app, environment });
213
+ } catch (error) {
214
+ this.logger.warn(
215
+ `${error instanceof Error ? error.message : String(error)} - continuing without Redis: the single database mode does not use it unless an app adaptor does`,
216
+ );
217
+ return UNAVAILABLE_REDIS_HOST;
218
+ }
208
219
  }
209
220
  #startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
210
221
  if (this.#stopping) return;
@@ -1278,8 +1289,9 @@ export class AkanAppHost {
1278
1289
  void this.#wakeFromIdle(`${message.type} arrived while suspended`);
1279
1290
  return;
1280
1291
  }
1281
- // In `env`, which every builder spawn re-reads, so a restarted builder stays armed for a mobile dev session.
1282
- if (message.type === "build-csr" && this.env.AKAN_DEV_CSR_REBUILD !== "1") {
1292
+ // In `env`, which every builder spawn re-reads, so a restarted builder stays armed for a mobile dev session. A view's
1293
+ // shell is asked for per read, so it arms nothing.
1294
+ if (message.type === "build-csr" && !message.shell && this.env.AKAN_DEV_CSR_REBUILD !== "1") {
1283
1295
  Object.assign(this.env, { AKAN_DEV_CSR_REBUILD: "1" });
1284
1296
  this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
1285
1297
  }
@@ -16,6 +16,7 @@ import {
16
16
  mergeBackendRestartReasons,
17
17
  mergeInvalidateMessages,
18
18
  normalizeBackendReportedGeneration,
19
+ redisTunnelPolicy,
19
20
  resolveIdleSuspendMs,
20
21
  shouldAbandonBackendRecovery,
21
22
  shouldHoldForReturningBuilder,
@@ -31,6 +32,19 @@ import {
31
32
  shouldWarnBuilderRssCeilingTight,
32
33
  } from "./devHostPolicy";
33
34
 
35
+ describe("redisTunnelPolicy", () => {
36
+ test.each([
37
+ ["local", "single", "none"],
38
+ ["local", "multiple", "none"],
39
+ ["testing", "single", "optional"],
40
+ ["testing", "multiple", "required"],
41
+ ["testing", "cluster", "required"],
42
+ ["testing", undefined, "required"],
43
+ ] as const)("%s with %s Redis is %s", (environment, databaseMode, expected) => {
44
+ expect(redisTunnelPolicy({ environment, databaseMode })).toBe(expected);
45
+ });
46
+ });
47
+
34
48
  const status = (phase: BuildPhase, generation: number, ok: boolean): DevBuildStatus => ({
35
49
  generation,
36
50
  phase,
@@ -9,6 +9,17 @@ import type {
9
9
  } from "akanjs/server";
10
10
  import type { IncrementalBuilderStatus } from "../incrementalBuilder";
11
11
 
12
+ export const UNAVAILABLE_REDIS_HOST = "redis-tunnel-unavailable.invalid";
13
+
14
+ export const redisTunnelPolicy = ({
15
+ environment,
16
+ databaseMode,
17
+ }: {
18
+ environment: string;
19
+ databaseMode?: string;
20
+ }): "none" | "optional" | "required" =>
21
+ environment === "local" ? "none" : databaseMode === "single" ? "optional" : "required";
22
+
12
23
  const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
13
24
 
14
25
  // A page load asks for several routes, but a builder that never returns must not grow the hold queue unbounded.
@@ -342,7 +342,7 @@ describe("AkanAppConfig", () => {
342
342
  const clusterConfig = configOf({ modes: ["cluster"] });
343
343
  const edgeAndCloud = configOf({ modes: ["single", "cluster"] });
344
344
 
345
- for (const driver of ["ioredis", "bullmq", "postgres", "@libsql/client", "protobufjs"])
345
+ for (const driver of ["ioredis", "bullmq", "postgres", "protobufjs"])
346
346
  expect(dependenciesOf(singleConfig)).not.toHaveProperty(driver);
347
347
  expect(dependenciesOf(multipleConfig)).toMatchObject({
348
348
  bullmq: runtimeDependencies.bullmq,
@@ -353,9 +353,9 @@ describe("AkanAppConfig", () => {
353
353
  ioredis: runtimeDependencies.ioredis,
354
354
  postgres: runtimeDependencies.postgres,
355
355
  });
356
- // multiple opens the same SQLite as single and Redis replaced protobuf on the wire, so neither ships by default.
356
+ // Redis replaced protobuf on the wire, so it ships with no mode.
357
357
  for (const config of [multipleConfig, clusterConfig])
358
- for (const driver of ["@libsql/client", "protobufjs"]) expect(dependenciesOf(config)).not.toHaveProperty(driver);
358
+ expect(dependenciesOf(config)).not.toHaveProperty("protobufjs");
359
359
  expect(dependenciesOf(multipleConfig)).not.toHaveProperty("postgres");
360
360
  // One image for an edge site and a cloud cluster carries both modes' drivers, and says which it carries.
361
361
  expect(dependenciesOf(edgeAndCloud)).toMatchObject({ postgres: runtimeDependencies.postgres });
@@ -477,7 +477,7 @@ describe("AkanAppConfig", () => {
477
477
  ).toThrow("unknown basePath");
478
478
  });
479
479
 
480
- test("a desktop server is true or { omit }, its names trimmed, deduplicated and sorted", () => {
480
+ test("a desktop server is true or { omit, profile, modules }, its lists trimmed, deduplicated and sorted", () => {
481
481
  const resolve = (server: unknown) =>
482
482
  new AkanAppConfig(app, [], packageJson, { native: { desktop: { server } } } as never, baseDevEnv).native.targets
483
483
  .default.desktop?.server;
@@ -487,6 +487,41 @@ describe("AkanAppConfig", () => {
487
487
  expect(resolve({})).toEqual({ omit: [] });
488
488
  expect(() => resolve({ omit: "rclnodejs" })).toThrow("native.desktop.server.omit in apps/");
489
489
  expect(() => resolve({ exclude: ["rclnodejs"] })).toThrow("native.desktop.server.exclude in apps/");
490
+ expect(resolve({ profile: "edge", modules: ["@edge", " edgeAgent"] })).toEqual({
491
+ omit: [],
492
+ profile: "edge",
493
+ modules: ["@edge", "edgeAgent"],
494
+ });
495
+ expect(() => resolve({ profile: "../main" })).toThrow("native.desktop.server.profile in apps/");
496
+ expect(() => resolve({ modules: "@edge" })).toThrow("native.desktop.server.modules in apps/");
497
+ });
498
+
499
+ test("a desktop server's env, restart and watchdog: secret-free, what the stage owns refused, a watchdog defaulted", () => {
500
+ const resolve = (server: unknown) =>
501
+ new AkanAppConfig(app, [], packageJson, { native: { desktop: { server } } } as never, baseDevEnv).native.targets
502
+ .default.desktop?.server;
503
+
504
+ expect(
505
+ resolve({
506
+ env: { CLOUD_ORIGIN: "https://cloud.example", ENROLL_KEY: "fleet" },
507
+ restart: "forever",
508
+ watchdog: true,
509
+ }),
510
+ ).toEqual({
511
+ omit: [],
512
+ env: { CLOUD_ORIGIN: "https://cloud.example", ENROLL_KEY: "fleet" },
513
+ restart: "forever",
514
+ watchdog: { intervalMs: 10_000, failures: 3 },
515
+ });
516
+ expect(resolve({ restart: 8, watchdog: { failures: 5 } })).toMatchObject({
517
+ restart: 8,
518
+ watchdog: { intervalMs: 10_000, failures: 5 },
519
+ });
520
+ expect(() => resolve({ env: { SLACK_WEBHOOK_URL: "https://hooks" } })).toThrow("SLACK_WEBHOOK_URL");
521
+ expect(() => resolve({ env: { AKAN_MODULES: "@edge" } })).toThrow("the carried server's stage owns");
522
+ expect(() => resolve({ env: { PORT: 1 } })).toThrow("must be an object of strings");
523
+ expect(() => resolve({ restart: 0 })).toThrow('must be "forever" or a count');
524
+ expect(() => resolve({ watchdog: { intervalMs: -1 } })).toThrow("watchdog in apps/");
490
525
  });
491
526
 
492
527
  test("an app without basePaths has one target, default, with no basePath and the section's settings", () => {
@@ -589,10 +624,10 @@ describe("AkanAppConfig", () => {
589
624
  { native: { targets: { pilot: { updates: { channel: "pilot" } } } } },
590
625
  baseDevEnv,
591
626
  ),
592
- ).toThrow("native.targets.pilot.updates in apps/portal/akan.config.ts has no url or publicKey");
627
+ ).toThrow("native.targets.pilot.updates in apps/portal/akan.config.ts has no url or publicKeys");
593
628
  expect(
594
629
  () => new AkanAppConfig(app, [], packageJson, { native: { updates: { channel: "pilot" } } }, baseDevEnv),
595
- ).toThrow("native.updates in apps/portal/akan.config.ts has no url or publicKey.");
630
+ ).toThrow("native.updates in apps/portal/akan.config.ts has no url or publicKeys.");
596
631
  });
597
632
 
598
633
  test("merges the desktop settings field by field, the target winning", () => {
@@ -696,7 +731,7 @@ describe("AkanAppConfig", () => {
696
731
 
697
732
  describe("deriveDefaultAppId", () => {
698
733
  test("sanitizes org/app names into a valid reverse-DNS bundle id", () => {
699
- expect(deriveDefaultAppId("leading-flight-guidance", "myapp")).toBe("com.leadingflightguidance.myapp");
734
+ expect(deriveDefaultAppId("my-org", "myapp")).toBe("com.myorg.myapp");
700
735
  expect(deriveDefaultAppId("Acme Corp", "Store")).toBe("com.acmecorp.store");
701
736
  // Empty org and digit-leading segments stay valid package identifiers.
702
737
  expect(deriveDefaultAppId("", "app")).toBe("com.app.app");
@@ -895,3 +930,38 @@ describe("AkanAppConfig.importConfigModule", () => {
895
930
  }
896
931
  });
897
932
  });
933
+
934
+ describe("pipeline and workspace keys in app and lib configs", () => {
935
+ test("an app's pipeline is dropped before the instance exists, so it never reaches akan.app.json", async () => {
936
+ const config = await loadExtAppConfig(
937
+ "akan-config-app-keys-",
938
+ 'export default { externalLibs: ["ext-a"], pipeline: { stages: [{ use: "build" }] } };\n',
939
+ "export default {};\n",
940
+ );
941
+ expect(config.externalLibs).toContain("ext-a");
942
+ expect("pipeline" in config).toBe(false);
943
+ expect(Object.keys(JSON.parse(JSON.stringify(config)) as object)).not.toContain("pipeline");
944
+ });
945
+
946
+ test("an app refuses the workspace's subspace setting", async () => {
947
+ const loading = loadExtAppConfig(
948
+ "akan-config-subspace-",
949
+ "export default { subspace: { subspaces: [] } };\n",
950
+ "export default {};\n",
951
+ );
952
+ expect(loading).rejects.toThrow("a workspace setting");
953
+ });
954
+
955
+ test("a lib takes neither a pipeline nor a subspace", async () => {
956
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-lib-keys-"));
957
+ try {
958
+ const lib = { name: "extlib", cwdPath: root } as never;
959
+ fs.writeFileSync(path.join(root, "akan.config.ts"), "export default { pipeline: { stages: [] } };\n");
960
+ expect(AkanLibConfig.from(lib)).rejects.toThrow("which a lib does not take");
961
+ fs.writeFileSync(path.join(root, "akan.config.ts"), "export default { subspace: { subspaces: [] } };\n");
962
+ expect(AkanLibConfig.from(lib, { bustImportCache: true })).rejects.toThrow("a workspace setting");
963
+ } finally {
964
+ fs.rmSync(root, { recursive: true, force: true });
965
+ }
966
+ });
967
+ });
@@ -7,6 +7,7 @@ import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
7
7
  import type { AkanImageConfig } from "akanjs/server";
8
8
  import type { App, Lib } from "../commandDecorators";
9
9
  import { LibExecutor, WorkspaceExecutor } from "../executors";
10
+ import { ServerEnvProfile } from "../transforms/serverEnvProfile";
10
11
  import type { BaseDevEnv, PackageJson } from "../types";
11
12
  import { AkanBin } from "./akanBin";
12
13
  import {
@@ -280,9 +281,9 @@ export class AkanAppConfig implements AppConfigResult {
280
281
  if (basePath && !this.basePaths.has(basePath))
281
282
  throw new Error(`Native target '${name}' uses unknown basePath '${basePath}' in ${configPath}`);
282
283
  const { updates } = merged;
283
- if (updates && (!updates.url || !updates.publicKey))
284
+ if (updates && (!updates.url || !(updates.publicKey || updates.publicKeys?.length)))
284
285
  throw new Error(
285
- `${where}.updates in ${configPath} has no url or publicKey${named ? "; give them in native.updates or the target's own" : ""}.`,
286
+ `${where}.updates in ${configPath} has no url or publicKeys${named ? "; give them in native.updates or the target's own" : ""}.`,
286
287
  );
287
288
  AkanAppConfig.#assertFiles(own ?? {}, where, configPath);
288
289
  const {
@@ -330,7 +331,7 @@ export class AkanAppConfig implements AppConfigResult {
330
331
  );
331
332
  return { appName, appId, ...(root.fileName ? { fileName: root.fileName } : {}), version, buildNum, targets };
332
333
  }
333
- //? `true` or `{ omit }`: each omitted name trimmed once, so two targets that omit the same packages stage one server.
334
+ //? `true` or `{ omit, profile, modules }`: lists trimmed once, so two targets that agree on them stage one server.
334
335
  static #desktopServer<T extends { server?: AkanNativeDesktopConfig["server"] }>(
335
336
  desktop: T,
336
337
  where: string,
@@ -339,12 +340,74 @@ export class AkanAppConfig implements AppConfigResult {
339
340
  const { server } = desktop;
340
341
  if (server === undefined || typeof server === "boolean") return desktop;
341
342
  if (!AkanAppConfig.#isPlainObject(server))
342
- throw new Error(`${where} in ${configPath} must be true or { omit: string[] }.`);
343
- const omit = server.omit;
344
- if (omit !== undefined && (!Array.isArray(omit) || omit.some((name) => typeof name !== "string" || !name.trim())))
345
- throw new Error(`${where}.omit in ${configPath} must be a list of package names.`);
346
- const names = [...new Set((omit ?? []).map((name) => name.trim()))].sort();
347
- return { ...desktop, server: { omit: names } };
343
+ throw new Error(`${where} in ${configPath} must be true or { omit, profile, modules }.`);
344
+ const names = (list: unknown, key: string, what: string) => {
345
+ if (list === undefined) return [];
346
+ if (!Array.isArray(list) || list.some((name) => typeof name !== "string" || !name.trim()))
347
+ throw new Error(`${where}.${key} in ${configPath} must be a list of ${what}.`);
348
+ return [...new Set(list.map((name: string) => name.trim()))].sort();
349
+ };
350
+ const { profile } = server;
351
+ if (profile !== undefined && (typeof profile !== "string" || !ServerEnvProfile.namePattern.test(profile)))
352
+ throw new Error(
353
+ `${where}.profile in ${configPath} names env/env.server.<env>.<profile>.ts: a lowercase-first name of letters, digits and dashes.`,
354
+ );
355
+ const modules = names(server.modules, "modules", "module names or @<operationMode> selectors");
356
+ const env = AkanAppConfig.#desktopServerEnv(server.env, where, configPath);
357
+ const { restart } = server;
358
+ if (restart !== undefined && restart !== "forever" && !(Number.isInteger(restart) && (restart as number) > 0))
359
+ throw new Error(`${where}.restart in ${configPath} must be "forever" or a count of crashes in a row from 1.`);
360
+ const watchdog = AkanAppConfig.#desktopServerWatchdog(server.watchdog, where, configPath);
361
+ return {
362
+ ...desktop,
363
+ server: {
364
+ omit: names(server.omit, "omit", "package names"),
365
+ ...(profile ? { profile } : {}),
366
+ ...(modules.length ? { modules } : {}),
367
+ ...(Object.keys(env).length ? { env } : {}),
368
+ ...(restart !== undefined ? { restart } : {}),
369
+ ...(watchdog ? { watchdog } : {}),
370
+ },
371
+ };
372
+ }
373
+ //? The stage owns these: the operation mode, the database mode and what is served are what make it the carried server.
374
+ static readonly #stagedServerEnv = new Set([
375
+ "AKAN_PUBLIC_OPERATION_MODE",
376
+ "AKAN_DATABASE_MODE",
377
+ "AKAN_DATABASE_MODES",
378
+ "AKAN_SSR",
379
+ "AKAN_CSR",
380
+ "AKAN_MODULES",
381
+ ]);
382
+ static #desktopServerEnv(env: unknown, where: string, configPath: string): Record<string, string> {
383
+ if (env === undefined) return {};
384
+ if (!AkanAppConfig.#isPlainObject(env) || Object.values(env).some((value) => typeof value !== "string"))
385
+ throw new Error(`${where}.env in ${configPath} must be an object of strings.`);
386
+ const staged = Object.keys(env).filter((key) => AkanAppConfig.#stagedServerEnv.has(key.toUpperCase()));
387
+ if (staged.length)
388
+ throw new Error(
389
+ `${where}.env in ${configPath} sets ${staged.join(", ")}, which the carried server's stage owns (modules: desktop.server.modules).`,
390
+ );
391
+ const secrets = ServerEnvProfile.secretPaths(env);
392
+ if (secrets.length)
393
+ throw new Error(
394
+ `${where}.env in ${configPath} holds secret-named values (${secrets.join(", ")}), and the app carries its env where anyone on the computer reads it. Leave them out.`,
395
+ );
396
+ return env as Record<string, string>;
397
+ }
398
+ static #desktopServerWatchdog(
399
+ watchdog: unknown,
400
+ where: string,
401
+ configPath: string,
402
+ ): { intervalMs: number; failures: number } | null {
403
+ if (watchdog === undefined || watchdog === false) return null;
404
+ const { intervalMs = 10_000, failures = 3 } = (watchdog === true ? {} : watchdog) as Record<string, unknown>;
405
+ const whole = (value: unknown) => Number.isInteger(value) && (value as number) > 0;
406
+ if ((watchdog !== true && !AkanAppConfig.#isPlainObject(watchdog)) || !whole(intervalMs) || !whole(failures))
407
+ throw new Error(
408
+ `${where}.watchdog in ${configPath} must be true or { intervalMs, failures }, whole numbers from 1.`,
409
+ );
410
+ return { intervalMs: intervalMs as number, failures: failures as number };
348
411
  }
349
412
  static #platformIndexPath<T extends { indexPath?: string }>(section: T): T {
350
413
  const { indexPath: rawIndexPath, ...rest } = section;
@@ -356,7 +419,7 @@ export class AkanAppConfig implements AppConfigResult {
356
419
  const merge = (kept: unknown, value: unknown, top: boolean, key: string): unknown => {
357
420
  if (!AkanAppConfig.#isPlainObject(kept) || !AkanAppConfig.#isPlainObject(value)) return value;
358
421
  //? An icon or splash given as an object is one value: its image and color go together.
359
- if (top && (key === "icon" || key === "splash")) return value;
422
+ if (top && (key === "icon" || key === "splash" || key === "backgroundColor")) return value;
360
423
  const out: Record<string, unknown> = { ...kept };
361
424
  for (const [inner, next] of Object.entries(value))
362
425
  if (next !== undefined) out[inner] = merge(out[inner], next, false, inner);
@@ -381,10 +444,12 @@ export class AkanAppConfig implements AppConfigResult {
381
444
  "buildNum",
382
445
  "icon",
383
446
  "splash",
447
+ "backgroundColor",
384
448
  "permissions",
385
449
  "plugins",
386
450
  "deepLinks",
387
451
  "updates",
452
+ "devtools",
388
453
  "ios",
389
454
  "android",
390
455
  "desktop",
@@ -401,8 +466,18 @@ export class AkanAppConfig implements AppConfigResult {
401
466
  "activity",
402
467
  "files",
403
468
  ],
404
- desktop: ["indexPath", "server", "recovery", "window", "screenCapture", "entitlements", "dmg"],
405
- desktopServer: ["omit"],
469
+ desktop: [
470
+ "indexPath",
471
+ "server",
472
+ "recovery",
473
+ "window",
474
+ "screenCapture",
475
+ "webview2",
476
+ "publisher",
477
+ "entitlements",
478
+ "dmg",
479
+ ],
480
+ desktopServer: ["omit", "profile", "modules", "env", "restart", "watchdog"],
406
481
  dmg: ["background", "background2x", "window", "iconSize", "textSize", "app", "applications"],
407
482
  deepLinks: ["schemes", "domains"],
408
483
  } as const;
@@ -586,7 +661,19 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
586
661
  app.workspace.getPackageJson(),
587
662
  ]);
588
663
  const resolved = typeof configImp === "function" ? configImp(app) : configImp;
589
- const { plugins, ...config } = (resolved ?? {}) as AppConfigDeclaration & { plugins?: AkanPlugin[] };
664
+ //* `pipeline` is read by `akan pipeline` alone, from the module itself: off the instance, it never reaches akan.app.json.
665
+ const {
666
+ plugins,
667
+ pipeline: _pipeline,
668
+ ...config
669
+ } = (resolved ?? {}) as AppConfigDeclaration & {
670
+ plugins?: AkanPlugin[];
671
+ pipeline?: unknown;
672
+ };
673
+ if ("subspace" in config)
674
+ throw new Error(
675
+ `apps/${app.name}/akan.config.ts declares \`subspace\`, a workspace setting: it belongs in the workspace root akan.config.ts.`,
676
+ );
590
677
  const libContributions = await AkanAppConfig.#collectLibContributions(app, libs, bustImportCache);
591
678
  return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? [], libContributions);
592
679
  }
@@ -664,6 +751,9 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
664
751
  .map((lib) => `${lib}@${this.#resolveProductionDependencyVersion(lib)}`);
665
752
  }
666
753
  get akanVersion() {
754
+ return AkanAppConfig.akanVersion;
755
+ }
756
+ static get akanVersion() {
667
757
  return getAkanPackageJson().version;
668
758
  }
669
759
  getProductionPackageJson(data: Partial<PackageJson> = {}): PackageJson {
@@ -752,6 +842,14 @@ export class AkanLibConfig implements LibConfigResult {
752
842
  const configImp = await AkanAppConfig.importConfigModule(lib.cwdPath, { bustImportCache });
753
843
  const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
754
844
  const { plugins, ...config } = (resolved ?? {}) as LibConfigDeclaration & { plugins?: AkanPlugin[] };
845
+ if ("pipeline" in config)
846
+ throw new Error(
847
+ `libs/${lib.name}/akan.config.ts declares \`pipeline\`, which a lib does not take: pipelines belong to apps and the workspace root, and a lib's build-time work goes in an akan build plugin.`,
848
+ );
849
+ if ("subspace" in config)
850
+ throw new Error(
851
+ `libs/${lib.name}/akan.config.ts declares \`subspace\`, a workspace setting: it belongs in the workspace root akan.config.ts.`,
852
+ );
755
853
  return new AkanLibConfig(lib, config, plugins ?? []);
756
854
  }
757
855
  }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { GlobalConfig } from "../cloud/globalConfig";
6
+ import { WorkspaceExecutor } from "../executors";
7
+ import { AkanWorkspaceConfig } from "./akanWorkspaceConfig";
8
+
9
+ const withWorkspace = async (files: Record<string, string>, run: (workspace: WorkspaceExecutor) => Promise<void>) => {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-workspace-config-"));
11
+ try {
12
+ for (const [relative, content] of Object.entries(files)) {
13
+ fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true });
14
+ fs.writeFileSync(path.join(root, relative), content);
15
+ }
16
+ await run(WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "repo" }));
17
+ } finally {
18
+ fs.rmSync(root, { recursive: true, force: true });
19
+ }
20
+ };
21
+
22
+ describe("AkanWorkspaceConfig", () => {
23
+ test("a workspace without a root akan.config.ts has an empty one", async () => {
24
+ await withWorkspace({}, async (workspace) => {
25
+ const config = await AkanWorkspaceConfig.from(workspace);
26
+ expect(config.file).toBeNull();
27
+ expect(config.subspace).toBeNull();
28
+ expect(config.pipeline).toBeNull();
29
+ });
30
+ });
31
+
32
+ test("reads subspace and pipeline, and the root file is never taken for an app", async () => {
33
+ await withWorkspace(
34
+ {
35
+ "akan.config.ts":
36
+ 'export default { subspace: { subspaces: [{ name: "a", repo: "a.git", apps: ["web"] }] }, pipeline: { before: [] } };\n',
37
+ "apps/web/akan.config.ts": "export default {};\n",
38
+ },
39
+ async (workspace) => {
40
+ const config = await workspace.getConfig();
41
+ expect(config.file).toBe(path.join(workspace.workspaceRoot, "akan.config.ts"));
42
+ expect(config.subspace?.subspaces.map((subspace) => subspace.name)).toEqual(["a"]);
43
+ expect(config.pipeline).toEqual({ before: [] });
44
+ expect(await workspace.getApps()).toEqual(["web"]);
45
+ },
46
+ );
47
+ });
48
+
49
+ test("cloud.host names the cloud the CLI talks to without --host, https unless it is this computer", async () => {
50
+ const hostOf = (cloud: unknown) => new AkanWorkspaceConfig(null, { cloud }).cloudHost;
51
+ expect(hostOf(undefined)).toBeNull();
52
+ expect(hostOf({})).toBeNull();
53
+ expect(hostOf({ host: "https://cloud.example.com/" })).toBe("https://cloud.example.com");
54
+ expect(hostOf({ host: "https://example.com/akasys//" })).toBe("https://example.com/akasys");
55
+ expect(hostOf({ host: "http://localhost:8283" })).toBe("http://localhost:8283");
56
+ expect(() => hostOf({ host: "http://cloud.example.com" })).toThrow("https only");
57
+ expect(() => hostOf({ host: "cloud.example.com" })).toThrow("is not a URL");
58
+ expect(() => hostOf({ host: "https://cloud.example.com?x=1" })).toThrow("is not a URL");
59
+ expect(() => hostOf({ host: "https://cloud.example.com", token: "x" })).toThrow("keys it does not take: token");
60
+
61
+ const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
62
+ expect(await GlobalConfig.hostFor(workspace("https://akasys.example.com"), "https://given.example.com")).toBe(
63
+ "https://given.example.com",
64
+ );
65
+ expect(await GlobalConfig.hostFor(workspace("https://akasys.example.com"), null)).toBe(
66
+ "https://akasys.example.com",
67
+ );
68
+ expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
69
+ });
70
+
71
+ test("refuses app settings and a default export that is not an object", async () => {
72
+ await withWorkspace({ "akan.config.ts": 'export default { docker: { image: "x" } };\n' }, async (workspace) => {
73
+ expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("declares `docker`, which it does not take");
74
+ });
75
+ await withWorkspace({ "akan.config.ts": "export default () => ({});\n" }, async (workspace) => {
76
+ expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("must default-export a WorkspaceConfig object");
77
+ });
78
+ });
79
+ });