@akanjs/devkit 3.0.0-alpha.11 → 3.0.0-alpha.110

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 (187) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +34 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +21 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +30 -14
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +37 -2
  22. package/applicationBuildRunner.ts +52 -23
  23. package/artifact/implicitRootLayout.test.ts +70 -1
  24. package/artifact/implicitRootLayout.ts +41 -14
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +28 -1
  27. package/biomeBase.ts +1 -1
  28. package/capacitorApp.ts +10 -0
  29. package/cloud/globalConfig.ts +9 -2
  30. package/codegenLock.test.ts +137 -0
  31. package/codegenLock.ts +136 -0
  32. package/commandDecorators/argMeta.ts +9 -1
  33. package/commandDecorators/command.ts +94 -7
  34. package/commandDecorators/commandBuilder.ts +20 -5
  35. package/commandDecorators/commandDecorators.test.ts +115 -3
  36. package/commandDecorators/helpFormatter.ts +4 -0
  37. package/dependencyScanner.test.ts +99 -0
  38. package/dependencyScanner.ts +27 -24
  39. package/devkitUtils.test.ts +0 -104
  40. package/executors.test.ts +135 -7
  41. package/executors.ts +115 -25
  42. package/fileEditor.ts +19 -19
  43. package/fileSys.ts +19 -1
  44. package/formSetterScanner.test.ts +80 -0
  45. package/formSetterScanner.ts +92 -0
  46. package/frontendBuild/autoImportSync.test.ts +58 -0
  47. package/frontendBuild/autoImportSync.ts +7 -2
  48. package/frontendBuild/buildRouteClient.test.ts +47 -24
  49. package/frontendBuild/clientBuildTypes.ts +4 -0
  50. package/frontendBuild/clientEntriesBundler.ts +4 -1
  51. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  52. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  53. package/frontendBuild/cssCompiler.ts +63 -10
  54. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  55. package/frontendBuild/fontOptimizer.test.ts +75 -2
  56. package/frontendBuild/fontOptimizer.ts +98 -27
  57. package/frontendBuild/fontPruner.test.ts +220 -0
  58. package/frontendBuild/fontPruner.ts +206 -0
  59. package/frontendBuild/frontendBuild.test.ts +124 -10
  60. package/frontendBuild/hmrWatcher.ts +1 -1
  61. package/frontendBuild/index.ts +1 -1
  62. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  63. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  64. package/frontendBuild/routeClientBuilder.ts +12 -5
  65. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  66. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  67. package/frontendBuild/styleContract.ts +14 -20
  68. package/frontendBuild/themeValidator.ts +22 -17
  69. package/frontendBuild/vendorSpecifiers.ts +1 -0
  70. package/frontendBuild/watchRootResolver.test.ts +129 -0
  71. package/frontendBuild/watchRootResolver.ts +66 -1
  72. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  73. package/incrementalBuilder/devWatchBatch.ts +6 -2
  74. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  75. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  76. package/index.ts +0 -7
  77. package/integration/devStabilityHarness.ts +13 -19
  78. package/libSource.test.ts +112 -0
  79. package/libSource.ts +126 -0
  80. package/lint/__fixtures__/README.md +40 -0
  81. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  82. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  83. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  84. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  85. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  86. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  87. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  88. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  89. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  90. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  91. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  92. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  93. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  95. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  96. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  97. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  98. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  99. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  100. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  101. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  102. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  103. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  104. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  105. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  106. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  107. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  108. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  110. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  111. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  112. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  113. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  114. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  116. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  117. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  118. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  119. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  120. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  121. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  122. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  123. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  124. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  125. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  126. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  127. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  128. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  129. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  130. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  131. package/lint/gritRules.test.ts +178 -0
  132. package/lint/no-arbitrary-color.grit +1 -1
  133. package/lint/no-async-component-in-ui.grit +35 -0
  134. package/lint/no-bang-comment-in-client.grit +23 -10
  135. package/lint/no-daisyui-legacy-class.grit +26 -9
  136. package/lint/no-deprecated-log-level.grit +17 -0
  137. package/lint/no-init-fetch-in-client.grit +47 -0
  138. package/lint/no-inline-color.grit +10 -8
  139. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  140. package/lint/no-model-type-in-util-zone.grit +58 -0
  141. package/lint/no-raw-palette-class.grit +3 -3
  142. package/lint/no-unpublished-form-setter.grit +41 -0
  143. package/lint/non-scalar-props-restricted.grit +16 -7
  144. package/linter.test.ts +80 -0
  145. package/linter.ts +82 -13
  146. package/package.json +5 -8
  147. package/prompter.ts +9 -4
  148. package/qualityScanner.test.ts +80 -142
  149. package/qualityScanner.ts +89 -31
  150. package/recipeScanner.ts +4 -1
  151. package/routeSourceValidator.test.ts +63 -0
  152. package/routeSourceValidator.ts +123 -16
  153. package/scanInfo.ts +32 -26
  154. package/semver.test.ts +26 -0
  155. package/semver.ts +31 -0
  156. package/slicePlanner.test.ts +182 -0
  157. package/slicePlanner.ts +235 -0
  158. package/ssrScanner.test.ts +301 -0
  159. package/subspace.test.ts +418 -0
  160. package/subspace.ts +723 -0
  161. package/subspaceConfig.ts +76 -0
  162. package/transforms/asyncDefaultExportDetector.ts +105 -0
  163. package/transforms/barrelImportsPlugin.ts +1 -1
  164. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  165. package/transforms/transforms.test.ts +5 -5
  166. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  167. package/tsconfig.json +6 -2
  168. package/typeChecker.ts +1 -1
  169. package/types.ts +1 -0
  170. package/uploadRelease.ts +2 -2
  171. package/workflow/executor.test.ts +146 -0
  172. package/workspaceLayout.test.ts +56 -4
  173. package/workspaceLayout.ts +48 -2
  174. package/builder.ts +0 -164
  175. package/extractDeps.ts +0 -86
  176. package/frontendBuild/styleGuard.test.ts +0 -165
  177. package/frontendBuild/styleGuard.ts +0 -322
  178. package/getCredentials.ts +0 -19
  179. package/getModelFileData.ts +0 -62
  180. package/mcpScanner.ts +0 -217
  181. package/src/capacitorApp.ts +0 -282
  182. package/storeScanner.ts +0 -173
  183. package/streamAi.ts +0 -45
  184. package/ui/MultiScrollList.tsx +0 -242
  185. package/ui/ScrollList.tsx +0 -107
  186. package/ui/index.ts +0 -2
  187. package/useStdoutDimensions.ts +0 -20
@@ -5,7 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
6
6
  import type { App } from "../commandDecorators";
7
7
  import { ClientEntriesBundler } from "./clientEntriesBundler";
8
- import { CssCompiler } from "./cssCompiler";
8
+ import { CssCompiler, type ImportedStylesheet } from "./cssCompiler";
9
9
  import { FontOptimizer } from "./fontOptimizer";
10
10
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
11
11
  import { RouteClientBuilder } from "./routeClientBuilder";
@@ -77,6 +77,7 @@ export class SsrBaseArtifactBuilder {
77
77
  branches: [...akanConfig.branches],
78
78
  i18n: akanConfig.i18n,
79
79
  imageConfig: akanConfig.images,
80
+ web: akanConfig.web,
80
81
  deepLinkAssociations: Object.values(akanConfig.mobile.targets)
81
82
  .filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
82
83
  .map((target) => ({
@@ -110,7 +111,7 @@ export class SsrBaseArtifactBuilder {
110
111
  const ssrBundle = await new ClientEntriesBundler({
111
112
  app: this.#app,
112
113
  entries: [rscSegmentOutletEntry],
113
- ...RouteClientBuilder.resolveSsrClientExternalOptions(this.#command),
114
+ ...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
114
115
  outputSubdir: "client-ssr",
115
116
  command: this.#command,
116
117
  }).bundle();
@@ -192,7 +193,7 @@ export class SsrBaseArtifactBuilder {
192
193
  Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
193
194
  const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
194
195
  if (!cssText) return [];
195
- return [this.#writeCssAsset(basePath, cssText)];
196
+ return [this.#writeCssAsset(basePath, cssText, cssCompiler.importedStylesheetsByBasePath[basePath] ?? [])];
196
197
  }),
197
198
  ),
198
199
  );
@@ -201,7 +202,7 @@ export class SsrBaseArtifactBuilder {
201
202
  return { cssCompiler, optimizedFonts, cssAssets };
202
203
  }
203
204
 
204
- async #writeCssAsset(basePath: string, cssText: string) {
205
+ async #writeCssAsset(basePath: string, cssText: string, imported: ImportedStylesheet[]) {
205
206
  const cssAssetName = basePath || "root";
206
207
  const preparedCssText = await prepareCssAsset(this.#command, basePath, cssText);
207
208
  const cssHash = Bun.hash(`${basePath}\n${preparedCssText}`).toString(36);
@@ -210,7 +211,23 @@ export class SsrBaseArtifactBuilder {
210
211
  `/_akan/styles/${cssAssetName}-${cssHash}.css`,
211
212
  ];
212
213
  await Bun.write(path.join(this.#absArtifactDir, cssRelPath), preparedCssText);
214
+ SsrBaseArtifactBuilder.#warnDroppedImports(this.#app, cssRelPath, preparedCssText, imported);
213
215
  this.#app.verbose(`[base-artifact] wrote ${preparedCssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
214
216
  return [basePath, { cssUrl, cssRelPath }] as const;
215
217
  }
218
+
219
+ /**
220
+ * Checked against the file written here rather than against the compiled text, because this is the stylesheet
221
+ * `base-artifact.json` points at and therefore the only one an SSR render serves. A declaration can survive
222
+ * the compile and still be missing from the asset — a build that ships CSS to the CSR bundle and not to the
223
+ * server is indistinguishable, in the browser, from a theme that was never written.
224
+ */
225
+ static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
226
+ for (const { cssPath, declaredNames } of imported) {
227
+ if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
228
+ app.logger.warn(
229
+ `[base-artifact] @import ${cssPath} declares ${declaredNames.length} custom propert${declaredNames.length === 1 ? "y" : "ies"} and none of them are in ${cssRelPath}`,
230
+ );
231
+ }
232
+ }
216
233
  }
@@ -1,29 +1,23 @@
1
1
  /**
2
- * styleGuard + themeValidator 결과를 배선(build/dev/lint)이 공유하는 포맷으로 정리한다.
3
- * severity 사다리: style 위반은 severity==="error" 인 것만, theme 위반은 전부 차단(build/CI) 대상.
4
- * dev 는 이 결과를 경고로만 출력한다.
2
+ * Reports the style contract that `akan lint` enforces, in the shape build/dev/lint share.
3
+ *
4
+ * Only contrast lives here. The vocabulary closure — raw palette classes, arbitrary colors, dropped
5
+ * daisyUI slots, inline color literals, interpolated arbitrary values — is enforced by the grit plugins
6
+ * in `lint/*.grit` during the biome run, so it is not re-scanned. Contrast cannot be a lint rule at all:
7
+ * it is arithmetic over resolved token *values*, which no syntactic pattern can reach.
5
8
  */
6
- import type { StyleGuardViolation } from "./styleGuard";
7
9
  import type { ThemeContrastViolation } from "./themeValidator";
8
10
 
9
11
  export interface StyleContractViolations {
10
- style: StyleGuardViolation[];
11
12
  theme: ThemeContrastViolation[];
12
13
  }
13
14
 
14
- export const countBlocking = (v: StyleContractViolations): number =>
15
- v.style.filter((s) => s.severity === "error").length + v.theme.length;
15
+ export const countBlocking = (violations: StyleContractViolations): number => violations.theme.length;
16
16
 
17
- export const formatStyleContract = (v: StyleContractViolations): string => {
18
- const lines: string[] = [];
19
- for (const s of v.style) {
20
- lines.push(` [${s.severity}] ${s.rule} ${s.path}:${s.line}`);
21
- lines.push(` ${s.snippet}`);
22
- lines.push(` → ${s.suggestion}`);
23
- }
24
- for (const t of v.theme) {
25
- lines.push(` [error] contrast ${t.scope} ${t.pair} = ${t.ratio}:1 (min ${t.threshold}:1)`);
26
- lines.push(` → ${t.suggestion}`);
27
- }
28
- return lines.join("\n");
29
- };
17
+ export const formatStyleContract = (violations: StyleContractViolations): string =>
18
+ violations.theme
19
+ .flatMap((theme) => [
20
+ ` [error] contrast ${theme.scope} ${theme.pair} = ${theme.ratio}:1 (min ${theme.threshold}:1)`,
21
+ ` → ${theme.suggestion}`,
22
+ ])
23
+ .join("\n");
@@ -1,13 +1,17 @@
1
1
  /**
2
- * themeValidator — 시맨틱 토큰 페어의 WCAG 콘트라스트 자동 검사 (의존성 0).
2
+ * WCAG contrast checking over the semantic token pairs. No dependencies, pure functions, and no akanjs
3
+ * runtime import — it has to run inside the lint path.
3
4
  *
4
- * 토큰 구조(`--x` ↔ `--x-foreground`)라서 가능해진 검사. AI 가 생성한 팔레트가 "안 읽히는 사이트"가
5
- * 되는 것을 원천 차단한다. akanjs 런타임을 import 하지 않는다 — 순수 함수만.
5
+ * The `--x` / `--x-foreground` pairing is what makes the check possible at all: it says which two values
6
+ * are going to end up on top of each other. That is the guard against a generated palette shipping a site
7
+ * nobody can read.
6
8
  *
7
- * 임계값(WCAG 2.1):
8
- * - 본문/주요 표면 페어(background·primary·secondary·accent·neutral·card·popover): 4.5:1 (AA normal text)
9
- * - 상태/보조 페어(info·success·warning·destructive·open·muted): 3:1 (UI 컴포넌트 / 큰 텍스트 / 보조 표면)
10
- * 현행 styles.css 기본 팔레트(light/dark)는 이 임계값을 모두 통과한다.
9
+ * Thresholds (WCAG 2.1):
10
+ * - body and primary surfaces (background, primary, secondary, accent, neutral, card, popover): 4.5:1,
11
+ * the AA floor for normal text
12
+ * - status and secondary pairs (info, success, warning, destructive, open, muted): 3:1, the AA floor for
13
+ * UI components and large text
14
+ * The shipped light/dark palette in styles.css clears all of them.
11
15
  */
12
16
 
13
17
  export interface ThemeContrastViolation {
@@ -42,13 +46,13 @@ const PAIRS: PairDef[] = [
42
46
  { base: "open", fg: "open-foreground", threshold: 3 },
43
47
  ];
44
48
 
45
- // 검사 대상 스코프. 그 외 셀렉터(.campaign-x 스코프 토큰 등)는 페어 검사에서 제외.
49
+ // Only these scopes are paired up; tokens scoped to something else (`.campaign-x`, …) are left alone.
46
50
  const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
47
51
 
48
52
  export type ThemeTokensByScope = Record<string, Record<string, string>>;
49
53
 
50
54
  export class ThemeValidator {
51
- /** CSS 문자열에서 토큰을 추출해 알려진 테마 스코프 전체를 검사한다. */
55
+ /** Extracts tokens from the css text and checks every scope it recognizes. */
52
56
  validate(css: string): ThemeContrastViolation[] {
53
57
  const tokensByScope = ThemeValidator.parseThemeTokens(css);
54
58
  const violations: ThemeContrastViolation[] = [];
@@ -67,7 +71,7 @@ export class ThemeValidator {
67
71
  if (!bg || !front) continue;
68
72
  const bgRgb = ThemeValidator.parseHex(bg);
69
73
  const fgRgb = ThemeValidator.parseHex(front);
70
- if (!bgRgb || !fgRgb) continue; // var()/비-hex 값은 검사 불가 → 건너뜀
74
+ if (!bgRgb || !fgRgb) continue; // a var() or non-hex value has no ratio to compute
71
75
  const ratio = ThemeValidator.contrastRatio(bgRgb, fgRgb);
72
76
  if (ratio >= threshold) continue;
73
77
  violations.push({
@@ -77,20 +81,21 @@ export class ThemeValidator {
77
81
  foreground: front,
78
82
  ratio: Math.round(ratio * 100) / 100,
79
83
  threshold,
80
- suggestion: `${scope}의 --${base}(${bg})와 --${fg}(${front}) 대비가 ${ratio.toFixed(2)}:1 로 최소 ${threshold}:1 미만입니다. 한쪽을 더 밝게/어둡게 조정해 대비를 확보하세요.`,
84
+ suggestion: `In ${scope}, --${base} (${bg}) against --${fg} (${front}) is ${ratio.toFixed(2)}:1, under the ${threshold}:1 minimum. Lighten or darken one of them until it clears.`,
81
85
  });
82
86
  }
83
87
  return violations;
84
88
  }
85
89
 
86
90
  /**
87
- * `:root` / `[data-theme="…"]` 블록에서 `--token: value` 를 파싱한다. 그룹 셀렉터
88
- * (`:root, [data-theme="dark"] { … }`)는 각 셀렉터에 동일 토큰을 분배. 동일 스코프 재등장 시 나중 값이 이긴다
89
- * (프레임워크-먼저 / 앱-나중 순서로 넘기면 앱 override 가 반영됨).
91
+ * Reads `--token: value` out of `:root` / `[data-theme="…"]` blocks. A grouped selector
92
+ * (`:root, [data-theme="dark"] { … }`) distributes the same tokens to each selector, and a scope that
93
+ * appears twice keeps the later value — so passing framework css first and the app's second reflects the
94
+ * app's overrides.
90
95
  */
91
96
  static parseThemeTokens(css: string): ThemeTokensByScope {
92
97
  const result: ThemeTokensByScope = {};
93
- // 중첩 없는 단순 규칙 블록만 매칭(@theme/@keyframes 등 at-rule 은 셀렉터에 @ 포함이라 제외).
98
+ // Flat rule blocks only; an at-rule (@theme, @keyframes) carries `@` in the selector and is skipped.
94
99
  const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
95
100
  for (const block of css.matchAll(blockRe)) {
96
101
  const selectors = block[1].split(",").map((s) => s.trim());
@@ -109,11 +114,11 @@ export class ThemeValidator {
109
114
  }
110
115
 
111
116
  static #normalizeScope(selector: string): string {
112
- // 따옴표 정규화: [data-theme=dark] / [data-theme='dark'] → [data-theme="dark"]
117
+ // Quote normalization: [data-theme=dark] / [data-theme='dark'] -> [data-theme="dark"]
113
118
  return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
114
119
  }
115
120
 
116
- /** #rgb / #rgba / #rrggbb / #rrggbbaa → [r,g,b] (alpha 무시). 비-hex 는 null. */
121
+ /** #rgb / #rgba / #rrggbb / #rrggbbaa -> [r,g,b], alpha ignored. Null for anything not hex. */
117
122
  static parseHex(value: string): [number, number, number] | null {
118
123
  const v = value.trim();
119
124
  if (!v.startsWith("#")) return null;
@@ -11,6 +11,7 @@ export const VENDOR_SPECIFIERS = [
11
11
  "akanjs/base",
12
12
  "akanjs/common",
13
13
  "akanjs/constant",
14
+ "akanjs/fetch",
14
15
  ] as const;
15
16
 
16
17
  export type VendorSpecifier = (typeof VENDOR_SPECIFIERS)[number];
@@ -0,0 +1,129 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { App } from "../commandDecorators";
6
+ import { WatchRootResolver } from "./watchRootResolver";
7
+
8
+ const roots: string[] = [];
9
+
10
+ const makeWorkspace = async (dirs: string[]) => {
11
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-watch-roots-"));
12
+ roots.push(root);
13
+ await Promise.all(dirs.map((dir) => mkdir(path.join(root, dir), { recursive: true })));
14
+ return root;
15
+ };
16
+
17
+ const writeManifest = (workspaceRoot: string, scope: string, libDeps: unknown) =>
18
+ writeFile(
19
+ path.join(workspaceRoot, scope, scope.startsWith("apps/") ? "akan.app.json" : "akan.lib.json"),
20
+ JSON.stringify({ libDeps }),
21
+ );
22
+
23
+ interface AppStub {
24
+ paths?: Record<string, string[]>;
25
+ libDeps?: string[] | null;
26
+ }
27
+
28
+ const makeApp = (workspaceRoot: string, name: string, { paths = {}, libDeps = null }: AppStub = {}) =>
29
+ ({
30
+ cwdPath: path.join(workspaceRoot, "apps", name),
31
+ workspace: { workspaceRoot },
32
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
33
+ getScanInfo: () => (libDeps ? { type: "app", libDeps } : null),
34
+ }) as unknown as App;
35
+
36
+ afterEach(async () => {
37
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
38
+ });
39
+
40
+ describe("WatchRootResolver", () => {
41
+ test("narrows the apps container to the app being served", async () => {
42
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "apps/app2/page", "libs/util"]);
43
+ const resolved = await new WatchRootResolver(
44
+ makeApp(workspaceRoot, "app1", { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } }),
45
+ ).resolve();
46
+
47
+ expect(resolved).toContain(path.join(workspaceRoot, "apps/app1"));
48
+ expect(resolved).not.toContain(path.join(workspaceRoot, "apps"));
49
+ expect(resolved).not.toContain(path.join(workspaceRoot, "apps/app2"));
50
+ });
51
+
52
+ test("narrows the libs container to the app's own dependencies", async () => {
53
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
54
+ const resolved = await new WatchRootResolver(
55
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] }, libDeps: ["util"] }),
56
+ ).resolve();
57
+
58
+ expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
59
+ expect(resolved).not.toContain(path.join(workspaceRoot, "libs"));
60
+ expect(resolved).not.toContain(path.join(workspaceRoot, "libs/shared"));
61
+ });
62
+
63
+ test("drops the libs container entirely when the app depends on no lib", async () => {
64
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util"]);
65
+ await writeManifest(workspaceRoot, "apps/app1", []);
66
+ const resolved = await new WatchRootResolver(
67
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
68
+ ).resolve();
69
+
70
+ expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
71
+ });
72
+
73
+ test("takes the transitive closure from the synced manifests when nothing scanned in-process", async () => {
74
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared", "libs/unused"]);
75
+ await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
76
+ await writeManifest(workspaceRoot, "libs/shared", ["util"]);
77
+ await writeManifest(workspaceRoot, "libs/util", []);
78
+ await writeManifest(workspaceRoot, "libs/unused", []);
79
+ const resolved = await new WatchRootResolver(
80
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
81
+ ).resolve();
82
+
83
+ expect(resolved).toContain(path.join(workspaceRoot, "libs/shared"));
84
+ expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
85
+ expect(resolved).not.toContain(path.join(workspaceRoot, "libs/unused"));
86
+ });
87
+
88
+ test("keeps the libs container whole when the app manifest is missing", async () => {
89
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
90
+ const resolved = await new WatchRootResolver(
91
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
92
+ ).resolve();
93
+
94
+ expect(resolved).toContain(path.join(workspaceRoot, "libs"));
95
+ });
96
+
97
+ test("keeps the libs container whole when a dependency lib is unsynced", async () => {
98
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
99
+ await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
100
+ const resolved = await new WatchRootResolver(
101
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
102
+ ).resolve();
103
+
104
+ expect(resolved).toContain(path.join(workspaceRoot, "libs"));
105
+ });
106
+
107
+ test("resolves a package alias to its own directory, filename and glob stripped", async () => {
108
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "pkgs/akanjs/base"]);
109
+ const resolved = await new WatchRootResolver(
110
+ makeApp(workspaceRoot, "app1", {
111
+ paths: {
112
+ akanjs: ["./pkgs/akanjs/index.ts"],
113
+ "akanjs/*": ["./pkgs/akanjs/*"],
114
+ missing: ["./pkgs/nothing/index.ts"],
115
+ },
116
+ }),
117
+ ).resolve();
118
+
119
+ expect(resolved).toContain(path.join(workspaceRoot, "pkgs/akanjs"));
120
+ expect(resolved).not.toContain(path.join(workspaceRoot, "pkgs/nothing"));
121
+ });
122
+
123
+ test("always watches the app's page tree", async () => {
124
+ const workspaceRoot = await makeWorkspace(["apps/app1/page"]);
125
+ const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1")).resolve();
126
+
127
+ expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
128
+ });
129
+ });
@@ -1,7 +1,25 @@
1
1
  import fs from "node:fs";
2
+ import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import type { App } from "../commandDecorators";
4
5
 
6
+ /**
7
+ * The directories one app's dev watcher follows, resolved from its tsconfig `paths`.
8
+ *
9
+ * Two aliases strip down to a workspace container rather than a package, and taking either verbatim puts
10
+ * code the app never imports under the watcher — where a save rebuilds, restarts and reloads it for
11
+ * nothing, and both builders rewrite the same generated barrels:
12
+ *
13
+ * - `@apps/*` becomes the `apps/` container. Apps are leaves of the workspace graph — never one another's
14
+ * dependencies — so it is replaced by this app's own directory.
15
+ * - `@libs/*` becomes the `libs/` container. A lib is a dependency only if the app reaches it, so it is
16
+ * replaced by the app's own lib dependencies, transitively. The set is resolved once, at watcher
17
+ * install: a lib that becomes a dependency mid-session needs a fresh `akan start` anyway, because the
18
+ * import that made it one also needs a `sync`.
19
+ *
20
+ * Failing to resolve that set keeps the container whole, because a wrong narrowing is a dev server that
21
+ * silently ignores edits.
22
+ */
5
23
  export class WatchRootResolver {
6
24
  #app: App;
7
25
 
@@ -11,6 +29,10 @@ export class WatchRootResolver {
11
29
 
12
30
  async resolve(): Promise<string[]> {
13
31
  const tsconfig = await this.#app.getTsConfig();
32
+ const appDir = path.resolve(this.#app.cwdPath);
33
+ const appsContainer = path.dirname(appDir);
34
+ const libsContainer = path.resolve(this.#app.workspace.workspaceRoot, "libs");
35
+ const libRoots = await this.#resolveLibRoots(libsContainer);
14
36
  const set = new Set<string>();
15
37
  set.add(path.resolve(`${this.#app.cwdPath}/page`));
16
38
  for (const targets of Object.values(tsconfig.compilerOptions.paths ?? {})) {
@@ -20,9 +42,52 @@ export class WatchRootResolver {
20
42
  // Strip the trailing filename and glob so we watch the package root dir.
21
43
  const cleaned = target.replace(/\/?\*+.*$/, "").replace(/\/[^/]+\.[^/]+$/, "");
22
44
  const resolved = path.resolve(this.#app.workspace.workspaceRoot, cleaned);
23
- if (fs.existsSync(resolved)) set.add(resolved);
45
+ if (resolved === libsContainer && libRoots) {
46
+ for (const root of libRoots) set.add(root);
47
+ continue;
48
+ }
49
+ const root = resolved === appsContainer ? appDir : resolved;
50
+ if (fs.existsSync(root)) set.add(root);
24
51
  }
25
52
  }
26
53
  return [...set];
27
54
  }
55
+
56
+ async #resolveLibRoots(libsContainer: string): Promise<string[] | null> {
57
+ const libDeps = await this.#resolveLibDeps(libsContainer);
58
+ if (!libDeps) return null;
59
+ return libDeps.map((name) => path.join(libsContainer, name)).filter((dir) => fs.existsSync(dir));
60
+ }
61
+
62
+ async #resolveLibDeps(libsContainer: string): Promise<string[] | null> {
63
+ const scanInfo = this.#app.getScanInfo({ allowEmpty: true });
64
+ // Already transitive, and present whenever this runs in a process that scanned. The builder and the
65
+ // idle watcher run in processes that did not, so they take the manifests `scan` wrote instead.
66
+ if (scanInfo?.type === "app") return scanInfo.libDeps;
67
+ const direct = await WatchRootResolver.#readManifestLibDeps(path.join(this.#app.cwdPath, "akan.app.json"));
68
+ if (!direct) return null;
69
+ const closure = new Set<string>();
70
+ const queue = [...direct];
71
+ while (queue.length > 0) {
72
+ const name = queue.shift();
73
+ if (!name || closure.has(name)) continue;
74
+ closure.add(name);
75
+ const nested = await WatchRootResolver.#readManifestLibDeps(path.join(libsContainer, name, "akan.lib.json"));
76
+ // An unsynced lib's own dependencies are unknowable, so no root can be ruled out.
77
+ if (!nested) return null;
78
+ queue.push(...nested);
79
+ }
80
+ return [...closure];
81
+ }
82
+
83
+ static async #readManifestLibDeps(manifestPath: string): Promise<string[] | null> {
84
+ try {
85
+ const parsed = JSON.parse(await readFile(manifestPath, "utf8")) as { libDeps?: unknown };
86
+ if (!Array.isArray(parsed.libDeps)) return null;
87
+ return parsed.libDeps.filter((name): name is string => typeof name === "string" && name.length > 0);
88
+ } catch {
89
+ // Missing before the first sync, and unparseable while sync is mid-write.
90
+ return null;
91
+ }
92
+ }
28
93
  }
@@ -22,27 +22,25 @@ describe("prepareDevWatchBatch", () => {
22
22
  expect(prepared.event.devPlan?.files).toEqual(prepared.files);
23
23
  });
24
24
 
25
- test.each([
26
- "common",
27
- "srvkit",
28
- "ui",
29
- "webkit",
30
- ])("keeps %s facet add/delete generated index in the same generation", (facet) => {
31
- const root = "/repo";
32
- const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
33
- const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
34
- const prepared = prepareDevWatchBatch({
35
- generation: 20,
36
- batch: { files: [changedFile], kinds: new Set(["code"]) },
37
- indexSync: { changedFiles: [generatedIndex], errors: [] },
38
- changePlanner: new DevChangePlanner({ workspaceRoot: root }),
39
- });
25
+ test.each(["common", "srvkit", "ui", "webkit"])(
26
+ "keeps %s facet add/delete generated index in the same generation",
27
+ (facet) => {
28
+ const root = "/repo";
29
+ const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
30
+ const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
31
+ const prepared = prepareDevWatchBatch({
32
+ generation: 20,
33
+ batch: { files: [changedFile], kinds: new Set(["code"]) },
34
+ indexSync: { changedFiles: [generatedIndex], errors: [] },
35
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
36
+ });
40
37
 
41
- expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
42
- expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
43
- expect(prepared.event.devPlan?.roles).toContain("barrel");
44
- expect(prepared.event.devPlan?.actions).toContain("sync-generated");
45
- });
38
+ expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
39
+ expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
40
+ expect(prepared.event.devPlan?.roles).toContain("barrel");
41
+ expect(prepared.event.devPlan?.actions).toContain("sync-generated");
42
+ },
43
+ );
46
44
 
47
45
  test("marks failed generated index sync as an error generation", () => {
48
46
  const root = "/repo";
@@ -1,4 +1,4 @@
1
- import type { BuilderEvent, ChangeBatch } from "akanjs/server";
1
+ import type { BuilderEvent, ChangeBatch, DevChangePlan } from "akanjs/server";
2
2
  import type { DevChangePlanner, GeneratedIndexSyncResult } from "../frontendBuild";
3
3
 
4
4
  export interface PrepareDevWatchBatchOptions {
@@ -12,6 +12,8 @@ export interface PreparedDevWatchBatch {
12
12
  files: string[];
13
13
  kinds: ("code" | "css" | "config")[];
14
14
  expandedBatch: ChangeBatch;
15
+ /** The same plan the event carries, where `devPlan` is optional — read it here, not off the event. */
16
+ devPlan: DevChangePlan;
15
17
  event: Extract<BuilderEvent, { type: "invalidate" }>;
16
18
  hasSyncErrors: boolean;
17
19
  }
@@ -35,13 +37,15 @@ export const prepareDevWatchBatch = ({
35
37
  });
36
38
 
37
39
  if (indexSync.errors.length > 0 && !devPlan.actions.includes("report-error")) {
38
- devPlan.actions = [...devPlan.actions, "report-error"].sort();
40
+ const withReport: DevChangePlan["actions"] = [...devPlan.actions, "report-error"];
41
+ devPlan.actions = withReport.sort();
39
42
  }
40
43
 
41
44
  return {
42
45
  files,
43
46
  kinds,
44
47
  expandedBatch,
48
+ devPlan,
45
49
  event: { type: "invalidate", kinds, files, generation, devPlan },
46
50
  hasSyncErrors: indexSync.errors.length > 0,
47
51
  };
@@ -14,11 +14,25 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
14
14
  "build-status",
15
15
  "builder-metrics",
16
16
  ]);
17
+ /**
18
+ * Where a dev child process writes. `"pipe"` is not only about keeping a TUI's frame clean: a Bun child
19
+ * that inherits the controlling terminal snapshots its termios at spawn and writes that snapshot back
20
+ * when it exits, so a builder spawned while something holds the terminal raw turns it raw again on the
21
+ * first recycle. See the XXX comment on `Spinner.oraOptions`.
22
+ */
23
+ export type DevStdioMode = "inherit" | "pipe";
24
+
17
25
  interface IncrementalBuilderHostOptions {
18
26
  app: App;
19
27
  entry: string;
20
28
  env: Record<string, string>;
29
+ stdio?: DevStdioMode;
21
30
  onMessage: (message: BuilderMessage) => void;
31
+ /**
32
+ * Required when `stdio` is `"pipe"`: an undrained pipe fills and blocks the builder's next write.
33
+ * Text arrives as decoded chunks, not lines — a consumer that needs lines splits them itself.
34
+ */
35
+ onOutput?: (kind: "stdout" | "stderr", text: string) => void;
22
36
  }
23
37
 
24
38
  /**
@@ -63,7 +77,9 @@ export class IncrementalBuilderHost {
63
77
  app: App;
64
78
  ready = false;
65
79
  readonly #onMessage: (message: BuilderMessage) => void;
66
- #proc: Bun.Subprocess<"ignore", "inherit", "inherit"> | null = null;
80
+ readonly #stdio: DevStdioMode;
81
+ readonly #onOutput: ((kind: "stdout" | "stderr", text: string) => void) | null;
82
+ #proc: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
67
83
  #status: IncrementalBuilderStatus = "stopped";
68
84
  #restartAttempts = 0;
69
85
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
@@ -83,11 +99,13 @@ export class IncrementalBuilderHost {
83
99
  */
84
100
  readonly #inFlight = new Map<number, "build-route" | "build-csr">();
85
101
  #startOptions: IncrementalBuilderStartOptions = {};
86
- constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
102
+ constructor({ app, entry, env, stdio = "inherit", onMessage, onOutput }: IncrementalBuilderHostOptions) {
87
103
  this.app = app;
88
104
  this.entry = entry;
89
105
  this.env = env;
106
+ this.#stdio = stdio;
90
107
  this.#onMessage = onMessage;
108
+ this.#onOutput = onOutput ?? null;
91
109
  }
92
110
  get status() {
93
111
  return this.#status;
@@ -115,11 +133,11 @@ export class IncrementalBuilderHost {
115
133
  // the flag is what tells it to re-announce what it booted with.
116
134
  const afterRecycle = this.#spawnAfterRecycle;
117
135
  this.#spawnAfterRecycle = false;
118
- let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
136
+ let proc!: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe">;
119
137
  proc = Bun.spawn(["bun", this.entry], {
120
138
  cwd: this.app.cwdPath,
121
139
  env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
122
- stdio: ["ignore", "inherit", "inherit"],
140
+ stdio: ["ignore", this.#stdio, this.#stdio],
123
141
  ipc: (msg: BuilderMessage) => {
124
142
  if (this.#proc !== proc) return;
125
143
  if (!msg || typeof msg !== "object") return;
@@ -167,8 +185,25 @@ export class IncrementalBuilderHost {
167
185
  },
168
186
  });
169
187
  this.#proc = proc;
188
+ // Re-attached per spawn, not once per host: a recycle or a crash-restart brings new pipes.
189
+ if (this.#stdio === "pipe" && this.#onOutput) {
190
+ void this.#drain(proc.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
191
+ void this.#drain(proc.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
192
+ }
170
193
  this.logger.verbose(`builder spawned pid=${proc.pid} entry=${this.entry}${isRestart ? " restart=1" : ""}`);
171
194
  }
195
+ async #drain(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
196
+ if (!stream) return;
197
+ const decoder = new TextDecoder();
198
+ try {
199
+ for await (const chunk of stream) {
200
+ const text = decoder.decode(chunk, { stream: true });
201
+ if (text) this.#onOutput?.(kind, text);
202
+ }
203
+ } catch {
204
+ // The stream closes when the builder exits; nothing further to surface here.
205
+ }
206
+ }
172
207
  #scheduleRestart() {
173
208
  if (this.#manualStop || this.#restartTimer) return;
174
209
  this.#status = "restarting";
@@ -200,7 +235,7 @@ export class IncrementalBuilderHost {
200
235
  // one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
201
236
  // field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
202
237
  this.#status = "recycling";
203
- this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
238
+ this.logger.debug(`recycling builder pid=${proc.pid} (${reason})`);
204
239
  this.#recycleTimer = setTimeout(() => {
205
240
  this.#recycleTimer = null;
206
241
  if (this.#proc !== proc) return;
@@ -278,7 +313,15 @@ export class IncrementalBuilderHost {
278
313
  this.ready = false;
279
314
  this.#status = "stopped";
280
315
  }
281
- static async create(app: App, env: Record<string, string>, onMessage: (message: BuilderMessage) => void) {
316
+ static async create(
317
+ app: App,
318
+ env: Record<string, string>,
319
+ onMessage: (message: BuilderMessage) => void,
320
+ {
321
+ stdio = "inherit",
322
+ onOutput,
323
+ }: { stdio?: DevStdioMode; onOutput?: (kind: "stdout" | "stderr", text: string) => void } = {},
324
+ ) {
282
325
  const candidates = [
283
326
  path.join(app.workspace.workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts"),
284
327
  path.join(
@@ -289,7 +332,8 @@ export class IncrementalBuilderHost {
289
332
  path.join(import.meta.dir, "incrementalBuilder.proc.ts"),
290
333
  ];
291
334
  for (const c of candidates)
292
- if (await Bun.file(c).exists()) return new IncrementalBuilderHost({ app, entry: c, env, onMessage });
335
+ if (await Bun.file(c).exists())
336
+ return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput });
293
337
  throw new Error(`[cli] frontend builder entry not found; looked in: ${candidates.join(", ")}`);
294
338
  }
295
339
  }