@akanjs/devkit 3.1.0-rc.4 → 3.1.0-rc.5

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 (81) hide show
  1. package/abstractDoc.ts +1 -1
  2. package/agentsIndex.test.ts +4 -1
  3. package/akanConfig/akanConfig.ts +2 -2
  4. package/akanContext.ts +4 -4
  5. package/akanMcpContract.ts +1 -1
  6. package/applicationBuildRunner.test.ts +24 -1
  7. package/applicationBuildRunner.ts +11 -1
  8. package/biome.base.json +0 -10
  9. package/cloud/configLock.ts +2 -1
  10. package/cloud/globalConfig.ts +3 -2
  11. package/codeAgent/agent/CodeAgentProxy.ts +2 -2
  12. package/codeAgent/agent/akanCodeModel.ts +1 -1
  13. package/codeAgent/agent/akanContextFiles.test.ts +3 -1
  14. package/codeAgent/tools/WebToolPack.ts +4 -4
  15. package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
  16. package/commandDecorators/command.ts +5 -4
  17. package/createTunnel.test.ts +14 -0
  18. package/createTunnel.ts +3 -1
  19. package/dependencyScanner.ts +1 -1
  20. package/devkitUtils.test.ts +1 -1
  21. package/executors.test.ts +62 -3
  22. package/executors.ts +26 -9
  23. package/frontendBuild/autoImportSync.test.ts +3 -1
  24. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  25. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  26. package/frontendBuild/csrArtifactBuilder.ts +5 -3
  27. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  28. package/frontendBuild/cssBlockParser.test.ts +123 -0
  29. package/frontendBuild/cssBlockParser.ts +132 -0
  30. package/frontendBuild/cssCompiler.test.ts +53 -0
  31. package/frontendBuild/cssCompiler.ts +8 -4
  32. package/frontendBuild/fontOptimizer.ts +2 -1
  33. package/frontendBuild/frontendBuild.test.ts +14 -0
  34. package/frontendBuild/hmrWatcher.ts +1 -1
  35. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  36. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  37. package/frontendBuild/routeClientBuilder.ts +1 -1
  38. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  39. package/frontendBuild/themeValidator.test.ts +83 -2
  40. package/frontendBuild/themeValidator.ts +37 -15
  41. package/getRelatedCnsts.ts +2 -1
  42. package/integration/devStabilityHarness.ts +5 -3
  43. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  44. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  45. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  46. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  47. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  48. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  49. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  50. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  51. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  52. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  53. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  54. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  55. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  56. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  57. package/mobile/mobileTarget.ts +1 -2
  58. package/mobile/nativeConfig.test.ts +3 -0
  59. package/mobile/nativeConfig.ts +3 -2
  60. package/mobile/nativeWebDir.ts +3 -2
  61. package/mobile/updateUploader.test.ts +1 -0
  62. package/mobile/updateUploader.ts +3 -3
  63. package/package.json +2 -2
  64. package/platformTest/LinuxDockerTarget.ts +2 -1
  65. package/platformTest/PlatformTestTarget.ts +7 -5
  66. package/platformTest/WindowsSshTarget.ts +2 -1
  67. package/qualityScanner.ts +1 -1
  68. package/recipeScanner.test.ts +9 -3
  69. package/scanInfo.ts +1 -1
  70. package/subspaceClone.ts +5 -4
  71. package/testHelpers.ts +2 -0
  72. package/transforms/barrelImportsPlugin.ts +1 -1
  73. package/transforms/browserDirnamePlugin.test.ts +88 -0
  74. package/transforms/browserDirnamePlugin.ts +25 -0
  75. package/transforms/rscUseClientTransform.ts +2 -2
  76. package/transforms/transforms.test.ts +18 -2
  77. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  78. package/types.ts +1 -0
  79. package/workflow/artifacts.ts +1 -1
  80. package/workflow/rolloutGate.test.ts +5 -1
  81. package/workflow/source.ts +2 -2
@@ -37,6 +37,67 @@ describe("ThemeValidator.parseThemeTokens", () => {
37
37
  const tokens = ThemeValidator.parseThemeTokens(css);
38
38
  expect(Object.keys(tokens)).toEqual([":root"]);
39
39
  });
40
+
41
+ test("reads every block, not every other one", () => {
42
+ const css = `.a {} :root { --background: #fff; } .b {} [data-theme='light'] { --background: #eee; } [data-theme="dark"] { --background: #111; }`;
43
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({
44
+ ":root": { background: "#fff" },
45
+ '[data-theme="light"]': { background: "#eee" },
46
+ '[data-theme="dark"]': { background: "#111" },
47
+ });
48
+ });
49
+
50
+ test("reads the first block after @source and @import lines", () => {
51
+ const css = [
52
+ `@source "./**/*";`,
53
+ `@import "tailwindcss";`,
54
+ `@plugin "tailwind-scrollbar";`,
55
+ `:root { --background: #ffffff; --foreground: #0a0a0a; }`,
56
+ `[data-theme="dark"] { --background: #0a0a0a; --foreground: #fafafa; }`,
57
+ `[data-theme="light"] { --background: #fafafa; --foreground: #171717; }`,
58
+ ].join("\n");
59
+ expect(Object.keys(ThemeValidator.parseThemeTokens(css))).toEqual([
60
+ ":root",
61
+ '[data-theme="dark"]',
62
+ '[data-theme="light"]',
63
+ ]);
64
+ });
65
+
66
+ test("keeps a block's own declarations, skipping comments and nested rules", () => {
67
+ const css = `:root { /* --background: #000; */ --background: #fff; .x { --foreground: #000; } --foreground: #111 }`;
68
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({ ":root": { background: "#fff", foreground: "#111" } });
69
+ });
70
+
71
+ test("merges a scope declared twice, the later value winning", () => {
72
+ const css = `:root { --background: #fff; --foreground: #000; } :root { --foreground: #111; }`;
73
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({ ":root": { background: "#fff", foreground: "#111" } });
74
+ });
75
+
76
+ test("reads through @layer and keys a conditional scope by its condition", () => {
77
+ const css = [
78
+ `@layer base { :root { --background: #fff; --foreground: #000; } }`,
79
+ `@media (prefers-color-scheme: dark) { :root { --background: #000; } }`,
80
+ ].join("\n");
81
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({
82
+ ":root": { background: "#fff", foreground: "#000" },
83
+ "@media (prefers-color-scheme: dark) :root": { background: "#000" },
84
+ });
85
+ });
86
+
87
+ test("adversarial input finishes well under a second", () => {
88
+ for (const css of [
89
+ " ".repeat(100_000),
90
+ `}${" ".repeat(100_000)}`,
91
+ `:root{${"--".repeat(100_000)}`,
92
+ `:root{---:${" ".repeat(100_000)}`,
93
+ ":root{--background:#fff}".repeat(100_000),
94
+ `${"@media x{".repeat(100_000)}:root{--background:#fff}`,
95
+ ]) {
96
+ const started = performance.now();
97
+ ThemeValidator.parseThemeTokens(css);
98
+ expect(performance.now() - started).toBeLessThan(1000);
99
+ }
100
+ });
40
101
  });
41
102
 
42
103
  describe("ThemeValidator.validate", () => {
@@ -58,12 +119,32 @@ describe("ThemeValidator.validate", () => {
58
119
  const css = `:root { --primary: var(--brand); --primary-foreground: #000000; }`;
59
120
  expect(validator.validate(css)).toHaveLength(0);
60
121
  });
122
+
123
+ test("checks a conditional scope over the scope it overrides", () => {
124
+ const css = [
125
+ `:root { --background: #ffffff; --foreground: #000000; }`,
126
+ `@media (prefers-color-scheme: dark) { :root { --background: #111111; } }`,
127
+ ].join("\n");
128
+ const violations = validator.validate(css);
129
+ expect(violations.map(({ scope, pair }) => [scope, pair])).toEqual([
130
+ ["@media (prefers-color-scheme: dark) :root", "background / foreground"],
131
+ ]);
132
+ });
61
133
  });
62
134
 
63
135
  describe("shipped framework palette", () => {
136
+ const css = readFileSync(path.resolve(import.meta.dir, "../../../akanjs/ui/styles.css"), "utf8");
137
+
138
+ test("pkgs/akanjs/ui/styles.css parses :root, dark and light", () => {
139
+ const tokens = ThemeValidator.parseThemeTokens(css);
140
+ expect(Object.keys(tokens)).toEqual([":root", '[data-theme="dark"]', '[data-theme="light"]']);
141
+ expect(tokens[":root"].background).toBe("#0a0a0a");
142
+ expect(tokens['[data-theme="dark"]'].background).toBe("#0a0a0a");
143
+ expect(tokens['[data-theme="light"]'].background).toBe("#ffffff");
144
+ for (const scopeTokens of Object.values(tokens)) expect(Object.keys(scopeTokens).length).toBeGreaterThanOrEqual(26);
145
+ });
146
+
64
147
  test("pkgs/akanjs/ui/styles.css passes WCAG contrast for all scopes", () => {
65
- const stylesPath = path.resolve(import.meta.dir, "../../../akanjs/ui/styles.css");
66
- const css = readFileSync(stylesPath, "utf8");
67
148
  const violations = validator.validate(css);
68
149
  expect(violations).toEqual([]);
69
150
  });
@@ -1,4 +1,5 @@
1
1
  // No akanjs runtime import: this runs inside the lint path.
2
+ import { type CssBlock, CssBlockParser } from "./cssBlockParser";
2
3
 
3
4
  export interface ThemeContrastViolation {
4
5
  scope: string;
@@ -34,6 +35,7 @@ const PAIRS: PairDef[] = [
34
35
  ];
35
36
 
36
37
  const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
38
+ const CONDITIONAL_AT_RULES = new Set(["@media", "@supports", "@container"]);
37
39
 
38
40
  export type ThemeTokensByScope = Record<string, Record<string, string>>;
39
41
 
@@ -42,8 +44,9 @@ export class ThemeValidator {
42
44
  const tokensByScope = ThemeValidator.parseThemeTokens(css);
43
45
  const violations: ThemeContrastViolation[] = [];
44
46
  for (const [scope, tokens] of Object.entries(tokensByScope)) {
45
- if (!THEME_SCOPES.has(scope)) continue;
46
- violations.push(...this.validateScope(tokens, scope));
47
+ const underlying = THEME_SCOPES.has(scope) ? null : [...THEME_SCOPES].find((name) => scope.endsWith(` ${name}`));
48
+ const effective = underlying ? ThemeValidator.#overlayPairs(tokensByScope[underlying] ?? {}, tokens) : tokens;
49
+ violations.push(...this.validateScope(effective, scope));
47
50
  }
48
51
  return violations;
49
52
  }
@@ -75,23 +78,42 @@ export class ThemeValidator {
75
78
  /** A scope declared twice keeps the later value, so pass the framework css before the app's. */
76
79
  static parseThemeTokens(css: string): ThemeTokensByScope {
77
80
  const result: ThemeTokensByScope = {};
78
- const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
79
- for (const block of css.matchAll(blockRe)) {
80
- const selectors = block[1].split(",").map((s) => s.trim());
81
- const relevant = selectors.filter((s) => THEME_SCOPES.has(ThemeValidator.#normalizeScope(s)));
82
- if (relevant.length === 0) continue;
83
- const decls: Record<string, string> = {};
84
- for (const decl of block[2].matchAll(/--([\w-]+)\s*:\s*([^;]+?)\s*(?:;|$)/g)) {
85
- decls[decl[1]] = decl[2].trim();
81
+ CssBlockParser.walk(CssBlockParser.parse(css), "", (node, condition) => {
82
+ if (node.kind !== "block") return null;
83
+ if (node.atRule === "@layer") return condition;
84
+ if (node.atRule) return CONDITIONAL_AT_RULES.has(node.atRule) ? node.prelude.replace(/\s+/g, " ") : null;
85
+ const scopes = node.prelude
86
+ .split(",")
87
+ .map((selector) => ThemeValidator.#normalizeScope(selector))
88
+ .filter((scope) => THEME_SCOPES.has(scope));
89
+ if (scopes.length === 0) return null;
90
+ const tokens = ThemeValidator.#customProperties(node);
91
+ for (const scope of scopes) {
92
+ const key = condition ? `${condition} ${scope}` : scope;
93
+ result[key] = Object.assign(result[key] ?? {}, tokens);
86
94
  }
87
- for (const selector of relevant) {
88
- const scope = ThemeValidator.#normalizeScope(selector);
89
- result[scope] = { ...(result[scope] ?? {}), ...decls };
90
- }
91
- }
95
+ return null;
96
+ });
92
97
  return result;
93
98
  }
94
99
 
100
+ static #customProperties(block: CssBlock): Record<string, string> {
101
+ const tokens: Record<string, string> = {};
102
+ for (const node of block.nodes) {
103
+ if (node.kind === "declaration" && node.property.startsWith("--")) tokens[node.property.slice(2)] = node.value;
104
+ }
105
+ return tokens;
106
+ }
107
+
108
+ static #overlayPairs(underlying: Record<string, string>, tokens: Record<string, string>): Record<string, string> {
109
+ const effective: Record<string, string> = {};
110
+ for (const name of PAIRS.flatMap(({ base, fg }) => [base, fg])) {
111
+ const value = tokens[name] ?? underlying[name];
112
+ if (value) effective[name] = value;
113
+ }
114
+ return effective;
115
+ }
116
+
95
117
  static #normalizeScope(selector: string): string {
96
118
  return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
97
119
  }
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import ora from "ora";
3
4
  import * as ts from "typescript";
4
5
 
@@ -45,7 +46,7 @@ export const parseTsConfig = (tsConfigPath: string = "./tsconfig.json") => {
45
46
  return ts.sys.readFile(path);
46
47
  });
47
48
 
48
- return ts.parseJsonConfigFileContent(configFile.config, ts.sys, realpathSync(tsConfigPath).replace(/[^/\\]+$/, ""));
49
+ return ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(realpathSync(tsConfigPath)));
49
50
  };
50
51
 
51
52
  const collectFiles = (constantFilePath: string, parsedConfig: ts.ParsedCommandLine, includeExports: boolean) => {
@@ -772,9 +772,11 @@ export const dictionary = serviceDictionary(["en", "ko"])
772
772
  }
773
773
 
774
774
  static async #psRowsOrThrow(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
775
- const rows = (await DevStabilityHarness.#psRows()) ?? (await DevStabilityHarness.#psRows());
776
- if (!rows) throw new Error("ps did not answer twice in a row; cannot measure the process tree");
777
- return rows;
775
+ for (let attempt = 0; attempt < 2; attempt += 1) {
776
+ const rows = await DevStabilityHarness.#psRows();
777
+ if (rows) return rows;
778
+ }
779
+ throw new Error("ps did not answer twice in a row; cannot measure the process tree");
778
780
  }
779
781
 
780
782
  static #collectDescendants(rows: Array<{ pid: number; ppid: number }>, rootPid: number): Set<number> {
@@ -1,3 +1,4 @@
1
1
  import { userConstant } from "@libs/shared/lib/user/user.constant"; // @flag
2
2
  import { helper } from "../../common/helper"; // @flag
3
3
  import * as cnst from "../cnst"; // @flag
4
+ export { cnst, helper, userConstant };
@@ -1,3 +1,4 @@
1
1
  import { cnst, st } from "@apps/demo/client"; // @ok
2
2
  import { Task } from "./task.constant"; // @ok
3
3
  import { env } from "@apps/demo/env/env.client"; // @ok
4
+ export { cnst, env, st, Task };
@@ -1,2 +1,3 @@
1
1
  import { useState } from "react"; // @flag
2
2
  import { st } from "../st"; // @flag
3
+ export { st, useState };
@@ -2,3 +2,4 @@ import type { ReactNode } from "react"; // @ok
2
2
  import { cnst } from "@libs/shared/client"; // @ok
3
3
  import { usePage } from "akanjs/client"; // @ok
4
4
  export const Card = ({ children }: { children: ReactNode }) => <div>{children}</div>; // @ok
5
+ export { cnst, usePage };
@@ -4,3 +4,4 @@ import { Button } from "@libs/shared/ui"; // @flag
4
4
  import { useSpeech } from "@libs/util/webkit"; // @flag
5
5
  import { cn } from "akanjs/client"; // @flag
6
6
  import { st } from "../st"; // @flag
7
+ export { Button, Card, cn, st, TaskStore, useSpeech };
@@ -3,3 +3,4 @@ import * as cnst from "../cnst"; // @ok
3
3
  import * as db from "../db"; // @ok
4
4
  import { Err } from "../dict"; // @ok
5
5
  import { Admin } from "@libs/shared/srvkit"; // @ok
6
+ export { Admin, cnst, db, Err };
@@ -2,3 +2,4 @@ import dayjs from "dayjs"; // @flag
2
2
  import { z } from "zod"; // @flag
3
3
  import fs from "node:fs"; // @flag
4
4
  import { clsx } from "clsx"; // @flag
5
+ export { clsx, dayjs, fs, z };
@@ -3,3 +3,4 @@ import { cnst } from "@libs/shared/client"; // @ok
3
3
  import { field } from "akanjs/constant"; // @ok
4
4
  import { useState } from "react"; // @ok
5
5
  import { test } from "bun:test"; // @ok
6
+ export { cnst, field, test, Ticket, useState };
@@ -4,3 +4,4 @@ import fs from "node:fs"; // @ok
4
4
  import { Database } from "bun:sqlite"; // @ok
5
5
  import { describe } from "bun:test"; // @ok
6
6
  import { cnst } from "../cnst"; // @ok
7
+ export { cnst, Database, dayjs, describe, fs, z };
@@ -4,3 +4,4 @@ import { tmpdir } from "node:os"; // @ok
4
4
  import { Database } from "bun:sqlite"; // @ok
5
5
  import { describe, expect, test } from "bun:test"; // @ok
6
6
  import { fetch } from "../sig"; // @ok
7
+ export { createServer, Database, describe, expect, fetch, mkdtemp, test, tmpdir };
@@ -4,3 +4,4 @@ import { Admin } from "@libs/shared/srvkit"; // @flag
4
4
  import { AkanServer } from "akanjs/server"; // @flag
5
5
  import * as db from "../db"; // @flag
6
6
  import { Err } from "../dict"; // @flag
7
+ export { AkanServer, Admin, db, Err, TaskModel, TaskService };
@@ -2,3 +2,4 @@ import type { TaskService } from "./task.service"; // @ok
2
2
  import * as cnst from "../cnst"; // @ok
3
3
  import { st } from "../st"; // @ok
4
4
  import { Err, Task } from "@libs/shared/client"; // @ok
5
+ export { cnst, Err, st, Task };
@@ -1 +1 @@
1
- export class TaskStore { async reload(orgId: string) { const snapshot = await fetch.initTaskInOrg(orgId); } } // @flag
1
+ export class TaskStore { async reload(orgId: string) { await fetch.initTaskInOrg(orgId); } } // @flag
@@ -1,4 +1,4 @@
1
- import { enumOf, ID, Int } from "akanjs/base";
1
+ import { ID, Int } from "akanjs/base";
2
2
  import { via } from "akanjs/constant";
3
3
 
4
4
  export class QuizInput extends via((field) => ({
@@ -12,7 +12,7 @@ export class QuizObject extends via(QuizInput, (field) => ({
12
12
  static readonly minNum = 1; // @flag
13
13
  private static secretNum = 2; // @flag
14
14
  static { // @flag
15
- QuizObject.maxNum = 5;
15
+ QuizObject.maxNum += 1;
16
16
  }
17
17
  }
18
18
 
@@ -1,4 +1,5 @@
1
1
  import type { AkanNativeAppId } from "akanjs";
2
+ import { trimSlashes } from "akanjs/common";
2
3
  import type { AkanAppConfig, AkanNativeTarget, NativeEnv } from "../akanConfig";
3
4
  import type { App } from "../commandDecorators";
4
5
 
@@ -14,8 +15,6 @@ export interface ResolvedMobileTarget {
14
15
 
15
16
  export const MOBILE_ENVS = ["local", "debug", "develop", "main"] as const satisfies readonly NativeEnv[];
16
17
 
17
- const trimSlashes = (value: string) => value.replace(/^\/+|\/+$/g, "");
18
-
19
18
  export const getMobileTargets = async (app: App): Promise<ResolvedMobileTarget[]> => {
20
19
  const config = await app.getConfig();
21
20
  return Object.entries(config.native.targets).map(([name, target]) => ({ name, config: target }));
@@ -489,6 +489,9 @@ describe("NativeConfig.build", () => {
489
489
  expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
490
490
  expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
491
491
  expect(NativeConfig.fileNameOf("...")).toBe("app");
492
+ expect(NativeConfig.fileNameOf(".-my app!-.")).toBe("my-app");
493
+ expect(NativeConfig.fileNameOf("-a-")).toBe("a");
494
+ expect(NativeConfig.fileNameOf(`a${"-".repeat(50_000)}b`)).toHaveLength(50_002);
492
495
  });
493
496
  });
494
497
 
@@ -9,6 +9,7 @@ import type {
9
9
  NativeEnv,
10
10
  NativePermission,
11
11
  } from "akanjs";
12
+ import { trimSlashes } from "akanjs/common";
12
13
  import type { DesktopServerBundle } from "./desktopServerStage";
13
14
  import { type NativePlatform, resolveAppId } from "./mobileTarget";
14
15
  import type { NativePluginFolder } from "./nativePluginFolders";
@@ -271,7 +272,7 @@ export class NativeConfig {
271
272
 
272
273
  /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
273
274
  static fileNameOf(name: string) {
274
- return name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "app";
275
+ return /[^-.](?:.*[^-.])?/.exec(name.replace(/[^A-Za-z0-9._-]/g, "-"))?.[0] ?? "app";
275
276
  }
276
277
 
277
278
  //? A permission names a plugin by its id, and a plugin given by folder is named in its manifest; a spec the runtime
@@ -311,7 +312,7 @@ export class NativeConfig {
311
312
 
312
313
  //* Every route sits under /:lang, so an app link limited to a basePath lists it once per locale.
313
314
  static #deepLinks(target: AkanNativeTarget, locales: readonly string[]) {
314
- const basePath = target.basePath?.replace(/^\/+|\/+$/g, "");
315
+ const basePath = target.basePath ? trimSlashes(target.basePath) : "";
315
316
  const pathPrefixes = basePath ? locales.map((locale) => `/${locale}/${basePath}`) : undefined;
316
317
  return {
317
318
  ...(target.deepLinks?.schemes?.length ? { schemes: target.deepLinks.schemes } : {}),
@@ -1,6 +1,7 @@
1
1
  import { cp, mkdir, rm, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { AkanNativeTarget } from "akanjs";
4
+ import { trimSlashes } from "akanjs/common";
4
5
 
5
6
  export interface NativeWebDirSources {
6
7
  /** The target's CSR bundle: one HTML file with its scripts and styles inlined. */
@@ -21,13 +22,13 @@ export class NativeWebDir {
21
22
  //* it goes first in <head>, ahead of every script, because the page reads it as it boots.
22
23
  static injectTarget(html: string, target: Pick<AkanNativeTarget, "name" | "basePath" | "indexPath">) {
23
24
  if (html.includes(NativeWebDir.#targetTag)) return html;
24
- const basePath = target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
25
+ const basePath = target.basePath ? trimSlashes(target.basePath) : "";
25
26
  const script = `${NativeWebDir.#targetTag}${JSON.stringify({
26
27
  name: target.name,
27
28
  basePath,
28
29
  indexPath: target.indexPath,
29
30
  })};</script>`;
30
- const head = /<head(?:\s[^>]*)?>/i.exec(html);
31
+ const head = /<head(?:\s[^<>]*)?>/i.exec(html);
31
32
  if (!head) return `${script}\n${html}`;
32
33
  const at = head.index + head[0].length;
33
34
  return `${html.slice(0, at)}\n${script}${html.slice(at)}`;
@@ -45,6 +45,7 @@ describe("UpdateUploader", () => {
45
45
  prefix: "acme/kiosk",
46
46
  });
47
47
  expect(UpdateUploader.parseTarget("s3://releases")).toEqual({ bucket: "releases", prefix: "" });
48
+ expect(UpdateUploader.parseTarget("s3://releases//acme//")).toEqual({ bucket: "releases", prefix: "/acme" });
48
49
  expect(() => UpdateUploader.parseTarget("https://releases.example/acme")).toThrow("s3://<bucket>/<prefix>");
49
50
  });
50
51
 
@@ -1,7 +1,7 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
4
- import { isRecord } from "akanjs/common";
4
+ import { isRecord, trimTrailingSlashes } from "akanjs/common";
5
5
  import { UpdatePackFolder } from "./updatePackFolder";
6
6
 
7
7
  /** What the uploader needs of an S3 client; Bun.S3Client is one. */
@@ -32,9 +32,9 @@ export class UpdateUploader {
32
32
 
33
33
  /** `s3://bucket/prefix`: the credentials and endpoint come from S3_* (or AWS_*) in the environment. */
34
34
  static parseTarget(target: string): { bucket: string; prefix: string } {
35
- const match = /^s3:\/\/([^/]+)\/?(.*)$/.exec(target.trim());
35
+ const match = /^s3:\/\/([^/]+)(?:\/(.*))?$/.exec(target.trim());
36
36
  if (!match?.[1]) throw new Error(`--upload takes s3://<bucket>/<prefix>, not ${JSON.stringify(target)}.`);
37
- return { bucket: match[1], prefix: (match[2] ?? "").replace(/\/+$/, "") };
37
+ return { bucket: match[1], prefix: trimTrailingSlashes(match[2] ?? "") };
38
38
  }
39
39
 
40
40
  static order(files: string[]): { releases: string[]; manifests: string[] } {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.4",
3
+ "version": "3.1.0-rc.5",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "0.86.1",
49
49
  "@inquirer/prompts": "^8.4.3",
50
50
  "@tailwindcss/node": "^4.3.0",
51
- "akanjs": "3.1.0-rc.4",
51
+ "akanjs": "3.1.0-rc.5",
52
52
  "chalk": "^5.6.2",
53
53
  "commander": "^14.0.3",
54
54
  "dayjs": "^1.11.20",
@@ -19,9 +19,10 @@ export class LinuxDockerTarget extends PlatformTestTarget {
19
19
  }
20
20
 
21
21
  //* No compiler in the image: with one, ssh2 builds its native addon and Bun panics loading it (oven-sh/bun#18546).
22
+ //* apt goes over https: a captive portal answers plain http with its splash page, which apt reports as unsigned.
22
23
  static readonly dockerfile = [
23
24
  `FROM oven/bun:${Bun.version}-slim`,
24
- "RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
25
+ "RUN sed -i 's|http://|https://|g' /etc/apt/sources.list.d/debian.sources && apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
25
26
  ].join("\n");
26
27
 
27
28
  get image() {
@@ -108,14 +108,16 @@ export abstract class PlatformTestTarget {
108
108
  const logPath = this.#logPath(context, "install");
109
109
  const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
110
110
  if (install.exitCode === 0) return;
111
- const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map(
112
- (match) => match[1] ?? "",
113
- );
111
+ const failedPackages = [
112
+ ...install.output.matchAll(
113
+ /(?:pre|post)?install script from "([^"]+)" exited|failed to link binaries for package: (@?[^@\s]+)@/g,
114
+ ),
115
+ ].map((match) => match[1] ?? match[2] ?? "");
114
116
  const tolerable =
115
- failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
117
+ failedPackages.length > 0 && failedPackages.every((pkg) => this.tolerateScriptFailures.includes(pkg));
116
118
  if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
117
119
  //* A failed lifecycle script makes bun skip linking the root `.bin`; a second pass without scripts links it.
118
- envWarnings.push(`install script failed for ${failedScripts.join(", ")} — tolerated on ${this.platform}`);
120
+ envWarnings.push(`install failed for ${failedPackages.join(", ")} — tolerated on ${this.platform}`);
119
121
  const relink = await this.exec(
120
122
  "bun install --frozen-lockfile --ignore-scripts",
121
123
  this.#logPath(context, "install-relink"),
@@ -9,7 +9,8 @@ export class WindowsSshTarget extends PlatformTestTarget {
9
9
  static readonly utmctlPath = "/Applications/UTM.app/Contents/MacOS/utmctl";
10
10
  static readonly defaultWorkRoot = "C:\\akan-test";
11
11
  //* arm64 has neither a sharp@0.32 prebuilt nor a Chrome for puppeteer's postinstall; x64 users get both.
12
- static readonly defaultTolerateScriptFailures = ["sharp", "puppeteer"];
12
+ //* Nor a msgpackr-extract prebuilt: bun deletes it when node-gyp fails, racing the link of msgpackr's bins.
13
+ static readonly defaultTolerateScriptFailures = ["sharp", "puppeteer", "msgpackr"];
13
14
  static readonly #runDirPattern = "^\\d{8}-\\d{6}$";
14
15
  static readonly #keptRuns = 2;
15
16
 
package/qualityScanner.ts CHANGED
@@ -215,7 +215,7 @@ export class AkanQualityScanner {
215
215
  if (!(await isDirectory(absoluteTargetRoot))) continue;
216
216
  await this.#walkTargetFiles(workspaceRoot, absoluteTargetRoot, ignoreFilter, files);
217
217
  }
218
- return files.sort();
218
+ return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
219
219
  }
220
220
 
221
221
  async #readGitIgnore(workspaceRoot: string) {
@@ -48,7 +48,10 @@ export default function Page() {
48
48
  describe("scanRecipes", () => {
49
49
  test("detects framework recipes with full variant surface", () => {
50
50
  const recipes = scanRecipes([{ path: "recipe.ts", content: FRAMEWORK, importFrom: "akanjs/ui" }]);
51
- expect(recipes.map((r) => r.name).sort()).toEqual(["badgeRecipe", "buttonRecipe"]);
51
+ expect(recipes.map((r) => r.name).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
52
+ "badgeRecipe",
53
+ "buttonRecipe",
54
+ ]);
52
55
 
53
56
  const button = byName(recipes, "buttonRecipe");
54
57
  expect(button?.importFrom).toBe("akanjs/ui");
@@ -91,7 +94,10 @@ describe("scanRecipes", () => {
91
94
  { path: "a.ts", content: src, importFrom: "@a" },
92
95
  { path: "b.ts", content: `export const other = recipe(tv({ base: "c" }));`, importFrom: "@b" },
93
96
  ]);
94
- expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort()).toEqual(["other@@b", "shown@@a"]);
97
+ expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
98
+ "other@@b",
99
+ "shown@@a",
100
+ ]);
95
101
  });
96
102
  });
97
103
 
@@ -154,7 +160,7 @@ describe("collectRecipeSources", () => {
154
160
  expect(
155
161
  scanRecipes(sources)
156
162
  .map((r) => r.name)
157
- .sort(),
163
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
158
164
  ).toEqual(["appBox", "appCard"]);
159
165
  });
160
166
 
package/scanInfo.ts CHANGED
@@ -113,7 +113,7 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
113
113
  if (violations.length > 0) {
114
114
  throw new Error(
115
115
  `[scan-convention]\n${violations
116
- .sort()
116
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
117
117
  .map((violation) => `- ${violation}`)
118
118
  .join("\n")}`,
119
119
  );
package/subspaceClone.ts CHANGED
@@ -221,19 +221,20 @@ export class SubspaceClone {
221
221
 
222
222
  // An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
223
223
  #localEnv() {
224
- const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
224
+ const { serveDomain, defaulted = [] } = WorkspaceExecutor.getBaseDevEnv();
225
225
  return {
226
226
  AKAN_WORKSPACE_ID: "local",
227
227
  AKAN_PUBLIC_REPO_NAME: this.#declaration.name,
228
- AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
228
+ //? Written down, the local default would read as chosen and pass the clone's build check for a real environment.
229
+ ...(defaulted.includes("AKAN_PUBLIC_SERVE_DOMAIN") ? {} : { AKAN_PUBLIC_SERVE_DOMAIN: serveDomain }),
229
230
  AKAN_PUBLIC_ENV: "local",
230
231
  AKAN_PUBLIC_OPERATION_MODE: "local",
231
232
  AKAN_PUBLIC_LOG_LEVEL: "warn",
232
233
  };
233
234
  }
234
235
 
235
- // The CLI needs a `.env` to recognise a workspace root and none arrives with the slice. Excluded through
236
- // `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
236
+ // No `.env` arrives with the slice, and the clone runs under the customer's repo name in a local env. Excluded
237
+ // through `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
237
238
  async #writeLocalEnv() {
238
239
  const excludePath = path.join(this.#clonePath, ".git/info/exclude");
239
240
  const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
package/testHelpers.ts CHANGED
@@ -80,6 +80,8 @@ export const createFakeExecutor = <Extra extends object = object>(
80
80
  export const makeCliTempWorkspace = async (parentDir = os.tmpdir()) => {
81
81
  await mkdir(parentDir, { recursive: true });
82
82
  const root = await mkdtemp(path.join(parentDir, "akan-cli-"));
83
+ //? The CLI recognises a workspace root by an apps/, libs/ or pkgs/ folder beside package.json and tsconfig.json.
84
+ await mkdir(path.join(root, "apps"));
83
85
  await writeText(path.join(root, ".gitignore"), "");
84
86
  await writeText(
85
87
  path.join(root, ".env"),
@@ -195,7 +195,7 @@ const resolvePackageExport = (exportsMap: Record<string, ExportValue> | undefine
195
195
  if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
196
196
  const wildcard = subpath.slice(prefix.length, subpath.length - suffix.length);
197
197
  const resolved = resolveExportValue(value);
198
- if (resolved) return resolved.replace("*", wildcard);
198
+ if (resolved) return resolved.split("*").join(wildcard);
199
199
  }
200
200
 
201
201
  return null;