@akanjs/devkit 3.1.1-rc.1 → 3.1.1-rc.11

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 (45) hide show
  1. package/akanApp/akanApp.host.test.ts +12 -0
  2. package/akanApp/akanApp.host.ts +12 -0
  3. package/akanConfig/akanConfig.test.ts +43 -2
  4. package/akanConfig/akanConfig.ts +15 -5
  5. package/akanConfig/productionLockfile.test.ts +111 -0
  6. package/akanConfig/productionLockfile.ts +34 -0
  7. package/akanContext.ts +25 -0
  8. package/applicationBuildRunner.test.ts +41 -0
  9. package/applicationBuildRunner.ts +23 -3
  10. package/biomeBase.test.ts +2 -2
  11. package/cloud/cloudApi.ts +10 -4
  12. package/cloud/cloudHttpError.test.ts +8 -0
  13. package/cloud/cloudHttpError.ts +11 -1
  14. package/cloud/cloudUpdates.ts +48 -2
  15. package/cloud/updatePackUpload.test.ts +38 -2
  16. package/cloud/updatePackUpload.ts +5 -1
  17. package/commandDecorators/command.ts +3 -14
  18. package/commandDecorators/commandDecorators.test.ts +1 -1
  19. package/commandDecorators/workspaceVersionCheck.test.ts +80 -0
  20. package/commandDecorators/workspaceVersionCheck.ts +53 -0
  21. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  22. package/executors.test.ts +21 -1
  23. package/integration/devStability.integration.test.ts +9 -5
  24. package/lint/gritRules.test.ts +3 -3
  25. package/linter.test.ts +18 -1
  26. package/linter.ts +10 -6
  27. package/mobile/desktopRelease.test.ts +473 -0
  28. package/mobile/desktopRelease.ts +309 -0
  29. package/mobile/desktopServerStage.ts +4 -2
  30. package/mobile/index.ts +2 -0
  31. package/mobile/nativeApi.test.ts +4 -1
  32. package/mobile/nativeApi.ts +3 -3
  33. package/mobile/nativeApp.test.ts +2 -2
  34. package/mobile/nativeApp.ts +29 -5
  35. package/mobile/nativeConfig.test.ts +16 -8
  36. package/mobile/nativeConfig.ts +1 -1
  37. package/mobile/tarWriter.test.ts +89 -0
  38. package/mobile/tarWriter.ts +93 -0
  39. package/mobile/updateUploader.test.ts +34 -0
  40. package/mobile/updateUploader.ts +9 -6
  41. package/package.json +2 -2
  42. package/platformTest/LinuxDockerTarget.ts +3 -0
  43. package/signalMutationIndex.test.ts +15 -0
  44. package/signalMutationIndex.ts +1 -1
  45. package/types.ts +1 -0
@@ -13,6 +13,18 @@ describe("readProcessRssBytes", () => {
13
13
  expect(rssBytes).toBeLessThan(64 * 1024 * 1024 * 1024);
14
14
  });
15
15
 
16
+ // The builder's own report triggers a recycle and this reading settles it, so the two must agree.
17
+ test.skipIf(process.platform !== "darwin")("reads what the process reports once it frees memory", async () => {
18
+ let held: number[][] | null = Array.from({ length: 64 }, (_, idx) => new Array<number>(500_000).fill(idx + 0.5));
19
+ expect(held.length).toBe(64);
20
+ held = null;
21
+ Bun.gc(true);
22
+ await Bun.sleep(500);
23
+ const rssBytes = await AkanAppHost.readProcessRssBytes(process.pid);
24
+ if (rssBytes === null) throw new Error("expected to read this process's own rss");
25
+ expect(Math.abs(rssBytes - process.memoryUsage.rss())).toBeLessThan(64 * 1024 * 1024);
26
+ });
27
+
16
28
  // A 0 would read as "settled below the ceiling" and cancel a recycle that should happen.
17
29
  test("returns null for a pid that does not exist", async () => {
18
30
  expect(await AkanAppHost.readProcessRssBytes(2_147_483_646)).toBeNull();
@@ -651,6 +651,13 @@ export class AkanAppHost {
651
651
  ["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
652
652
  AkanAppHost.#tasklistRssKb,
653
653
  );
654
+ if (process.platform === "darwin") {
655
+ const footprintBytes = await AkanAppHost.#readRssKbVia(
656
+ ["footprint", "-p", String(pid), "-f", "bytes"],
657
+ AkanAppHost.#footprintKb,
658
+ );
659
+ if (footprintBytes !== null) return footprintBytes;
660
+ }
654
661
  const status = await Bun.file(`/proc/${pid}/status`)
655
662
  .text()
656
663
  .catch(() => null);
@@ -687,6 +694,11 @@ export class AkanAppHost {
687
694
  const memUsage = /"([^"]*)"\s*$/m.exec(output)?.[1];
688
695
  return memUsage ? Number(memUsage.replace(/\D/g, "")) : Number.NaN;
689
696
  }
697
+ // The footprint is what `process.memoryUsage.rss()` reports on macOS; `ps` also counts pages freed for reuse.
698
+ static #footprintKb(output: string): number {
699
+ const bytes = /Footprint: (\d+) B\b/.exec(output)?.[1];
700
+ return bytes ? Number(bytes) / 1024 : Number.NaN;
701
+ }
690
702
  #cancelRssRecycle(): void {
691
703
  this.#rssRecycleReason = null;
692
704
  this.#rssRecycleOver = null;
@@ -154,6 +154,17 @@ describe("AkanAppConfig", () => {
154
154
  expect(config.dockerfile).toContain('RUN if [ "$TARGETARCH" = "arm64"');
155
155
  });
156
156
 
157
+ test("installs from the build's bun.lock frozen, or unlocked when the build wrote none", () => {
158
+ const config = new AkanAppConfig(app, [], packageJson, { docker: { postRuns: ["echo after"] } }, baseDevEnv);
159
+ const locked = config.dockerfileFor("main");
160
+ const unlocked = config.dockerfileFor("main", { lockfile: false });
161
+
162
+ expect(locked).toContain("COPY ./package.json ./bun.lock ./\nRUN bun install --production --frozen-lockfile\n");
163
+ expect(locked.indexOf("--frozen-lockfile")).toBeLessThan(locked.indexOf("echo after"));
164
+ expect(unlocked).toContain("COPY ./package.json ./package.json\nRUN bun install --production\n");
165
+ expect(unlocked).not.toContain("bun.lock");
166
+ });
167
+
157
168
  test("defaults both web surfaces on and keeps the image free of web env overrides", () => {
158
169
  const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
159
170
 
@@ -219,8 +230,8 @@ describe("AkanAppConfig", () => {
219
230
  "",
220
231
  "RUN mkdir -p /workspace",
221
232
  "WORKDIR /workspace",
222
- "COPY ./package.json ./package.json",
223
- "RUN bun install --production",
233
+ "COPY ./package.json ./bun.lock ./",
234
+ "RUN bun install --production --frozen-lockfile",
224
235
  "",
225
236
  "COPY . .",
226
237
  "ENV PORT=8282",
@@ -306,6 +317,36 @@ describe("AkanAppConfig", () => {
306
317
  expect(() => brokenConfig.getProductionPackageJson()).toThrow("Dependency missing-lib not found");
307
318
  });
308
319
 
320
+ test("carries the root's overrides and resolutions, so the image resolves what the workspace locked", () => {
321
+ const pinned = new AkanAppConfig(
322
+ app,
323
+ [],
324
+ { ...packageJson, overrides: { "js-tokens": "2.0.0" }, resolutions: { ms: "2.0.0" } },
325
+ {},
326
+ baseDevEnv,
327
+ );
328
+ expect(pinned.getProductionPackageJson()).toMatchObject({
329
+ overrides: { "js-tokens": "2.0.0" },
330
+ resolutions: { ms: "2.0.0" },
331
+ });
332
+ const local = new AkanAppConfig(
333
+ app,
334
+ [],
335
+ {
336
+ ...packageJson,
337
+ overrides: { "js-tokens": "2.0.0", patched: "file:./patches/patched", shared: "workspace:*" },
338
+ resolutions: { ms: "link:../ms" },
339
+ },
340
+ {},
341
+ baseDevEnv,
342
+ ).getProductionPackageJson();
343
+ expect(local.overrides).toEqual({ "js-tokens": "2.0.0" });
344
+ expect(local).not.toHaveProperty("resolutions");
345
+ const unpinned = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).getProductionPackageJson();
346
+ expect(unpinned).not.toHaveProperty("overrides");
347
+ expect(unpinned).not.toHaveProperty("resolutions");
348
+ });
349
+
309
350
  test("falls back to akanjs package versions for built-in runtime dependencies", () => {
310
351
  const runtimeDependencies = {
311
352
  ...akanPackageJson.dependencies,
@@ -634,10 +634,10 @@ export class AkanAppConfig implements AppConfigResult {
634
634
  ...(this.web.csr ? {} : { AKAN_CSR: "false" }),
635
635
  };
636
636
  }
637
- dockerfileFor(environment: string): string {
638
- return this.#makeDockerfile(environment);
637
+ dockerfileFor(environment: string, { lockfile = true }: { lockfile?: boolean } = {}): string {
638
+ return this.#makeDockerfile(environment, lockfile);
639
639
  }
640
- #makeDockerfile(environment?: string): string {
640
+ #makeDockerfile(environment?: string, lockfile = true): string {
641
641
  if (typeof this.docker === "string") return this.docker;
642
642
  const { image, preRuns, postRuns, command } = this.docker;
643
643
  const preRunScripts = this.#getDockerRunScripts(preRuns);
@@ -651,8 +651,8 @@ ARG TARGETARCH
651
651
  ${preRunScripts.join("\n")}
652
652
  RUN mkdir -p /workspace
653
653
  WORKDIR /workspace
654
- COPY ./package.json ./package.json
655
- RUN bun install --production
654
+ ${lockfile ? "COPY ./package.json ./bun.lock ./" : "COPY ./package.json ./package.json"}
655
+ RUN bun install --production${lockfile ? " --frozen-lockfile" : ""}
656
656
  ${postRunScripts.join("\n")}
657
657
  COPY . .
658
658
  ${envLines.join("\n")}
@@ -776,7 +776,15 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
776
776
  static get akanVersion() {
777
777
  return getAkanPackageJson().version;
778
778
  }
779
+ //? The image installs from the registry alone: a pin to a path or a workspace package resolves nowhere there.
780
+ static #registryPins(pins: Record<string, string> | undefined) {
781
+ if (!pins) return undefined;
782
+ const kept = Object.entries(pins).filter(([, spec]) => !/^(?:file:|link:|workspace:|portal:|[./~])/.test(spec));
783
+ return kept.length ? Object.fromEntries(kept) : undefined;
784
+ }
779
785
  getProductionPackageJson(data: Partial<PackageJson> = {}): PackageJson {
786
+ const overrides = AkanAppConfig.#registryPins(this.rootPackageJson.overrides);
787
+ const resolutions = AkanAppConfig.#registryPins(this.rootPackageJson.resolutions);
780
788
  return {
781
789
  name: this.app.name,
782
790
  description: this.app.name,
@@ -789,6 +797,8 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
789
797
  ]),
790
798
  ),
791
799
  ...(this.trustedDependencies.length ? { trustedDependencies: this.trustedDependencies } : {}),
800
+ ...(overrides ? { overrides } : {}),
801
+ ...(resolutions ? { resolutions } : {}),
792
802
  ...data,
793
803
  };
794
804
  }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { ProductionLockfile } from "./productionLockfile";
6
+
7
+ // js-tokens 3.0.2 is locked although 4.0.0 satisfies the range: a re-resolve would move it.
8
+ const workspaceLock = `{
9
+ "lockfileVersion": 1,
10
+ "configVersion": 1,
11
+ "workspaces": {
12
+ "": {
13
+ "name": "repo",
14
+ "dependencies": {
15
+ "loose-envify": "^1.4.0",
16
+ "ms": "^2.0.0",
17
+ },
18
+ },
19
+ },
20
+ "packages": {
21
+ "js-tokens": ["js-tokens@3.0.2", "", {}, "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg=="],
22
+
23
+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
24
+
25
+ "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
26
+ }
27
+ }
28
+ `;
29
+
30
+ const productionPackageJson = {
31
+ name: "portal",
32
+ description: "portal",
33
+ version: "1.0.0",
34
+ dependencies: { "loose-envify": "^1.4.0" },
35
+ };
36
+
37
+ const withWorkspace = async (lock: string | null, run: (root: string) => Promise<void>) => {
38
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-production-lock-test-"));
39
+ try {
40
+ if (lock !== null) await writeFile(path.join(root, "bun.lock"), lock);
41
+ await run(root);
42
+ } finally {
43
+ await rm(root, { recursive: true, force: true });
44
+ }
45
+ };
46
+
47
+ describe("ProductionLockfile", () => {
48
+ test("keeps the workspace's locked versions and drops what the production package.json does not reach", async () => {
49
+ await withWorkspace(workspaceLock, async (root) => {
50
+ const lock = await ProductionLockfile.prune(root, productionPackageJson);
51
+ expect(lock).toContain('"loose-envify": ["loose-envify@1.4.0"');
52
+ expect(lock).toContain('"js-tokens": ["js-tokens@3.0.2"');
53
+ expect(lock).not.toContain("ms@2.0.0");
54
+ expect(lock).not.toContain('"ms": "^2.0.0"');
55
+ });
56
+ });
57
+
58
+ test("a bun install --frozen-lockfile of the pruned lockfile changes nothing", async () => {
59
+ await withWorkspace(workspaceLock, async (root) => {
60
+ const lock = await ProductionLockfile.prune(root, productionPackageJson);
61
+ await writeFile(path.join(root, "package.json"), JSON.stringify(productionPackageJson, null, 2));
62
+ await writeFile(path.join(root, "bun.lock"), lock);
63
+ const install = Bun.spawn([process.execPath, "install", "--lockfile-only", "--frozen-lockfile"], {
64
+ cwd: root,
65
+ stdout: "ignore",
66
+ stderr: "pipe",
67
+ });
68
+ expect(await install.exited).toBe(0);
69
+ expect(await Bun.file(path.join(root, "bun.lock")).text()).toBe(lock);
70
+ });
71
+ });
72
+
73
+ test("keeps a version an override pinned outside its parent's range", async () => {
74
+ const overriddenLock = `{
75
+ "lockfileVersion": 1,
76
+ "configVersion": 1,
77
+ "workspaces": {
78
+ "": {
79
+ "name": "repo",
80
+ "dependencies": {
81
+ "loose-envify": "^1.4.0",
82
+ },
83
+ },
84
+ },
85
+ "overrides": {
86
+ "js-tokens": "2.0.0",
87
+ },
88
+ "packages": {
89
+ "js-tokens": ["js-tokens@2.0.0", "", {}, "sha512-kk4RjoztHFeAlCNPdTr/uF04oN5z53L18LJfADBQ7YHkO3iR2rP2ZwOH0n33r2FRw0psRmjTDEkOWPE//zmujg=="],
90
+
91
+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
92
+ }
93
+ }
94
+ `;
95
+ await withWorkspace(overriddenLock, async (root) => {
96
+ const lock = await ProductionLockfile.prune(root, {
97
+ ...productionPackageJson,
98
+ overrides: { "js-tokens": "2.0.0" },
99
+ });
100
+ expect(lock).toContain('"js-tokens": ["js-tokens@2.0.0"');
101
+ });
102
+ });
103
+
104
+ test("refuses a workspace without a bun.lock, so the build can fall back to an unlocked install", async () => {
105
+ await withWorkspace(null, async (root) => {
106
+ await expect(ProductionLockfile.prune(root, productionPackageJson)).rejects.toThrow(
107
+ "the workspace root has no bun.lock",
108
+ );
109
+ });
110
+ });
111
+ });
@@ -0,0 +1,34 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { PackageJson } from "../types";
5
+
6
+ export class ProductionLockfile {
7
+ static readonly fileName = "bun.lock";
8
+
9
+ //? Beside a copy of the workspace lockfile, Bun keeps each version it locked and drops what the package.json no
10
+ //? longer reaches; only a package the workspace never locked is resolved from the registry.
11
+ static async prune(workspaceRoot: string, packageJson: PackageJson): Promise<string> {
12
+ const workspaceLock = Bun.file(path.join(workspaceRoot, ProductionLockfile.fileName));
13
+ if (!(await workspaceLock.exists())) throw new Error(`the workspace root has no ${ProductionLockfile.fileName}`);
14
+ const dir = await mkdtemp(path.join(os.tmpdir(), "akan-production-lock-"));
15
+ try {
16
+ const npmrc = Bun.file(path.join(workspaceRoot, ".npmrc"));
17
+ await Promise.all([
18
+ Bun.write(path.join(dir, "package.json"), JSON.stringify(packageJson, null, 2)),
19
+ Bun.write(path.join(dir, ProductionLockfile.fileName), workspaceLock),
20
+ ...((await npmrc.exists()) ? [Bun.write(path.join(dir, ".npmrc"), npmrc)] : []),
21
+ ]);
22
+ const install = Bun.spawn([process.execPath, "install", "--lockfile-only", "--no-progress"], {
23
+ cwd: dir,
24
+ stdout: "ignore",
25
+ stderr: "pipe",
26
+ });
27
+ const [exitCode, stderr] = await Promise.all([install.exited, new Response(install.stderr).text()]);
28
+ if (exitCode !== 0) throw new Error(stderr.trim() || `bun install --lockfile-only exited with ${exitCode}`);
29
+ return await Bun.file(path.join(dir, ProductionLockfile.fileName)).text();
30
+ } finally {
31
+ await rm(dir, { recursive: true, force: true });
32
+ }
33
+ }
34
+ }
package/akanContext.ts CHANGED
@@ -632,6 +632,28 @@ export class AkanContextAnalyzer {
632
632
  };
633
633
  }
634
634
 
635
+ //? Older templates wrote these for Next.js: nothing installs or reads them, and editors warn about the plugin.
636
+ static async #configLeftovers(workspace: WorkspaceExecutor, sysPaths: string[]) {
637
+ const leftovers: { path: string; message: string }[] = [];
638
+ for (const sysPath of sysPaths) {
639
+ const tsconfigPath = `${sysPath}/tsconfig.json`;
640
+ const tsconfig = await safeReadText(path.join(workspace.workspaceRoot, tsconfigPath));
641
+ if (tsconfig && (/"name"\s*:\s*"next"/.test(tsconfig) || tsconfig.includes(".next/types")))
642
+ leftovers.push({
643
+ path: tsconfigPath,
644
+ message: `${tsconfigPath} still names the Next.js TS plugin or .next/types, which Akan never uses — delete the "next" plugin entry and the ".next/types/**/*.ts" include.`,
645
+ });
646
+ }
647
+ const bunfig = await safeReadText(path.join(workspace.workspaceRoot, "bunfig.toml"));
648
+ if (bunfig?.includes("bun-plugin-tailwind"))
649
+ leftovers.push({
650
+ path: "bunfig.toml",
651
+ message:
652
+ "bunfig.toml still loads bun-plugin-tailwind, which is not installed and which Akan never uses (it compiles Tailwind itself) — delete the [serve.static] section, which Akan never reads.",
653
+ });
654
+ return leftovers;
655
+ }
656
+
635
657
  static async doctor(
636
658
  workspace: WorkspaceExecutor,
637
659
  {
@@ -701,6 +723,9 @@ export class AkanContextAnalyzer {
701
723
  { severity: "warning", code: agentDrift.code, path: "AGENTS.md", message: agentDrift.message },
702
724
  repairAction("generated", "akan agent install agents-md", agentDrift.hint, true),
703
725
  );
726
+ const sysPaths = [...context.apps, ...context.libs].map((sys) => sys.path);
727
+ for (const leftover of await AkanContextAnalyzer.#configLeftovers(workspace, sysPaths))
728
+ diagnostics.push({ severity: "warning", code: "config-leftover", ...leftover });
704
729
 
705
730
  for (const sys of [...context.apps, ...context.libs]) {
706
731
  for (const module of sys.modules) {
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AkanAppConfig } from "./akanConfig";
6
+ import { ProductionLockfile } from "./akanConfig/productionLockfile";
6
7
  import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
7
8
  import type { App } from "./commandDecorators";
8
9
  import { AppExecutor, Executor } from "./executors";
@@ -70,6 +71,7 @@ console.info(new SampleService().name);
70
71
  getConfig: async () => config,
71
72
  dist: new Executor("dist", root),
72
73
  workspace: { workspaceRoot: root },
74
+ logger: { warn: () => undefined },
73
75
  } as unknown as App;
74
76
  try {
75
77
  await new ApplicationBuildRunner(app, { environment: "debug" }).buildAppMeta();
@@ -81,6 +83,45 @@ console.info(new SampleService().name);
81
83
  }
82
84
  });
83
85
 
86
+ test("writes the pruned lockfile beside package.json, or warns and leaves the image's install unlocked", async () => {
87
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
88
+ const config = new AkanAppConfig(
89
+ { name: "portal" } as never,
90
+ [],
91
+ { name: "repo", version: "1.0.0", description: "repo" },
92
+ {},
93
+ { repoName: "akanjs", serveDomain: "akanjs.com", env: "local", portOffset: 0, workspaceRoot: root },
94
+ );
95
+ const warnings: string[] = [];
96
+ const app = {
97
+ getConfig: async () => config,
98
+ dist: new Executor("dist", root),
99
+ workspace: { workspaceRoot: root },
100
+ logger: { warn: (message: string) => warnings.push(message) },
101
+ } as unknown as App;
102
+ const prune = spyOn(ProductionLockfile, "prune");
103
+ try {
104
+ prune.mockResolvedValueOnce('{ "lockfileVersion": 1 }\n');
105
+ await new ApplicationBuildRunner(app).buildAppMeta();
106
+ expect(prune.mock.calls[0]?.[1]).toEqual(JSON.parse(await readFile(path.join(root, "package.json"), "utf8")));
107
+ expect(await readFile(path.join(root, "bun.lock"), "utf8")).toBe('{ "lockfileVersion": 1 }\n');
108
+ expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("--frozen-lockfile");
109
+ expect(warnings).toEqual([]);
110
+
111
+ await rm(path.join(root, "bun.lock"));
112
+ prune.mockRejectedValueOnce(new Error("the workspace root has no bun.lock"));
113
+ await new ApplicationBuildRunner(app).buildAppMeta();
114
+ expect(await Bun.file(path.join(root, "bun.lock")).exists()).toBe(false);
115
+ expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("RUN bun install --production\n");
116
+ expect(warnings).toEqual([
117
+ "The image installs its dependencies unlocked, at the newest versions package.json allows: the workspace root has no bun.lock",
118
+ ]);
119
+ } finally {
120
+ prune.mockRestore();
121
+ await rm(root, { recursive: true, force: true });
122
+ }
123
+ });
124
+
84
125
  test("bakes into the route bundles the env their server env is, not the shell's that the root .env outranks", async () => {
85
126
  const { root, workspace } = await createTempApp("portal");
86
127
  const shellEnv = process.env.AKAN_PUBLIC_ENV;
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
+ import { toError } from "akanjs/common";
2
3
  import type { BunPlugin } from "bun";
4
+ import { ProductionLockfile } from "./akanConfig/productionLockfile";
3
5
  import type {
4
6
  ApplicationBuildPhaseResult,
5
7
  ApplicationBuildProgressReporter,
@@ -18,7 +20,7 @@ import { Spinner } from "./spinner";
18
20
  import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
19
21
  import { ServerEnvProfile } from "./transforms/serverEnvProfile";
20
22
  import { ProjectTypecheck } from "./typecheck/projectTypecheck";
21
- import type { BaseDevEnv } from "./types";
23
+ import type { BaseDevEnv, PackageJson } from "./types";
22
24
 
23
25
  export interface TypecheckOptions {
24
26
  clean?: boolean;
@@ -236,9 +238,16 @@ export class ApplicationBuildRunner {
236
238
 
237
239
  async buildAppMeta() {
238
240
  const akanConfig = await this.#app.getConfig();
239
- const dockerfile = akanConfig.dockerfileFor(this.#environment ?? akanConfig.baseDevEnv.env);
241
+ const packageJson = akanConfig.getProductionPackageJson();
242
+ const lockfile = await this.#pruneLockfile(packageJson);
243
+ const dockerfile = akanConfig.dockerfileFor(this.#environment ?? akanConfig.baseDevEnv.env, {
244
+ lockfile: lockfile !== null,
245
+ });
240
246
  await Promise.all([
241
- this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
247
+ this.#app.dist.writeJson("package.json", packageJson),
248
+ ...(lockfile !== null
249
+ ? [this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/${ProductionLockfile.fileName}`, lockfile)]
250
+ : []),
242
251
  this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, dockerfile),
243
252
  this.#app.dist.writeJson("akan.build.json", {
244
253
  buildId: await this.#resolveBuildId(),
@@ -248,6 +257,17 @@ export class ApplicationBuildRunner {
248
257
  ]);
249
258
  }
250
259
 
260
+ async #pruneLockfile(packageJson: PackageJson): Promise<string | null> {
261
+ try {
262
+ return await ProductionLockfile.prune(this.#app.workspace.workspaceRoot, packageJson);
263
+ } catch (error) {
264
+ this.#app.logger.warn(
265
+ `The image installs its dependencies unlocked, at the newest versions package.json allows: ${toError(error).message}`,
266
+ );
267
+ return null;
268
+ }
269
+ }
270
+
251
271
  //* Read back at runtime as `buildId` on the ops channel; a deployment's own AKAN_BUILD_ID still wins over it there.
252
272
  async #resolveBuildId() {
253
273
  const fromEnv = process.env.AKAN_BUILD_ID?.trim();
package/biomeBase.test.ts CHANGED
@@ -7,7 +7,7 @@ import { biomeBaseConfig } from "./biomeBase";
7
7
  import { Linter, parseBiomeReport } from "./linter";
8
8
 
9
9
  const devkitDir = import.meta.dir;
10
- const biomeBin = path.resolve(devkitDir, "../../../node_modules/.bin", Linter.biomeBinName);
10
+ const [biome, ...biomeArgs] = Linter.biomeCommand(path.resolve(devkitDir, "../../.."));
11
11
 
12
12
  const hookProbe = `export const Early = ({ id }: { id?: string }) => {
13
13
  if (!id) return null;
@@ -28,7 +28,7 @@ afterAll(async () => {
28
28
 
29
29
  const runBiome = (args: string[], cwd: string) =>
30
30
  new Promise<string>((resolve, reject) => {
31
- const proc = spawn(biomeBin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
31
+ const proc = spawn(biome, [...biomeArgs, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
32
32
  let stdout = "";
33
33
  let stderr = "";
34
34
  proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
package/cloud/cloudApi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isRecord } from "akanjs/common";
1
+ import { isRecord, Logger } from "akanjs/common";
2
2
  import type { Workspace } from "../commandDecorators";
3
3
  import { CloudHttpError } from "./cloudHttpError";
4
4
  import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
@@ -86,7 +86,10 @@ export interface CloudRecord {
86
86
  export interface UpdateUploadRequest {
87
87
  appName: string;
88
88
  target: string;
89
- platform: "ios" | "android";
89
+ platform: "ios" | "android" | "macos" | "windows" | "linux";
90
+ //? Absent is a phone's web bundle, so a phone request reads as it did before desktop releases.
91
+ kind?: "app";
92
+ arch?: "arm64" | "x64";
90
93
  channel: string;
91
94
  notes: string | null;
92
95
  size: number;
@@ -120,8 +123,11 @@ export class CloudApi {
120
123
  if (error.status === 401)
121
124
  return new Error("The server did not accept your sign-in: run `akan login`, then try again.", { cause: error });
122
125
  if (error.status === 403) return new Error(`The project refused: ${who}.`, { cause: error });
123
- if (error.status === 400)
124
- return new Error(`The project refused the request${error.error ? `: ${error.error}` : ""}.`, { cause: error });
126
+ if (error.status === 400) {
127
+ const { data } = error;
128
+ const why = `${error.error ? `: ${error.error}` : ""}${data ? ` ${Logger.inlineData(data)}` : ""}`;
129
+ return new Error(`The project refused the request${why}.`, { cause: error });
130
+ }
125
131
  //? A proxy, or the gateway with no replica ready, answers 503 too; only the server's own names a domain Err key.
126
132
  const { errKey } = error;
127
133
  if (error.status === 503 && unavailable && errKey && errKey !== "base.error.serverUnavailable")
@@ -20,6 +20,14 @@ describe("CloudHttpError", () => {
20
20
  expect(plain.errKey).toBeNull();
21
21
  });
22
22
 
23
+ test("reads the data a server Err carried, and none from a body without it", () => {
24
+ const body = JSON.stringify({ error: "release.error.desktopMalformed", data: { reason: "a directory entry" } });
25
+
26
+ expect(new CloudHttpError(400, "Bad Request", "/x", body).data).toEqual({ reason: "a directory entry" });
27
+ expect(new CloudHttpError(400, "Bad Request", "/x", '{"error":"Bad Request","data":"x"}').data).toBeNull();
28
+ expect(new CloudHttpError(502, "Bad Gateway", "/x", "<html>502</html>").data).toBeNull();
29
+ });
30
+
23
31
  test("takes Retry-After as seconds or as a date, and nothing else", () => {
24
32
  expect(CloudHttpError.retryAfterMs("7")).toBe(7000);
25
33
  expect(CloudHttpError.retryAfterMs(new Date(Date.now() + 60_000).toUTCString())).toBeGreaterThan(55_000);
@@ -26,9 +26,19 @@ export class CloudHttpError extends Error {
26
26
 
27
27
  /** The `error` of a JSON refusal: an Err key from the server itself, or whatever a proxy put there. */
28
28
  get error(): string | null {
29
+ const error = this.#payload?.error;
30
+ return typeof error === "string" ? error : null;
31
+ }
32
+
33
+ get data(): Record<string, unknown> | null {
34
+ const data = this.#payload?.data;
35
+ return isRecord(data) ? data : null;
36
+ }
37
+
38
+ get #payload(): Record<string, unknown> | null {
29
39
  try {
30
40
  const parsed: unknown = JSON.parse(this.body);
31
- return isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : null;
41
+ return isRecord(parsed) ? parsed : null;
32
42
  } catch {
33
43
  return null;
34
44
  }
@@ -3,15 +3,18 @@ import { Logger } from "akanjs/common";
3
3
  import type { NativeEnv } from "../akanConfig";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { ExitCodeError } from "../commandDecorators/exitCodeError";
6
+ import type { DesktopRelease, DesktopSigner } from "../mobile/desktopRelease";
6
7
  import { type MobilePlatform, resolveMobileTargets } from "../mobile/mobileTarget";
7
8
  import { NativeApp } from "../mobile/nativeApp";
8
9
  import { NativePublicEnv } from "../mobile/nativePublicEnv";
9
10
  import { type NativeUpdatePack, UpdatePackFolder } from "../mobile/updatePackFolder";
10
11
  import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
12
+ import { CloudHttpError } from "./cloudHttpError";
11
13
  import { UpdatePackUpload } from "./updatePackUpload";
12
14
 
13
15
  //* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
14
- //* it to sign and publish, and the store build a pack of the same commit is embedded in.
16
+ //* it to sign and publish, and the store build a pack of the same commit is embedded in. A desktop release is signed
17
+ //* where it was published, and goes up as it is.
15
18
  export class CloudUpdates {
16
19
  constructor(
17
20
  readonly cloudApi: CloudApi,
@@ -59,7 +62,7 @@ export class CloudUpdates {
59
62
  const release = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
60
63
  .send(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel: target, notes })
61
64
  .catch((error: unknown) => {
62
- throw CloudApi.refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
65
+ throw CloudUpdates.#refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
63
66
  });
64
67
  const label = `${pack.app} ${pack.target} ${pack.platform} on channel ${target}: release ${release.id}`;
65
68
  if (release.status === "rejected")
@@ -77,6 +80,49 @@ export class CloudUpdates {
77
80
  });
78
81
  }
79
82
 
83
+ //? `manifestUrl` is where the project serves the channel once published; before that, the URL the app was built with.
84
+ async pushRelease(
85
+ release: DesktopRelease,
86
+ {
87
+ appName,
88
+ target,
89
+ notes,
90
+ signer,
91
+ url,
92
+ }: { appName: string; target: string; notes: string | null; signer: DesktopSigner; url: string },
93
+ ) {
94
+ const { platform, arch, channel } = release;
95
+ return await release.withArchive(release.record(signer), async (archive) => {
96
+ const record = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
97
+ .send(archive, { appName, target, platform, kind: "app", arch, channel, notes })
98
+ .catch((error: unknown) => {
99
+ throw CloudUpdates.#refusal(error, "a release is uploaded by a project member signed in with `akan login`");
100
+ });
101
+ const label = `${appName} ${target} ${platform}-${arch} on channel ${channel}: release ${record.id}`;
102
+ const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
103
+ if (record.status === "rejected") throw new ExitCodeError(`The project rejected ${label}:\n${problems}`, 2);
104
+ const manifestUrl =
105
+ typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}/app.json`;
106
+ const files = 1 + release.manifestFiles.length + release.payloads.length;
107
+ const what = `${label} (${release.manifest.bundle}, ${files} files)`;
108
+ Logger.rawLog(
109
+ record.status === "published"
110
+ ? `Published ${what}. Installed apps read ${manifestUrl}.`
111
+ : `Staged ${what}. Publish it from the project's release screen; installed apps read ${manifestUrl}.`,
112
+ );
113
+ if (problems) Logger.rawLog(`The project noted:\n${problems}`);
114
+ return record;
115
+ });
116
+ }
117
+
118
+ //? A 400 naming a release Err key is the project's verdict on the release, as a rejected one is: a retry changes nothing.
119
+ static #refusal(error: unknown, who: string) {
120
+ const refusal = CloudApi.refusal(error, who);
121
+ const verdict =
122
+ error instanceof CloudHttpError && error.status === 400 && !!error.errKey?.startsWith("release.error.");
123
+ return verdict && refusal instanceof Error ? new ExitCodeError(refusal.message, 2) : refusal;
124
+ }
125
+
80
126
  //? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
81
127
  async registerBinary(
82
128
  app: App,