@akanjs/devkit 3.0.1 → 3.0.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 (61) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/akanApp/akanApp.host.test.ts +62 -0
  3. package/akanApp/akanApp.host.ts +13 -2
  4. package/akanApp/devHostPolicy.test.ts +14 -0
  5. package/akanApp/devHostPolicy.ts +11 -0
  6. package/akanConfig/akanConfig.test.ts +4 -1
  7. package/akanConfig/akanConfig.ts +3 -1
  8. package/akanContext.ts +18 -1
  9. package/biome.base.json +8 -1
  10. package/commandDecorators/command.ts +3 -2
  11. package/commandDecorators/commandDecorators.test.ts +7 -0
  12. package/createTunnel.test.ts +51 -0
  13. package/createTunnel.ts +32 -14
  14. package/csrE2e/csrPageStack.e2e.test.ts +12 -3
  15. package/executors.test.ts +329 -4
  16. package/executors.ts +49 -47
  17. package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
  18. package/frontendBuild/csrArtifactBuilder.ts +14 -8
  19. package/frontendBuild/csrDevModuleCompiler.ts +0 -1
  20. package/frontendBuild/csrDevResolver.ts +0 -2
  21. package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
  22. package/frontendBuild/devGeneratedIndexSync.ts +21 -4
  23. package/frontendBuild/frontendBuild.test.ts +1 -1
  24. package/jsonFormatter.test.ts +64 -0
  25. package/jsonFormatter.ts +78 -0
  26. package/lint/__fixtures__/README.md +12 -3
  27. package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
  28. package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
  29. package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
  30. package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
  31. package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
  32. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
  33. package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
  34. package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
  35. package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
  36. package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
  37. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
  38. package/lint/gritRules.test.ts +23 -15
  39. package/lint/no-double-brace-placeholder.grit +15 -0
  40. package/lint/no-inline-color.grit +21 -5
  41. package/lint/no-throw-raw-error.grit +18 -33
  42. package/lint/no-unguarded-endpoint.grit +73 -0
  43. package/manifestDependencies.ts +65 -0
  44. package/mobile/desktopBin.test.ts +1 -1
  45. package/mobile/desktopServerStage.test.ts +7 -4
  46. package/mobile/nativeApp.test.ts +57 -8
  47. package/mobile/nativeApp.ts +27 -9
  48. package/mobile/nativeConfig.test.ts +26 -4
  49. package/mobile/nativeConfig.ts +19 -1
  50. package/package.json +2 -2
  51. package/platformTest/LinuxDockerTarget.ts +1 -1
  52. package/platformTest/PlatformTestTarget.ts +3 -1
  53. package/platformTest/WindowsSshTarget.ts +1 -1
  54. package/qualityScanner.test.ts +51 -0
  55. package/qualityScanner.ts +22 -1
  56. package/scanInfo.ts +14 -2
  57. package/testHelpers.ts +17 -0
  58. package/transforms/transforms.test.ts +13 -0
  59. package/tsconfig.json +4 -15
  60. package/workspaceLayout.test.ts +78 -1
  61. package/workspaceLayout.ts +19 -2
@@ -0,0 +1,73 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `SignalContext` checks `signalOption.guards ?? []`, so an endpoint, a named slice, or a slice's root query declaring
5
+ // none is open to every HTTP and websocket caller. An option the rule cannot read, a variable or a spread, is left alone.
6
+ or {
7
+ JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) as $call where {
8
+ or {
9
+ and {
10
+ $callee <: r"(?:query|mutation|pubsub|message)",
11
+ $call <: within JsCallExpression(callee = `endpoint`),
12
+ or { $args <: [$returns], $args <: [$returns, $option] }
13
+ },
14
+ and {
15
+ $callee <: `init`,
16
+ $call <: within JsCallExpression(callee = `slice`),
17
+ or { $args <: [], $args <: [$option] }
18
+ }
19
+ },
20
+ or {
21
+ $option <: undefined,
22
+ $option <: JsObjectExpression(members = $members) where {
23
+ or {
24
+ $members <: not some or {
25
+ JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')"),
26
+ JsShorthandPropertyObjectMember(name = r"guards"),
27
+ JsSpread()
28
+ },
29
+ $members <: some JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')", value = JsArrayExpression(elements = []))
30
+ }
31
+ }
32
+ },
33
+ register_diagnostic(
34
+ span = $callee,
35
+ message = "No `guards`, so every HTTP and websocket caller reaches this endpoint: a missing or empty `guards` fails open, which is also why MCP refuses it. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
36
+ severity = "warn"
37
+ )
38
+ },
39
+ JsCallExpression(callee = `slice`, arguments = JsCallArguments(args = [$srv, $sliceOption, ...])) where {
40
+ $sliceOption <: JsObjectExpression(members = $sliceMembers),
41
+ or {
42
+ and {
43
+ $sliceMembers <: not some or {
44
+ JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')"),
45
+ JsShorthandPropertyObjectMember(name = r"guards"),
46
+ JsSpread()
47
+ },
48
+ register_diagnostic(
49
+ span = $sliceOption,
50
+ message = "No `guards` on this slice, so every HTTP and websocket caller reaches its root query and generated reads and writes: a missing guard fails open. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
51
+ severity = "warn"
52
+ )
53
+ },
54
+ and {
55
+ $sliceMembers <: some JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')", value = $guardMap),
56
+ $guardMap <: JsObjectExpression(members = $guardKeys),
57
+ or {
58
+ $guardKeys <: not some or {
59
+ JsPropertyObjectMember(name = r"(?:root|\x22root\x22|'root')"),
60
+ JsShorthandPropertyObjectMember(name = r"root"),
61
+ JsSpread()
62
+ },
63
+ $guardKeys <: some JsPropertyObjectMember(name = r"(?:root|\x22root\x22|'root')", value = JsArrayExpression(elements = []))
64
+ },
65
+ register_diagnostic(
66
+ span = $guardMap,
67
+ message = "No `root` guard on this slice, so its root query lists any filter to every HTTP and websocket caller: a missing or empty `root` fails open. Write `root: Admin`.",
68
+ severity = "warn"
69
+ )
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,65 @@
1
+ import { LibSource } from "./libSource";
2
+ import type { PackageJson } from "./types";
3
+
4
+ type DependencyMap = Record<string, string>;
5
+
6
+ export interface ScannedImports {
7
+ runtime: readonly string[];
8
+ typeOnly: readonly string[];
9
+ }
10
+
11
+ export class ManifestDependencies {
12
+ readonly #root: PackageJson;
13
+ readonly #runtime: Set<string>;
14
+ readonly #typeOnly: Set<string>;
15
+ readonly #isKept: (dep: string) => boolean;
16
+
17
+ constructor(root: PackageJson, { runtime, typeOnly }: ScannedImports, isKept: (dep: string) => boolean) {
18
+ this.#root = root;
19
+ this.#runtime = new Set(runtime);
20
+ this.#typeOnly = new Set(typeOnly);
21
+ this.#isKept = isKept;
22
+ }
23
+
24
+ static keptBy(manifest: PackageJson): string[] {
25
+ const { keepDependencies } = (manifest[LibSource.manifestKey] ?? {}) as { keepDependencies?: unknown };
26
+ return Array.isArray(keepDependencies)
27
+ ? keepDependencies.filter((dep): dep is string => typeof dep === "string")
28
+ : [];
29
+ }
30
+
31
+ //* The scan recognises only the names the root declares, so only those can be proven unused and pruned.
32
+ realign(manifest: PackageJson) {
33
+ const removed: string[] = [];
34
+ const unverifiable: string[] = [];
35
+ const dependencies: DependencyMap = {};
36
+ for (const [dep, version] of Object.entries(manifest.dependencies ?? {})) {
37
+ if (this.#typeOnly.has(dep)) continue;
38
+ const rootVersion = this.#rootVersionOf(dep);
39
+ if (!rootVersion) {
40
+ unverifiable.push(dep);
41
+ dependencies[dep] = version;
42
+ } else if (this.#runtime.has(dep) || this.#isKept(dep)) dependencies[dep] = rootVersion;
43
+ else removed.push(dep);
44
+ }
45
+ const runtimeOfRootDependencies = [...this.#runtime].filter((dep) => this.#root.dependencies?.[dep]);
46
+ this.#append(dependencies, runtimeOfRootDependencies);
47
+
48
+ const devDependencies: DependencyMap = {};
49
+ for (const [dep, version] of Object.entries(manifest.devDependencies ?? {}))
50
+ if (!Object.hasOwn(dependencies, dep)) devDependencies[dep] = this.#rootVersionOf(dep) ?? version;
51
+ this.#append(devDependencies, [...this.#typeOnly]);
52
+ return { dependencies, devDependencies, removed, unverifiable };
53
+ }
54
+
55
+ #append(map: DependencyMap, deps: string[]) {
56
+ for (const dep of deps.filter((dep) => !Object.hasOwn(map, dep)).sort((a, b) => (a < b ? -1 : 1))) {
57
+ const rootVersion = this.#rootVersionOf(dep);
58
+ if (rootVersion) map[dep] = rootVersion;
59
+ }
60
+ }
61
+
62
+ #rootVersionOf(dep: string) {
63
+ return this.#root.dependencies?.[dep] ?? this.#root.devDependencies?.[dep];
64
+ }
65
+ }
@@ -28,7 +28,7 @@ const stage = async (
28
28
  bin: AkanBinConfig,
29
29
  { libBins = [], libs = [] }: { libBins?: { lib: string; bin: AkanBinConfig }[]; libs?: string[] } = {},
30
30
  ) => {
31
- const binDir = path.join(root, "apps/portal/.akan/native/default/bin");
31
+ const binDir = path.join(root, "dist/native/portal/default/bin");
32
32
  const config = { app: { name: "portal" }, bin, libBins } as never;
33
33
  const carried = await new DesktopBin(fakeApp(path.join(root, "apps/portal"), libs), config).stage(binDir);
34
34
  return { carried, binDir };
@@ -241,10 +241,13 @@ describe("DesktopServerStage", () => {
241
241
  });
242
242
 
243
243
  test("a phone build cannot carry the server", async () => {
244
- const app = new NativeApp({ cwdPath: "/repo/apps/portal" } as never, {
245
- name: "default",
246
- config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
247
- });
244
+ const app = new NativeApp(
245
+ { name: "portal", cwdPath: "/repo/apps/portal", workspace: { workspaceRoot: "/repo" } } as never,
246
+ {
247
+ name: "default",
248
+ config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
249
+ },
250
+ );
248
251
  const server = { dir: "/tmp/server", entry: "main.js", env: {} };
249
252
  await expect(app.build("ios", { server })).rejects.toThrow("Only a desktop app carries its server");
250
253
  await expect(app.run("android", { server })).rejects.toThrow("Only a desktop app carries its server");
@@ -22,6 +22,7 @@ const target = (config: Partial<AkanNativeTarget> = {}) => ({
22
22
  });
23
23
 
24
24
  const appDir = "/repo/apps/portal";
25
+ const nativeDir = "/repo/dist/native/portal";
25
26
 
26
27
  const fakeApp = (plugins: AkanPlugin[] = []) =>
27
28
  ({
@@ -31,24 +32,67 @@ const fakeApp = (plugins: AkanPlugin[] = []) =>
31
32
  getScanInfo: () => ({ getLibs: () => [] }),
32
33
  dist: { cwdPath: "/repo/dist/apps/portal" },
33
34
  getConfig: async () => ({ i18n: { locales: ["en", "ko"] }, api: { prefix: "/api", websocketPrefix: "/ws" } }),
35
+ getDevPort: async () => 8284,
34
36
  collectPlugins: async () => plugins,
35
37
  logger: { warn: () => undefined },
36
38
  }) as unknown as App;
37
39
 
38
40
  describe("NativeApp", () => {
39
- test("keeps each target's web root and each platform's output apart", () => {
41
+ test("keeps each target's web root and each platform's output apart, outside the dist akan build empties", () => {
40
42
  const admin = new NativeApp(fakeApp(), target({ name: "admin", basePath: "admin" }));
41
43
 
42
- expect(admin.web.dir).toBe(path.join(appDir, ".akan/native/admin/web"));
43
- expect(admin.outDir("ios")).toBe(path.join(appDir, ".akan/native/admin/build/ios"));
44
- expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/native/admin/build/android"));
45
- expect(admin.devOutDir("macos")).toBe(path.join(appDir, ".akan/native/admin/dev/macos"));
44
+ expect(admin.web.dir).toBe(path.join(nativeDir, "admin/web"));
45
+ expect(admin.outDir("ios")).toBe(path.join(nativeDir, "admin/build/ios"));
46
+ expect(admin.outDir("android")).toBe(path.join(nativeDir, "admin/build/android"));
47
+ expect(admin.devOutDir("macos")).toBe(path.join(nativeDir, "admin/dev/macos"));
48
+ expect(admin.updatesDir).toBe(path.join(nativeDir, "admin/updates"));
49
+ expect(path.relative("/repo/dist/apps/portal", admin.targetRoot)).toStartWith("..");
50
+ });
51
+
52
+ describe("the server a release build's page calls", () => {
53
+ const runtimeEnvOf = async (env: "local" | "debug", options: { server?: boolean } = {}) => {
54
+ const nativeApp = new NativeApp(fakeApp(), target(), env);
55
+ let seen: Record<string, string> | undefined;
56
+ nativeApp.assembleWeb = async () => undefined as never;
57
+ nativeApp.prepare = async () =>
58
+ ({
59
+ config: {},
60
+ api: {
61
+ build: async ({ env }: { env?: Record<string, string> }) => {
62
+ seen = env;
63
+ return { artifacts: [], warnings: [] };
64
+ },
65
+ },
66
+ }) as never;
67
+ await nativeApp.build("linux", options.server ? { server: { dir: "/s", entry: "main.js", env: {} } } : {});
68
+ return seen;
69
+ };
70
+
71
+ test("a local release calls the app's own dev server, not 8282", async () => {
72
+ expect(await runtimeEnvOf("local")).toEqual({ PUBLIC_AKAN_SERVER_URL: "http://localhost:8284" });
73
+ });
74
+
75
+ test("a cloud release and a release carrying its server name none", async () => {
76
+ expect(await runtimeEnvOf("debug")).toEqual({});
77
+ expect(await runtimeEnvOf("local", { server: true })).toEqual({});
78
+ });
79
+
80
+ test("an AKAN_PUBLIC_SERVER_URL the bundle was built with is left to win", async () => {
81
+ process.env.AKAN_PUBLIC_SERVER_URL = "http://192.168.0.10:8284";
82
+ try {
83
+ expect(await runtimeEnvOf("local")).toEqual({});
84
+ } finally {
85
+ delete process.env.AKAN_PUBLIC_SERVER_URL;
86
+ }
87
+ });
46
88
  });
47
89
 
48
90
  test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
91
+ const root = await makeTempRoot();
49
92
  const app = {
50
93
  ...fakeApp(),
51
- cwdPath: await makeTempRoot(),
94
+ cwdPath: path.join(root, "apps/portal"),
95
+ workspace: { workspaceRoot: root },
52
96
  logger: { info: () => undefined, debug: () => undefined, warn: () => undefined },
53
97
  } as unknown as App;
54
98
  const nativeApp = new NativeApp(app, target());
@@ -118,7 +162,12 @@ describe("NativeApp", () => {
118
162
  test("assembles each platform's web root with that platform's indexPath", async () => {
119
163
  const root = await makeTempRoot();
120
164
  await writeText(path.join(root, "dist/csr/index.html"), "<html><head></head><body></body></html>");
121
- const app = { ...fakeApp(), cwdPath: root, dist: { cwdPath: path.join(root, "dist") } } as unknown as App;
165
+ const app = {
166
+ ...fakeApp(),
167
+ cwdPath: root,
168
+ workspace: { workspaceRoot: root },
169
+ dist: { cwdPath: path.join(root, "dist") },
170
+ } as unknown as App;
122
171
  const nativeApp = new NativeApp(app, target({ indexPath: "/mobile", desktop: { indexPath: "/console" } }));
123
172
  const injected = async () => await Bun.file(path.join(nativeApp.web.dir, "index.html")).text();
124
173
 
@@ -139,7 +188,7 @@ describe("NativeApp", () => {
139
188
 
140
189
  expect(warnings).toEqual([]);
141
190
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
142
- expect(config.web.dir).toBe(path.join(appDir, ".akan/native/default/web"));
191
+ expect(config.web.dir).toBe(path.join(nativeDir, "default/web"));
143
192
  });
144
193
 
145
194
  test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
@@ -63,7 +63,8 @@ export class NativeApp {
63
63
  readonly target: ResolvedMobileTarget,
64
64
  readonly env?: NativeEnv,
65
65
  ) {
66
- this.targetRoot = path.join(app.cwdPath, ".akan", "native", target.name);
66
+ //* Outside `dist/apps/<app>`, which every `akan build` empties: a signed release waiting for upload lives here.
67
+ this.targetRoot = path.join(app.workspace.workspaceRoot, "dist", "native", app.name, target.name);
67
68
  this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
68
69
  }
69
70
 
@@ -161,13 +162,18 @@ export class NativeApp {
161
162
  };
162
163
 
163
164
  //* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
164
- #task(platform: NativePlatform, config: AkanNativeConfig, outDir = this.outDir(platform)): TaskOptions {
165
+ #task(
166
+ platform: NativePlatform,
167
+ config: AkanNativeConfig,
168
+ { outDir = this.outDir(platform), env = {} }: { outDir?: string; env?: Record<string, string> } = {},
169
+ ): TaskOptions {
165
170
  return {
166
171
  appDir: this.app.cwdPath,
167
172
  config,
168
173
  platform,
169
174
  outDir,
170
175
  envFiles: false,
176
+ env,
171
177
  skipWebBuild: true,
172
178
  log: (event) => {
173
179
  if (event.level === "error") this.app.logger.error(event.message);
@@ -179,6 +185,13 @@ export class NativeApp {
179
185
  };
180
186
  }
181
187
 
188
+ //* A local release has no dev gateway behind it, so its page calls the app's dev server (baseEnv csrServerUrl), on
189
+ //* the port `akan start` gives this app. A carried server hands the page its own URL at launch instead.
190
+ async #runtimeEnv(server?: DesktopServerBundle): Promise<Record<string, string>> {
191
+ if (this.env !== "local" || server || process.env.AKAN_PUBLIC_SERVER_URL) return {};
192
+ return { PUBLIC_AKAN_SERVER_URL: `http://localhost:${await this.app.getDevPort()}` };
193
+ }
194
+
182
195
  async build(
183
196
  platform: NativePlatform,
184
197
  { profile = "release", server, installer = false, arch }: NativeBuildOptions = {},
@@ -188,7 +201,7 @@ export class NativeApp {
188
201
  await this.assembleWeb(platform);
189
202
  const { api, config } = await this.prepare(platform, server, arch);
190
203
  return await api.build({
191
- ...this.#task(platform, config),
204
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
192
205
  profile,
193
206
  ...(arch ? { arch } : {}),
194
207
  ...(platform === "windows"
@@ -219,7 +232,7 @@ export class NativeApp {
219
232
  await this.assembleWeb(platform);
220
233
  const { api, config } = await this.prepare(platform, server);
221
234
  return await api.run({
222
- ...this.#task(platform, config),
235
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
223
236
  profile,
224
237
  onLine: this.#appLine,
225
238
  ...(device ? { device } : {}),
@@ -238,7 +251,7 @@ export class NativeApp {
238
251
  await this.web.clear();
239
252
  const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
240
253
  const session = await api.dev({
241
- ...this.#task(platform, config, this.devOutDir(platform)),
254
+ ...this.#task(platform, config, { outDir: this.devOutDir(platform) }),
242
255
  upstream,
243
256
  hmrPath: "/_akan/hmr",
244
257
  //? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
@@ -279,7 +292,7 @@ export class NativeApp {
279
292
  await this.assembleWeb("ios");
280
293
  const { api, config } = await this.prepare("ios");
281
294
  return await api.release({
282
- ...this.#task("ios", config),
295
+ ...this.#task("ios", config, { env: await this.#runtimeEnv() }),
283
296
  platform: "ios",
284
297
  signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
285
298
  });
@@ -300,7 +313,7 @@ export class NativeApp {
300
313
  await this.assembleWeb(platform);
301
314
  const { api, config } = await this.prepare(platform, server);
302
315
  return await api.publishUpdate({
303
- ...this.#task(platform, config),
316
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
304
317
  platform,
305
318
  out: this.updatesDir,
306
319
  ...(channel ? { channel } : {}),
@@ -363,7 +376,12 @@ export class NativeApp {
363
376
  const signing = NativeApp.androidSigning();
364
377
  await this.assembleWeb("android");
365
378
  const { api, config } = await this.prepare("android");
366
- return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
379
+ return await api.release({
380
+ ...this.#task("android", config, { env: await this.#runtimeEnv() }),
381
+ platform: "android",
382
+ signing,
383
+ formats,
384
+ });
367
385
  }
368
386
 
369
387
  /** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
@@ -377,7 +395,7 @@ export class NativeApp {
377
395
  const packed = await api.packUpdate({
378
396
  ...this.#task(platform, config),
379
397
  platform,
380
- out: out ?? path.join(this.targetRoot, "updates", platform),
398
+ out: out ?? path.join(this.updatesDir, platform),
381
399
  });
382
400
  return { ...packed, channel: config.updates.channel ?? "production" };
383
401
  }
@@ -45,11 +45,33 @@ const adminTarget: AkanNativeTarget = {
45
45
  const updatesKey = Buffer.alloc(32, 7).toString("base64");
46
46
 
47
47
  describe("NativeConfig.build", () => {
48
+ test("hands the dmg window over with its pictures resolved against the app folder", () => {
49
+ const build = (dmg: NonNullable<AkanNativeTarget["desktop"]>["dmg"]) =>
50
+ NativeConfig.build({
51
+ appPath: "/repo/apps/minimal",
52
+ target: { ...minimalTarget, desktop: { dmg } },
53
+ webDir: "/repo/dist/native/minimal/default/web",
54
+ contributions: [],
55
+ locales: ["en"],
56
+ platform: "macos",
57
+ }).config.desktop?.dmg;
58
+
59
+ expect(build({ background: "assets/dmg.png", background2x: "assets/dmg@2x.png", app: { x: 150, y: 190 } })).toEqual(
60
+ {
61
+ background: path.resolve("/repo/apps/minimal", "assets/dmg.png"),
62
+ background2x: path.resolve("/repo/apps/minimal", "assets/dmg@2x.png"),
63
+ app: { x: 150, y: 190 },
64
+ },
65
+ );
66
+ expect(build({ background: false, iconSize: 96 })).toEqual({ background: false, iconSize: 96 });
67
+ expect(build(undefined)).toBeUndefined();
68
+ });
69
+
48
70
  test("turns the minimal app's push target into the runtime's config, single-instance carrying its link scheme", () => {
49
71
  const { config, warnings } = NativeConfig.build({
50
72
  appPath: "/repo/apps/minimal",
51
73
  target: minimalTarget,
52
- webDir: "/repo/apps/minimal/.akan/native/default/web",
74
+ webDir: "/repo/dist/native/minimal/default/web",
53
75
  contributions: [{ permission: "push", plugins: ["push"] }],
54
76
  locales: ["en", "ko"],
55
77
  platform: "android",
@@ -58,7 +80,7 @@ describe("NativeConfig.build", () => {
58
80
  expect(warnings).toEqual([]);
59
81
  expect(config).toEqual({
60
82
  app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
61
- web: { dir: "/repo/apps/minimal/.akan/native/default/web" },
83
+ web: { dir: "/repo/dist/native/minimal/default/web" },
62
84
  plugins: [...NativeConfig.basePlugins, "single-instance", "push"],
63
85
  capabilities: [
64
86
  {
@@ -80,7 +102,7 @@ describe("NativeConfig.build", () => {
80
102
  const { config, warnings } = NativeConfig.build({
81
103
  appPath: "/repo/apps/portal",
82
104
  target: adminTarget,
83
- webDir: "/repo/apps/portal/.akan/native/admin/web",
105
+ webDir: "/repo/dist/native/portal/admin/web",
84
106
  contributions: [],
85
107
  locales: ["en", "ko"],
86
108
  platform: "android",
@@ -89,7 +111,7 @@ describe("NativeConfig.build", () => {
89
111
  expect(warnings).toEqual(["Permission 'speech' has no native plugin yet; the app ships without it."]);
90
112
  expect(config).toEqual({
91
113
  app: { id: "com.portal.admin", name: "Portal Admin", fileName: "portal-admin", version: "2.1.0", build: 42 },
92
- web: { dir: "/repo/apps/portal/.akan/native/admin/web" },
114
+ web: { dir: "/repo/dist/native/portal/admin/web" },
93
115
  plugins: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"],
94
116
  capabilities: [
95
117
  {
@@ -1,7 +1,14 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { AkanNativeConfig } from "@akanjs/native/config";
4
- import type { AkanNativeTarget, AkanNativeValue, AkanPluginNativeConfig, NativeEnv, NativePermission } from "akanjs";
4
+ import type {
5
+ AkanNativeDmgConfig,
6
+ AkanNativeTarget,
7
+ AkanNativeValue,
8
+ AkanPluginNativeConfig,
9
+ NativeEnv,
10
+ NativePermission,
11
+ } from "akanjs";
5
12
  import type { DesktopServerBundle } from "./desktopServerStage";
6
13
  import { type NativePlatform, resolveAppId } from "./mobileTarget";
7
14
  import type { NativePluginFolder } from "./nativePluginFolders";
@@ -171,6 +178,7 @@ export class NativeConfig {
171
178
  window: NativeConfig.#compact({ ...target.desktop?.window }),
172
179
  server: desktopServer,
173
180
  bin: desktopBin,
181
+ dmg: NativeConfig.#dmg(target.desktop?.dmg, abs),
174
182
  });
175
183
  const config: AkanNativeConfig = {
176
184
  app: {
@@ -234,6 +242,16 @@ export class NativeConfig {
234
242
  return { ...splash, ...(splash.image ? { image: abs(splash.image) } : {}) };
235
243
  }
236
244
 
245
+ static #dmg(dmg: AkanNativeDmgConfig | undefined, abs: (relative: string) => string) {
246
+ if (!dmg) return undefined;
247
+ const { background, background2x, ...layout } = dmg;
248
+ return {
249
+ ...layout,
250
+ ...(background === undefined ? {} : { background: background === false ? false : abs(background) }),
251
+ ...(background2x ? { background2x: abs(background2x) } : {}),
252
+ };
253
+ }
254
+
237
255
  //? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
238
256
  static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
239
257
  const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  "@earendil-works/pi-tui": "0.86.1",
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
- "akanjs": "3.0.1",
50
+ "akanjs": "3.0.2",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",
@@ -73,7 +73,7 @@ export class LinuxDockerTarget extends PlatformTestTarget {
73
73
  [
74
74
  "docker",
75
75
  "exec",
76
- ...Object.entries(PlatformTestTarget.gitIdentityEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
76
+ ...Object.entries(PlatformTestTarget.remoteEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
77
77
  container,
78
78
  "sh",
79
79
  "-c",
@@ -36,7 +36,9 @@ export interface PlatformRunContext {
36
36
  export abstract class PlatformTestTarget {
37
37
  static readonly installIdleMs = 5 * 60_000;
38
38
  static readonly testIdleMs = 2 * 60_000;
39
- protected static readonly gitIdentityEnv = {
39
+ protected static readonly remoteEnv = {
40
+ //? No suite drives puppeteer's Chrome, and downloading it into every fresh container fails install on a DNS blip.
41
+ PUPPETEER_SKIP_DOWNLOAD: "true",
40
42
  GIT_AUTHOR_NAME: "akan-test",
41
43
  GIT_AUTHOR_EMAIL: "akan-test@localhost",
42
44
  GIT_COMMITTER_NAME: "akan-test",
@@ -71,7 +71,7 @@ export class WindowsSshTarget extends PlatformTestTarget {
71
71
  "$ProgressPreference = 'SilentlyContinue'",
72
72
  "[Console]::OutputEncoding = [Text.Encoding]::UTF8",
73
73
  "'PID=' + $PID",
74
- ...Object.entries(PlatformTestTarget.gitIdentityEnv).map(([key, value]) => `$env:${key} = '${value}'`),
74
+ ...Object.entries(PlatformTestTarget.remoteEnv).map(([key, value]) => `$env:${key} = '${value}'`),
75
75
  `Set-Location -LiteralPath '${runDir}\\w'`,
76
76
  `cmd /c "${command} 2>&1"`,
77
77
  "exit $LASTEXITCODE",
@@ -219,6 +219,20 @@ describe("AkanQualityScanner layout rules", () => {
219
219
  ]);
220
220
  });
221
221
 
222
+ test("flags a hand-written facet index beside the generated barrel, but not a folder namespace", async () => {
223
+ const root = await makeWorkspace({
224
+ "libs/demo/ui/index.tsx": 'export { Chat } from "./Chat";\n',
225
+ "libs/demo/ui/Chat.tsx": "export const Chat = () => null;\n",
226
+ "libs/demo/ui/Page/index.tsx": 'export { Inner } from "./Inner";\n',
227
+ });
228
+
229
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.facet-index-shadow");
230
+
231
+ expect(warnings.map((warning) => warning.file)).toEqual(["libs/demo/ui/index.tsx"]);
232
+ expect(warnings[0]?.message).toContain("index.tsx shadows the generated ui/index.ts");
233
+ expect(warnings[0]?.fix).toContain("akan sync");
234
+ });
235
+
222
236
  test("keeps a root signal test out of the lib facet rule", async () => {
223
237
  const root = await makeWorkspace({
224
238
  "libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
@@ -233,6 +247,43 @@ describe("AkanQualityScanner layout rules", () => {
233
247
  });
234
248
  });
235
249
 
250
+ describe("AkanQualityScanner class export files", () => {
251
+ test("allow a type only the class reads, and flag an exported type and any helper beside the class", async () => {
252
+ const root = await makeWorkspace({
253
+ "libs/demo/srvkit/vendorApi.ts": [
254
+ "interface VendorUser {",
255
+ " id: string;",
256
+ "}",
257
+ "type VendorPage = { items: VendorUser[] };",
258
+ "export interface VendorApiOptions {",
259
+ " key: string;",
260
+ "}",
261
+ "export interface VendorReceipt {",
262
+ " id: string;",
263
+ "}",
264
+ 'export type VendorStatus = "ok" | "fail";',
265
+ "enum VendorRegion {",
266
+ " Kr,",
267
+ "}",
268
+ "const pageSize = 100;",
269
+ "const toUser = (page: VendorPage) => page.items[0];",
270
+ "export class VendorApi {}",
271
+ "",
272
+ ].join("\n"),
273
+ });
274
+
275
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.file.class-export-global-declaration");
276
+
277
+ expect(warnings.map((warning) => /"(\w+)"/.exec(warning.message)?.[1])).toEqual([
278
+ "VendorReceipt",
279
+ "VendorStatus",
280
+ "VendorRegion",
281
+ "pageSize",
282
+ "toUser",
283
+ ]);
284
+ });
285
+ });
286
+
236
287
  describe("bang comments in browser-reachable files", () => {
237
288
  const bangWarningsIn = async (files: Record<string, string>) => {
238
289
  const result = await new AkanQualityScanner().scan(await makeWorkspace(files));
package/qualityScanner.ts CHANGED
@@ -7,7 +7,13 @@ import ts from "typescript";
7
7
  import { AbstractDoc } from "./abstractDoc";
8
8
  import { FormSetterScanner } from "./formSetterScanner";
9
9
  import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
10
- import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles, rootEntryHintOf } from "./workspaceLayout";
10
+ import {
11
+ facetIndexShadowOf,
12
+ isAllowedLibFacetRootFile,
13
+ rootAllowedDirs,
14
+ rootAllowedFiles,
15
+ rootEntryHintOf,
16
+ } from "./workspaceLayout";
11
17
 
12
18
  type QualitySeverity = "warning";
13
19
  type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
@@ -140,6 +146,8 @@ const RULE_FIXES: Record<string, string> = {
140
146
  "Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
141
147
  "akan.layout.lib-facet-file":
142
148
  "Move the file into a domain module folder under lib/; keep the lib facet root limited to generated support facets.",
149
+ "akan.layout.facet-index-shadow":
150
+ "Delete the hand-written index file and run akan sync; move a namespace it builds into <facet>/<Folder>/index.tsx.",
143
151
  "akan.layout.module-ui-file":
144
152
  "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
145
153
  "akan.ssr.unnecessary-use-client":
@@ -325,6 +333,8 @@ export class AkanQualityScanner {
325
333
  for (const declaration of getTopLevelDeclarations(sourceFile)) {
326
334
  if (declaration.kind === "class" && exportedClassNames.includes(declaration.name)) continue;
327
335
  if (declaration.kind === "interface" && allowedInterfaceNames.has(declaration.name)) continue;
336
+ //? A shape only this class reads (a vendor response it parses) is erased at build; a sibling file buys nothing.
337
+ if ((declaration.kind === "interface" || declaration.kind === "type") && !declaration.exported) continue;
328
338
  warnings.push(
329
339
  fileWarning(
330
340
  "akan.file.class-export-global-declaration",
@@ -432,6 +442,17 @@ export class AkanQualityScanner {
432
442
  );
433
443
  }
434
444
 
445
+ const facetShadow = rootEntry?.isDir ? facetIndexShadowOf(sourceFile.file.split("/").slice(2).join("/")) : null;
446
+ if (facetShadow)
447
+ warnings.push(
448
+ fileWarning(
449
+ "akan.layout.facet-index-shadow",
450
+ "layout",
451
+ sourceFile.file,
452
+ `${path.basename(sourceFile.file)} ${facetShadow}.`,
453
+ ),
454
+ );
455
+
435
456
  const libFacetFile = getLibFacetRootFile(sourceFile.file);
436
457
  if (libFacetFile && !isAllowedLibFacetRootFile(libFacetFile))
437
458
  warnings.push(