@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.3

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 (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +69 -0
  24. package/executors.ts +30 -27
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -25,19 +25,21 @@ import {
25
25
  pipelineActions,
26
26
  pipelineMilestones,
27
27
  pipelineOtaPlatforms,
28
+ pipelinePlanLimits,
28
29
  pipelinePlanVersion,
30
+ toError,
29
31
  } from "akanjs/common";
30
- import {
31
- type PipelineActionStageRule,
32
- type PipelineAppRule,
33
- PipelineInputReader,
34
- type PipelinePostRule,
35
- type PipelineShellStageRule,
36
- type PipelineStepRule,
37
- type PipelineWhenRule,
38
- type PipelineWorkspaceRule,
39
- type PipelineWorkspaceStageRule,
40
- } from "./PipelineInputReader";
32
+ import { PipelineInputReader } from "./PipelineInputReader";
33
+ import type {
34
+ PipelineActionStageRule,
35
+ PipelineAppRule,
36
+ PipelinePostRule,
37
+ PipelineShellStageRule,
38
+ PipelineStepRule,
39
+ PipelineWhenRule,
40
+ PipelineWorkspaceRule,
41
+ PipelineWorkspaceStageRule,
42
+ } from "./PipelineRules";
41
43
 
42
44
  export interface PipelineSourceFile<T> {
43
45
  /** Relative to the workspace root, with `/`. */
@@ -59,7 +61,6 @@ export interface PipelineCompileInput {
59
61
  akanVersion: string;
60
62
  /** Function forms run against a sample context (`validate`), which is said once per file. */
61
63
  sampled?: boolean;
62
- /** What loading the files already found. */
63
64
  diagnostics?: readonly PipelineDiagnostic[];
64
65
  }
65
66
 
@@ -87,7 +88,6 @@ interface StageOptions {
87
88
  timeoutSeconds?: number | null;
88
89
  }
89
90
 
90
- /** Composes the workspace's and each app's rules into one plan for one run. */
91
91
  export class PipelineCompiler {
92
92
  static readonly #slotAfter = {
93
93
  build: "after-build",
@@ -112,6 +112,8 @@ export class PipelineCompiler {
112
112
  static readonly #reservedPhaseId =
113
113
  /^(?:build|docker|deploy|ota|(?:before-build|after-build|after-docker|after-deploy|after-ota)(?:-\d+)?)$/;
114
114
  static readonly #actionStepNames = { build: "akan build", docker: "image", deploy: "deploy", ota: "ota" } as const;
115
+ //? PipelinePlanSchema reads at most 1024 characters of a skip reason.
116
+ static readonly #reasonChars = 1024;
115
117
 
116
118
  static compile(input: PipelineCompileInput): PipelinePlanResult {
117
119
  return new PipelineCompiler(input).#compile();
@@ -130,13 +132,9 @@ export class PipelineCompiler {
130
132
  const { context, workspaceApps } = this.#input;
131
133
  this.#checkContext();
132
134
  const pipelineContext: PipelineContext = {
133
- branch: context.branch,
134
- env: context.env,
135
+ ...context,
135
136
  apps: [...context.apps],
136
137
  workspaceApps: [...workspaceApps],
137
- trigger: context.trigger,
138
- buildNum: context.buildNum,
139
- commit: context.commit,
140
138
  };
141
139
  const workspace = this.#readWorkspace(pipelineContext);
142
140
  const lanes = new Map<string, AppLane>();
@@ -164,11 +162,23 @@ export class PipelineCompiler {
164
162
  try {
165
163
  PipelinePlanSchema.parse(plan);
166
164
  } catch (error) {
167
- const message = error instanceof Error ? error.message : String(error);
165
+ const message = toError(error).message;
168
166
  this.#error(message.includes("at most") ? "pipeline.limit.exceeded" : "pipeline.internal", message, null, null);
169
167
  return this.#failed();
170
168
  }
171
- return { ok: true, plan, diagnostics: this.#diagnostics };
169
+ const result: PipelinePlanResult = { ok: true, plan, diagnostics: this.#diagnostics };
170
+ //? Sized as `akan pipeline plan --out` writes it, since that text is what an executor's parseText bounds.
171
+ const bytes = new TextEncoder().encode(`${JSON.stringify(result, null, 2)}\n`).length;
172
+ if (bytes > pipelinePlanLimits.planBytes) {
173
+ this.#error(
174
+ "pipeline.limit.exceeded",
175
+ `the plan is ${bytes} bytes as written, and an executor reads at most ${pipelinePlanLimits.planBytes}`,
176
+ null,
177
+ null,
178
+ );
179
+ return this.#failed();
180
+ }
181
+ return result;
172
182
  }
173
183
 
174
184
  #checkContext() {
@@ -184,11 +194,11 @@ export class PipelineCompiler {
184
194
 
185
195
  #readWorkspace(context: PipelineContext): PipelineWorkspaceRule | null {
186
196
  const source = this.#input.workspace;
187
- if (!source?.pipeline) return null;
188
- const value = this.#evaluate(source.pipeline, context, source.file);
189
- if (value === undefined) return null;
197
+ if (source?.pipeline == null) return null;
198
+ const evaluated = this.#evaluate(source.pipeline, context, source.file);
199
+ if (!evaluated) return null;
190
200
  const reader = new PipelineInputReader(source.file);
191
- const rule = reader.workspace(value);
201
+ const rule = reader.workspace(evaluated.value);
192
202
  this.#diagnostics.push(...reader.diagnostics);
193
203
  return rule;
194
204
  }
@@ -197,34 +207,55 @@ export class PipelineCompiler {
197
207
  const source = this.#input.apps.get(app);
198
208
  if (!source) return null;
199
209
  const appContext: AppPipelineContext = { ...context, app };
200
- const value = source.pipeline ? this.#evaluate(source.pipeline, appContext, source.file) : defaultAppPipeline;
201
- if (value === undefined) return null;
210
+ const evaluated =
211
+ source.pipeline == null
212
+ ? { value: defaultAppPipeline }
213
+ : this.#evaluate(source.pipeline, appContext, source.file);
214
+ if (!evaluated) return null;
202
215
  const reader = new PipelineInputReader(source.file);
203
- const rule = reader.app(value);
216
+ const rule = reader.app(evaluated.value);
204
217
  this.#diagnostics.push(...reader.diagnostics);
205
218
  return rule ? this.#lane(app, rule, source.file) : null;
206
219
  }
207
220
 
208
- #evaluate<C>(config: unknown, context: C, file: string): unknown {
209
- if (typeof config !== "function") return config;
210
- if (this.#input.sampled)
211
- this.#warning(
212
- "pipeline.function.sampled",
213
- "a function form was checked against one sample context; other branches, triggers or apps may differ",
214
- file,
215
- "pipeline",
216
- );
217
- try {
218
- return (config as (context: C) => unknown)(context);
219
- } catch (error) {
221
+ #evaluate<C>(config: unknown, context: C, file: string): { value: unknown } | null {
222
+ let value = config;
223
+ if (typeof config === "function") {
224
+ if (this.#input.sampled)
225
+ this.#warning(
226
+ "pipeline.function.sampled",
227
+ "a function form was checked against one sample context; other branches, triggers or apps may differ",
228
+ file,
229
+ "pipeline",
230
+ );
231
+ try {
232
+ value = (config as (context: C) => unknown)(context);
233
+ } catch (error) {
234
+ this.#error("pipeline.load.failed", `the pipeline function threw: ${toError(error).message}`, file, "pipeline");
235
+ return null;
236
+ }
237
+ if (value === undefined) {
238
+ this.#error(
239
+ "pipeline.schema.invalid",
240
+ "the pipeline function returned nothing for this run: return a pipeline on every path",
241
+ file,
242
+ "pipeline",
243
+ );
244
+ return null;
245
+ }
246
+ }
247
+ if (typeof (value as PromiseLike<unknown> | null)?.then === "function") {
248
+ //? Handled so a rejection cannot end the CLI before it writes the envelope.
249
+ void Promise.resolve(value).catch(() => undefined);
220
250
  this.#error(
221
- "pipeline.load.failed",
222
- `the pipeline function threw: ${error instanceof Error ? error.message : String(error)}`,
251
+ "pipeline.schema.invalid",
252
+ "is a Promise, which the planner cannot wait for: return the pipeline itself, not from an async function",
223
253
  file,
224
254
  "pipeline",
225
255
  );
226
- return undefined;
256
+ return null;
227
257
  }
258
+ return { value };
228
259
  }
229
260
 
230
261
  #lane(app: string, rule: PipelineAppRule, file: string): AppLane {
@@ -552,7 +583,9 @@ export class PipelineCompiler {
552
583
  maxParallel: stage.maxParallel,
553
584
  failFast: false,
554
585
  timeoutSeconds: stage.timeoutSeconds,
555
- stages: apps.map((app) => this.#shellStage(copy, { id: `${stage.id}:${app}`, name: app, app, milestone, env })),
586
+ stages: apps.map((app) =>
587
+ this.#shellStage(copy, { id: `${stage.id}.apps.${app}`, name: app, app, milestone, env }),
588
+ ),
556
589
  },
557
590
  ];
558
591
  }
@@ -603,10 +636,11 @@ export class PipelineCompiler {
603
636
 
604
637
  #skipOf(when: PipelineWhenRule): PipelineSkip | null {
605
638
  const { branch, trigger } = this.#input.context;
639
+ const skip = (reason: string) => ({ reason: PipelineInputReader.clip(reason, PipelineCompiler.#reasonChars) });
606
640
  if (when.branch && !PipelinePattern.matches(when.branch, branch))
607
- return { reason: `branch "${branch}" does not match ${when.branch.map((glob) => `"${glob}"`).join(", ")}` };
641
+ return skip(`branch "${branch}" does not match ${when.branch.map((glob) => `"${glob}"`).join(", ")}`);
608
642
  if (when.trigger && !when.trigger.includes(trigger))
609
- return { reason: `trigger "${trigger}" is not ${when.trigger.map((name) => `"${name}"`).join(" or ")}` };
643
+ return skip(`trigger "${trigger}" is not ${when.trigger.map((name) => `"${name}"`).join(" or ")}`);
610
644
  return null;
611
645
  }
612
646
 
@@ -615,7 +649,7 @@ export class PipelineCompiler {
615
649
  const sourceOf = (source: PipelineSourceFile<unknown>): PipelinePlanSource => ({
616
650
  file: source.file,
617
651
  sha256: source.sha256,
618
- declared: source.pipeline !== null,
652
+ declared: source.pipeline != null,
619
653
  });
620
654
  return {
621
655
  workspace: workspace ? sourceOf(workspace) : null,
@@ -70,6 +70,15 @@ describe("PipelineDefinition", () => {
70
70
  );
71
71
  });
72
72
 
73
+ test("the workspace's apps are in code-point order, whatever the machine's locale", async () => {
74
+ await withWorkspace(
75
+ { "apps/api/akan.config.ts": "export default {};\n", "apps/Web/akan.config.ts": "export default {};\n" },
76
+ async (workspace) => {
77
+ expect((await PipelineDefinition.load(workspace, [])).workspaceApps).toEqual(["Web", "api"]);
78
+ },
79
+ );
80
+ });
81
+
73
82
  test("a config that cannot load is an error naming the file, never a silent default", async () => {
74
83
  await withWorkspace(
75
84
  {
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { AppPipelineConfig, WorkspacePipelineConfig } from "akanjs";
4
4
  import type { PipelineDiagnostic, PipelinePlanContext, PipelinePlanResult } from "akanjs/common";
5
+ import { toError } from "akanjs/common";
5
6
  import { AkanAppConfig, AkanWorkspaceConfig } from "../akanConfig";
6
7
  import { AppExecutor, type WorkspaceExecutor } from "../executors";
7
8
  import { FileSys } from "../fileSys";
@@ -44,7 +45,7 @@ export class PipelineDefinition {
44
45
  }
45
46
 
46
47
  async #load(apps: readonly string[]) {
47
- this.workspaceApps = (await this.#workspace.getApps()).sort((a, b) => a.localeCompare(b));
48
+ this.workspaceApps = (await this.#workspace.getApps()).sort();
48
49
  this.workspaceSource = await this.#loadWorkspace();
49
50
  for (const app of new Set(apps)) {
50
51
  if (!this.workspaceApps.includes(app)) continue;
@@ -68,7 +69,7 @@ export class PipelineDefinition {
68
69
  const targets = await resolveMobileTargets(AppExecutor.from(this.#workspace, app), "all");
69
70
  return { targets: targets.map(({ name, config }) => ({ name, updates: !!config.updates })) };
70
71
  } catch (error) {
71
- return { error: error instanceof Error ? error.message : String(error) };
72
+ return { error: toError(error).message };
72
73
  }
73
74
  }
74
75
 
@@ -105,7 +106,7 @@ export class PipelineDefinition {
105
106
  this.diagnostics.push({
106
107
  severity: "error",
107
108
  code: "pipeline.load.failed",
108
- message: `could not load: ${error instanceof Error ? error.message : String(error)}`,
109
+ message: `could not load: ${toError(error).message}`,
109
110
  file,
110
111
  path: null,
111
112
  });
@@ -76,6 +76,33 @@ describe("PipelineDryRun", () => {
76
76
  );
77
77
  });
78
78
 
79
+ test("a phase of one stage still names its app", () => {
80
+ const result = PipelineCompiler.compile({
81
+ context: { ...context, apps: ["web"] },
82
+ workspaceApps: ["web"],
83
+ workspace: {
84
+ file: "akan.config.ts",
85
+ sha256: sha,
86
+ pipeline: { after: [{ name: "E2E", matrix: "apps", sh: "bun run e2e" }] },
87
+ },
88
+ apps: new Map([["web", { file: "apps/web/akan.config.ts", sha256: sha, pipeline: null }]]),
89
+ akanVersion: "3.0.3",
90
+ });
91
+ expect(PipelineDryRun.render(result)).toBe(
92
+ [
93
+ "plan v1 · branch develop · env develop · trigger push · apps web",
94
+ "from akan.config.ts, apps/web/akan.config.ts (default)",
95
+ "",
96
+ "prepare (executor) checkout · env · install · plan",
97
+ "build build Build web action build",
98
+ "package docker Docker web action docker",
99
+ "deploy deploy Deploy → web (one at a time)",
100
+ "finish e2e E2E web bun run e2e",
101
+ "finish (executor) cleanup",
102
+ ].join("\n"),
103
+ );
104
+ });
105
+
79
106
  test("a failed plan prints its diagnostics", () => {
80
107
  const rendered = PipelineDryRun.render({
81
108
  ok: false,
@@ -6,7 +6,7 @@ import type {
6
6
  PipelinePlanStage,
7
7
  } from "akanjs/common";
8
8
 
9
- /** A plan as a person reads it: one line per phase in run order, the executor's own work included. Runs nothing. */
9
+ /** A plan as a person reads it: one line per phase in run order, the executor's own work included. */
10
10
  export class PipelineDryRun {
11
11
  static render(result: PipelinePlanResult) {
12
12
  const lines = result.ok ? PipelineDryRun.#plan(result.plan) : ["no plan: the definition has errors"];
@@ -61,7 +61,7 @@ export class PipelineDryRun {
61
61
  const command = stage?.steps
62
62
  .map((step) => (step.kind === "sh" ? step.name : `action ${step.action}`))
63
63
  .join(" · ");
64
- return [labels[0], command].filter((part) => part && part !== stage?.name).join(" ");
64
+ return [labels[0], command].filter((part) => part && part !== phase.name).join(" ");
65
65
  }
66
66
  return `‖ ${labels.join(" · ")}`;
67
67
  }