handoff-app 1.2.0-0 → 1.2.0-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/app-builder/build.js +24 -10
  2. package/dist/app-builder/config-diff/index.d.ts +1 -1
  3. package/dist/app-builder/config-diff/registry.d.ts +2 -2
  4. package/dist/app-builder/config-diff/registry.js +2 -2
  5. package/dist/app-builder/config-diff/strategies/pattern.js +14 -1
  6. package/dist/app-builder/config-diff/types.d.ts +15 -1
  7. package/dist/app-builder/paths.js +18 -1
  8. package/dist/app-builder/watchers.js +35 -3
  9. package/dist/app-builder/websocket.js +10 -4
  10. package/dist/cli.js +1 -1
  11. package/dist/config/normalizers/pattern.d.ts +0 -1
  12. package/dist/config/normalizers/pattern.js +0 -3
  13. package/dist/config/runtime.js +10 -4
  14. package/dist/pipeline/patterns.d.ts +3 -1
  15. package/dist/pipeline/patterns.js +2 -2
  16. package/dist/transformers/plugins/csf-render.js +2 -1
  17. package/dist/transformers/plugins/ssr-render.js +2 -1
  18. package/dist/transformers/preview/component/api.d.ts +2 -13
  19. package/dist/transformers/preview/component/api.js +7 -46
  20. package/dist/transformers/preview/component/artifacts.d.ts +4 -0
  21. package/dist/transformers/preview/component/artifacts.js +126 -0
  22. package/dist/transformers/preview/component/builder.js +62 -6
  23. package/dist/transformers/preview/component/css.d.ts +2 -0
  24. package/dist/transformers/preview/component/css.js +25 -9
  25. package/dist/transformers/preview/component/javascript.d.ts +1 -1
  26. package/dist/transformers/preview/component/javascript.js +45 -19
  27. package/dist/transformers/preview/component/summary.d.ts +6 -0
  28. package/dist/transformers/preview/component/summary.js +55 -0
  29. package/dist/transformers/preview/component.js +3 -25
  30. package/dist/transformers/preview/pattern/api.d.ts +4 -0
  31. package/dist/transformers/preview/pattern/api.js +19 -1
  32. package/dist/transformers/preview/pattern/builder.d.ts +9 -1
  33. package/dist/transformers/preview/pattern/builder.js +123 -42
  34. package/dist/transformers/utils/vite-logger.js +4 -4
  35. package/dist/transformers/vite-config.js +2 -0
  36. package/dist/utils/duration.d.ts +2 -0
  37. package/dist/utils/duration.js +6 -0
  38. package/dist/utils/logger.d.ts +42 -5
  39. package/dist/utils/logger.js +135 -15
  40. package/package.json +1 -1
  41. package/src/app/pages/system/pattern/[pattern]/index.tsx +178 -55
  42. package/src/app-builder/build.ts +26 -10
  43. package/src/app-builder/config-diff/index.ts +1 -1
  44. package/src/app-builder/config-diff/registry.ts +3 -3
  45. package/src/app-builder/config-diff/strategies/pattern.ts +17 -2
  46. package/src/app-builder/config-diff/types.ts +16 -1
  47. package/src/app-builder/paths.ts +22 -1
  48. package/src/app-builder/watchers.ts +15 -3
  49. package/src/app-builder/websocket.ts +16 -5
  50. package/src/cli.ts +1 -1
  51. package/src/config/normalizers/pattern.ts +0 -7
  52. package/src/config/runtime.ts +11 -4
  53. package/src/pipeline/patterns.ts +5 -3
  54. package/src/transformers/plugins/csf-render.ts +2 -1
  55. package/src/transformers/plugins/ssr-render.ts +2 -1
  56. package/src/transformers/preview/component/api.ts +2 -50
  57. package/src/transformers/preview/component/artifacts.ts +131 -0
  58. package/src/transformers/preview/component/builder.ts +72 -6
  59. package/src/transformers/preview/component/css.ts +26 -10
  60. package/src/transformers/preview/component/javascript.ts +50 -23
  61. package/src/transformers/preview/component/summary.ts +54 -0
  62. package/src/transformers/preview/component.ts +2 -1
  63. package/src/transformers/preview/pattern/api.ts +17 -0
  64. package/src/transformers/preview/pattern/builder.ts +156 -58
  65. package/src/transformers/utils/vite-logger.ts +4 -4
  66. package/src/transformers/vite-config.ts +2 -0
  67. package/src/utils/duration.ts +3 -0
  68. package/src/utils/logger.ts +143 -16
@@ -46,6 +46,23 @@ export const writePatternSummaryApi = async (
46
46
  );
47
47
  };
48
48
 
49
+ /**
50
+ * Reads the pattern list summary when present (used for partial pattern rebuilds).
51
+ */
52
+ export const readPatternSummaryApi = async (handoff: Handoff): Promise<PatternListObject[] | null> => {
53
+ const summaryPath = path.resolve(handoff.workingPath, 'public/api/patterns.json');
54
+ if (!fs.existsSync(summaryPath)) {
55
+ return null;
56
+ }
57
+ try {
58
+ const raw = await fs.readFile(summaryPath, 'utf8');
59
+ const parsed = JSON.parse(raw) as PatternListObject[];
60
+ return Array.isArray(parsed) ? parsed : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
49
66
  export const syncPatternArtifacts = async (
50
67
  handoff: Handoff,
51
68
  validIds: Iterable<string>
@@ -2,93 +2,191 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import Handoff from '../../../index';
4
4
  import { Logger } from '../../../utils/logger';
5
- import { PatternListObject } from '../types';
6
- import { syncPatternArtifacts, writePatternApi, writePatternHtml, writePatternSummaryApi } from './api';
5
+ import { PatternListObject, PatternObject } from '../types';
6
+ import { readPatternSummaryApi, syncPatternArtifacts, writePatternApi, writePatternHtml, writePatternSummaryApi } from './api';
7
7
  import { composePatternHtml } from './html';
8
8
 
9
+ export type ProcessPatternsOptions = {
10
+ /** When set, only these pattern ids are recomposed; others keep existing artifacts. */
11
+ onlyPatternIds?: Set<string>;
12
+ };
13
+
14
+ /**
15
+ * Returns pattern ids whose declarations reference any of the given component ids.
16
+ */
17
+ export const getPatternIdsReferencingComponents = (handoff: Handoff, componentIds: string[]): string[] => {
18
+ if (componentIds.length === 0) {
19
+ return [];
20
+ }
21
+ const idSet = new Set(componentIds);
22
+ const runtimePatterns = handoff.runtimeConfig?.entries?.patterns ?? {};
23
+ const result: string[] = [];
24
+
25
+ for (const patternId of Object.keys(runtimePatterns)) {
26
+ const pattern = runtimePatterns[patternId];
27
+ if (pattern.components?.some((ref) => idSet.has(ref.id))) {
28
+ result.push(patternId);
29
+ }
30
+ }
31
+
32
+ return result;
33
+ };
34
+
35
+ async function buildPattern(
36
+ handoff: Handoff,
37
+ patternId: string,
38
+ pattern: PatternObject,
39
+ componentOutputDir: string,
40
+ basePath: string
41
+ ): Promise<PatternListObject | null> {
42
+ const fragments: { componentId: string; html: string }[] = [];
43
+ let hasErrors = false;
44
+
45
+ for (let i = 0; i < pattern.components.length; i++) {
46
+ const ref = pattern.components[i];
47
+
48
+ if (ref.resolved === false && !ref.resolvedPreview) {
49
+ hasErrors = true;
50
+ continue;
51
+ }
52
+
53
+ const previewKey = ref.resolvedPreview || ref.preview;
54
+ if (!previewKey) {
55
+ const error =
56
+ `Pattern "${patternId}" component[${i}] ("${ref.id}") has no resolved preview key. Skipping.`;
57
+ Logger.warn(error);
58
+ ref.resolved = false;
59
+ hasErrors = true;
60
+ continue;
61
+ }
62
+ const htmlFileName = `${ref.id}-${previewKey}.html`;
63
+ const htmlFilePath = path.resolve(componentOutputDir, htmlFileName);
64
+
65
+ if (!fs.existsSync(htmlFilePath)) {
66
+ const error =
67
+ `Pattern "${patternId}" component[${i}] ("${ref.id}") preview file not found: ${htmlFileName}. ` +
68
+ `Ensure the component has a preview named "${previewKey}".`;
69
+ Logger.warn(error);
70
+ ref.resolved = false;
71
+ hasErrors = true;
72
+ continue;
73
+ }
74
+
75
+ ref.resolved = true;
76
+ const html = await fs.readFile(htmlFilePath, 'utf8');
77
+ fragments.push({ componentId: ref.id, html });
78
+ }
79
+
80
+ if (fragments.length === 0) {
81
+ Logger.warn(`Pattern "${patternId}" produced no fragments. Skipping.`);
82
+ return null;
83
+ }
84
+
85
+ if (hasErrors) {
86
+ Logger.warn(`Pattern "${patternId}" has missing components but will be composed from available fragments.`);
87
+ }
88
+
89
+ const composedHtml = composePatternHtml(patternId, pattern.title, fragments, basePath);
90
+ const patternUrl = `${patternId}.html`;
91
+
92
+ const patternData: PatternListObject = {
93
+ ...pattern,
94
+ id: patternId,
95
+ url: patternUrl,
96
+ path: `${basePath}/api/pattern/${patternId}.json`,
97
+ };
98
+
99
+ await writePatternHtml(patternId, composedHtml, handoff);
100
+ await writePatternApi(patternId, patternData, handoff);
101
+
102
+ Logger.debug(`Built pattern: ${patternId} (${fragments.length} component(s))`);
103
+ return patternData;
104
+ }
105
+
9
106
  /**
10
107
  * Process all pattern declarations and compose their HTML from pre-built
11
108
  * component preview files. This function must run AFTER buildComponents
12
109
  * so that all component preview HTML files already exist on disk.
13
110
  */
14
- export async function processPatterns(handoff: Handoff): Promise<PatternListObject[]> {
111
+ export async function processPatterns(handoff: Handoff, options?: ProcessPatternsOptions): Promise<PatternListObject[]> {
15
112
  const runtimePatterns = handoff.runtimeConfig?.entries?.patterns ?? {};
16
113
  const patternIds = Object.keys(runtimePatterns);
17
- const result: PatternListObject[] = [];
114
+ const onlyPatternIds = options?.onlyPatternIds;
115
+ const isPartial = onlyPatternIds !== undefined;
18
116
 
19
117
  if (patternIds.length === 0) {
20
118
  await syncPatternArtifacts(handoff, []);
21
- await writePatternSummaryApi(handoff, result);
22
- return result;
119
+ await writePatternSummaryApi(handoff, []);
120
+ return [];
23
121
  }
24
122
 
25
- Logger.info(`Building ${patternIds.length} pattern(s)...`);
26
-
27
123
  const componentOutputDir = path.resolve(handoff.workingPath, 'public/api/component');
28
124
  const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
29
125
 
30
- for (const patternId of patternIds) {
31
- const pattern = runtimePatterns[patternId];
32
- const fragments: { componentId: string; html: string }[] = [];
33
- let hasErrors = false;
34
-
35
- for (let i = 0; i < pattern.components.length; i++) {
36
- const ref = pattern.components[i];
37
-
38
- if (ref.resolved === false && !ref.resolvedPreview) {
39
- hasErrors = true;
40
- continue;
41
- }
126
+ if (!isPartial) {
127
+ Logger.info(`Building ${patternIds.length} pattern(s)...`);
128
+ const result: PatternListObject[] = [];
42
129
 
43
- const previewKey = ref.resolvedPreview || ref.preview || 'default';
44
- const htmlFileName = `${ref.id}-${previewKey}.html`;
45
- const htmlFilePath = path.resolve(componentOutputDir, htmlFileName);
46
-
47
- if (!fs.existsSync(htmlFilePath)) {
48
- const error =
49
- `Pattern "${patternId}" component[${i}] ("${ref.id}") preview file not found: ${htmlFileName}. ` +
50
- `Ensure the component has a preview named "${previewKey}".`;
51
- Logger.warn(error);
52
- ref.resolved = false;
53
- hasErrors = true;
54
- continue;
130
+ for (const patternId of patternIds) {
131
+ const pattern = runtimePatterns[patternId];
132
+ const built = await buildPattern(handoff, patternId, pattern, componentOutputDir, basePath);
133
+ if (built) {
134
+ result.push(built);
55
135
  }
56
-
57
- ref.resolved = true;
58
- const html = await fs.readFile(htmlFilePath, 'utf8');
59
- fragments.push({ componentId: ref.id, html });
60
136
  }
61
137
 
62
- if (fragments.length === 0) {
63
- Logger.warn(`Pattern "${patternId}" produced no fragments. Skipping.`);
64
- continue;
65
- }
138
+ await syncPatternArtifacts(handoff, result.map((p) => p.id));
139
+ await writePatternSummaryApi(handoff, result);
140
+ Logger.info(`Finished building ${result.length} pattern(s).`);
141
+ return result;
142
+ }
66
143
 
67
- if (hasErrors) {
68
- Logger.warn(`Pattern "${patternId}" has missing components but will be composed from available fragments.`);
69
- }
144
+ if (onlyPatternIds.size === 0) {
145
+ const existing = await readPatternSummaryApi(handoff);
146
+ return existing ?? [];
147
+ }
70
148
 
71
- const composedHtml = composePatternHtml(patternId, pattern.title, fragments, basePath);
72
- const patternUrl = `${patternId}.html`;
149
+ const existingList = await readPatternSummaryApi(handoff);
150
+ if (!existingList) {
151
+ Logger.warn('Partial pattern rebuild requested but patterns.json is missing; rebuilding all patterns.');
152
+ return processPatterns(handoff);
153
+ }
154
+
155
+ const existingById = new Map(existingList.map((p) => [p.id, p]));
73
156
 
74
- const patternData: PatternListObject = {
75
- ...pattern,
76
- url: patternUrl,
77
- path: `${basePath}/api/pattern/${patternId}.json`,
78
- };
157
+ Logger.info(`Rebuilding ${onlyPatternIds.size} of ${patternIds.length} pattern(s) (incremental)...`);
79
158
 
80
- await writePatternHtml(patternId, composedHtml, handoff);
81
- await writePatternApi(patternId, patternData, handoff);
159
+ for (const patternId of patternIds) {
160
+ if (onlyPatternIds.has(patternId)) {
161
+ const pattern = runtimePatterns[patternId];
162
+ const built = await buildPattern(handoff, patternId, pattern, componentOutputDir, basePath);
163
+ if (built) {
164
+ existingById.set(patternId, built);
165
+ } else {
166
+ existingById.delete(patternId);
167
+ }
168
+ } else if (!existingById.has(patternId)) {
169
+ const pattern = runtimePatterns[patternId];
170
+ const built = await buildPattern(handoff, patternId, pattern, componentOutputDir, basePath);
171
+ if (built) {
172
+ existingById.set(patternId, built);
173
+ }
174
+ }
175
+ }
82
176
 
83
- result.push(patternData);
84
- Logger.debug(`Built pattern: ${patternId} (${fragments.length} component(s))`);
177
+ const merged: PatternListObject[] = [];
178
+ for (const patternId of patternIds) {
179
+ const entry = existingById.get(patternId);
180
+ if (entry) {
181
+ merged.push(entry);
182
+ }
85
183
  }
86
184
 
87
- await syncPatternArtifacts(handoff, result.map((pattern) => pattern.id));
88
- await writePatternSummaryApi(handoff, result);
89
- Logger.info(`Finished building ${result.length} pattern(s).`);
185
+ await syncPatternArtifacts(handoff, merged.map((p) => p.id));
186
+ await writePatternSummaryApi(handoff, merged);
187
+ Logger.info(`Finished incremental pattern build (${onlyPatternIds.size} recomposed).`);
90
188
 
91
- return result;
189
+ return merged;
92
190
  }
93
191
 
94
192
  export default processPatterns;
@@ -7,20 +7,20 @@ export const createViteLogger = (): ViteLogger => {
7
7
  return {
8
8
  hasWarned: false,
9
9
  info(msg: string, options?: LogOptions) {
10
- Logger.info(msg);
10
+ Logger.info(msg, 'vite');
11
11
  },
12
12
  warn(msg: string, options?: LogOptions) {
13
13
  this.hasWarned = true;
14
- Logger.warn(msg);
14
+ Logger.warn(msg, 'vite');
15
15
  },
16
16
  warnOnce(msg: string, options?: LogOptions) {
17
17
  if (warnedMessages.has(msg)) return;
18
18
  warnedMessages.add(msg);
19
19
  this.hasWarned = true;
20
- Logger.warn(msg);
20
+ Logger.warn(msg, 'vite');
21
21
  },
22
22
  error(msg: string, options?: LogOptions) {
23
- Logger.error(msg);
23
+ Logger.error(msg, undefined, 'vite');
24
24
  },
25
25
  clearScreen(type: string) {
26
26
  // No-op to preserve terminal history
@@ -1,7 +1,9 @@
1
1
  // vite.config.ts
2
2
  import { defineConfig } from 'vite';
3
+ import { createViteLogger } from './utils/vite-logger';
3
4
 
4
5
  export default defineConfig({
6
+ customLogger: createViteLogger(),
5
7
  publicDir: false,
6
8
  build: {
7
9
  lib: {
@@ -0,0 +1,3 @@
1
+ /** Human-readable duration for CLI build logs (ms under 1s, seconds with 2 decimals otherwise). */
2
+ export const formatDurationMs = (elapsedMs: number): string =>
3
+ elapsedMs < 1000 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1000).toFixed(2)}s`;
@@ -1,12 +1,78 @@
1
+ import readline from 'readline';
2
+
1
3
  import chalk from 'chalk';
2
4
 
5
+ /** Logical source of a log line — used for a fixed-width–style colored tag after the timestamp. */
6
+ export type LogScope = 'handoff' | 'vite' | 'next';
7
+
8
+ /**
9
+ * Env (e.g. project `.env` via `dotenv/config`):
10
+ * - `HANDOFF_LOG_LEVEL` — `debug` | `info` | `warn` | `error` | `silent` (default: `info`).
11
+ * Controls minimum severity: at `warn`, info/success/debug lines are hidden; at `error`, only errors.
12
+ * - `HANDOFF_LOG_SCOPES` — comma-separated allowlist: `handoff`, `vite`, `next`. Defaults to all three if unset or empty.
13
+ *
14
+ * Programmatic `Logger.init({ debug: true })` (Handoff `debug` flag) forces debug-level verbosity unless overridden.
15
+ */
3
16
  export class Logger {
17
+ /** From Handoff constructor; enables debug lines together with env. */
4
18
  private static debugMode = false;
5
19
 
20
+ /** Minimum message severity to print: 0=debug, 1=info, 2=warn, 3=error. `silent` uses 99. */
21
+ private static severityThreshold = 1;
22
+
23
+ /** Enabled scopes; defaults to all of `handoff`, `vite`, `next`. */
24
+ private static scopesFilter: Set<LogScope> = new Set<LogScope>(['handoff', 'vite', 'next']);
25
+
6
26
  static init(options?: { debug?: boolean }) {
27
+ this.applyEnvConfig();
7
28
  if (options?.debug !== undefined) {
8
29
  this.debugMode = options.debug;
9
30
  }
31
+ if (options?.debug === true) {
32
+ this.severityThreshold = 0;
33
+ }
34
+ }
35
+
36
+ /** Re-read `process.env` (useful if env is loaded after first `init`). */
37
+ static applyEnvConfig() {
38
+ this.severityThreshold = 1;
39
+ const allScopes: LogScope[] = ['handoff', 'vite', 'next'];
40
+ this.scopesFilter = new Set(allScopes);
41
+
42
+ const levelRaw = process.env.HANDOFF_LOG_LEVEL?.trim().toLowerCase();
43
+ const levelMap: Record<string, number> = {
44
+ debug: 0,
45
+ info: 1,
46
+ warn: 2,
47
+ error: 3,
48
+ silent: 99,
49
+ };
50
+ if (levelRaw && levelRaw in levelMap) {
51
+ this.severityThreshold = levelMap[levelRaw];
52
+ }
53
+
54
+ const scopesRaw = process.env.HANDOFF_LOG_SCOPES?.trim();
55
+ if (scopesRaw) {
56
+ const set = new Set<LogScope>();
57
+ for (const part of scopesRaw.split(',')) {
58
+ const s = part.trim().toLowerCase();
59
+ if (s === 'handoff' || s === 'vite' || s === 'next') {
60
+ set.add(s);
61
+ }
62
+ }
63
+ if (set.size > 0) {
64
+ this.scopesFilter = set;
65
+ }
66
+ }
67
+ }
68
+
69
+ private static scopeEnabled(scope: LogScope): boolean {
70
+ return this.scopesFilter.has(scope);
71
+ }
72
+
73
+ /** Message severities: debug=0, info-tier=1, warn=2, error=3 */
74
+ private static shouldEmit(severity: 0 | 1 | 2 | 3): boolean {
75
+ return severity >= this.severityThreshold;
10
76
  }
11
77
 
12
78
  private static getTimestamp(): string {
@@ -14,36 +80,97 @@ export class Logger {
14
80
  return chalk.gray(`[${now.toISOString()}]`);
15
81
  }
16
82
 
17
- static log(message: string) {
18
- console.log(`${this.getTimestamp()} ${message}`);
83
+ /** Short label shown after the timestamp so you can scan for handoff vs vite vs next. */
84
+ private static scopeTag(scope: LogScope): string {
85
+ const tags: Record<LogScope, string> = {
86
+ handoff: chalk.blue.bold('[handoff]'),
87
+ vite: chalk.magenta.bold('[vite]'),
88
+ next: chalk.green.bold('[next]'),
89
+ };
90
+ return tags[scope];
91
+ }
92
+
93
+ private static basePrefix(scope: LogScope): string {
94
+ return `${this.getTimestamp()} ${this.scopeTag(scope)}`;
95
+ }
96
+
97
+ static log(message: string, scope: LogScope = 'handoff') {
98
+ if (!this.scopeEnabled(scope) || !this.shouldEmit(1)) return;
99
+ console.log(`${this.basePrefix(scope)} ${message}`);
19
100
  }
20
101
 
21
- static info(message: string) {
22
- console.log(`${this.getTimestamp()} ${chalk.cyan(message)}`);
102
+ /**
103
+ * Vite often passes strings that already contain ANSI; avoid wrapping those in chalk.cyan so colors stay correct.
104
+ */
105
+ static info(message: string, scope: LogScope = 'handoff') {
106
+ if (!this.scopeEnabled(scope) || !this.shouldEmit(1)) return;
107
+ const body = scope === 'vite' ? message : chalk.cyan(message);
108
+ console.log(`${this.basePrefix(scope)} ${body}`);
23
109
  }
24
110
 
25
- static success(message: string) {
26
- console.log(`${this.getTimestamp()} ${chalk.green(message)}`);
111
+ static success(message: string, scope: LogScope = 'handoff') {
112
+ if (!this.scopeEnabled(scope) || !this.shouldEmit(1)) return;
113
+ console.log(`${this.basePrefix(scope)} ${chalk.green(message)}`);
27
114
  }
28
115
 
29
- static warn(message: string) {
30
- console.warn(`${this.getTimestamp()} ${chalk.yellow(message)}`);
116
+ static warn(message: string, scope: LogScope = 'handoff') {
117
+ if (!this.scopeEnabled(scope) || !this.shouldEmit(2)) return;
118
+ const body = scope === 'vite' ? message : chalk.yellow(message);
119
+ console.warn(`${this.basePrefix(scope)} ${body}`);
31
120
  }
32
121
 
33
- static error(message: string, error?: any) {
34
- console.error(`${this.getTimestamp()} ${chalk.red(message)}`);
122
+ static error(message: string, error?: any, scope: LogScope = 'handoff') {
123
+ if (!this.scopeEnabled(scope) || !this.shouldEmit(3)) return;
124
+ const body = scope === 'vite' ? message : chalk.red(message);
125
+ console.error(`${this.basePrefix(scope)} ${body}`);
35
126
  if (error) {
36
127
  console.error(error);
37
128
  }
38
129
  }
39
130
 
40
131
  static debug(message: string, data?: any) {
41
- if (this.debugMode) {
42
- console.log(`${this.getTimestamp()} ${chalk.gray(`[DEBUG] ${message}`)}`);
43
- if (data) {
44
- console.log(data);
45
- }
132
+ if (this.severityThreshold >= 99) return;
133
+ if (!this.scopeEnabled('handoff')) return;
134
+ const allowDebug = this.debugMode || this.severityThreshold === 0;
135
+ if (!allowDebug) return;
136
+ console.log(`${this.basePrefix('handoff')} ${chalk.gray(`[DEBUG] ${message}`)}`);
137
+ if (data) {
138
+ console.log(data);
46
139
  }
47
140
  }
48
- }
49
141
 
142
+ /**
143
+ * Log one line from a child process with timestamp + scope tag. Child ANSI is preserved (no extra chalk on the line).
144
+ */
145
+ static childProcessLine(line: string, scope: LogScope = 'next') {
146
+ if (!this.scopeEnabled(scope) || !this.shouldEmit(1)) return;
147
+ console.log(`${this.basePrefix(scope)} ${line}`);
148
+ }
149
+
150
+ /**
151
+ * Pipe stdout/stderr from a spawned child through {@link childProcessLine}. Use with
152
+ * `stdio: ['inherit' | 'ignore', 'pipe', 'pipe']`.
153
+ */
154
+ static pipeChildStreams(
155
+ stdout: NodeJS.ReadableStream | null,
156
+ stderr: NodeJS.ReadableStream | null,
157
+ scope: LogScope = 'next'
158
+ ) {
159
+ for (const stream of [stdout, stderr]) {
160
+ if (!stream) continue;
161
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
162
+ rl.on('line', (line) => {
163
+ this.childProcessLine(line, scope);
164
+ });
165
+ }
166
+ }
167
+
168
+ /** Forward a completed child-process buffer (e.g. from `spawn.sync` with stdio pipe) line-by-line. */
169
+ static childProcessBuffer(buffer: Buffer | null | undefined, scope: LogScope = 'next') {
170
+ if (!buffer?.length) return;
171
+ const text = buffer.toString('utf8');
172
+ for (const line of text.split(/\r?\n/)) {
173
+ this.childProcessLine(line, scope);
174
+ }
175
+ }
176
+ }