@akanjs/devkit 3.0.0-alpha.7 → 3.0.0-alpha.70

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 (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/agentsIndex.test.ts +10 -0
  5. package/agentsIndex.ts +47 -1
  6. package/aiEditor.ts +1 -1
  7. package/akanConfig/akanConfig.test.ts +181 -14
  8. package/akanConfig/akanConfig.ts +128 -47
  9. package/akanConfig/types.ts +8 -0
  10. package/akanContext.ts +53 -11
  11. package/applicationBuildRunner.test.ts +1 -1
  12. package/applicationBuildRunner.ts +45 -21
  13. package/artifact/implicitRootLayout.ts +2 -2
  14. package/biome.base.json +336 -0
  15. package/biomeBase.ts +9 -0
  16. package/executors.test.ts +87 -5
  17. package/executors.ts +40 -9
  18. package/formSetterScanner.test.ts +80 -0
  19. package/formSetterScanner.ts +92 -0
  20. package/frontendBuild/buildRouteClient.test.ts +28 -2
  21. package/frontendBuild/clientBuildTypes.ts +4 -0
  22. package/frontendBuild/clientEntriesBundler.ts +4 -1
  23. package/frontendBuild/cssCompiler.ts +122 -11
  24. package/frontendBuild/cssImportResolver.ts +8 -7
  25. package/frontendBuild/fontPruner.test.ts +220 -0
  26. package/frontendBuild/fontPruner.ts +206 -0
  27. package/frontendBuild/frontendBuild.test.ts +88 -1
  28. package/frontendBuild/hmrWatcher.ts +1 -1
  29. package/frontendBuild/index.ts +1 -0
  30. package/frontendBuild/routeClientBuilder.ts +12 -5
  31. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  32. package/frontendBuild/styleGuard.test.ts +15 -0
  33. package/frontendBuild/styleGuard.ts +17 -0
  34. package/frontendBuild/vendorSpecifiers.ts +1 -0
  35. package/getCredentials.ts +1 -3
  36. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  37. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  38. package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
  39. package/integration/devStabilityHarness.ts +2 -10
  40. package/lint/no-async-component-in-ui.grit +35 -0
  41. package/lint/no-daisyui-legacy-class.grit +26 -9
  42. package/lint/no-import-client-in-server.grit +48 -0
  43. package/lint/no-import-server-in-client.grit +45 -0
  44. package/lint/no-init-fetch-in-client.grit +47 -0
  45. package/lint/no-model-type-in-util-zone.grit +58 -0
  46. package/lint/no-unpublished-form-setter.grit +41 -0
  47. package/linter.ts +17 -12
  48. package/package.json +5 -5
  49. package/qualityScanner.test.ts +52 -0
  50. package/qualityScanner.ts +46 -18
  51. package/repoIdentity.ts +42 -0
  52. package/scanInfo.ts +29 -23
  53. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  54. package/tsconfig.json +1 -1
  55. package/workspaceLayout.test.ts +56 -4
  56. package/workspaceLayout.ts +49 -4
@@ -5,19 +5,26 @@ import type { AkanPlugin } from "akanjs";
5
5
  import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
6
6
  import type { AkanImageConfig } from "akanjs/server";
7
7
  import type { App, Lib } from "../commandDecorators";
8
- import { WorkspaceExecutor } from "../executors";
8
+ import { LibExecutor, WorkspaceExecutor } from "../executors";
9
9
  import type { BaseDevEnv, PackageJson } from "../types";
10
10
  import {
11
+ type AkanAssetsConfig,
11
12
  type AkanMobileConfig,
12
13
  type AkanMobileTargetConfig,
13
14
  type AkanRouteConfig,
15
+ type AkanWebConfig,
16
+ type AkanWebOption,
14
17
  type AppConfigResult,
15
18
  type Arch,
16
19
  archs,
17
20
  type DatabaseMode,
18
21
  type DeepPartial,
19
22
  type DockerConfig,
23
+ type DockerOption,
24
+ type DockerRun,
25
+ type LibAssetsConfig,
20
26
  type LibConfigResult,
27
+ type LibDockerConfig,
21
28
  } from "./types";
22
29
 
23
30
  const DEFAULT_BARREL_IMPORTS = ["akanjs/webkit", "akanjs/common", "akanjs/ui", "akanjs/server"];
@@ -49,9 +56,8 @@ const DEFAULT_OPTIMIZE_IMPORTS = [
49
56
  "react-icons/*",
50
57
  ];
51
58
  const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] as const;
59
+ const DEFAULT_DOCKER_IMAGE = "oven/bun:1-slim";
52
60
  const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
53
- const NATIVE_RUNTIME_PACKAGES = ["sharp"] as const;
54
- const DEFAULT_BACKEND_RUNTIME_PACKAGES = ["croner"] as const;
55
61
  // The firebase client (push tokens) and the Capacitor toolchain — `@capacitor/cli` (`npx cap`),
56
62
  // `@capacitor/assets` (`npx @capacitor/assets`) plus the `@capacitor/core`/`ios`/`android` runtime
57
63
  // and native-platform packages that `npx cap add`/`sync` resolve from the workspace node_modules.
@@ -92,8 +98,6 @@ const DATABASE_MODE_RUNTIME_PACKAGES = {
92
98
  } satisfies Record<DatabaseMode, readonly string[]>;
93
99
  const AKAN_RUNTIME_PACKAGES = new Set<string>([
94
100
  ...SSR_RUNTIME_PACKAGES,
95
- ...NATIVE_RUNTIME_PACKAGES,
96
- ...DEFAULT_BACKEND_RUNTIME_PACKAGES,
97
101
  ...MOBILE_RUNTIME_PACKAGES,
98
102
  ...Object.values(DATABASE_MODE_RUNTIME_PACKAGES).flat(),
99
103
  ]);
@@ -109,6 +113,7 @@ const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
109
113
  maximumRedirects: 3,
110
114
  fetchTimeoutMs: 7000,
111
115
  maxRemoteBytes: 25 * 1024 * 1024,
116
+ maxConcurrency: 0,
112
117
  };
113
118
 
114
119
  const normalizeIndexPath = (indexPath: string | undefined): string | undefined => {
@@ -167,11 +172,45 @@ const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepL
167
172
  } satisfies AkanMobileTargetConfig["deepLinks"];
168
173
  };
169
174
 
175
+ /** What `akan.config.ts` may write: the resolved shape made partial, with `docker` and `web` in their unions. */
176
+ type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
177
+ docker?: DockerOption;
178
+ web?: AkanWebOption;
179
+ };
180
+
181
+ /** What the workspace's libs add to an app's build, read off each `libs/<lib>/akan.config.ts`. */
182
+ export interface LibContributions {
183
+ externalLibs: string[];
184
+ docker: LibDockerConfig;
185
+ /** Keep globs rewritten to the app's own `public/`, where `akan sync` mounts each lib's assets. */
186
+ keepFonts?: string[];
187
+ }
188
+
189
+ const emptyLibContributions = (): LibContributions => ({
190
+ externalLibs: [],
191
+ docker: { preRuns: [], postRuns: [] },
192
+ keepFonts: [],
193
+ });
194
+
195
+ const normalizeKeepFonts = (keepFonts: string[] | undefined) => [
196
+ ...new Set((keepFonts ?? []).map((glob) => glob.trim().replace(/^\/+/, "")).filter(Boolean)),
197
+ ];
198
+
199
+ /** First occurrence wins, so a step a lib and its app both declare becomes one layer. */
200
+ const dedupeDockerRuns = (runs: DockerRun[]): DockerRun[] => {
201
+ const byKey = new Map<string, DockerRun>();
202
+ for (const run of runs) byKey.set(typeof run === "string" ? run : JSON.stringify(run), run);
203
+ return [...byKey.values()];
204
+ };
205
+
170
206
  export class AkanAppConfig implements AppConfigResult {
171
207
  app: App;
172
208
  rootPackageJson: PackageJson;
173
209
  docker: DockerConfig;
210
+ /** The Dockerfile `akan build` writes: the declared string verbatim, or one assembled from the parts. */
211
+ dockerfile: string;
174
212
  defaultDatabaseMode: DatabaseMode;
213
+ web: AkanWebConfig;
175
214
  externalLibs: string[];
176
215
  barrelImports: string[];
177
216
  optimizeImports: string[];
@@ -182,6 +221,7 @@ export class AkanAppConfig implements AppConfigResult {
182
221
  /** True only when the app's akan.config.ts explicitly declares a `mobile` section (vs. the synthesized default). */
183
222
  hasMobileConfig: boolean;
184
223
  secrets: string[];
224
+ assets: AkanAssetsConfig;
185
225
  /** Raw setting; resolved against the app's lib deps at sync time (see `AppExecutor.syncPages`). */
186
226
  syncPageLibs: string[] | boolean;
187
227
  baseDevEnv: BaseDevEnv;
@@ -196,9 +236,10 @@ export class AkanAppConfig implements AppConfigResult {
196
236
  app: App,
197
237
  libs: string[],
198
238
  rootPackageJson: PackageJson,
199
- config: DeepPartial<AppConfigResult>,
239
+ config: AppConfigDeclaration,
200
240
  baseDevEnv: BaseDevEnv,
201
241
  plugins: AkanPlugin[] = [],
242
+ libContributions: LibContributions = emptyLibContributions(),
202
243
  ) {
203
244
  this.app = app;
204
245
  this.rootPackageJson = rootPackageJson;
@@ -207,7 +248,7 @@ export class AkanAppConfig implements AppConfigResult {
207
248
  this.plugins = plugins;
208
249
  this.#applyRoutes(config?.routes);
209
250
  this.defaultDatabaseMode = config?.defaultDatabaseMode ?? "single";
210
- this.externalLibs = config?.externalLibs ?? [];
251
+ this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libContributions.externalLibs])];
211
252
  this.barrelImports = [
212
253
  ...DEFAULT_BARREL_IMPORTS,
213
254
  ...WORKSPACE_BARREL_FACETS.map((facet) => `@apps/${app.name}/${facet}`),
@@ -221,10 +262,28 @@ export class AkanAppConfig implements AppConfigResult {
221
262
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
222
263
  this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
223
264
  this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
265
+ this.assets = {
266
+ pruneFonts: config?.assets?.pruneFonts ?? true,
267
+ keepFonts: [
268
+ ...normalizeKeepFonts(config?.assets?.keepFonts as string[] | undefined),
269
+ ...(libContributions.keepFonts ?? []),
270
+ ],
271
+ };
224
272
  this.syncPageLibs = (config?.syncPageLibs as string[] | boolean | undefined) ?? false;
225
273
  this.hasMobileConfig = Boolean(config.mobile);
226
274
  this.mobile = this.#resolveMobileConfig(config.mobile);
227
- this.docker = this.#makeDockerContent(config?.docker ?? {});
275
+ this.web = this.#resolveWebConfig(config.web);
276
+ this.docker = AkanAppConfig.#resolveDocker(config.docker, libContributions.docker);
277
+ this.dockerfile = this.#makeDockerfile();
278
+ }
279
+ #resolveWebConfig(web: AkanWebOption | undefined): AkanWebConfig {
280
+ const resolved = typeof web === "object" ? { ssr: true, csr: web.csr } : { ssr: web ?? true, csr: web ?? true };
281
+ // `akan build-ios` / `build-android` copy `dist/apps/<app>/csr/<target>.html` into the native project.
282
+ if (!resolved.csr && this.hasMobileConfig)
283
+ throw new Error(
284
+ `apps/${this.app.name}/akan.config.ts turns the CSR bundle off but declares mobile targets; the Capacitor build ships that bundle. Drop the mobile section or leave CSR on.`,
285
+ );
286
+ return resolved;
228
287
  }
229
288
  #resolveMobileConfig(mobile: DeepPartial<AkanMobileConfig> | undefined): AkanMobileConfig {
230
289
  const {
@@ -325,7 +384,7 @@ export class AkanAppConfig implements AppConfigResult {
325
384
  this.branches.forEach((domain) => void domains.add(`${basePath}-${domain}.${serveDomain}`));
326
385
  });
327
386
  }
328
- #getDockerRunScripts(runs: (string | { [key in Arch]?: string })[]) {
387
+ #getDockerRunScripts(runs: DockerRun[]) {
329
388
  return runs.map((run) => {
330
389
  if (typeof run === "string") return `RUN ${run}`;
331
390
  else
@@ -342,26 +401,31 @@ export class AkanAppConfig implements AppConfigResult {
342
401
  if (typeof image === "string") return `FROM ${image}`;
343
402
  else return archs.map((arch) => `FROM ${image[arch] ?? defaultImage} AS ${arch}`).join("\n");
344
403
  }
345
- #makeDockerContent(docker: DeepPartial<DockerConfig>): DockerConfig {
346
- if (docker.content)
347
- return {
348
- content: docker.content,
349
- image: {},
350
- preRuns: [],
351
- postRuns: [],
352
- command: [],
353
- };
354
- const preRunScripts = this.#getDockerRunScripts(docker.preRuns ?? []);
355
- const postRunScripts = this.#getDockerRunScripts(docker.postRuns ?? []);
356
-
357
- const imageScript = docker.image
358
- ? this.#getDockerImageScript(docker.image, "oven/bun:1-slim")
359
- : "FROM oven/bun:1-slim";
360
- const command = docker.command ?? ["bun", "main.js"];
361
- const content = `${imageScript}
404
+ /** A declared Dockerfile string is verbatim, so a lib's steps are dropped rather than silently unapplied. */
405
+ static #resolveDocker(docker: DockerOption | undefined, libDocker: LibDockerConfig): DockerConfig {
406
+ if (typeof docker === "string") return docker;
407
+ return {
408
+ image: docker?.image ?? DEFAULT_DOCKER_IMAGE,
409
+ preRuns: dedupeDockerRuns([...libDocker.preRuns, ...(docker?.preRuns ?? [])]),
410
+ postRuns: dedupeDockerRuns([...libDocker.postRuns, ...(docker?.postRuns ?? [])]),
411
+ command: docker?.command ?? ["bun", "main.js"],
412
+ };
413
+ }
414
+ #makeDockerfile(): string {
415
+ if (typeof this.docker === "string") return this.docker;
416
+ const { image, preRuns, postRuns, command } = this.docker;
417
+ const preRunScripts = this.#getDockerRunScripts(preRuns);
418
+ const postRunScripts = this.#getDockerRunScripts(postRuns);
419
+ const imageScript = this.#getDockerImageScript(image, DEFAULT_DOCKER_IMAGE);
420
+ // The image default matches what the build actually produced; a deployment narrows it further with its
421
+ // own env, and can never widen it past the artifacts that are in the image.
422
+ const webEnvLines = [
423
+ ...(this.web.ssr ? [] : ["ENV AKAN_SSR=false"]),
424
+ ...(this.web.csr ? [] : ["ENV AKAN_CSR=false"]),
425
+ ].join("\n");
426
+ return `${imageScript}
427
+ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
362
428
  RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime
363
- RUN apt-get update && apt-get upgrade -y
364
- RUN apt-get install -y --no-install-recommends git redis build-essential python3 ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils udev ffmpeg
365
429
  ARG TARGETARCH
366
430
  ${preRunScripts.join("\n")}
367
431
  RUN mkdir -p /workspace
@@ -380,15 +444,8 @@ ${this.basePaths.size ? `ENV AKAN_PUBLIC_BASE_PATHS=${[...this.basePaths].join("
380
444
  ENV AKAN_PUBLIC_DEFAULT_LOCALE=${this.i18n.defaultLocale}
381
445
  ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
382
446
  ENV AKAN_PUBLIC_OPERATION_MODE=cloud
383
-
447
+ ${webEnvLines}
384
448
  CMD [${command.map((c) => `"${c}"`).join(",")}]`;
385
- return {
386
- content,
387
- image: imageScript,
388
- preRuns: docker.preRuns ?? [],
389
- postRuns: docker.postRuns ?? [],
390
- command,
391
- };
392
449
  }
393
450
  static #importGeneration = 0;
394
451
  /**
@@ -415,8 +472,34 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
415
472
  app.workspace.getPackageJson(),
416
473
  ]);
417
474
  const resolved = typeof configImp === "function" ? configImp(app) : configImp;
418
- const { plugins, ...config } = (resolved ?? {}) as DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
419
- return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? []);
475
+ const { plugins, ...config } = (resolved ?? {}) as AppConfigDeclaration & { plugins?: AkanPlugin[] };
476
+ const libContributions = await AkanAppConfig.#collectLibContributions(app, libs, bustImportCache);
477
+ return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? [], libContributions);
478
+ }
479
+ //* Every workspace lib is read, not just this app's lib deps: narrowing the set needs the dependency
480
+ //* scan, and the incremental page rebundle re-reads this config on every file change.
481
+ static async #collectLibContributions(app: App, libs: string[], bustImportCache: boolean): Promise<LibContributions> {
482
+ const libConfigs = await Promise.all(
483
+ libs.map(async (libName) =>
484
+ LibExecutor.from(app, libName)
485
+ .getConfig({ refresh: bustImportCache })
486
+ .catch((error: unknown) => {
487
+ app.logger.warn(`Skipped libs/${libName}/akan.config.ts contributions: ${String(error)}`);
488
+ return null;
489
+ }),
490
+ ),
491
+ );
492
+ return {
493
+ externalLibs: libConfigs.flatMap((libConfig) => libConfig?.externalLibs ?? []),
494
+ docker: {
495
+ preRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.preRuns ?? []),
496
+ postRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.postRuns ?? []),
497
+ },
498
+ //* A lib writes the glob against its own `public/`; `akan sync` mounts that at `public/libs/<lib>`.
499
+ keepFonts: libConfigs.flatMap((libConfig) =>
500
+ (libConfig?.assets.keepFonts ?? []).map((glob) => `libs/${libConfig?.lib.name}/${glob}`),
501
+ ),
502
+ };
420
503
  }
421
504
  #resolveProductionDependencyVersion(lib: string) {
422
505
  const rootVersion = this.rootPackageJson.dependencies?.[lib] ?? this.rootPackageJson.devDependencies?.[lib];
@@ -428,13 +511,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
428
511
  return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
429
512
  }
430
513
  #getProductionRuntimePackages() {
431
- return [
432
- ...this.externalLibs,
433
- ...SSR_RUNTIME_PACKAGES,
434
- ...NATIVE_RUNTIME_PACKAGES,
435
- ...DEFAULT_BACKEND_RUNTIME_PACKAGES,
436
- ...this.getDatabaseModeRuntimePackages(),
437
- ];
514
+ return [...this.externalLibs, ...SSR_RUNTIME_PACKAGES, ...this.getDatabaseModeRuntimePackages()];
438
515
  }
439
516
  getDatabaseModeRuntimePackages(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
440
517
  return [...DATABASE_MODE_RUNTIME_PACKAGES[databaseMode]];
@@ -532,15 +609,19 @@ function mergeImageConfig(config: Partial<AkanImageConfig> = {}): AkanImageConfi
532
609
  export class AkanLibConfig implements LibConfigResult {
533
610
  lib: Lib;
534
611
  externalLibs: string[];
612
+ docker: LibDockerConfig;
613
+ assets: LibAssetsConfig;
535
614
  /** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
536
615
  plugins: AkanPlugin[];
537
616
  constructor(lib: Lib, config: DeepPartial<LibConfigResult>, plugins: AkanPlugin[] = []) {
538
617
  this.lib = lib;
539
618
  this.externalLibs = config?.externalLibs ?? [];
619
+ this.docker = { preRuns: config?.docker?.preRuns ?? [], postRuns: config?.docker?.postRuns ?? [] };
620
+ this.assets = { keepFonts: normalizeKeepFonts(config?.assets?.keepFonts as string[] | undefined) };
540
621
  this.plugins = plugins;
541
622
  }
542
- static async from(lib: Lib) {
543
- const [configImp] = await Promise.all([import(`${lib.cwdPath}/akan.config.ts`).then((mod) => mod.default)]);
623
+ static async from(lib: Lib, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
624
+ const configImp = await AkanAppConfig.importConfigModule(lib.cwdPath, { bustImportCache });
544
625
  const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
545
626
  const { plugins, ...config } = (resolved ?? {}) as DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
546
627
  return new AkanLibConfig(lib, config, plugins ?? []);
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ AkanAssetsConfig,
2
3
  AkanConfigFile,
3
4
  AkanExecutor,
4
5
  AkanMobileConfig,
@@ -9,6 +10,8 @@ export type {
9
10
  AkanRouteConfig,
10
11
  AkanScanInfo,
11
12
  AkanSyncContext,
13
+ AkanWebConfig,
14
+ AkanWebOption,
12
15
  AppConfig,
13
16
  AppConfigInput,
14
17
  AppConfigResult,
@@ -17,10 +20,15 @@ export type {
17
20
  DatabaseMode,
18
21
  DeepPartial,
19
22
  DockerConfig,
23
+ DockerImageConfig,
24
+ DockerOption,
25
+ DockerRun,
20
26
  FileConventionScanResult,
27
+ LibAssetsConfig,
21
28
  LibConfig,
22
29
  LibConfigInput,
23
30
  LibConfigResult,
31
+ LibDockerConfig,
24
32
  LibScanResult,
25
33
  MobileEnv,
26
34
  MobilePermission,
package/akanContext.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { capitalize } from "akanjs/common";
4
+ import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
4
5
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
5
6
  import { FileSys } from "./fileSys";
6
7
  import { collectRecipeSources, findInlineRecipeDuplicates, scanRecipes } from "./recipeScanner";
@@ -14,7 +15,7 @@ import {
14
15
  workflowRunArtifactPath,
15
16
  workflowSyncDir,
16
17
  } from "./workflow";
17
- import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
18
+ import { isScannedRootEntry, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
18
19
 
19
20
  export type AkanContextFormat = "json" | "markdown";
20
21
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -276,6 +277,8 @@ const validationCommands = [
276
277
  "akan test <app-or-lib-or-pkg>",
277
278
  "akan build <app-name>",
278
279
  "akan doctor --strict --format json",
280
+ "akan quality scan [--format json]",
281
+ "akan quality ssr [--format json]",
279
282
  ];
280
283
 
281
284
  const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
@@ -618,6 +621,30 @@ export class AkanContextAnalyzer {
618
621
  };
619
622
  }
620
623
 
624
+ // The conventions in AGENTS.md ship with the package, but nothing re-renders them on `bun update` — a workspace
625
+ // keeps whichever release wrote its block until someone re-runs the install. Comparing the stamp against the
626
+ // running devkit is the only signal that the guide an agent is reading is older than the framework it describes.
627
+ static async #agentGuideDrift(workspace: WorkspaceExecutor) {
628
+ const installed = await readDevkitVersion();
629
+ if (!installed) return null;
630
+ const content = await safeReadText(path.join(workspace.workspaceRoot, "AGENTS.md"));
631
+ if (content === null) return null;
632
+ const stamped = extractBlockVersion(content);
633
+ if (stamped === installed) return null;
634
+ const hint = "Re-render the AGENTS.md managed block from the installed framework release.";
635
+ if (!stamped)
636
+ return {
637
+ code: "agent-guide-unstamped",
638
+ message: `AGENTS.md carries no generated-version stamp, so its conventions may predate @akanjs/devkit ${installed}`,
639
+ hint,
640
+ };
641
+ return {
642
+ code: "agent-guide-stale",
643
+ message: `AGENTS.md was generated by @akanjs/devkit ${stamped}, but ${installed} is installed`,
644
+ hint,
645
+ };
646
+ }
647
+
621
648
  static async doctor(
622
649
  workspace: WorkspaceExecutor,
623
650
  {
@@ -639,23 +666,25 @@ export class AkanContextAnalyzer {
639
666
  ),
640
667
  ];
641
668
 
642
- for (const app of context.apps) {
643
- const appPath = path.join(workspace.workspaceRoot, app.path);
644
- for (const entry of await safeReadDir(appPath)) {
645
- if (!isScannedAppRootEntry(entry.name)) continue;
646
- const allowed = entry.isDirectory() ? appRootAllowedDirs.has(entry.name) : appRootAllowedFiles.has(entry.name);
669
+ for (const sys of [...context.apps, ...context.libs]) {
670
+ const sysPath = path.join(workspace.workspaceRoot, sys.path);
671
+ for (const entry of await safeReadDir(sysPath)) {
672
+ if (!isScannedRootEntry(sys.type, entry.name)) continue;
673
+ const allowed = entry.isDirectory()
674
+ ? rootAllowedDirs[sys.type].has(entry.name)
675
+ : rootAllowedFiles[sys.type].has(entry.name);
647
676
  if (!allowed) {
648
677
  const action = repairAction(
649
678
  "module-shape",
650
- `akan repair module-shape --app ${app.name}`,
651
- "Review app root shape and remove or move the unknown entry.",
679
+ `akan repair module-shape --app ${sys.name}`,
680
+ `Review ${sys.type} root shape and remove or move the unknown entry.`,
652
681
  false,
653
682
  );
654
683
  diagnostics.push({
655
684
  severity: "error",
656
- code: "app-root-unknown-entry",
657
- path: `${app.path}/${entry.name}`,
658
- message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in app root: ${app.path}/${entry.name}`,
685
+ code: `${sys.type}-root-unknown-entry`,
686
+ path: `${sys.path}/${entry.name}`,
687
+ message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}`,
659
688
  repairActions: [action],
660
689
  });
661
690
  repairActions.push(action);
@@ -663,6 +692,19 @@ export class AkanContextAnalyzer {
663
692
  }
664
693
  }
665
694
 
695
+ const agentDrift = await AkanContextAnalyzer.#agentGuideDrift(workspace);
696
+ if (agentDrift) {
697
+ const action = repairAction("generated", "akan agent install agents-md", agentDrift.hint, true);
698
+ diagnostics.push({
699
+ severity: "warning",
700
+ code: agentDrift.code,
701
+ path: "AGENTS.md",
702
+ message: agentDrift.message,
703
+ repairActions: [action],
704
+ });
705
+ repairActions.push(action);
706
+ }
707
+
666
708
  for (const sys of [...context.apps, ...context.libs]) {
667
709
  for (const module of sys.modules) {
668
710
  if (!module.abstract.exists) {
@@ -4,7 +4,7 @@ import { AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
4
4
  describe("ApplicationBuildRunner", () => {
5
5
  test("externalizes Akan optional backend dependencies", () => {
6
6
  expect(AKAN_OPTIONAL_BACKEND_EXTERNALS).toEqual(
7
- expect.arrayContaining(["@libsql/client", "bullmq", "croner", "ioredis", "postgres", "protobufjs"]),
7
+ expect.arrayContaining(["@libsql/client", "bullmq", "ioredis", "postgres", "protobufjs"]),
8
8
  );
9
9
  });
10
10
  });
@@ -7,7 +7,13 @@ import type {
7
7
  ApplicationBuildResult,
8
8
  } from "./applicationBuildReporter";
9
9
  import type { App } from "./commandDecorators";
10
- import { AllRoutesBuilder, CsrArtifactBuilder, precompressArtifacts, SsrBaseArtifactBuilder } from "./frontendBuild";
10
+ import {
11
+ AllRoutesBuilder,
12
+ CsrArtifactBuilder,
13
+ FontPruner,
14
+ precompressArtifacts,
15
+ SsrBaseArtifactBuilder,
16
+ } from "./frontendBuild";
11
17
  import { Spinner } from "./spinner";
12
18
 
13
19
  export interface TypecheckOptions {
@@ -15,7 +21,7 @@ export interface TypecheckOptions {
15
21
  incremental?: boolean;
16
22
  }
17
23
 
18
- export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "compress" | "metadata";
24
+ export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
19
25
 
20
26
  export type BuildPhaseResult = ApplicationBuildPhaseResult & { id: BuildPhaseId };
21
27
  export type BuildResult = ApplicationBuildResult;
@@ -37,6 +43,7 @@ const BUILD_PHASE_EMOJIS: Record<BuildPhaseId, string> = {
37
43
  backend: "📦",
38
44
  ssr: "🧭",
39
45
  csr: "🎨",
46
+ assets: "✂️",
40
47
  compress: "🗜️",
41
48
  metadata: "📝",
42
49
  };
@@ -56,7 +63,6 @@ const SSR_RENDER_EXTERNALS = [
56
63
  export const AKAN_OPTIONAL_BACKEND_EXTERNALS = [
57
64
  "@libsql/client",
58
65
  "bullmq",
59
- "croner",
60
66
  "ioredis",
61
67
  "postgres",
62
68
  "protobufjs",
@@ -78,6 +84,7 @@ export class ApplicationBuildRunner {
78
84
  async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
79
85
  // serial build is needed because of Bun.build is unstable for parallel build
80
86
  const phaseOptions = { spinner };
87
+ const { web, assets } = await this.#app.getConfig();
81
88
  await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"), undefined, {
82
89
  spinner,
83
90
  });
@@ -92,18 +99,30 @@ export class ApplicationBuildRunner {
92
99
  await this.#runPhase(
93
100
  "ssr",
94
101
  "Building SSR route artifacts",
95
- () => this.#buildSsr(),
102
+ async () => (web.ssr ? await this.#buildSsr() : null),
96
103
  (result) =>
97
104
  result
98
105
  ? `${result.allRoutes.manifest.routeIds.length} routes, ${result.allRoutes.manifest.knownEntries.length} entries`
99
- : "skipped",
106
+ : web.ssr
107
+ ? "skipped"
108
+ : "disabled by akan.config.ts web.ssr",
100
109
  phaseOptions,
101
110
  );
102
111
  await this.#runPhase(
103
112
  "csr",
104
113
  "Building CSR assets",
105
- () => this.#buildCsr(),
106
- (result) => result?.outputDir ?? "skipped",
114
+ async () => (web.csr ? await this.#buildCsr() : null),
115
+ (result) => result?.outputDir ?? (web.csr ? "skipped" : "disabled by akan.config.ts web.csr"),
116
+ phaseOptions,
117
+ );
118
+ await this.#runPhase(
119
+ "assets",
120
+ "Trimming unread static assets",
121
+ async () => (assets.pruneFonts ? await new FontPruner(this.#app, assets).prune() : null),
122
+ (result) =>
123
+ result
124
+ ? `${result.removed.length} font file(s) dropped, ${ApplicationBuildRunner.formatBytes(result.freedBytes)} freed; ${result.kept.length} kept`
125
+ : "disabled by akan.config.ts assets.pruneFonts",
107
126
  phaseOptions,
108
127
  );
109
128
  await this.#runPhase(
@@ -166,7 +185,7 @@ export class ApplicationBuildRunner {
166
185
  const akanConfig = await this.#app.getConfig();
167
186
  await Promise.all([
168
187
  this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
169
- this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.docker.content),
188
+ this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.dockerfile),
170
189
  ]);
171
190
  }
172
191
 
@@ -175,6 +194,7 @@ export class ApplicationBuildRunner {
175
194
  const backendExternals = [
176
195
  ...new Set([...akanConfig.externalLibs, ...SSR_RENDER_EXTERNALS, ...AKAN_OPTIONAL_BACKEND_EXTERNALS]),
177
196
  ];
197
+ const { web } = akanConfig;
178
198
  const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
179
199
  for (const entrypoint of backendEntryPoints) {
180
200
  if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
@@ -188,17 +208,20 @@ export class ApplicationBuildRunner {
188
208
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
189
209
  plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
190
210
  });
191
- const rscWorkerResult = await this.#buildOrThrow("rsc-worker", {
192
- entrypoints: [this.#resolveRscWorkerBuildEntry()],
193
- outdir: this.#app.dist.cwdPath,
194
- target: "bun",
195
- minify: true,
196
- naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
197
- conditions: ["react-server"],
198
- // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
199
- define: { "process.env.NODE_ENV": JSON.stringify("production") },
200
- plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
201
- });
211
+ // Nothing spawns the RSC worker without SSR, so an api-only image does not carry it.
212
+ const rscWorkerResult = web.ssr
213
+ ? await this.#buildOrThrow("rsc-worker", {
214
+ entrypoints: [this.#resolveRscWorkerBuildEntry()],
215
+ outdir: this.#app.dist.cwdPath,
216
+ target: "bun",
217
+ minify: true,
218
+ naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
219
+ conditions: ["react-server"],
220
+ // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
221
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
222
+ plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
223
+ })
224
+ : null;
202
225
  const consoleRuntimeResult = await this.#buildOrThrow("console-runtime", {
203
226
  entrypoints: [this.#resolveConsoleRuntimeBuildEntry()],
204
227
  outdir: this.#app.dist.cwdPath,
@@ -209,8 +232,9 @@ export class ApplicationBuildRunner {
209
232
  });
210
233
  await this.#writeConsoleShim();
211
234
  return {
212
- entrypoints: backendEntryPoints.length + 2,
213
- outputs: backendResult.outputs.length + rscWorkerResult.outputs.length + consoleRuntimeResult.outputs.length + 1,
235
+ entrypoints: backendEntryPoints.length + (rscWorkerResult ? 2 : 1),
236
+ outputs:
237
+ backendResult.outputs.length + (rscWorkerResult?.outputs.length ?? 0) + consoleRuntimeResult.outputs.length + 1,
214
238
  };
215
239
  }
216
240
 
@@ -55,7 +55,7 @@ interface RootBoundary {
55
55
  segments: string[];
56
56
  }
57
57
 
58
- function getRootBoundarySegments(key: string): string[] | null {
58
+ export function getRootBoundarySegments(key: string): string[] | null {
59
59
  const match = LAYOUT_KEY_RE.exec(key);
60
60
  if (!match) return null;
61
61
  const prefix = match[1]?.replace(/\/$/, "");
@@ -76,7 +76,7 @@ function implicitDictionaryMacroAbsPath(appCwdPath: string): string {
76
76
  return path.join(path.resolve(appCwdPath), IMPLICIT_DICT_DIR, "useDict.ts");
77
77
  }
78
78
 
79
- function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
79
+ export function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
80
80
  const firstVisibleIndex = segments.findIndex((segment) => !/^\(.+\)$/.test(segment));
81
81
  if (firstVisibleIndex === -1) return segments.length <= 1;
82
82
  if (segments.slice(firstVisibleIndex + 1).some((segment) => /^\(.+\)$/.test(segment))) return false;