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

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 +46 -0
  24. package/executors.ts +26 -23
  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
@@ -0,0 +1,92 @@
1
+ import type {
2
+ PipelineActionName,
3
+ PipelineMilestone,
4
+ PipelineOtaPlatform,
5
+ PipelinePlanRetry,
6
+ PipelineTrigger,
7
+ } from "akanjs/common";
8
+
9
+ export interface PipelineWhenRule {
10
+ branch: string[] | null;
11
+ trigger: PipelineTrigger[] | null;
12
+ changed: string[] | null;
13
+ /** A workspace `matrix: "apps"` stage's narrowing; never set elsewhere. */
14
+ app: string[] | null;
15
+ }
16
+
17
+ export interface PipelineStepRule {
18
+ localId: string;
19
+ name: string;
20
+ timeoutSeconds: number | null;
21
+ retry: PipelinePlanRetry | null;
22
+ allowFailure: boolean;
23
+ run: string;
24
+ cwd: string;
25
+ /** The stage's env under the step's own. */
26
+ env: Record<string, string>;
27
+ secrets: string[];
28
+ skipExitCodes: number[];
29
+ }
30
+
31
+ interface PipelineStageRuleBase {
32
+ id: string;
33
+ name: string;
34
+ description: string | null;
35
+ milestone: PipelineMilestone | null;
36
+ when: PipelineWhenRule;
37
+ timeoutSeconds: number | null;
38
+ allowFailure: boolean;
39
+ path: string;
40
+ }
41
+
42
+ export interface PipelineShellStageRule extends PipelineStageRuleBase {
43
+ kind: "sh";
44
+ steps: PipelineStepRule[];
45
+ }
46
+
47
+ export interface PipelineActionStageRule extends PipelineStageRuleBase {
48
+ kind: "action";
49
+ action: PipelineActionName;
50
+ named: boolean;
51
+ retry: PipelinePlanRetry | null;
52
+ /** `ota` only; `null` takes the default. */
53
+ targets: string[] | null;
54
+ platforms: PipelineOtaPlatform[] | null;
55
+ }
56
+
57
+ export interface PipelineParallelStageRule extends PipelineStageRuleBase {
58
+ kind: "parallel";
59
+ maxParallel: number | null;
60
+ failFast: boolean;
61
+ branches: PipelineShellStageRule[];
62
+ }
63
+
64
+ export interface PipelineMatrixStageRule extends PipelineStageRuleBase {
65
+ kind: "matrix";
66
+ maxParallel: number | null;
67
+ steps: PipelineStepRule[];
68
+ }
69
+
70
+ export type PipelineWorkspaceStageRule = PipelineShellStageRule | PipelineParallelStageRule | PipelineMatrixStageRule;
71
+ export type PipelineAppStageRule = PipelineShellStageRule | PipelineActionStageRule;
72
+
73
+ export interface PipelinePostRule {
74
+ success: PipelineStepRule[];
75
+ failure: PipelineStepRule[];
76
+ canceled: PipelineStepRule[];
77
+ always: PipelineStepRule[];
78
+ }
79
+
80
+ export interface PipelineWorkspaceRule {
81
+ env: Record<string, string>;
82
+ timeoutSeconds: number | null;
83
+ before: PipelineWorkspaceStageRule[];
84
+ after: PipelineWorkspaceStageRule[];
85
+ post: PipelinePostRule;
86
+ }
87
+
88
+ export interface PipelineAppRule {
89
+ env: Record<string, string>;
90
+ after: string[];
91
+ stages: PipelineAppStageRule[];
92
+ }
package/pipeline/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./PipelineCompiler";
2
2
  export * from "./PipelineDefinition";
3
3
  export * from "./PipelineDryRun";
4
4
  export * from "./PipelineInputReader";
5
+ export * from "./PipelineRules";
@@ -243,7 +243,7 @@ describe("AkanQualityScanner layout rules", () => {
243
243
 
244
244
  const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-facet-file");
245
245
 
246
- expect(warnings.map((warning) => warning.file).sort()).toEqual([
246
+ expect(warnings.map((warning) => warning.file ?? "").sort((a, b) => a.localeCompare(b))).toEqual([
247
247
  "libs/demo/lib/helper.ts",
248
248
  "libs/demo/lib/user.signal.test.ts",
249
249
  ]);
@@ -302,7 +302,7 @@ describe("bang comments in browser-reachable files", () => {
302
302
  "apps/demo/ui/Last.tsx": "export const Last = () => null;\n//! left behind\n",
303
303
  });
304
304
 
305
- expect(warnings.map((warning) => `${warning.file}:${warning.line}`).sort()).toEqual([
305
+ expect(warnings.map((warning) => `${warning.file}:${warning.line}`).sort((a, b) => a.localeCompare(b))).toEqual([
306
306
  "apps/demo/ui/First.tsx:1",
307
307
  "apps/demo/ui/Last.tsx:2",
308
308
  ]);
@@ -350,6 +350,8 @@ describe("bang comments in browser-reachable files", () => {
350
350
  ];
351
351
  const warnings = await bangWarningsIn(Object.fromEntries(files.map((file) => [file, body])));
352
352
 
353
- expect(warnings.map((warning) => warning.file).sort()).toEqual([...files].sort());
353
+ expect(warnings.map((warning) => warning.file ?? "").sort((a, b) => a.localeCompare(b))).toEqual(
354
+ [...files].sort((a, b) => a.localeCompare(b)),
355
+ );
354
356
  });
355
357
  });
package/slicePlanner.ts CHANGED
@@ -51,6 +51,10 @@ export class SlicePlanner {
51
51
  return found ? peers : null;
52
52
  }
53
53
 
54
+ static rootEntriesOf(rootFiles: string[]) {
55
+ return [...new Set(rootFiles.map((file) => file.split("/")[0] ?? file))].sort();
56
+ }
57
+
54
58
  /** DefinitelyTyped's name for a package: `lodash` -> `@types/lodash`, `@scope/x` -> `@types/scope__x`. */
55
59
  static typesPackageOf(dependency: string) {
56
60
  return dependency.startsWith("@") ? `@types/${dependency.slice(1).replace("/", "__")}` : `@types/${dependency}`;
@@ -191,8 +195,6 @@ export class SlicePlanner {
191
195
  }
192
196
  }
193
197
 
194
- const rootEntriesOf = (rootFiles: string[]) => [...new Set(rootFiles.map((file) => file.split("/")[0] ?? file))].sort();
195
-
196
198
  export function formatSlicePlan(plan: SlicePlan) {
197
199
  const libCounts = Object.entries(plan.libFiles).map(([lib, files]) => ` - libs/${lib}: ${files.length} files`);
198
200
  const sections = [
@@ -200,7 +202,7 @@ export function formatSlicePlan(plan: SlicePlan) {
200
202
  `app: ${plan.app} (apps/${plan.app}: ${plan.appFiles.length} files)`,
201
203
  `libs: ${plan.libs.length ? plan.libs.join(", ") : "(none)"}`,
202
204
  ...libCounts,
203
- `workspace shell: ${plan.rootFiles.length} files across ${rootEntriesOf(plan.rootFiles).join(", ")}`,
205
+ `workspace shell: ${plan.rootFiles.length} files across ${SlicePlanner.rootEntriesOf(plan.rootFiles).join(", ")}`,
204
206
  "",
205
207
  `Root dependencies this slice does not use (${plan.unusedDependencies.length}) — pruned:`,
206
208
  "",
package/subspace.test.ts CHANGED
@@ -3,14 +3,10 @@ import { mkdir, readdir, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Executor, WorkspaceExecutor } from "./executors";
5
5
  import { FileSys } from "./fileSys";
6
- import {
7
- formatSubspaceDiff,
8
- formatSubspacePlan,
9
- formatSubspacePushResults,
10
- formatSubspaceStatuses,
11
- Subspace,
12
- } from "./subspace";
6
+ import { Subspace } from "./subspace";
7
+ import { SubspaceClone } from "./subspaceClone";
13
8
  import { SubspaceConfig } from "./subspaceConfig";
9
+ import { SubspaceReport } from "./subspaceReport";
14
10
  import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
15
11
 
16
12
  //? Each case drives several real git clones and pushes; on a 4-core Windows VM that alone lands at 2-5s.
@@ -189,7 +185,7 @@ describe("Subspace push", () => {
189
185
  await subspace.push("develop", { verify: false });
190
186
  const subspaceRoot = await cloneSubspace(workRoot, bareRoot, "check");
191
187
 
192
- const anchor = (await FileSys.readJson(path.join(subspaceRoot, Subspace.anchorFile))) as {
188
+ const anchor = (await FileSys.readJson(path.join(subspaceRoot, SubspaceClone.anchorFile))) as {
193
189
  branch: string;
194
190
  apps: string[];
195
191
  };
@@ -257,7 +253,7 @@ describe("Subspace push", () => {
257
253
  await git(customer, ["commit", "--quiet", "-m", "our own global stages"]);
258
254
  await write(path.join(customer, "Jenkinsfile"), "pipeline { agent none }\n");
259
255
  await git(customer, ["add", "-A"]);
260
- await git(customer, ["commit", "--quiet", "-m", `${Subspace.syncSubjectPrefix}workspace@0000000`]);
256
+ await git(customer, ["commit", "--quiet", "-m", `${SubspaceClone.syncSubjectPrefix}workspace@0000000`]);
261
257
  await git(customer, ["push", "--quiet", "origin", "develop"]);
262
258
 
263
259
  expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
@@ -268,8 +264,8 @@ describe("Subspace push", () => {
268
264
  expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(false);
269
265
  });
270
266
 
271
- test("refuses a lock that resolves from a registry only this machine reaches", async () => {
272
- const { wsRoot, subspace } = await makeMirror("lock-served", "lock-private", "lock-kit");
267
+ test("refuses a lock that resolves from a registry only this machine reaches, before it clones", async () => {
268
+ const { wsRoot, subspace, clonePath } = await makeMirror("lock-served", "lock-private", "lock-kit");
273
269
  await write(
274
270
  path.join(wsRoot, "bun.lock"),
275
271
  '{ "packages": { "lodash": ["lodash@4.0.0", "http://127.0.0.1:4873/lodash/-/lodash-4.0.0.tgz"] } }\n',
@@ -279,6 +275,40 @@ describe("Subspace push", () => {
279
275
  const result = (await subspace.push("develop", { verify: false })) as { outcome: string; reason?: string };
280
276
  expect(result.outcome).toBe("refused");
281
277
  expect(result.reason).toContain("http://127.0.0.1:4873");
278
+ expect(await FileSys.dirExists(clonePath)).toBe(false);
279
+ });
280
+
281
+ test("refuses a push and a pull while akan.subspace.ts is left at the workspace root", async () => {
282
+ const { wsRoot, workRoot, bareRoot, subspace } = await makeMirror("left-served", "left-private", "left-kit");
283
+ await write(
284
+ path.join(wsRoot, Subspace.retiredConfigFile),
285
+ 'export default { subspaces: [{ name: "globex", repo: "git@example.com:globex/app.git", apps: ["left-private"] }] };\n',
286
+ );
287
+ await git(wsRoot, ["add", "-A"]);
288
+ await git(wsRoot, ["commit", "--quiet", "-m", "moved the declaration, kept the file"]);
289
+
290
+ const result = await subspace.push("develop", { verify: false });
291
+ expect(result.outcome).toBe("refused");
292
+ expect(result.reason).toContain("move it under `subspace` in akan.config.ts and delete it");
293
+ await expect(subspace.pull("develop")).rejects.toThrow("delete it");
294
+ const subspaceRoot = await cloneSubspace(workRoot, bareRoot, "left-check");
295
+ expect(await FileSys.fileExists(path.join(subspaceRoot, Subspace.retiredConfigFile))).toBe(false);
296
+ });
297
+
298
+ test("removes an akan.subspace.ts an earlier push left in the subspace", async () => {
299
+ const { workRoot, bareRoot, subspace } = await makeMirror("leaked-served", "leaked-private", "leaked-kit");
300
+ const customer = await cloneSubspace(workRoot, bareRoot, "leaked-customer");
301
+ await write(
302
+ path.join(customer, Subspace.retiredConfigFile),
303
+ 'export default { subspaces: [{ name: "globex" }] };\n',
304
+ );
305
+ await git(customer, ["add", "-A"]);
306
+ await git(customer, ["commit", "--quiet", "-m", `${SubspaceClone.syncSubjectPrefix}workspace@0000000`]);
307
+ await git(customer, ["push", "--quiet", "origin", "develop"]);
308
+
309
+ expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
310
+ const subspaceRoot = await cloneSubspace(workRoot, bareRoot, "leaked-check");
311
+ expect(await FileSys.fileExists(path.join(subspaceRoot, Subspace.retiredConfigFile))).toBe(false);
282
312
  });
283
313
 
284
314
  test("refuses a branch the subspace does not have, and one the config does not allow", async () => {
@@ -319,7 +349,7 @@ describe("Subspace push", () => {
319
349
  });
320
350
 
321
351
  test("reports a refusal's whole reason, indented, instead of one truncated line", () => {
322
- const report = formatSubspacePushResults([
352
+ const report = SubspaceReport.pushResults([
323
353
  { name: "acme", outcome: "refused", reason: "verification failed\nexit code: 1", changedFiles: 12 },
324
354
  { name: "beta", outcome: "pushed", commit: "abc1234", changedFiles: 3 },
325
355
  ]);
@@ -341,7 +371,7 @@ describe("Subspace status", () => {
341
371
  expect(status.driftedLibs).toEqual([]);
342
372
  expect(status.anchor.commit).toMatch(/^[0-9a-f]{40}$/);
343
373
  expect(status.anchor.incoming).toEqual([]);
344
- expect(formatSubspaceStatuses([status])).toContain("up to date, no customer commits");
374
+ expect(SubspaceReport.statuses([status])).toContain("up to date, no customer commits");
345
375
  });
346
376
 
347
377
  test("names the drifted library and counts the customer commits", async () => {
@@ -358,7 +388,7 @@ describe("Subspace status", () => {
358
388
 
359
389
  expect(status.driftedLibs).toEqual(["sdrift-kit"]);
360
390
  expect(status.anchor.incoming.map((commit) => commit.subject)).toEqual(["lib hack"]);
361
- expect(formatSubspaceStatuses([status])).toContain("DRIFTED LIBS: sdrift-kit");
391
+ expect(SubspaceReport.statuses([status])).toContain("DRIFTED LIBS: sdrift-kit");
362
392
  });
363
393
 
364
394
  test("reports a subspace with no such branch instead of creating one", async () => {
@@ -366,7 +396,7 @@ describe("Subspace status", () => {
366
396
  const status = await subspace.status("main");
367
397
 
368
398
  expect(status.hasBranch).toBe(false);
369
- expect(formatSubspaceStatuses([status])).toContain("no such branch");
399
+ expect(SubspaceReport.statuses([status])).toContain("no such branch");
370
400
  });
371
401
  });
372
402
 
@@ -389,7 +419,7 @@ describe("Subspace diff", () => {
389
419
  //? Direction: the workspace would restore `helper = 1` and delete the file the subspace added.
390
420
  expect(result.libs.patch).toContain("-export const helper = 99;");
391
421
  expect(result.libs.patch).toContain("+export const helper = 1;");
392
- expect(formatSubspaceDiff(result)).toContain("LIBRARY changes (1)");
422
+ expect(SubspaceReport.diff(result)).toContain("LIBRARY changes (1)");
393
423
  });
394
424
 
395
425
  test("is empty right after a push", async () => {
@@ -399,7 +429,7 @@ describe("Subspace diff", () => {
399
429
  const result = await subspace.diff("develop");
400
430
  expect(result.app.files).toEqual([]);
401
431
  expect(result.libs.files).toEqual([]);
402
- expect(formatSubspaceDiff(result)).toContain("identical to the workspace");
432
+ expect(SubspaceReport.diff(result)).toContain("identical to the workspace");
403
433
  });
404
434
  });
405
435
 
@@ -509,6 +539,16 @@ describe("SubspaceConfig", () => {
509
539
  expect(() => config.assertPushable("feature/x")).toThrow(/not pushable/);
510
540
  expect(() => config.assertPushable("develop")).not.toThrow();
511
541
  });
542
+
543
+ test("names the setting that allows a branch when it refuses one", () => {
544
+ const config = new SubspaceConfig({
545
+ pushableBranches: ["main"],
546
+ subspaces: [{ name: "a", repo: "a.git", apps: ["one"] }],
547
+ });
548
+ expect(() => config.assertPushable("develop")).toThrow(
549
+ 'Branch "develop" is not pushable. `subspace.pushableBranches` in the workspace root akan.config.ts allows: main',
550
+ );
551
+ });
512
552
  });
513
553
 
514
554
  describe("Subspace.plan", () => {
@@ -532,7 +572,7 @@ describe("Subspace.plan", () => {
532
572
  expect(plan.files).toBeGreaterThan(0);
533
573
  expect((await git(wsRoot, ["remote"])).trim()).toBe("");
534
574
  expect(await FileSys.dirExists(clonePath)).toBe(false);
535
- expect(formatSubspacePlan(plan)).toContain("Nothing here would make a push refuse.");
575
+ expect(SubspaceReport.plan(plan)).toContain("Nothing here would make a push refuse.");
536
576
  });
537
577
 
538
578
  test("names what a push would refuse: a branch it may not push and a loopback-registry lock", async () => {
@@ -545,7 +585,38 @@ describe("Subspace.plan", () => {
545
585
  await git(wsRoot, ["commit", "--quiet", "-m", "lock"]);
546
586
  const plan = await subspace.plan("main");
547
587
  expect(plan.branchRefusal).toContain('Branch "main" is not pushable');
588
+ expect(plan.branchRefusal).toContain("`subspace.pushableBranches`");
548
589
  expect(plan.lockProblem).toContain("http://127.0.0.1:4873");
549
- expect(formatSubspacePlan(plan)).toContain("A push would be refused:");
590
+ expect(SubspaceReport.plan(plan)).toContain("A push would be refused:");
591
+ });
592
+
593
+ test("names a dirty tree as a blocker, as push refuses one, and judges HEAD's lock, the one a push ships", async () => {
594
+ const { wsRoot, subspace } = await makeMirror("served", "private", "kit");
595
+ await write(path.join(wsRoot, "bun.lock"), '{ "packages": {} }\n');
596
+ await git(wsRoot, ["add", "-f", "bun.lock"]);
597
+ await git(wsRoot, ["commit", "--quiet", "-m", "a public lock"]);
598
+ await write(
599
+ path.join(wsRoot, "bun.lock"),
600
+ '{ "packages": { "x": ["x@1.0.0", "http://127.0.0.1:4873/x/-/x-1.0.0.tgz"] } }\n',
601
+ );
602
+
603
+ const plan = await subspace.plan("develop");
604
+
605
+ expect(plan.workspaceRefusal).toBe("workspace working tree is dirty");
606
+ expect(plan.lockProblem).toBeNull();
607
+ expect(Subspace.planBlockers(plan)).toEqual(["workspace working tree is dirty"]);
608
+ expect(SubspaceReport.plan(plan)).toContain(" - workspace working tree is dirty");
609
+ });
610
+
611
+ test("names a leftover akan.subspace.ts as a blocker and never as an entry a push ships", async () => {
612
+ const { wsRoot, subspace } = await makeMirror("served", "private", "kit");
613
+ await write(path.join(wsRoot, Subspace.retiredConfigFile), "export default {};\n");
614
+ await git(wsRoot, ["add", "-A"]);
615
+ await git(wsRoot, ["commit", "--quiet", "-m", "kept the old declaration"]);
616
+
617
+ const plan = await subspace.plan("develop");
618
+
619
+ expect(plan.workspaceRefusal).toContain("delete it");
620
+ expect(plan.rootEntries).not.toContain(Subspace.retiredConfigFile);
550
621
  });
551
622
  });