@webstir-io/webstir 0.1.1 → 0.1.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 (77) hide show
  1. package/README.md +13 -0
  2. package/assets/deployment/docker/.dockerignore +7 -0
  3. package/assets/deployment/docker/Dockerfile +17 -0
  4. package/assets/deployment/docker/README.md +44 -0
  5. package/assets/deployment/docker/example.env +3 -0
  6. package/assets/features/client_nav/client_nav.ts +369 -264
  7. package/assets/features/client_nav/document_navigation.ts +344 -0
  8. package/assets/features/client_nav/form_enhancement.ts +275 -0
  9. package/assets/templates/api/src/backend/index.ts +71 -10
  10. package/assets/templates/api/src/backend/tsconfig.json +6 -1
  11. package/assets/templates/full/src/backend/index.ts +71 -10
  12. package/assets/templates/full/src/backend/module.ts +515 -0
  13. package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
  14. package/assets/templates/full/src/backend/tsconfig.json +6 -1
  15. package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
  16. package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
  17. package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
  18. package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
  19. package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
  20. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
  21. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
  22. package/package.json +31 -13
  23. package/scripts/check-feature-projections.mjs +87 -0
  24. package/scripts/check-full-demo-sync.mjs +89 -0
  25. package/scripts/check-package-install.mjs +537 -0
  26. package/scripts/check-standalone-install.mjs +221 -0
  27. package/scripts/pack-standalone.mjs +52 -28
  28. package/scripts/publish.sh +9 -0
  29. package/scripts/run-tests.mjs +99 -0
  30. package/scripts/sync-assets.mjs +175 -17
  31. package/src/add-backend-compat.ts +628 -0
  32. package/src/add-backend.ts +155 -27
  33. package/src/add.ts +111 -4
  34. package/src/agent.ts +393 -0
  35. package/src/api-watch.ts +7 -4
  36. package/src/backend-inspect.ts +70 -2
  37. package/src/backend-runtime.ts +22 -14
  38. package/src/build.ts +1 -3
  39. package/src/bun-generated-frontend-watch.ts +209 -0
  40. package/src/bun-globals.d.ts +23 -0
  41. package/src/bun-spa-document.ts +310 -0
  42. package/src/bun-spa-routes.ts +159 -0
  43. package/src/bun-spa-watch.ts +29 -0
  44. package/src/bun-ssg-watch.ts +304 -0
  45. package/src/cli.ts +381 -50
  46. package/src/compile-tests.ts +37 -29
  47. package/src/dev-server.ts +214 -143
  48. package/src/doctor.ts +164 -0
  49. package/src/enable-assets.ts +18 -1
  50. package/src/enable.ts +133 -41
  51. package/src/execute.ts +28 -4
  52. package/src/external-workspace.ts +178 -0
  53. package/src/format.ts +296 -17
  54. package/src/frontend-inspect.ts +32 -0
  55. package/src/frontend-watch.ts +27 -102
  56. package/src/full-watch.ts +13 -18
  57. package/src/index.ts +7 -0
  58. package/src/init-assets.ts +41 -11
  59. package/src/init.ts +85 -71
  60. package/src/inspect.ts +112 -0
  61. package/src/mcp/run-cli-json.ts +46 -0
  62. package/src/mcp/server.ts +307 -0
  63. package/src/operations.ts +176 -0
  64. package/src/providers.ts +20 -18
  65. package/src/refresh.ts +29 -3
  66. package/src/repair.ts +110 -43
  67. package/src/runtime-filter.ts +41 -0
  68. package/src/runtime.ts +1 -1
  69. package/src/smoke.ts +48 -16
  70. package/src/test.ts +54 -16
  71. package/src/testing-runtime.ts +273 -0
  72. package/src/types.ts +1 -4
  73. package/src/watch-events.ts +46 -17
  74. package/src/watch.ts +5 -1
  75. package/src/workspace-watcher.ts +10 -6
  76. package/src/workspace.ts +4 -2
  77. package/src/watch-daemon-client.ts +0 -171
@@ -0,0 +1,178 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { cp, mkdtemp, readFile, writeFile } from 'node:fs/promises';
5
+
6
+ import { monorepoRoot } from './paths.ts';
7
+ import { resolveRuntimeCommand } from './runtime.ts';
8
+
9
+ const REPO_LOCAL_PACKAGES = new Map<string, string>([
10
+ [
11
+ '@webstir-io/module-contract',
12
+ path.join(monorepoRoot ?? '', 'packages', 'contracts', 'module-contract'),
13
+ ],
14
+ [
15
+ '@webstir-io/testing-contract',
16
+ path.join(monorepoRoot ?? '', 'packages', 'contracts', 'testing-contract'),
17
+ ],
18
+ [
19
+ '@webstir-io/webstir-frontend',
20
+ path.join(monorepoRoot ?? '', 'packages', 'tooling', 'webstir-frontend'),
21
+ ],
22
+ [
23
+ '@webstir-io/webstir-backend',
24
+ path.join(monorepoRoot ?? '', 'packages', 'tooling', 'webstir-backend'),
25
+ ],
26
+ [
27
+ '@webstir-io/webstir-testing',
28
+ path.join(monorepoRoot ?? '', 'packages', 'tooling', 'webstir-testing'),
29
+ ],
30
+ ]);
31
+
32
+ const REPO_LOCAL_TRANSITIVE_DEPENDENCIES = new Map<string, readonly string[]>([
33
+ ['@webstir-io/webstir-frontend', ['@webstir-io/module-contract']],
34
+ ['@webstir-io/webstir-backend', ['@webstir-io/module-contract']],
35
+ ['@webstir-io/webstir-testing', ['@webstir-io/testing-contract']],
36
+ ]);
37
+
38
+ export async function prepareExternalWorkspaceCopy(
39
+ workspaceRoot: string,
40
+ tempPrefix: string,
41
+ options: {
42
+ readonly forceLocalPackages?: boolean;
43
+ readonly installStdio?: 'inherit' | 'pipe';
44
+ } = {},
45
+ ): Promise<{ readonly workspaceRoot: string; readonly cleanupRoot: string } | null> {
46
+ if (!monorepoRoot || !isExternalWorkspace(workspaceRoot)) {
47
+ return null;
48
+ }
49
+
50
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), tempPrefix));
51
+ const tempWorkspaceRoot = path.join(tempRoot, path.basename(workspaceRoot));
52
+ await cp(workspaceRoot, tempWorkspaceRoot, { recursive: true });
53
+ await materializeRepoLocalWorkspaceDependencies(tempWorkspaceRoot, options);
54
+
55
+ return {
56
+ workspaceRoot: tempWorkspaceRoot,
57
+ cleanupRoot: tempRoot,
58
+ };
59
+ }
60
+
61
+ export async function materializeRepoLocalWorkspaceDependencies(
62
+ workspaceRoot: string,
63
+ options: {
64
+ readonly forceLocalPackages?: boolean;
65
+ readonly installStdio?: 'inherit' | 'pipe';
66
+ } = {},
67
+ ): Promise<void> {
68
+ if (!monorepoRoot || !isExternalWorkspace(workspaceRoot)) {
69
+ return;
70
+ }
71
+
72
+ const packageJsonPath = path.join(workspaceRoot, 'package.json');
73
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
74
+ dependencies?: Record<string, string>;
75
+ devDependencies?: Record<string, string>;
76
+ overrides?: Record<string, string>;
77
+ };
78
+ const normalized = normalizeRepoLocalDependencySpecs(packageJson, options);
79
+ if (!normalized.changed) {
80
+ return;
81
+ }
82
+
83
+ await writeFile(packageJsonPath, `${JSON.stringify(normalized.packageJson, null, 2)}\n`, 'utf8');
84
+ const install = spawnSync(resolveRuntimeCommand(), ['install'], {
85
+ cwd: workspaceRoot,
86
+ env: process.env,
87
+ stdio: options.installStdio ?? 'inherit',
88
+ });
89
+ if (install.error) {
90
+ throw install.error;
91
+ }
92
+ if (install.status !== 0) {
93
+ throw new Error(`Failed to install repo-local workspace dependencies for ${workspaceRoot}.`);
94
+ }
95
+ }
96
+
97
+ export function isExternalWorkspace(workspaceRoot: string): boolean {
98
+ if (!monorepoRoot) {
99
+ return true;
100
+ }
101
+
102
+ const relativeToRepo = path.relative(monorepoRoot, workspaceRoot);
103
+ return relativeToRepo.startsWith('..') || path.isAbsolute(relativeToRepo);
104
+ }
105
+
106
+ export function normalizeRepoLocalDependencySpecs(
107
+ source: {
108
+ dependencies?: Record<string, string>;
109
+ devDependencies?: Record<string, string>;
110
+ overrides?: Record<string, string>;
111
+ },
112
+ options: {
113
+ readonly forceLocalPackages?: boolean;
114
+ } = {},
115
+ ): {
116
+ readonly packageJson: {
117
+ dependencies?: Record<string, string>;
118
+ devDependencies?: Record<string, string>;
119
+ overrides?: Record<string, string>;
120
+ };
121
+ readonly changed: boolean;
122
+ } {
123
+ let changed = false;
124
+ const packageJson = structuredClone(source);
125
+
126
+ function setLocalOverride(packageName: string, packageRoot: string): void {
127
+ packageJson.overrides ??= {};
128
+ const dependencySpec = `file:${packageRoot}`;
129
+ if (packageJson.overrides[packageName] === dependencySpec) {
130
+ return;
131
+ }
132
+
133
+ packageJson.overrides[packageName] = dependencySpec;
134
+ changed = true;
135
+ }
136
+
137
+ for (const field of ['dependencies', 'devDependencies'] as const) {
138
+ const entries = packageJson[field];
139
+ if (!entries) {
140
+ continue;
141
+ }
142
+
143
+ const localizedPackages = new Set<string>();
144
+ for (const [packageName, packageRoot] of REPO_LOCAL_PACKAGES) {
145
+ if (!entries[packageName]) {
146
+ continue;
147
+ }
148
+ if (!options.forceLocalPackages && entries[packageName] !== 'workspace:*') {
149
+ continue;
150
+ }
151
+
152
+ entries[packageName] = `file:${packageRoot}`;
153
+ setLocalOverride(packageName, packageRoot);
154
+ localizedPackages.add(packageName);
155
+ changed = true;
156
+ }
157
+
158
+ for (const packageName of localizedPackages) {
159
+ for (const dependencyName of REPO_LOCAL_TRANSITIVE_DEPENDENCIES.get(packageName) ?? []) {
160
+ const dependencyRoot = REPO_LOCAL_PACKAGES.get(dependencyName);
161
+ if (!dependencyRoot) {
162
+ continue;
163
+ }
164
+
165
+ const dependencySpec = `file:${dependencyRoot}`;
166
+ setLocalOverride(dependencyName, dependencyRoot);
167
+ if (entries[dependencyName] === dependencySpec) {
168
+ continue;
169
+ }
170
+
171
+ entries[dependencyName] = dependencySpec;
172
+ changed = true;
173
+ }
174
+ }
175
+ }
176
+
177
+ return { packageJson, changed };
178
+ }
package/src/format.ts CHANGED
@@ -1,11 +1,15 @@
1
+ import type { AgentResult } from './agent.ts';
2
+ import type { FrontendInspectResult } from './frontend-inspect.ts';
1
3
  import type { EnableResult } from './enable.ts';
4
+ import type { InspectResult } from './inspect.ts';
5
+ import type { DoctorResult } from './doctor.ts';
2
6
  import type { InitResult } from './init.ts';
7
+ import type { WebstirOperationDescriptor } from './operations.ts';
3
8
  import type { RefreshResult } from './refresh.ts';
4
9
  import type { RepairResult } from './repair.ts';
5
10
  import type { BackendInspectResult } from './backend-inspect.ts';
6
11
  import type { SmokeResult } from './smoke.ts';
7
12
  import type { TestCommandResult } from './test.ts';
8
- import { formatFailedTests } from './test.ts';
9
13
  import type { CommandExecutionResult } from './types.ts';
10
14
 
11
15
  export function formatBuildSummary(result: CommandExecutionResult): string {
@@ -37,11 +41,21 @@ export function formatEnableSummary(result: EnableResult): string {
37
41
  }
38
42
 
39
43
  export function formatInitSummary(result: InitResult): string {
40
- return formatWorkspaceMutationSummary('[webstir] init complete', result.mode, result.workspaceRoot, result.changes);
44
+ return formatWorkspaceMutationSummary(
45
+ '[webstir] init complete',
46
+ result.mode,
47
+ result.workspaceRoot,
48
+ result.changes,
49
+ );
41
50
  }
42
51
 
43
52
  export function formatRefreshSummary(result: RefreshResult): string {
44
- return formatWorkspaceMutationSummary('[webstir] refresh complete', result.mode, result.workspaceRoot, result.changes);
53
+ return formatWorkspaceMutationSummary(
54
+ '[webstir] refresh complete',
55
+ result.mode,
56
+ result.workspaceRoot,
57
+ result.changes,
58
+ );
45
59
  }
46
60
 
47
61
  export function formatRepairSummary(result: RepairResult): string {
@@ -65,6 +79,224 @@ export function formatRepairSummary(result: RepairResult): string {
65
79
  return lines.join('\n');
66
80
  }
67
81
 
82
+ export function formatRepairJson(result: RepairResult): string {
83
+ return JSON.stringify(
84
+ {
85
+ command: 'repair',
86
+ workspaceRoot: result.workspaceRoot,
87
+ mode: result.mode,
88
+ dryRun: result.dryRun,
89
+ changes: result.changes,
90
+ },
91
+ null,
92
+ 2,
93
+ );
94
+ }
95
+
96
+ export function formatOperationsSummary(operations: readonly WebstirOperationDescriptor[]): string {
97
+ const lines = ['[webstir] operations', `count: ${operations.length}`];
98
+
99
+ for (const operation of operations) {
100
+ const details = [
101
+ operation.requiresWorkspace ? 'workspace' : 'no-workspace',
102
+ operation.mutatesWorkspace ? 'mutates' : 'read-only',
103
+ operation.supportsJson ? 'json' : 'text',
104
+ operation.stableForMcp ? 'mcp-ready' : 'manual-only',
105
+ operation.workspaceModes ? `modes: ${operation.workspaceModes.join(', ')}` : undefined,
106
+ ].filter(Boolean);
107
+ lines.push(
108
+ ` - ${operation.id}: ${operation.summary}${details.length > 0 ? ` (${details.join(', ')})` : ''}`,
109
+ );
110
+ }
111
+
112
+ return lines.join('\n');
113
+ }
114
+
115
+ export function formatOperationsJson(operations: readonly WebstirOperationDescriptor[]): string {
116
+ return JSON.stringify(
117
+ {
118
+ command: 'operations',
119
+ operations,
120
+ },
121
+ null,
122
+ 2,
123
+ );
124
+ }
125
+
126
+ export function formatDoctorSummary(result: DoctorResult): string {
127
+ const lines = [
128
+ '[webstir] doctor complete',
129
+ `workspace: ${result.workspace.name}`,
130
+ `mode: ${result.workspace.mode}`,
131
+ `root: ${result.workspace.root}`,
132
+ `healthy: ${result.healthy ? 'true' : 'false'}`,
133
+ ];
134
+
135
+ lines.push(`checks: ${result.checks.length}`);
136
+ for (const check of result.checks) {
137
+ lines.push(` - ${check.id}: ${check.status} (${check.summary})`);
138
+ if (check.detail) {
139
+ lines.push(` detail: ${check.detail}`);
140
+ }
141
+ }
142
+
143
+ if (result.issues.length === 0) {
144
+ lines.push('issues: none');
145
+ } else {
146
+ lines.push(`issues: ${result.issues.length}`);
147
+ for (const issue of result.issues) {
148
+ lines.push(` - ${issue.code}: ${issue.message}`);
149
+ if (issue.changes && issue.changes.length > 0) {
150
+ for (const change of issue.changes) {
151
+ lines.push(` change: ${change}`);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ if (result.repair.changes.length > 0) {
158
+ lines.push(`repair: webstir ${result.repair.command} ${result.repair.args.join(' ')}`);
159
+ }
160
+
161
+ return lines.join('\n');
162
+ }
163
+
164
+ export function formatDoctorJson(result: DoctorResult): string {
165
+ return JSON.stringify(
166
+ {
167
+ command: 'doctor',
168
+ workspace: {
169
+ name: result.workspace.name,
170
+ mode: result.workspace.mode,
171
+ root: result.workspace.root,
172
+ },
173
+ healthy: result.healthy,
174
+ checks: result.checks,
175
+ issues: result.issues,
176
+ repair: result.repair,
177
+ ...(result.backend ? { backend: result.backend } : {}),
178
+ },
179
+ null,
180
+ 2,
181
+ );
182
+ }
183
+
184
+ export function formatFrontendInspectSummary(result: FrontendInspectResult): string {
185
+ const enabledFeatures = listEnabledFlags(result.frontend.packageJson.enable.known);
186
+ const lines = [
187
+ '[webstir] frontend-inspect complete',
188
+ `workspace: ${result.workspace.name}`,
189
+ `mode: ${result.workspace.mode}`,
190
+ `root: ${result.workspace.root}`,
191
+ `app-shell: ${result.frontend.appShell.exists ? 'present' : 'missing'}`,
192
+ `frontend-features: ${enabledFeatures.length > 0 ? enabledFeatures.join(', ') : 'none'}`,
193
+ `pages: ${result.frontend.pages.length}`,
194
+ ];
195
+
196
+ for (const page of result.frontend.pages) {
197
+ const details = [
198
+ page.htmlExists ? 'html' : undefined,
199
+ page.stylesheetExists ? 'css' : undefined,
200
+ page.scriptExists ? 'script' : undefined,
201
+ ].filter(Boolean);
202
+ lines.push(` - ${page.name}${details.length > 0 ? ` (${details.join(', ')})` : ''}`);
203
+ }
204
+
205
+ lines.push(`content-root: ${result.frontend.content.root}`);
206
+ lines.push(`content-present: ${result.frontend.content.exists ? 'true' : 'false'}`);
207
+ lines.push(
208
+ `content-sidebar-override: ${result.frontend.content.sidebarOverrideExists ? 'true' : 'false'}`,
209
+ );
210
+
211
+ return lines.join('\n');
212
+ }
213
+
214
+ export function formatFrontendInspectJson(result: FrontendInspectResult): string {
215
+ return JSON.stringify(
216
+ {
217
+ command: 'frontend-inspect',
218
+ workspace: {
219
+ name: result.workspace.name,
220
+ mode: result.workspace.mode,
221
+ root: result.workspace.root,
222
+ },
223
+ frontend: result.frontend,
224
+ },
225
+ null,
226
+ 2,
227
+ );
228
+ }
229
+
230
+ export function formatInspectSummary(result: InspectResult): string {
231
+ const lines = [
232
+ '[webstir] inspect complete',
233
+ `workspace: ${result.workspace.name}`,
234
+ `mode: ${result.workspace.mode}`,
235
+ `root: ${result.workspace.root}`,
236
+ `success: ${result.success ? 'true' : 'false'}`,
237
+ `steps: ${result.steps.length}`,
238
+ ];
239
+
240
+ for (const step of result.steps) {
241
+ lines.push(` - ${step.id}: ${step.status} (${step.summary})`);
242
+ }
243
+
244
+ return lines.join('\n');
245
+ }
246
+
247
+ export function formatInspectJson(result: InspectResult): string {
248
+ return JSON.stringify(
249
+ {
250
+ command: 'inspect',
251
+ workspace: {
252
+ name: result.workspace.name,
253
+ mode: result.workspace.mode,
254
+ root: result.workspace.root,
255
+ },
256
+ success: result.success,
257
+ steps: result.steps,
258
+ doctor: result.doctor,
259
+ ...(result.frontend ? { frontend: result.frontend } : {}),
260
+ ...(result.backend ? { backend: result.backend } : {}),
261
+ },
262
+ null,
263
+ 2,
264
+ );
265
+ }
266
+
267
+ export function formatAgentSummary(result: AgentResult): string {
268
+ const lines = [
269
+ '[webstir] agent complete',
270
+ `goal: ${result.goal}`,
271
+ `root: ${result.workspaceRoot}`,
272
+ `success: ${result.success ? 'true' : 'false'}`,
273
+ `steps: ${result.steps.length}`,
274
+ ];
275
+
276
+ for (const step of result.steps) {
277
+ lines.push(` - ${step.id}: ${step.status} (${step.summary})`);
278
+ }
279
+
280
+ return lines.join('\n');
281
+ }
282
+
283
+ function listEnabledFlags(flags: object): string[] {
284
+ return Object.entries(flags)
285
+ .filter(([, enabled]) => enabled === true)
286
+ .map(([name]) => name);
287
+ }
288
+
289
+ export function formatAgentJson(result: AgentResult): string {
290
+ return JSON.stringify(
291
+ {
292
+ command: 'agent',
293
+ ...result,
294
+ },
295
+ null,
296
+ 2,
297
+ );
298
+ }
299
+
68
300
  export function formatBackendInspectSummary(result: BackendInspectResult): string {
69
301
  const lines = [
70
302
  '[webstir] backend-inspect complete',
@@ -82,6 +314,12 @@ export function formatBackendInspectSummary(result: BackendInspectResult): strin
82
314
  lines.push(` - ${route.method} ${route.path}${route.name ? ` (${route.name})` : ''}`);
83
315
  }
84
316
 
317
+ const views = result.manifest.views ?? [];
318
+ lines.push(`views: ${views.length}`);
319
+ for (const view of views) {
320
+ lines.push(` - ${view.path}${view.name ? ` (${view.name})` : ''}`);
321
+ }
322
+
85
323
  const jobs = result.manifest.jobs ?? [];
86
324
  lines.push(`jobs: ${jobs.length}`);
87
325
  for (const job of jobs) {
@@ -95,9 +333,38 @@ export function formatBackendInspectSummary(result: BackendInspectResult): strin
95
333
  lines.push(` - ${job.name}${details.length > 0 ? ` (${details.join(', ')})` : ''}`);
96
334
  }
97
335
 
336
+ const migrations = result.data.migrations;
337
+ lines.push('data-migrations:');
338
+ lines.push(` runner: ${migrations.runnerPresent ? 'present' : 'missing'}`);
339
+ lines.push(
340
+ ` directory: ${migrations.migrationsDirectoryPresent ? 'present' : 'missing'} (${migrations.migrationsDirectory})`,
341
+ );
342
+ lines.push(` files: ${migrations.migrationFilesCount}`);
343
+ lines.push(` example: ${migrations.exampleMigrationPresent ? 'present' : 'missing'}`);
344
+ lines.push(` table-env: ${migrations.tableEnvKey}`);
345
+ lines.push(` configured-table: ${migrations.configuredTable}`);
346
+
98
347
  return lines.join('\n');
99
348
  }
100
349
 
350
+ export function formatBackendInspectJson(result: BackendInspectResult): string {
351
+ return JSON.stringify(
352
+ {
353
+ command: 'backend-inspect',
354
+ workspace: {
355
+ name: result.workspace.name,
356
+ mode: result.workspace.mode,
357
+ root: result.workspace.root,
358
+ },
359
+ buildRoot: result.buildRoot,
360
+ manifest: result.manifest,
361
+ data: result.data,
362
+ },
363
+ null,
364
+ 2,
365
+ );
366
+ }
367
+
101
368
  export function formatTestSummary(result: TestCommandResult): string {
102
369
  const lines = [
103
370
  '[webstir] test complete',
@@ -154,13 +421,9 @@ export function formatAddSummary(
154
421
  target: string,
155
422
  workspaceRoot: string,
156
423
  changes: readonly string[],
157
- note?: string
424
+ note?: string,
158
425
  ): string {
159
- const lines = [
160
- header,
161
- `target: ${target}`,
162
- `root: ${workspaceRoot}`,
163
- ];
426
+ const lines = [header, `target: ${target}`, `root: ${workspaceRoot}`];
164
427
 
165
428
  if (changes.length === 0) {
166
429
  lines.push('changes: none');
@@ -195,12 +458,12 @@ function formatExecutionSummary(result: CommandExecutionResult): string {
195
458
  lines.push(
196
459
  `${target.kind}: ${target.result.artifacts.length} artifacts, ` +
197
460
  `${target.result.manifest.entryPoints.length} entries, ` +
198
- `${target.result.manifest.staticAssets.length} static assets -> ${target.outputRoot}`
461
+ `${target.result.manifest.staticAssets.length} static assets -> ${target.outputRoot}`,
199
462
  );
200
463
 
201
464
  if (diagnostics.errors > 0 || diagnostics.warnings > 0) {
202
465
  lines.push(
203
- `${target.kind}: ${diagnostics.errors} error(s), ${diagnostics.warnings} warning(s), ${diagnostics.info} info`
466
+ `${target.kind}: ${diagnostics.errors} error(s), ${diagnostics.warnings} warning(s), ${diagnostics.info} info`,
204
467
  );
205
468
  }
206
469
  }
@@ -208,17 +471,33 @@ function formatExecutionSummary(result: CommandExecutionResult): string {
208
471
  return lines.join('\n');
209
472
  }
210
473
 
474
+ function formatFailedTests(
475
+ results: readonly {
476
+ readonly passed: boolean;
477
+ readonly file: string;
478
+ readonly name: string;
479
+ readonly message?: string | null;
480
+ }[],
481
+ ): string[] {
482
+ return results
483
+ .filter((result) => !result.passed)
484
+ .map(
485
+ (result) =>
486
+ `${result.file}: ${result.name}${result.message ? ` — ${firstLine(result.message)}` : ''}`,
487
+ );
488
+ }
489
+
490
+ function firstLine(message: string): string {
491
+ return message.split(/\r?\n/, 1)[0] ?? message;
492
+ }
493
+
211
494
  function formatWorkspaceMutationSummary(
212
495
  header: string,
213
496
  mode: string,
214
497
  workspaceRoot: string,
215
- changes: readonly string[]
498
+ changes: readonly string[],
216
499
  ): string {
217
- const lines = [
218
- header,
219
- `mode: ${mode}`,
220
- `root: ${workspaceRoot}`,
221
- ];
500
+ const lines = [header, `mode: ${mode}`, `root: ${workspaceRoot}`];
222
501
 
223
502
  if (changes.length === 0) {
224
503
  lines.push('changes: none');
@@ -0,0 +1,32 @@
1
+ import type { FrontendWorkspaceInspectResult } from '@webstir-io/webstir-frontend';
2
+
3
+ import { inspectFrontendWorkspace } from '@webstir-io/webstir-frontend';
4
+
5
+ import type { WorkspaceDescriptor } from './types.ts';
6
+
7
+ import { readWorkspaceDescriptor } from './workspace.ts';
8
+
9
+ export interface RunFrontendInspectOptions {
10
+ readonly workspaceRoot: string;
11
+ }
12
+
13
+ export interface FrontendInspectResult {
14
+ readonly workspace: WorkspaceDescriptor;
15
+ readonly frontend: FrontendWorkspaceInspectResult;
16
+ }
17
+
18
+ export async function runFrontendInspect(
19
+ options: RunFrontendInspectOptions,
20
+ ): Promise<FrontendInspectResult> {
21
+ const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
22
+ if (workspace.mode === 'api') {
23
+ throw new Error(
24
+ `frontend-inspect only supports spa, ssg, and full workspaces. Received mode "${workspace.mode}".`,
25
+ );
26
+ }
27
+
28
+ return {
29
+ workspace,
30
+ frontend: await inspectFrontendWorkspace(workspace.root),
31
+ };
32
+ }