@akanjs/devkit 3.0.2 → 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 (71) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.ts +3 -2
  3. package/akanConfig/akanConfig.test.ts +76 -6
  4. package/akanConfig/akanConfig.ts +111 -13
  5. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  6. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +23 -1
  9. package/akanContext.ts +1 -1
  10. package/applicationBuildRunner.test.ts +1 -1
  11. package/applicationBuildRunner.ts +73 -84
  12. package/artifact/implicitRootLayout.test.ts +6 -2
  13. package/artifact/implicitRootLayout.ts +12 -7
  14. package/biome.base.json +7 -2
  15. package/cloud/cloudApi.ts +82 -0
  16. package/cloud/globalConfig.ts +4 -0
  17. package/cloud/index.ts +1 -0
  18. package/cloud/updatePackUpload.test.ts +195 -0
  19. package/cloud/updatePackUpload.ts +115 -0
  20. package/commandDecorators/command.ts +8 -2
  21. package/commandDecorators/targetMeta.ts +1 -0
  22. package/executors.test.ts +21 -0
  23. package/executors.ts +9 -2
  24. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  25. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  26. package/frontendBuild/cssCandidateCache.ts +4 -2
  27. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  29. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  30. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  32. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  33. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  34. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  35. package/lint/no-import-external-library-in-test.grit +24 -0
  36. package/mobile/desktopServerStage.test.ts +6 -0
  37. package/mobile/desktopServerStage.ts +25 -3
  38. package/mobile/index.ts +1 -0
  39. package/mobile/nativeApp.test.ts +1 -0
  40. package/mobile/nativeApp.ts +96 -34
  41. package/mobile/nativeConfig.test.ts +59 -1
  42. package/mobile/nativeConfig.ts +13 -3
  43. package/mobile/serverBundleSecrets.test.ts +61 -0
  44. package/mobile/serverBundleSecrets.ts +85 -0
  45. package/mobile/updateUploader.test.ts +49 -0
  46. package/mobile/updateUploader.ts +71 -0
  47. package/package.json +3 -2
  48. package/pipeline/PipelineCompiler.test.ts +631 -0
  49. package/pipeline/PipelineCompiler.ts +646 -0
  50. package/pipeline/PipelineDefinition.test.ts +89 -0
  51. package/pipeline/PipelineDefinition.ts +117 -0
  52. package/pipeline/PipelineDryRun.test.ts +93 -0
  53. package/pipeline/PipelineDryRun.ts +89 -0
  54. package/pipeline/PipelineInputReader.ts +739 -0
  55. package/pipeline/index.ts +4 -0
  56. package/qualityScanner.test.ts +9 -3
  57. package/qualityScanner.ts +5 -2
  58. package/scanInfo.ts +13 -6
  59. package/subspace.test.ts +108 -4
  60. package/subspace.ts +162 -10
  61. package/subspaceConfig.ts +11 -14
  62. package/testHelpers.ts +4 -0
  63. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  64. package/transforms/serverEnvPlugin.test.ts +93 -2
  65. package/transforms/serverEnvPlugin.ts +71 -18
  66. package/transforms/serverEnvProfile.test.ts +82 -0
  67. package/transforms/serverEnvProfile.ts +114 -0
  68. package/typecheck/projectTypecheck.test.ts +32 -0
  69. package/typecheck/projectTypecheck.ts +71 -0
  70. package/workspaceLayout.test.ts +26 -3
  71. package/workspaceLayout.ts +15 -4
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { WorkspaceExecutor } from "../executors";
7
+ import { PipelineDefinition } from "./PipelineDefinition";
8
+
9
+ const context = {
10
+ branch: "main",
11
+ env: "main",
12
+ apps: ["web", "api"],
13
+ trigger: "push",
14
+ buildNum: 1,
15
+ commit: null,
16
+ } as const;
17
+
18
+ const withWorkspace = async (files: Record<string, string>, run: (workspace: WorkspaceExecutor) => Promise<void>) => {
19
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-plan-definition-"));
20
+ try {
21
+ for (const [relative, content] of Object.entries(files)) {
22
+ fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true });
23
+ fs.writeFileSync(path.join(root, relative), content);
24
+ }
25
+ await run(WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "repo" }));
26
+ } finally {
27
+ fs.rmSync(root, { recursive: true, force: true });
28
+ }
29
+ };
30
+
31
+ const webConfig = `import type { AppConfig } from "akanjs";
32
+
33
+ const config: AppConfig = {
34
+ externalLibs: ["shiki"],
35
+ pipeline: { stages: [{ use: "build" }, { name: "Test", sh: "bun test" }, { use: "docker" }, { use: "deploy" }] },
36
+ };
37
+
38
+ export default config;
39
+ `;
40
+
41
+ describe("PipelineDefinition", () => {
42
+ test("reads the root config and each app's, type-only imports with no node_modules at all", async () => {
43
+ await withWorkspace(
44
+ {
45
+ "akan.config.ts":
46
+ 'import type { WorkspaceConfig } from "akanjs";\n\nexport default { pipeline: { before: [{ name: "Lint", sh: "bun lint" }] } } satisfies WorkspaceConfig;\n',
47
+ "apps/web/akan.config.ts": webConfig,
48
+ "apps/api/akan.config.ts": "export default (app) => ({ externalLibs: [app.name] });\n",
49
+ },
50
+ async (workspace) => {
51
+ const definition = await PipelineDefinition.load(workspace, context.apps);
52
+ const result = definition.compile({ ...context, apps: [...context.apps] });
53
+ if (!result.ok) throw new Error(JSON.stringify(result.diagnostics));
54
+ expect(result.plan.phases.map((phase) => phase.id)).toEqual([
55
+ "lint",
56
+ "build",
57
+ "after-build",
58
+ "docker",
59
+ "deploy",
60
+ ]);
61
+ expect(result.plan.sources.workspace?.declared).toBe(true);
62
+ expect(result.plan.sources.apps.web).toEqual({
63
+ file: "apps/web/akan.config.ts",
64
+ sha256: createHash("sha256").update(webConfig).digest("hex"),
65
+ declared: true,
66
+ });
67
+ expect(result.plan.sources.apps.api?.declared).toBe(false);
68
+ expect(definition.workspaceApps).toEqual(["api", "web"]);
69
+ },
70
+ );
71
+ });
72
+
73
+ test("a config that cannot load is an error naming the file, never a silent default", async () => {
74
+ await withWorkspace(
75
+ {
76
+ "apps/web/akan.config.ts": 'throw new Error("boom");\n',
77
+ "apps/api/akan.config.ts": "export default {};\n",
78
+ },
79
+ async (workspace) => {
80
+ const result = (await PipelineDefinition.load(workspace, context.apps)).compile({
81
+ ...context,
82
+ apps: [...context.apps],
83
+ });
84
+ expect(result.ok).toBe(false);
85
+ expect(result.diagnostics[0]).toMatchObject({ code: "pipeline.load.failed", file: "apps/web/akan.config.ts" });
86
+ },
87
+ );
88
+ });
89
+ });
@@ -0,0 +1,117 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AppPipelineConfig, WorkspacePipelineConfig } from "akanjs";
4
+ import type { PipelineDiagnostic, PipelinePlanContext, PipelinePlanResult } from "akanjs/common";
5
+ import { AkanAppConfig, AkanWorkspaceConfig } from "../akanConfig";
6
+ import { AppExecutor, type WorkspaceExecutor } from "../executors";
7
+ import { FileSys } from "../fileSys";
8
+ import { resolveMobileTargets } from "../mobile/mobileTarget";
9
+ import { PipelineCompiler, type PipelineNativeTargets, type PipelineSourceFile } from "./PipelineCompiler";
10
+
11
+ /**
12
+ * The workspace root config and each app's, for one run. Loading evaluates the workspace's own TypeScript, so a plan is
13
+ * made where that code may run (the build pod) and what leaves is data only.
14
+ */
15
+ export class PipelineDefinition {
16
+ static async load(workspace: WorkspaceExecutor, apps: readonly string[]) {
17
+ const definition = new PipelineDefinition(workspace);
18
+ await definition.#load(apps);
19
+ return definition;
20
+ }
21
+
22
+ readonly diagnostics: PipelineDiagnostic[] = [];
23
+ readonly appSources = new Map<string, PipelineSourceFile<AppPipelineConfig>>();
24
+ readonly nativeTargets = new Map<string, PipelineNativeTargets>();
25
+ workspaceApps: string[] = [];
26
+ workspaceSource: PipelineSourceFile<WorkspacePipelineConfig> | null = null;
27
+ readonly #workspace: WorkspaceExecutor;
28
+
29
+ constructor(workspace: WorkspaceExecutor) {
30
+ this.#workspace = workspace;
31
+ }
32
+
33
+ compile(context: PipelinePlanContext, { sampled = false }: { sampled?: boolean } = {}): PipelinePlanResult {
34
+ return PipelineCompiler.compile({
35
+ context,
36
+ workspaceApps: this.workspaceApps,
37
+ workspace: this.workspaceSource,
38
+ apps: this.appSources,
39
+ nativeTargets: this.nativeTargets,
40
+ akanVersion: AkanAppConfig.akanVersion,
41
+ sampled,
42
+ diagnostics: this.diagnostics,
43
+ });
44
+ }
45
+
46
+ async #load(apps: readonly string[]) {
47
+ this.workspaceApps = (await this.#workspace.getApps()).sort((a, b) => a.localeCompare(b));
48
+ this.workspaceSource = await this.#loadWorkspace();
49
+ for (const app of new Set(apps)) {
50
+ if (!this.workspaceApps.includes(app)) continue;
51
+ const source = await this.#loadApp(app);
52
+ this.appSources.set(app, source);
53
+ if (PipelineDefinition.#mayTakeOta(source.pipeline))
54
+ this.nativeTargets.set(app, await this.#loadNativeTargets(app));
55
+ }
56
+ }
57
+
58
+ //? A function form is only known once the compiler runs it, so it pays for the config load either way.
59
+ static #mayTakeOta(pipeline: AppPipelineConfig | null) {
60
+ if (typeof pipeline === "function") return true;
61
+ const stages: unknown = pipeline?.stages;
62
+ return Array.isArray(stages) && stages.some((stage) => (stage as { use?: unknown } | null)?.use === "ota");
63
+ }
64
+
65
+ //? The whole app config, libs included: a target's `updates` may come from `native.updates` or its own section.
66
+ async #loadNativeTargets(app: string): Promise<PipelineNativeTargets> {
67
+ try {
68
+ const targets = await resolveMobileTargets(AppExecutor.from(this.#workspace, app), "all");
69
+ return { targets: targets.map(({ name, config }) => ({ name, updates: !!config.updates })) };
70
+ } catch (error) {
71
+ return { error: error instanceof Error ? error.message : String(error) };
72
+ }
73
+ }
74
+
75
+ async #loadWorkspace(): Promise<PipelineSourceFile<WorkspacePipelineConfig> | null> {
76
+ const file = AkanWorkspaceConfig.fileName;
77
+ const absolute = path.join(this.#workspace.workspaceRoot, file);
78
+ if (!(await FileSys.fileExists(absolute))) return null;
79
+ const sha256 = await PipelineDefinition.#sha256(absolute);
80
+ try {
81
+ return { file, sha256, pipeline: (await this.#workspace.getConfig()).pipeline };
82
+ } catch (error) {
83
+ this.#failed(file, error);
84
+ return { file, sha256, pipeline: null };
85
+ }
86
+ }
87
+
88
+ async #loadApp(app: string): Promise<PipelineSourceFile<AppPipelineConfig>> {
89
+ const executor = AppExecutor.from(this.#workspace, app);
90
+ const file = `apps/${app}/akan.config.ts`;
91
+ const sha256 = await PipelineDefinition.#sha256(path.join(executor.cwdPath, "akan.config.ts"));
92
+ try {
93
+ const module = await AkanAppConfig.importConfigModule(executor.cwdPath);
94
+ const config = typeof module === "function" ? (module as (app: AppExecutor) => unknown)(executor) : module;
95
+ const pipeline =
96
+ typeof config === "object" && config !== null ? (config as { pipeline?: AppPipelineConfig }).pipeline : null;
97
+ return { file, sha256, pipeline: pipeline ?? null };
98
+ } catch (error) {
99
+ this.#failed(file, error);
100
+ return { file, sha256, pipeline: null };
101
+ }
102
+ }
103
+
104
+ #failed(file: string, error: unknown) {
105
+ this.diagnostics.push({
106
+ severity: "error",
107
+ code: "pipeline.load.failed",
108
+ message: `could not load: ${error instanceof Error ? error.message : String(error)}`,
109
+ file,
110
+ path: null,
111
+ });
112
+ }
113
+
114
+ static async #sha256(file: string) {
115
+ return new Bun.CryptoHasher("sha256").update(await readFile(file)).digest("hex");
116
+ }
117
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PipelinePlanContext } from "akanjs/common";
3
+ import { PipelineCompiler } from "./PipelineCompiler";
4
+ import { PipelineDryRun } from "./PipelineDryRun";
5
+
6
+ const sha = "0".repeat(64);
7
+ const context: PipelinePlanContext = {
8
+ branch: "develop",
9
+ env: "develop",
10
+ apps: ["web", "api"],
11
+ trigger: "push",
12
+ buildNum: 3,
13
+ commit: null,
14
+ };
15
+
16
+ describe("PipelineDryRun", () => {
17
+ test("shows each phase in run order, with skips, allowed failures and the executor's own work", () => {
18
+ const result = PipelineCompiler.compile({
19
+ context,
20
+ workspaceApps: ["api", "web"],
21
+ workspace: {
22
+ file: "akan.config.ts",
23
+ sha256: sha,
24
+ pipeline: {
25
+ before: [
26
+ {
27
+ name: "Checks",
28
+ parallel: [
29
+ { name: "Lint", sh: "bun lint" },
30
+ { name: "Test", sh: "bun test" },
31
+ ],
32
+ },
33
+ ],
34
+ post: { failure: [{ name: "Notify", sh: "./notify.sh", secrets: ["DISCORD_WEBHOOK"] }] },
35
+ },
36
+ },
37
+ apps: new Map([
38
+ [
39
+ "web",
40
+ {
41
+ file: "apps/web/akan.config.ts",
42
+ sha256: sha,
43
+ pipeline: {
44
+ after: ["api"],
45
+ stages: [
46
+ { use: "build" },
47
+ { name: "Test", sh: "bun run akan test web" },
48
+ { use: "docker" },
49
+ { use: "deploy", when: { branch: "main" } },
50
+ { name: "Smoke", sh: "curl -fsS https://web.example.com/api/ping", allowFailure: true },
51
+ ],
52
+ },
53
+ },
54
+ ],
55
+ ["api", { file: "apps/api/akan.config.ts", sha256: sha, pipeline: null }],
56
+ ]),
57
+ akanVersion: "3.0.3",
58
+ });
59
+ expect(PipelineDryRun.render(result)).toBe(
60
+ [
61
+ "plan v1 · branch develop · env develop · trigger push · apps web, api · order api → web",
62
+ "from akan.config.ts, apps/web/akan.config.ts, apps/api/akan.config.ts (default)",
63
+ "",
64
+ "prepare (executor) checkout · env · install · plan",
65
+ "prepare checks Checks ‖ Lint · Test",
66
+ "build build Build ‖ api · web",
67
+ "verify after-build After build web:Test bun run akan test web",
68
+ "package docker Docker ‖ api · web",
69
+ 'deploy deploy Deploy → api, web (skipped: branch "develop" does not match "main") (one at a time)',
70
+ "deploy after-deploy After deploy web:Smoke (may fail) curl -fsS https://web.example.com/api/ping",
71
+ "finish post.failure Notify",
72
+ "finish (executor) cleanup",
73
+ "",
74
+ "secrets required: DISCORD_WEBHOOK",
75
+ ].join("\n"),
76
+ );
77
+ });
78
+
79
+ test("a failed plan prints its diagnostics", () => {
80
+ const rendered = PipelineDryRun.render({
81
+ ok: false,
82
+ plan: null,
83
+ diagnostics: [
84
+ { severity: "error", code: "pipeline.action.order", message: "x", file: "apps/web/akan.config.ts", path: "p" },
85
+ ],
86
+ });
87
+ expect(rendered).toBe(
88
+ ["no plan: the definition has errors", "", "error pipeline.action.order (apps/web/akan.config.ts · p): x"].join(
89
+ "\n",
90
+ ),
91
+ );
92
+ });
93
+ });
@@ -0,0 +1,89 @@
1
+ import type {
2
+ PipelineDiagnostic,
3
+ PipelinePlan,
4
+ PipelinePlanPhase,
5
+ PipelinePlanResult,
6
+ PipelinePlanStage,
7
+ } from "akanjs/common";
8
+
9
+ /** A plan as a person reads it: one line per phase in run order, the executor's own work included. Runs nothing. */
10
+ export class PipelineDryRun {
11
+ static render(result: PipelinePlanResult) {
12
+ const lines = result.ok ? PipelineDryRun.#plan(result.plan) : ["no plan: the definition has errors"];
13
+ const diagnostics = PipelineDryRun.diagnostics(result.diagnostics);
14
+ return [...lines, ...(diagnostics.length ? ["", ...diagnostics] : [])].join("\n");
15
+ }
16
+
17
+ static #plan(plan: PipelinePlan) {
18
+ const { context } = plan;
19
+ const order = plan.appOrder.join() === context.apps.join() ? "" : ` · order ${plan.appOrder.join(" → ")}`;
20
+ const rows: [string, string, string, string][] = [
21
+ ["prepare", "(executor)", "", "checkout · env · install · plan"],
22
+ ...plan.phases.map((phase): [string, string, string, string] => [
23
+ phase.milestone,
24
+ phase.id,
25
+ phase.name,
26
+ PipelineDryRun.#stages(phase),
27
+ ]),
28
+ ...(["success", "failure", "canceled", "always"] as const).flatMap(
29
+ (result): [string, string, string, string][] => {
30
+ const steps = plan.post[result];
31
+ if (!steps.length) return [];
32
+ return [["finish", `post.${result}`, "", steps.map((step) => step.name).join(" · ")]];
33
+ },
34
+ ),
35
+ ["finish", "(executor)", "", "cleanup"],
36
+ ];
37
+ const widths = [0, 1, 2].map((column) => Math.max(...rows.map((row) => row[column]?.length ?? 0)));
38
+ const sources = [
39
+ ...(plan.sources.workspace ? [plan.sources.workspace] : []),
40
+ ...Object.values(plan.sources.apps),
41
+ ].map((source) => `${source.file}${source.declared ? "" : " (default)"}`);
42
+ return [
43
+ `plan v${plan.schemaVersion} · branch ${context.branch} · env ${context.env} · trigger ${context.trigger} · apps ${context.apps.join(", ") || "(none)"}${order}`,
44
+ `from ${sources.join(", ") || "(no files)"}`,
45
+ "",
46
+ ...rows.map((row) =>
47
+ row
48
+ .map((cell, column) => (column < 3 ? cell.padEnd(widths[column] ?? 0) : cell))
49
+ .join(" ")
50
+ .trimEnd(),
51
+ ),
52
+ ...(plan.requiredSecrets.length ? ["", `secrets required: ${plan.requiredSecrets.join(", ")}`] : []),
53
+ ];
54
+ }
55
+
56
+ static #stages(phase: PipelinePlanPhase) {
57
+ const labels = phase.stages.map((stage) => PipelineDryRun.#stage(phase, stage));
58
+ if (phase.order === "sequential") return `→ ${labels.join(", ")} (one at a time)`;
59
+ if (phase.stages.length === 1) {
60
+ const [stage] = phase.stages;
61
+ const command = stage?.steps
62
+ .map((step) => (step.kind === "sh" ? step.name : `action ${step.action}`))
63
+ .join(" · ");
64
+ return [labels[0], command].filter((part) => part && part !== stage?.name).join(" ");
65
+ }
66
+ return `‖ ${labels.join(" · ")}`;
67
+ }
68
+
69
+ static #stage(phase: PipelinePlanPhase, stage: PipelinePlanStage) {
70
+ const label = stage.app && !phase.action && stage.name !== stage.app ? `${stage.app}:${stage.name}` : stage.name;
71
+ const ota = stage.steps.find((step) => step.kind === "action" && step.action === "ota");
72
+ const notes = [
73
+ ota?.kind === "action" && ota.action === "ota"
74
+ ? `${ota.with.targets.join(", ")} × ${ota.with.platforms.join(", ")}`
75
+ : null,
76
+ stage.skip ? `skipped: ${stage.skip.reason}` : null,
77
+ stage.allowFailure ? "may fail" : null,
78
+ stage.runtimeWhen ? `if changed: ${stage.runtimeWhen.changed.join(", ")}` : null,
79
+ ].filter(Boolean);
80
+ return notes.length ? `${label} (${notes.join("; ")})` : label;
81
+ }
82
+
83
+ static diagnostics(diagnostics: readonly PipelineDiagnostic[]) {
84
+ return diagnostics.map((diagnostic) => {
85
+ const where = [diagnostic.file, diagnostic.path].filter(Boolean).join(" · ");
86
+ return `${diagnostic.severity} ${diagnostic.code}${where ? ` (${where})` : ""}: ${diagnostic.message}`;
87
+ });
88
+ }
89
+ }