@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.81

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 (172) 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/akanApp/BackendImportGraph.test.ts +120 -0
  8. package/akanApp/BackendImportGraph.ts +167 -0
  9. package/akanApp/akanApp.host.test.ts +16 -678
  10. package/akanApp/akanApp.host.ts +65 -562
  11. package/akanApp/devHostPolicy.test.ts +542 -0
  12. package/akanApp/devHostPolicy.ts +412 -0
  13. package/akanApp/index.ts +2 -0
  14. package/akanConfig/akanConfig.test.ts +182 -14
  15. package/akanConfig/akanConfig.ts +132 -47
  16. package/akanConfig/types.ts +8 -0
  17. package/akanContext.ts +70 -14
  18. package/akanMcpContract.ts +25 -4
  19. package/applicationBuildRunner.test.ts +1 -1
  20. package/applicationBuildRunner.ts +45 -21
  21. package/artifact/implicitRootLayout.test.ts +67 -0
  22. package/artifact/implicitRootLayout.ts +27 -7
  23. package/artifact/routeSeedIndex.test.ts +1 -0
  24. package/biome.base.json +340 -0
  25. package/biomeBase.ts +9 -0
  26. package/capacitorApp.ts +10 -0
  27. package/cloud/globalConfig.ts +9 -2
  28. package/commandDecorators/command.ts +30 -6
  29. package/commandDecorators/commandBuilder.ts +20 -5
  30. package/commandDecorators/commandDecorators.test.ts +115 -3
  31. package/dependencyScanner.test.ts +99 -0
  32. package/dependencyScanner.ts +27 -24
  33. package/devkitUtils.test.ts +0 -104
  34. package/executors.test.ts +132 -5
  35. package/executors.ts +89 -14
  36. package/fileEditor.ts +19 -19
  37. package/formSetterScanner.test.ts +80 -0
  38. package/formSetterScanner.ts +92 -0
  39. package/frontendBuild/autoImportSync.test.ts +58 -0
  40. package/frontendBuild/autoImportSync.ts +4 -0
  41. package/frontendBuild/buildRouteClient.test.ts +47 -24
  42. package/frontendBuild/clientBuildTypes.ts +4 -0
  43. package/frontendBuild/clientEntriesBundler.ts +4 -1
  44. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  45. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  46. package/frontendBuild/cssCompiler.ts +123 -12
  47. package/frontendBuild/cssImportResolver.ts +8 -7
  48. package/frontendBuild/fontOptimizer.ts +37 -18
  49. package/frontendBuild/fontPruner.test.ts +220 -0
  50. package/frontendBuild/fontPruner.ts +206 -0
  51. package/frontendBuild/frontendBuild.test.ts +178 -10
  52. package/frontendBuild/hmrWatcher.ts +1 -1
  53. package/frontendBuild/index.ts +1 -1
  54. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  55. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  56. package/frontendBuild/routeClientBuilder.ts +12 -5
  57. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  58. package/frontendBuild/styleContract.ts +14 -20
  59. package/frontendBuild/themeValidator.ts +22 -17
  60. package/frontendBuild/vendorSpecifiers.ts +1 -0
  61. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  62. package/incrementalBuilder/devWatchBatch.ts +6 -2
  63. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  64. package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
  65. package/index.ts +0 -5
  66. package/integration/devStabilityHarness.ts +2 -10
  67. package/libSource.test.ts +109 -0
  68. package/libSource.ts +126 -0
  69. package/lint/__fixtures__/README.md +40 -0
  70. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  71. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  72. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  73. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  74. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  75. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  76. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  77. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  78. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  79. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  80. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  81. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  82. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  83. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  84. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  85. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  86. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  87. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  88. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  89. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  90. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  91. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  92. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  93. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  94. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  95. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  96. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  97. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  98. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  99. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  100. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  101. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  102. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  103. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  104. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  105. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  106. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  107. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  108. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  109. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  110. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  111. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  112. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  113. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  114. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  116. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  117. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  118. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  119. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  120. package/lint/gritRules.test.ts +178 -0
  121. package/lint/no-arbitrary-color.grit +1 -1
  122. package/lint/no-async-component-in-ui.grit +35 -0
  123. package/lint/no-bang-comment-in-client.grit +23 -10
  124. package/lint/no-daisyui-legacy-class.grit +26 -9
  125. package/lint/no-deprecated-log-level.grit +17 -0
  126. package/lint/no-import-client-in-server.grit +48 -0
  127. package/lint/no-import-server-in-client.grit +45 -0
  128. package/lint/no-init-fetch-in-client.grit +47 -0
  129. package/lint/no-inline-color.grit +10 -8
  130. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  131. package/lint/no-model-type-in-util-zone.grit +58 -0
  132. package/lint/no-raw-palette-class.grit +3 -3
  133. package/lint/no-unpublished-form-setter.grit +41 -0
  134. package/lint/non-scalar-props-restricted.grit +16 -7
  135. package/linter.test.ts +80 -0
  136. package/linter.ts +99 -25
  137. package/package.json +6 -6
  138. package/prompter.ts +9 -4
  139. package/qualityScanner.test.ts +116 -0
  140. package/qualityScanner.ts +89 -21
  141. package/recipeScanner.ts +4 -1
  142. package/repoIdentity.ts +42 -0
  143. package/scanInfo.ts +32 -26
  144. package/semver.test.ts +26 -0
  145. package/semver.ts +31 -0
  146. package/slicePlanner.test.ts +182 -0
  147. package/slicePlanner.ts +235 -0
  148. package/ssrScanner.test.ts +301 -0
  149. package/subspace.test.ts +418 -0
  150. package/subspace.ts +723 -0
  151. package/subspaceConfig.ts +76 -0
  152. package/transforms/asyncDefaultExportDetector.ts +103 -0
  153. package/transforms/barrelImportsPlugin.ts +1 -1
  154. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  155. package/transforms/transforms.test.ts +5 -5
  156. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  157. package/tsconfig.json +6 -2
  158. package/typeChecker.ts +1 -1
  159. package/types.ts +1 -0
  160. package/ui/ScrollList.tsx +6 -8
  161. package/uploadRelease.ts +2 -2
  162. package/workflow/executor.test.ts +146 -0
  163. package/workspaceLayout.test.ts +56 -4
  164. package/workspaceLayout.ts +49 -4
  165. package/builder.ts +0 -164
  166. package/extractDeps.ts +0 -86
  167. package/frontendBuild/styleGuard.test.ts +0 -165
  168. package/frontendBuild/styleGuard.ts +0 -322
  169. package/getCredentials.ts +0 -19
  170. package/getModelFileData.ts +0 -62
  171. package/src/capacitorApp.ts +0 -282
  172. package/streamAi.ts +0 -45
@@ -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,34 @@ 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
+ // File logging is off in the image: a container's writable layer is ephemeral and nothing collects a
423
+ // file from it, so the rotating files would only fill the node disk (50MB x 100 at `trace`). stdout is
424
+ // the collection path; a deployment that wants the files back sets `AKAN_LOG_TO_FILE=1`.
425
+ const webEnvLines = [
426
+ ...(this.web.ssr ? [] : ["ENV AKAN_SSR=false"]),
427
+ ...(this.web.csr ? [] : ["ENV AKAN_CSR=false"]),
428
+ ].join("\n");
429
+ return `${imageScript}
430
+ 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
431
  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
432
  ARG TARGETARCH
366
433
  ${preRunScripts.join("\n")}
367
434
  RUN mkdir -p /workspace
@@ -380,15 +447,9 @@ ${this.basePaths.size ? `ENV AKAN_PUBLIC_BASE_PATHS=${[...this.basePaths].join("
380
447
  ENV AKAN_PUBLIC_DEFAULT_LOCALE=${this.i18n.defaultLocale}
381
448
  ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
382
449
  ENV AKAN_PUBLIC_OPERATION_MODE=cloud
383
-
450
+ ENV AKAN_LOG_TO_FILE=0
451
+ ${webEnvLines}
384
452
  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
453
  }
393
454
  static #importGeneration = 0;
394
455
  /**
@@ -415,8 +476,34 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
415
476
  app.workspace.getPackageJson(),
416
477
  ]);
417
478
  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 ?? []);
479
+ const { plugins, ...config } = (resolved ?? {}) as AppConfigDeclaration & { plugins?: AkanPlugin[] };
480
+ const libContributions = await AkanAppConfig.#collectLibContributions(app, libs, bustImportCache);
481
+ return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? [], libContributions);
482
+ }
483
+ //* Every workspace lib is read, not just this app's lib deps: narrowing the set needs the dependency
484
+ //* scan, and the incremental page rebundle re-reads this config on every file change.
485
+ static async #collectLibContributions(app: App, libs: string[], bustImportCache: boolean): Promise<LibContributions> {
486
+ const libConfigs = await Promise.all(
487
+ libs.map(async (libName) =>
488
+ LibExecutor.from(app, libName)
489
+ .getConfig({ refresh: bustImportCache })
490
+ .catch((error: unknown) => {
491
+ app.logger.warn(`Skipped libs/${libName}/akan.config.ts contributions: ${String(error)}`);
492
+ return null;
493
+ }),
494
+ ),
495
+ );
496
+ return {
497
+ externalLibs: libConfigs.flatMap((libConfig) => libConfig?.externalLibs ?? []),
498
+ docker: {
499
+ preRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.preRuns ?? []),
500
+ postRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.postRuns ?? []),
501
+ },
502
+ //* A lib writes the glob against its own `public/`; `akan sync` mounts that at `public/libs/<lib>`.
503
+ keepFonts: libConfigs.flatMap((libConfig) =>
504
+ (libConfig?.assets.keepFonts ?? []).map((glob) => `libs/${libConfig?.lib.name}/${glob}`),
505
+ ),
506
+ };
420
507
  }
421
508
  #resolveProductionDependencyVersion(lib: string) {
422
509
  const rootVersion = this.rootPackageJson.dependencies?.[lib] ?? this.rootPackageJson.devDependencies?.[lib];
@@ -428,13 +515,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
428
515
  return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
429
516
  }
430
517
  #getProductionRuntimePackages() {
431
- return [
432
- ...this.externalLibs,
433
- ...SSR_RUNTIME_PACKAGES,
434
- ...NATIVE_RUNTIME_PACKAGES,
435
- ...DEFAULT_BACKEND_RUNTIME_PACKAGES,
436
- ...this.getDatabaseModeRuntimePackages(),
437
- ];
518
+ return [...this.externalLibs, ...SSR_RUNTIME_PACKAGES, ...this.getDatabaseModeRuntimePackages()];
438
519
  }
439
520
  getDatabaseModeRuntimePackages(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
440
521
  return [...DATABASE_MODE_RUNTIME_PACKAGES[databaseMode]];
@@ -532,15 +613,19 @@ function mergeImageConfig(config: Partial<AkanImageConfig> = {}): AkanImageConfi
532
613
  export class AkanLibConfig implements LibConfigResult {
533
614
  lib: Lib;
534
615
  externalLibs: string[];
616
+ docker: LibDockerConfig;
617
+ assets: LibAssetsConfig;
535
618
  /** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
536
619
  plugins: AkanPlugin[];
537
620
  constructor(lib: Lib, config: DeepPartial<LibConfigResult>, plugins: AkanPlugin[] = []) {
538
621
  this.lib = lib;
539
622
  this.externalLibs = config?.externalLibs ?? [];
623
+ this.docker = { preRuns: config?.docker?.preRuns ?? [], postRuns: config?.docker?.postRuns ?? [] };
624
+ this.assets = { keepFonts: normalizeKeepFonts(config?.assets?.keepFonts as string[] | undefined) };
540
625
  this.plugins = plugins;
541
626
  }
542
- static async from(lib: Lib) {
543
- const [configImp] = await Promise.all([import(`${lib.cwdPath}/akan.config.ts`).then((mod) => mod.default)]);
627
+ static async from(lib: Lib, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
628
+ const configImp = await AkanAppConfig.importConfigModule(lib.cwdPath, { bustImportCache });
544
629
  const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
545
630
  const { plugins, ...config } = (resolved ?? {}) as DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
546
631
  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";
@@ -129,15 +130,29 @@ export type CursorMcpConfig = {
129
130
  mcpServers?: Record<string, unknown>;
130
131
  };
131
132
 
132
- export const resourceList = [
133
+ export const guidelineResourceUri = (name: string) => `akan://guidelines/${name}`;
134
+
135
+ /**
136
+ * Resources that exist whatever the workspace holds. The guideline entries are **not** here: they are
137
+ * one per directory under `cli/guidelines/`, and a hardcoded pair listed 2 of 31 while `readResource`
138
+ * threw on the other 29. `buildResourceList` is the only place the two halves meet.
139
+ */
140
+ const staticResourceList = [
133
141
  { uri: "akan://docs/framework", name: "Akan framework guide", mimeType: "text/markdown" },
134
- { uri: "akan://guidelines/framework", name: "Framework guideline", mimeType: "text/markdown" },
135
- { uri: "akan://guidelines/modelSignal", name: "Model signal guideline", mimeType: "text/markdown" },
136
142
  { uri: "akan://workspace/summary", name: "Workspace summary", mimeType: "application/json" },
137
143
  { uri: "akan://workspace/apps", name: "Workspace apps", mimeType: "application/json" },
138
144
  { uri: "akan://workspace/modules", name: "Workspace modules", mimeType: "application/json" },
139
145
  ];
140
146
 
147
+ export const buildResourceList = (guidelineNames: readonly string[]) => [
148
+ ...staticResourceList,
149
+ ...guidelineNames.map((name) => ({
150
+ uri: guidelineResourceUri(name),
151
+ name: `${name} guideline`,
152
+ mimeType: "text/markdown",
153
+ })),
154
+ ];
155
+
141
156
  export const cursorMcpConfigPath = ".cursor/mcp.json";
142
157
  // Claude Code reads project-scoped MCP servers from `.mcp.json` at the workspace root.
143
158
  export const claudeMcpConfigPath = ".mcp.json";
@@ -276,6 +291,8 @@ const validationCommands = [
276
291
  "akan test <app-or-lib-or-pkg>",
277
292
  "akan build <app-name>",
278
293
  "akan doctor --strict --format json",
294
+ "akan quality scan [--format json]",
295
+ "akan quality ssr [--format json]",
279
296
  ];
280
297
 
281
298
  const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
@@ -618,6 +635,30 @@ export class AkanContextAnalyzer {
618
635
  };
619
636
  }
620
637
 
638
+ // The conventions in AGENTS.md ship with the package, but nothing re-renders them on `bun update` — a workspace
639
+ // keeps whichever release wrote its block until someone re-runs the install. Comparing the stamp against the
640
+ // running devkit is the only signal that the guide an agent is reading is older than the framework it describes.
641
+ static async #agentGuideDrift(workspace: WorkspaceExecutor) {
642
+ const installed = await readDevkitVersion();
643
+ if (!installed) return null;
644
+ const content = await safeReadText(path.join(workspace.workspaceRoot, "AGENTS.md"));
645
+ if (content === null) return null;
646
+ const stamped = extractBlockVersion(content);
647
+ if (stamped === installed) return null;
648
+ const hint = "Re-render the AGENTS.md managed block from the installed framework release.";
649
+ if (!stamped)
650
+ return {
651
+ code: "agent-guide-unstamped",
652
+ message: `AGENTS.md carries no generated-version stamp, so its conventions may predate @akanjs/devkit ${installed}`,
653
+ hint,
654
+ };
655
+ return {
656
+ code: "agent-guide-stale",
657
+ message: `AGENTS.md was generated by @akanjs/devkit ${stamped}, but ${installed} is installed`,
658
+ hint,
659
+ };
660
+ }
661
+
621
662
  static async doctor(
622
663
  workspace: WorkspaceExecutor,
623
664
  {
@@ -639,23 +680,25 @@ export class AkanContextAnalyzer {
639
680
  ),
640
681
  ];
641
682
 
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);
683
+ for (const sys of [...context.apps, ...context.libs]) {
684
+ const sysPath = path.join(workspace.workspaceRoot, sys.path);
685
+ for (const entry of await safeReadDir(sysPath)) {
686
+ if (!isScannedRootEntry(sys.type, entry.name)) continue;
687
+ const allowed = entry.isDirectory()
688
+ ? rootAllowedDirs[sys.type].has(entry.name)
689
+ : rootAllowedFiles[sys.type].has(entry.name);
647
690
  if (!allowed) {
648
691
  const action = repairAction(
649
692
  "module-shape",
650
- `akan repair module-shape --app ${app.name}`,
651
- "Review app root shape and remove or move the unknown entry.",
693
+ `akan repair module-shape --app ${sys.name}`,
694
+ `Review ${sys.type} root shape and remove or move the unknown entry.`,
652
695
  false,
653
696
  );
654
697
  diagnostics.push({
655
698
  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}`,
699
+ code: `${sys.type}-root-unknown-entry`,
700
+ path: `${sys.path}/${entry.name}`,
701
+ message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}`,
659
702
  repairActions: [action],
660
703
  });
661
704
  repairActions.push(action);
@@ -663,6 +706,19 @@ export class AkanContextAnalyzer {
663
706
  }
664
707
  }
665
708
 
709
+ const agentDrift = await AkanContextAnalyzer.#agentGuideDrift(workspace);
710
+ if (agentDrift) {
711
+ const action = repairAction("generated", "akan agent install agents-md", agentDrift.hint, true);
712
+ diagnostics.push({
713
+ severity: "warning",
714
+ code: agentDrift.code,
715
+ path: "AGENTS.md",
716
+ message: agentDrift.message,
717
+ repairActions: [action],
718
+ });
719
+ repairActions.push(action);
720
+ }
721
+
666
722
  for (const sys of [...context.apps, ...context.libs]) {
667
723
  for (const module of sys.modules) {
668
724
  if (!module.abstract.exists) {
@@ -391,10 +391,31 @@ export const applyMcpTools: McpToolDefinition[] = [
391
391
  },
392
392
  ];
393
393
 
394
- export const listAkanMcpTools = (mode: "readonly" | "plan" | "apply" = "readonly") => {
395
- if (mode === "readonly") return readonlyMcpTools;
396
- if (mode === "plan") return [...readonlyMcpTools, ...planMcpTools];
397
- return [...readonlyMcpTools, ...planMcpTools, ...applyMcpTools];
394
+ /**
395
+ * `guidelineNames` fills the `get_guideline` enum. Without it the argument is a bare string and a model
396
+ * has to guess one of the ~30 directory names, which it cannot see from here — the names are a
397
+ * filesystem listing, so they can only arrive from a caller that already read it.
398
+ */
399
+ export const listAkanMcpTools = (
400
+ mode: "readonly" | "plan" | "apply" = "readonly",
401
+ { guidelineNames = [] }: { guidelineNames?: readonly string[] } = {},
402
+ ) => {
403
+ const tools =
404
+ mode === "readonly"
405
+ ? readonlyMcpTools
406
+ : mode === "plan"
407
+ ? [...readonlyMcpTools, ...planMcpTools]
408
+ : [...readonlyMcpTools, ...planMcpTools, ...applyMcpTools];
409
+ if (guidelineNames.length === 0) return tools;
410
+ return tools.map((tool) =>
411
+ tool.name === "get_guideline"
412
+ ? {
413
+ ...tool,
414
+ description: `${tool.description ?? ""} One of: ${guidelineNames.join(", ")}.`.trim(),
415
+ inputSchema: { ...tool.inputSchema, properties: { name: { type: "string", enum: [...guidelineNames] } } },
416
+ }
417
+ : tool,
418
+ );
398
419
  };
399
420
 
400
421
  export const createAkanValidationContract = (listTools = listAkanMcpTools) => ({
@@ -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
  });