@akanjs/devkit 3.1.1-rc.8 → 3.1.1

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 (137) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +30 -0
  8. package/akanConfig/akanConfig.ts +10 -0
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/applicationBuildRunner.test.ts +17 -0
  13. package/applicationBuildRunner.ts +3 -1
  14. package/biome.base.json +4 -1
  15. package/cloud/cloudUpdates.ts +3 -2
  16. package/codeAgent/agent/CodeAgent.ts +4 -0
  17. package/codeAgent/agent/SubagentPool.ts +5 -0
  18. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  19. package/codeAgent/agent/codeAgent.test.ts +41 -2
  20. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  21. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  22. package/codeAgent/index.ts +2 -2
  23. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  24. package/codeAgent/skills/akan-code/SKILL.md +1 -0
  25. package/codeAgent/skills/akan-debug/SKILL.md +40 -6
  26. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  27. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  28. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  29. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  30. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  31. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  32. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  33. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  34. package/codeAgent/tools/AkanCli.ts +2 -1
  35. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  36. package/codeAgent/tools/AkanEditScope.ts +8 -3
  37. package/codeAgent/tools/AkanToolPack.ts +4 -10
  38. package/codeAgent/tools/AkanVerifier.ts +13 -31
  39. package/codeAgent/tools/akanCodePlugins.test.ts +60 -58
  40. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  41. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  42. package/commentDensityScanner.test.ts +121 -0
  43. package/commentDensityScanner.ts +101 -0
  44. package/componentSizeScanner.test.ts +36 -0
  45. package/componentSizeScanner.ts +52 -0
  46. package/componentWriteChainScanner.test.ts +137 -0
  47. package/componentWriteChainScanner.ts +104 -0
  48. package/conventionSet.test.ts +73 -0
  49. package/conventionSet.ts +67 -0
  50. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  51. package/deprecatedCommentScanner.test.ts +54 -0
  52. package/deprecatedCommentScanner.ts +30 -0
  53. package/endpointReach.test.ts +58 -0
  54. package/endpointReach.ts +80 -0
  55. package/executors.test.ts +0 -4
  56. package/executors.ts +18 -2
  57. package/index.ts +1 -0
  58. package/integration/devStability.integration.test.ts +9 -5
  59. package/kindBranchScanner.test.ts +89 -0
  60. package/kindBranchScanner.ts +158 -0
  61. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  62. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  63. package/lint/no-oversized-list-limit.grit +73 -0
  64. package/linter.test.ts +12 -0
  65. package/linter.ts +35 -12
  66. package/maintainabilityBaseline.test.ts +280 -0
  67. package/maintainabilityBaseline.ts +220 -0
  68. package/maintainabilityContract.test.ts +846 -0
  69. package/maintainabilityContract.ts +404 -0
  70. package/maintainabilityExceptionScanner.test.ts +90 -0
  71. package/maintainabilityExceptionScanner.ts +85 -0
  72. package/maintainabilityLimits.ts +60 -0
  73. package/maintainabilityReport.test.ts +130 -0
  74. package/maintainabilityReport.ts +153 -0
  75. package/mobile/desktopRelease.test.ts +94 -23
  76. package/mobile/desktopRelease.ts +83 -45
  77. package/mobile/nativeApi.test.ts +4 -1
  78. package/mobile/nativeApi.ts +3 -3
  79. package/mobile/nativeApp.test.ts +2 -2
  80. package/mobile/nativeApp.ts +18 -11
  81. package/mobile/nativeConfig.test.ts +16 -8
  82. package/mobile/nativeConfig.ts +1 -1
  83. package/mobile/updateUploader.test.ts +34 -0
  84. package/mobile/updateUploader.ts +9 -6
  85. package/package.json +3 -2
  86. package/platformTest/LinuxDockerTarget.ts +3 -0
  87. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  88. package/preview/PreviewView.ts +219 -0
  89. package/preview/index.ts +2 -0
  90. package/preview/previewView.test.ts +76 -0
  91. package/prompter.ts +7 -0
  92. package/qualityScanner.test.ts +75 -0
  93. package/qualityScanner.ts +17 -1
  94. package/routeSourceValidator.test.ts +11 -2
  95. package/routeSourceValidator.ts +14 -2
  96. package/serviceLoopCallScanner.test.ts +56 -0
  97. package/serviceLoopCallScanner.ts +122 -0
  98. package/signalMutationIndex.test.ts +15 -0
  99. package/signalMutationIndex.ts +1 -1
  100. package/sourceComments.test.ts +45 -0
  101. package/sourceComments.ts +70 -0
  102. package/storeAccessScanner.test.ts +84 -0
  103. package/storeAccessScanner.ts +71 -0
  104. package/storeSource.ts +159 -0
  105. package/storeWriteScanner.test.ts +292 -0
  106. package/storeWriteScanner.ts +50 -0
  107. package/testOnlyExportScanner.test.ts +83 -0
  108. package/testOnlyExportScanner.ts +124 -0
  109. package/typeChecker.ts +32 -0
  110. package/typecheck/projectTypecheck.test.ts +18 -0
  111. package/typecheck/projectTypecheck.ts +29 -5
  112. package/typecheck/typecheck.proc.ts +6 -0
  113. package/typecheck/typecheckFailure.ts +13 -0
  114. package/types.ts +1 -0
  115. package/workflow/artifacts.ts +16 -2
  116. package/workflow/executor.ts +22 -7
  117. package/workflow/guardCatalog.test.ts +59 -0
  118. package/workflow/guardCatalog.ts +108 -0
  119. package/workflow/index.ts +4 -0
  120. package/workflow/interactionScaffold.ts +122 -0
  121. package/workflow/mutationScaffold.ts +519 -0
  122. package/workflow/mutationSource.test.ts +108 -0
  123. package/workflow/mutationSource.ts +219 -0
  124. package/workflow/plan.ts +54 -0
  125. package/workflow/render.ts +19 -4
  126. package/workflow/source.test.ts +185 -0
  127. package/workflow/source.ts +151 -24
  128. package/workflow/types.ts +18 -1
  129. package/workspaceLayout.ts +2 -0
  130. package/writePath.test.ts +109 -0
  131. package/writePath.ts +219 -0
  132. package/writePlacementScanner.test.ts +77 -0
  133. package/writePlacementScanner.ts +50 -0
  134. package/writingActionIndex.test.ts +99 -0
  135. package/writingActionIndex.ts +63 -0
  136. package/codeAgent/feedback/PreviewView.ts +0 -95
  137. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SizeViolation } from "./maintainabilityContract";
3
+ import { MaintainabilityReport } from "./maintainabilityReport";
4
+
5
+ const fileOf = (file: string, lineCount = 450): SizeViolation => ({
6
+ kind: "size",
7
+ rule: "akan.size.file",
8
+ key: file,
9
+ file,
10
+ line: 1,
11
+ lineCount,
12
+ maxLines: 400,
13
+ });
14
+
15
+ const fixLinesOf = (message: string) => message.split("How to fix:\n")[1]?.split("\n") ?? [];
16
+
17
+ describe("MaintainabilityReport", () => {
18
+ test("gives a client file, a ui/ or webkit/ file, a long component and a busy one the same ordered paths", () => {
19
+ const message = MaintainabilityReport.describe("apps/demo", [
20
+ fileOf("lib/court/Court.Zone.tsx"),
21
+ fileOf("ui/Board.tsx"),
22
+ fileOf("webkit/useBoard.tsx"),
23
+ {
24
+ ...fileOf("lib/court/Court.Zone.tsx", 300),
25
+ rule: "akan.size.component",
26
+ key: "lib/court/Court.Zone.tsx#Canvas",
27
+ line: 4,
28
+ maxLines: 250,
29
+ component: "Canvas",
30
+ },
31
+ {
32
+ kind: "storeAccess",
33
+ rule: "akan.component.store-access",
34
+ key: "lib/court/Court.Zone.tsx#Canvas",
35
+ file: "lib/court/Court.Zone.tsx",
36
+ line: 4,
37
+ component: "Canvas",
38
+ accessCount: 20,
39
+ maxAccesses: 16,
40
+ },
41
+ ]);
42
+ const [fix = "", ...rest] = fixLinesOf(message);
43
+ const order = ["mode/kind table", "webkit/use<X>.tsx", "`common/`", "add-mutation", "`lib/<sub>`"].map((path) =>
44
+ fix.indexOf(path),
45
+ );
46
+
47
+ expect(rest).toEqual([]);
48
+ expect(fix).toStartWith(
49
+ " *.Zone.tsx, ui/**, webkit/**, component or hook, component or hook reaching the store often: ",
50
+ );
51
+ expect(order.every((idx) => idx > 0)).toBe(true);
52
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
53
+ expect(fix).toContain('`{ [kind in cnst.X["value"]]: … }`');
54
+ expect(fix).toContain("akan workflow plan create-interaction");
55
+ expect(fix).toContain("never model-bound code moved into `ui/`");
56
+ });
57
+
58
+ test("names a write sent from the wrong place, a call per item and a raised entry with their own fixes", () => {
59
+ const message = MaintainabilityReport.describe("apps/demo", [
60
+ {
61
+ kind: "baselineGrowth",
62
+ rule: "akan.baseline.growth",
63
+ key: "lib/court/court.store.ts#assign",
64
+ file: "akan.baseline.json",
65
+ line: 5,
66
+ debtRule: "akan.store.multi-write",
67
+ count: 3,
68
+ committed: 2,
69
+ reference: "3f2a9c1d0b7e",
70
+ },
71
+ {
72
+ kind: "writePlacement",
73
+ rule: "akan.write.placement",
74
+ key: "webkit/useBoard.tsx",
75
+ file: "webkit/useBoard.tsx",
76
+ line: 7,
77
+ writes: ["st.do.removeBoard", "fetch.placeBoard"],
78
+ },
79
+ {
80
+ kind: "loopCall",
81
+ rule: "akan.service.call-in-loop",
82
+ key: "lib/court/court.service.ts#syncAll",
83
+ file: "lib/court/court.service.ts",
84
+ line: 12,
85
+ owner: "syncAll",
86
+ calls: ["this.pcService.getPc"],
87
+ recorded: 0,
88
+ },
89
+ {
90
+ kind: "multiWrite",
91
+ rule: "akan.component.write-chain",
92
+ key: "webkit/useBoard.tsx#useBoard",
93
+ file: "webkit/useBoard.tsx",
94
+ line: 9,
95
+ owner: "useBoard",
96
+ writes: ["st.do.removeBoard (per item)"],
97
+ writeCount: 2,
98
+ },
99
+ ]);
100
+
101
+ expect(message).toContain(
102
+ 'akan.baseline.growth entry "lib/court/court.store.ts#assign" under akan.store.multi-write allows 3 writes, above the 2 at 3f2a9c1d0b7e',
103
+ );
104
+ expect(message).toContain(
105
+ "akan.write.placement sends 2 server write(s) from outside a store action or a module component: st.do.removeBoard, fetch.placeBoard",
106
+ );
107
+ expect(message).toContain(
108
+ "akan.service.call-in-loop service method syncAll calls once per item: this.pcService.getPc, more than the 0 recorded",
109
+ );
110
+ expect(message).toContain("hook useBoard sends a write per item from one handler: st.do.removeBoard (per item)");
111
+ expect(fixLinesOf(message).map((line) => line.split(":")[0])).toEqual([
112
+ " baseline entry",
113
+ " write sent outside a store action or module component",
114
+ " service call per item",
115
+ " handler with several writes",
116
+ ]);
117
+ });
118
+
119
+ test("keeps the server files' own fixes", () => {
120
+ const message = MaintainabilityReport.describe("apps/demo", [
121
+ fileOf("lib/court/Court.View.tsx"),
122
+ fileOf("lib/court/court.service.ts", 520),
123
+ ]);
124
+
125
+ expect(fixLinesOf(message)).toEqual([
126
+ " *.View.tsx: Move model-independent pieces into ui/ components and display logic onto Light<Model>.",
127
+ " *.service.ts: Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
128
+ ]);
129
+ });
130
+ });
@@ -0,0 +1,153 @@
1
+ import { AbstractDoc } from "./abstractDoc";
2
+ import type {
3
+ DebtRuleId,
4
+ MaintainabilityCommands,
5
+ MaintainabilityFailure,
6
+ MaintainabilityRuleId,
7
+ } from "./maintainabilityContract";
8
+ import { type FileKind, MaintainabilityLimits } from "./maintainabilityLimits";
9
+
10
+ type FixKey = FileKind | Exclude<MaintainabilityRuleId, "akan.size.file">;
11
+
12
+ export class MaintainabilityReport {
13
+ static readonly #clientSplitFix =
14
+ 'Split it, in this order: a mode/kind table `{ [kind in cnst.X["value"]]: … }` in place of mode flags, each entry a component of the module owning the data of that kind; an interaction hook in `webkit/use<X>.tsx` with its pure logic in `common/` (`akan workflow plan create-interaction`); the writes behind one endpoint (`akan workflow plan add-mutation`); last, a sub-concept in its own module (`lib/<sub>` or `lib/_<feature>`), never model-bound code moved into `ui/`.';
15
+ static readonly #serverMarkupFix =
16
+ "Move model-independent pieces into ui/ components and display logic onto Light<Model>.";
17
+ static readonly #oneEndpointFix =
18
+ "Make it one service method that runs the writes inside `this.transaction(fn)`, with a side effect such as a push command in `this.afterCommit(fn)`, behind one endpoint the action calls once; a write per item becomes one endpoint taking the list. An MCP agent or another screen calling one endpoint alone skips the rest.";
19
+ static readonly #fixes: { [key in FixKey]: string } = {
20
+ ".Template.tsx": MaintainabilityReport.#clientSplitFix,
21
+ ".Unit.tsx": MaintainabilityReport.#serverMarkupFix,
22
+ ".Util.tsx": MaintainabilityReport.#clientSplitFix,
23
+ ".View.tsx": MaintainabilityReport.#serverMarkupFix,
24
+ ".Zone.tsx": MaintainabilityReport.#clientSplitFix,
25
+ "ui/": MaintainabilityReport.#clientSplitFix,
26
+ "webkit/": MaintainabilityReport.#clientSplitFix,
27
+ ".store.ts":
28
+ "Move business logic into the service behind an endpoint and derived values onto Light<Model>; an action is fetch, set, toast.",
29
+ ".document.ts":
30
+ "Move display and predicate logic onto Light<Model>, and split an unrelated concept into its own module.",
31
+ ".service.ts":
32
+ "Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
33
+ [AbstractDoc.suffix]:
34
+ "Trim the abstract to a title, one sentence naming what the module owns, and a `## Rules` list of the invariants the code cannot show.",
35
+ "akan.size.component": MaintainabilityReport.#clientSplitFix,
36
+ "akan.store.multi-write": MaintainabilityReport.#oneEndpointFix,
37
+ "akan.store.root-write-chain": `A store may call another through \`RootStore\`, but that does not make the writes one. ${MaintainabilityReport.#oneEndpointFix}`,
38
+ "akan.component.write-chain":
39
+ "Move the writes into one service method behind one endpoint, called by one store action the handler calls once; a component chaining `st.do` writes, or writing once per item, leaves an agent or another screen half-done.",
40
+ "akan.component.store-access": MaintainabilityReport.#clientSplitFix,
41
+ "akan.write.placement":
42
+ "A server write leaves the browser through one store action, started by one module component. Move a `fetch.<mutation>` into a store action, and hand `ui/` and `webkit/` code the write as an `onX` prop or hook argument from the module's `Zone` or `Util`: they stay free of any one model, and an agent or another screen reaches the write through the store.",
43
+ "akan.service.call-in-loop":
44
+ "Load the set once (`load<Model>Many(ids)`, one filter query) or write it once (one batch method), then loop in memory: a call per item is a round trip per item, so the endpoint slows as its input grows.",
45
+ "akan.baseline.growth":
46
+ "Restore the entry from the default branch, then split the file or move the writes instead: `akan lint` lowers an entry as the code shrinks and never raises one.",
47
+ };
48
+
49
+ static readonly #labels: { [key in FixKey]?: string } = {
50
+ "ui/": "ui/**",
51
+ "webkit/": "webkit/**",
52
+ "akan.size.component": "component or hook",
53
+ "akan.store.multi-write": "store action with several writes",
54
+ "akan.store.root-write-chain": "store method chaining other stores' writes",
55
+ "akan.component.write-chain": "handler with several writes",
56
+ "akan.component.store-access": "component or hook reaching the store often",
57
+ "akan.write.placement": "write sent outside a store action or module component",
58
+ "akan.service.call-in-loop": "service call per item",
59
+ "akan.baseline.growth": "baseline entry",
60
+ };
61
+
62
+ static readonly #units: { [rule in DebtRuleId]: string } = {
63
+ "akan.size.file": "lines",
64
+ "akan.size.component": "lines",
65
+ "akan.component.store-access": "store accesses",
66
+ "akan.store.multi-write": "writes",
67
+ "akan.store.root-write-chain": "writes",
68
+ "akan.component.write-chain": "writes",
69
+ "akan.write.placement": "writes",
70
+ "akan.service.call-in-loop": "calls per item",
71
+ };
72
+
73
+ static describe(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
74
+ const groups = new Map<string, { labels: string[]; rules: Set<MaintainabilityRuleId> }>();
75
+ for (const failure of failures) {
76
+ const fixKey = MaintainabilityReport.#fixKeyOf(failure);
77
+ const fix = MaintainabilityReport.#fixes[fixKey];
78
+ const group = groups.get(fix) ?? { labels: [], rules: new Set() };
79
+ const label = MaintainabilityReport.#labelOf(fixKey);
80
+ if (!group.labels.includes(label)) group.labels.push(label);
81
+ group.rules.add(failure.rule);
82
+ groups.set(fix, group);
83
+ }
84
+ return [
85
+ `[maintainability] ${failures.length} violation(s) in ${sysPath}:`,
86
+ ...failures.map(
87
+ (failure) =>
88
+ ` ${sysPath}/${failure.file}:${failure.line} ${failure.rule} ${MaintainabilityReport.#problemOf(failure)}`,
89
+ ),
90
+ "How to fix:",
91
+ ...[...groups].flatMap(([fix, { labels, rules }]) => [
92
+ ` ${labels.join(", ")}: ${fix}`,
93
+ ...[...new Set([...rules].flatMap((rule) => commands[rule] ?? []))].map((command) => ` Path: ${command}`),
94
+ ]),
95
+ ].join("\n");
96
+ }
97
+
98
+ static entriesOf(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
99
+ return failures.map((failure) => ({
100
+ file: `${sysPath}/${failure.file}`,
101
+ line: failure.line,
102
+ rule: failure.rule,
103
+ problem: MaintainabilityReport.#problemOf(failure),
104
+ fix: MaintainabilityReport.#fixes[MaintainabilityReport.#fixKeyOf(failure)],
105
+ path: commands[failure.rule]?.[0] ?? null,
106
+ }));
107
+ }
108
+
109
+ static #problemOf(failure: MaintainabilityFailure) {
110
+ if (failure.kind === "baselineGrowth") {
111
+ const entry = `entry "${failure.key}" under ${failure.debtRule}`;
112
+ return failure.committed === undefined
113
+ ? `${entry} is not in the baseline at ${failure.reference}, which may only shrink`
114
+ : `${entry} allows ${failure.count} ${MaintainabilityReport.#units[failure.debtRule]}, above the ${failure.committed} at ${failure.reference}; the baseline may only shrink`;
115
+ }
116
+ const grown = failure.recorded === undefined ? "" : `, more than the ${failure.recorded} recorded`;
117
+ if (failure.kind === "multiWrite") {
118
+ const writes = failure.writes.some((write) => write.endsWith("(per item)"))
119
+ ? "a write per item"
120
+ : `${failure.writes.length} writes`;
121
+ const listed = `${failure.writes.join(", ")}${grown}`;
122
+ if (failure.rule === "akan.component.write-chain")
123
+ return `${MaintainabilityReport.#unitOf(failure.owner)} ${failure.owner} sends ${writes} from one handler: ${listed}`;
124
+ if (failure.rule === "akan.store.root-write-chain")
125
+ return `store method ${failure.owner} chains ${writes} through other stores from the browser: ${listed}`;
126
+ return `store action ${failure.owner} sends ${writes} from the browser: ${listed}`;
127
+ }
128
+ if (failure.kind === "writePlacement")
129
+ return `sends ${failure.writes.length} server write(s) from outside a store action or a module component: ${failure.writes.join(", ")}${grown}`;
130
+ if (failure.kind === "loopCall")
131
+ return `service method ${failure.owner} calls once per item: ${failure.calls.join(", ")}${grown}`;
132
+ if (failure.kind === "storeAccess")
133
+ return `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} reaches the store ${failure.accessCount} times, over the ${failure.maxAccesses}-access limit${failure.recorded === undefined ? "" : `, up from ${failure.recorded}`}`;
134
+ const subject = failure.component
135
+ ? `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} has`
136
+ : "file has";
137
+ const up = failure.recorded === undefined ? "" : `, up from ${failure.recorded}`;
138
+ return `${subject} ${failure.lineCount} lines, over the ${failure.maxLines}-line limit${up}`;
139
+ }
140
+
141
+ static #unitOf(name: string) {
142
+ return /^use[A-Z]/.test(name) ? "hook" : "component";
143
+ }
144
+
145
+ static #fixKeyOf(failure: MaintainabilityFailure): FixKey {
146
+ if (failure.rule !== "akan.size.file") return failure.rule;
147
+ return MaintainabilityLimits.fileKindOf(failure.file) ?? "akan.size.component";
148
+ }
149
+
150
+ static #labelOf(fixKey: FixKey) {
151
+ return MaintainabilityReport.#labels[fixKey] ?? `*${fixKey}`;
152
+ }
153
+ }
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createHash, generateKeyPairSync, sign } from "node:crypto";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import * as nativeApi from "akanjs/native/api";
5
6
  import { AkanAppConfig } from "../akanConfig";
6
7
  import { tempDirs } from "../testHelpers";
7
8
  import type { DesktopArch } from "./desktopBin";
@@ -13,12 +14,18 @@ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes
13
14
 
14
15
  const keyOf = () => {
15
16
  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
17
+ const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
18
+ const signature = (bytes: string) => sign(null, Buffer.from(bytes), privateKey).toString("base64");
16
19
  return {
17
- publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64"),
18
- sign: (bytes: string) => `${sign(null, Buffer.from(bytes), privateKey).toString("base64")}\n`,
20
+ publicKey: raw,
21
+ keyId: nativeApi.keyIdOf(raw),
22
+ sign: (bytes: string) => `${signature(bytes)}\n`,
23
+ seal: (bytes: string) =>
24
+ `${JSON.stringify({ manifest: Buffer.from(bytes).toString("base64"), keyId: nativeApi.keyIdOf(raw), signature: signature(bytes) }, null, 2)}\n`,
19
25
  };
20
26
  };
21
27
  const key = keyOf();
28
+ const spare = keyOf();
22
29
  const appId = "com.akancad.app";
23
30
 
24
31
  interface Published {
@@ -35,12 +42,18 @@ const publish = async ({
35
42
  channel = "main",
36
43
  app = platform === "macos" ? "Akancad.app" : "akancad",
37
44
  gz = "the release's gzip tar",
45
+ target = "default",
46
+ signer = key,
47
+ pair = "same",
38
48
  }: {
39
49
  platform?: DesktopPlatform;
40
50
  arch?: DesktopArch;
41
51
  channel?: string;
42
52
  app?: string;
43
53
  gz?: string;
54
+ target?: string;
55
+ signer?: ReturnType<typeof keyOf>;
56
+ pair?: "same" | "earlier" | "none";
44
57
  } = {}) => {
45
58
  const updatesDir = await makeDir();
46
59
  const dir = path.join(updatesDir, `${platform}-${arch}`);
@@ -68,9 +81,17 @@ const publish = async ({
68
81
  patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
69
82
  server: true,
70
83
  };
71
- const bytes = `${JSON.stringify(manifest, null, 2)}\n`;
72
- await writeFile(path.join(dir, `${channel}.json`), bytes);
73
- await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
84
+ await mkdir(path.join(dir, channel), { recursive: true });
85
+ await writeFile(
86
+ path.join(dir, channel, "app.json"),
87
+ signer.seal(`${JSON.stringify({ ...manifest, schema: 2, target }, null, 2)}\n`),
88
+ );
89
+ if (pair !== "none") {
90
+ const earlier = { ...manifest, sequence: manifest.sequence - 100, bundle: "1.1.0-1761699900" };
91
+ const bytes = `${JSON.stringify(pair === "same" ? manifest : earlier, null, 2)}\n`;
92
+ await writeFile(path.join(dir, `${channel}.json`), bytes);
93
+ await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
94
+ }
74
95
  return { updatesDir, dir, tarSha, delta, build } satisfies Published;
75
96
  };
76
97
 
@@ -80,10 +101,30 @@ const read = (
80
101
  platform = "macos",
81
102
  arch = "arm64",
82
103
  channel = "main",
83
- publicKey = key.publicKey,
104
+ publicKeys = [key.publicKey, spare.publicKey],
105
+ revokedKeys = [],
106
+ target = "default",
84
107
  id = appId,
85
- }: { platform?: DesktopPlatform; arch?: DesktopArch; channel?: string; publicKey?: string; id?: string } = {},
86
- ) => DesktopRelease.read(updatesDir, { platform, arch, channel, appId: id, publicKey });
108
+ }: {
109
+ platform?: DesktopPlatform;
110
+ arch?: DesktopArch;
111
+ channel?: string;
112
+ publicKeys?: string[];
113
+ revokedKeys?: string[];
114
+ target?: string;
115
+ id?: string;
116
+ } = {},
117
+ ) =>
118
+ DesktopRelease.read(updatesDir, {
119
+ platform,
120
+ arch,
121
+ channel,
122
+ appId: id,
123
+ target,
124
+ publicKeys,
125
+ revokedKeys,
126
+ api: nativeApi,
127
+ });
87
128
 
88
129
  const entriesOf = (archive: string) =>
89
130
  Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
@@ -122,7 +163,8 @@ describe("DesktopRelease", () => {
122
163
  const published = await publish();
123
164
  const { dir, tarSha, delta } = published;
124
165
  await publish({ channel: "develop" }).then(async (other) => {
125
- for (const name of ["develop.json", "develop.json.sig"])
166
+ await mkdir(path.join(dir, "develop"), { recursive: true });
167
+ for (const name of ["develop.json", "develop.json.sig", "develop/app.json"])
126
168
  await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
127
169
  });
128
170
  await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
@@ -133,6 +175,7 @@ describe("DesktopRelease", () => {
133
175
 
134
176
  expect(entries).toEqual([
135
177
  "./release.json",
178
+ "./main/app.json",
136
179
  "./main.json",
137
180
  "./main.json.sig",
138
181
  `./app/${tarSha}.tar.gz`,
@@ -152,24 +195,24 @@ describe("DesktopRelease", () => {
152
195
  expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
153
196
  expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
154
197
  const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
155
- expect(files.slice(0, 3)).toEqual(["./release.json", "./main.json", "./main.json.sig"]);
156
- expect(files.slice(3).every((name) => name.startsWith("./app/"))).toBe(true);
157
- expect(files.slice(3)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
198
+ expect(files.slice(0, 4)).toEqual(["./release.json", "./main/app.json", "./main.json", "./main.json.sig"]);
199
+ expect(files.slice(4).every((name) => name.startsWith("./app/"))).toBe(true);
200
+ expect(files.slice(4)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
158
201
  expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
159
202
  });
160
203
 
161
- test("the manifest and its signature go in byte for byte, beside a release.json of the CLI and the build time", async () => {
204
+ test("the envelope and the v1 pair go in byte for byte, beside a release.json of the CLI and the build time", async () => {
162
205
  const published = await publish();
163
206
  const release = await read(published);
164
207
  const record = release.record({ teamId: "ABCDE12345" });
165
208
 
166
209
  const out = await release.withArchive(record, extract);
167
210
 
168
- for (const name of ["main.json", "main.json.sig"])
211
+ for (const name of ["main/app.json", "main.json", "main.json.sig"])
169
212
  expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
170
213
  expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
171
214
  expect(record).toEqual({
172
- schema: 1,
215
+ schema: 2,
173
216
  cli: AkanAppConfig.akanVersion,
174
217
  platform: "macos",
175
218
  arch: "arm64",
@@ -192,22 +235,50 @@ describe("DesktopRelease", () => {
192
235
  expect(await Bun.file(kept).exists()).toBe(false);
193
236
  });
194
237
 
195
- test("refuses a manifest the target's first key did not sign, before reading anything it names", async () => {
238
+ test("refuses an envelope no key of the ring signs, or a revoked one, before reading anything it names", async () => {
196
239
  const published = await publish();
240
+ const envelope = path.join(published.dir, "main", "app.json");
241
+ const untrusted = `${envelope}: the update envelope is signed by key ${key.keyId}, which is not trusted; publish the release again.`;
197
242
 
198
- await expect(read(published, { publicKey: keyOf().publicKey })).rejects.toThrow(
199
- "main.json.sig does not verify with updates.publicKeys[0], the one key installed apps trust",
243
+ await expect(read(published, { publicKeys: [keyOf().publicKey] })).rejects.toThrow(untrusted);
244
+ await expect(read(published, { revokedKeys: [key.keyId] })).rejects.toThrow(untrusted);
245
+ });
246
+
247
+ test("takes a release a later key signed, and leaves out a v1 pair that is not that release", async () => {
248
+ const entriesFor = async (published: Published) => {
249
+ const release = await read(published);
250
+ return await release.withArchive(release.record(null), async (archive) => entriesOf(archive).slice(0, 4));
251
+ };
252
+ const tail = (published: Published) => `./app/${published.tarSha}.tar.gz`;
253
+
254
+ const bySpare = await publish({ signer: spare, pair: "earlier" });
255
+ expect(await entriesFor(bySpare)).toEqual([
256
+ "./release.json",
257
+ "./main/app.json",
258
+ tail(bySpare),
259
+ `./app/${bySpare.delta}`,
260
+ ]);
261
+ const unpaired = await publish({ pair: "none" });
262
+ expect((await entriesFor(unpaired)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(unpaired)]);
263
+ const forged = await publish();
264
+ await writeFile(
265
+ path.join(forged.dir, "main.json.sig"),
266
+ spare.sign(await readFile(path.join(forged.dir, "main.json"), "utf8")),
200
267
  );
268
+ expect((await entriesFor(forged)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(forged)]);
201
269
  });
202
270
 
203
271
  test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
204
272
  const published = await publish();
205
273
 
206
274
  await expect(read(published, { id: "com.other" })).rejects.toThrow(
207
- "is not a release of com.other for macos-arm64 on channel main.",
275
+ "is not a release of com.other target default for macos-arm64 on channel main.",
276
+ );
277
+ await expect(read(published, { target: "pilot" })).rejects.toThrow(
278
+ `is not a release of ${appId} target pilot for macos-arm64 on channel main.`,
208
279
  );
209
280
  await expect(read(published, { channel: "develop" })).rejects.toThrow(
210
- `${path.join(published.dir, "develop.json")} is missing: run \`akan publish-update\` first.`,
281
+ `${path.join(published.dir, "develop", "app.json")} is missing: run \`akan publish-update\` first.`,
211
282
  );
212
283
  await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
213
284
  });
@@ -219,9 +290,9 @@ describe("DesktopRelease", () => {
219
290
  const send = () => release.withArchive(release.record(null), async () => "sent");
220
291
 
221
292
  await writeFile(archive, "the release's gzip tar!");
222
- await expect(send()).rejects.toThrow(`${archive} is not the file main.json names: publish the release again.`);
293
+ await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names: publish the release again.`);
223
294
  await writeFile(archive, "the release's gzip taR");
224
- await expect(send()).rejects.toThrow(`${archive} is not the file main.json names (its sha256 differs).`);
295
+ await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names (its sha256 differs).`);
225
296
  });
226
297
  });
227
298
 
@@ -310,7 +381,7 @@ describe("DesktopRelease signer", () => {
310
381
 
311
382
  const windowsTool = (powershell: (out: string) => Promise<number>) =>
312
383
  recordingTool(async (cmd, env) => {
313
- if (cmd[0] === "tar.exe") {
384
+ if (cmd[0] === path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe")) {
314
385
  await Bun.write(path.join(extractedTo(cmd), cmd.at(-1) ?? ""), "MZ");
315
386
  return { code: 0, output: "" };
316
387
  }