@webstir-io/webstir 0.1.0 → 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
package/src/refresh.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
- import { mkdir, readdir, rm } from 'node:fs/promises';
2
+ import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
3
4
 
4
5
  import { scaffoldWorkspace } from './init.ts';
5
6
  import type { WorkspaceMode } from './types.ts';
@@ -24,10 +25,11 @@ export async function runRefresh(options: RunRefreshOptions): Promise<RefreshRes
24
25
  }
25
26
 
26
27
  const mode = parseWorkspaceMode(modeToken);
28
+ const metadata = await readWorkspaceManifestMetadata(workspaceRoot);
27
29
  await mkdir(workspaceRoot, { recursive: true });
28
30
  await emptyDirectory(workspaceRoot);
29
31
 
30
- const result = await scaffoldWorkspace(mode, workspaceRoot, { force: true });
32
+ const result = await scaffoldWorkspace(mode, workspaceRoot, { force: true, metadata });
31
33
  return {
32
34
  workspaceRoot: result.workspaceRoot,
33
35
  mode: result.mode,
@@ -44,7 +46,12 @@ async function emptyDirectory(directoryPath: string): Promise<void> {
44
46
 
45
47
  function parseWorkspaceMode(value: string): WorkspaceMode {
46
48
  const normalized = value.trim().toLowerCase();
47
- if (normalized === 'ssg' || normalized === 'spa' || normalized === 'api' || normalized === 'full') {
49
+ if (
50
+ normalized === 'ssg' ||
51
+ normalized === 'spa' ||
52
+ normalized === 'api' ||
53
+ normalized === 'full'
54
+ ) {
48
55
  return normalized;
49
56
  }
50
57
 
@@ -54,3 +61,22 @@ function parseWorkspaceMode(value: string): WorkspaceMode {
54
61
 
55
62
  throw new Error(`Unknown refresh mode "${value}". Expected ssg, spa, api, or full.`);
56
63
  }
64
+
65
+ async function readWorkspaceManifestMetadata(
66
+ workspaceRoot: string,
67
+ ): Promise<{ readonly packageName?: string; readonly description?: string } | undefined> {
68
+ const packageJsonPath = path.join(workspaceRoot, 'package.json');
69
+ if (!existsSync(packageJsonPath)) {
70
+ return undefined;
71
+ }
72
+
73
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
74
+ readonly name?: string;
75
+ readonly description?: string;
76
+ };
77
+
78
+ return {
79
+ packageName: packageJson.name,
80
+ description: packageJson.description,
81
+ };
82
+ }
package/src/repair.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { chmod, mkdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
 
5
5
  import { getBackendScaffoldAssets } from '@webstir-io/webstir-backend';
@@ -47,12 +47,17 @@ export async function runRepair(options: RunRepairOptions): Promise<RepairResult
47
47
  const dryRun = options.rawArgs.includes('--dry-run');
48
48
  const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
49
49
  const packageJsonPath = path.join(workspace.root, 'package.json');
50
- const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as RepairPackageJson;
50
+ const packageJson = JSON.parse(await readTextFile(packageJsonPath)) as RepairPackageJson;
51
51
  const enable = packageJson.webstir?.enable ?? {};
52
52
  const changes: string[] = [];
53
53
 
54
54
  await restoreScaffoldAssets(workspace.root, getRootScaffoldAssets(), changes, dryRun);
55
- await restoreScaffoldAssets(workspace.root, await getModeScaffoldAssets(workspace.mode), changes, dryRun);
55
+ await restoreScaffoldAssets(
56
+ workspace.root,
57
+ filterModeScaffoldAssets(await getModeScaffoldAssets(workspace.mode), enable),
58
+ changes,
59
+ dryRun,
60
+ );
56
61
 
57
62
  if (enable.spa) {
58
63
  await restoreFeatureAssets(workspace.root, getSpaAssets(), changes, dryRun);
@@ -64,7 +69,13 @@ export async function runRepair(options: RunRepairOptions): Promise<RepairResult
64
69
  if (enable.search) {
65
70
  await restoreFeatureAssets(workspace.root, getSearchAssets(), changes, dryRun);
66
71
  await ensureCssLayerIncludes(workspace.root, 'features', changes, dryRun);
67
- await ensureAppCssImport(workspace.root, './styles/features/search.css', './styles/components/buttons.css', changes, dryRun);
72
+ await ensureAppCssImport(
73
+ workspace.root,
74
+ './styles/features/search.css',
75
+ './styles/components/buttons.css',
76
+ changes,
77
+ dryRun,
78
+ );
68
79
  await ensureAppImport(workspace.root, './scripts/features/search.js', changes, dryRun);
69
80
  await ensureHtmlSearchMode(workspace.root, changes, dryRun);
70
81
  }
@@ -76,12 +87,14 @@ export async function runRepair(options: RunRepairOptions): Promise<RepairResult
76
87
  './styles/features/content-nav.css',
77
88
  './styles/components/buttons.css',
78
89
  changes,
79
- dryRun
90
+ dryRun,
80
91
  );
81
92
  await ensureAppImport(workspace.root, './scripts/features/content-nav.js', changes, dryRun);
82
93
  }
83
- if (enable.backend || workspace.mode === 'api' || workspace.mode === 'full') {
94
+ if (enable.backend) {
84
95
  await restoreBackendAssets(workspace.root, changes, dryRun);
96
+ }
97
+ if (enable.backend || workspace.mode === 'api' || workspace.mode === 'full') {
85
98
  await ensureBackendTsReference(workspace.root, changes, dryRun);
86
99
  }
87
100
  if (enable.githubPages) {
@@ -98,11 +111,24 @@ export async function runRepair(options: RunRepairOptions): Promise<RepairResult
98
111
  };
99
112
  }
100
113
 
114
+ function filterModeScaffoldAssets(
115
+ assets: readonly { sourcePath: string; targetPath: string }[],
116
+ enable: RepairEnableFlags,
117
+ ): readonly { sourcePath: string; targetPath: string }[] {
118
+ if (!enable.backend) {
119
+ return assets;
120
+ }
121
+
122
+ return assets.filter(
123
+ (asset) => !normalizeRelativePath(asset.targetPath).startsWith('src/backend/'),
124
+ );
125
+ }
126
+
101
127
  async function restoreScaffoldAssets(
102
128
  workspaceRoot: string,
103
129
  assets: readonly { sourcePath: string; targetPath: string }[],
104
130
  changes: string[],
105
- dryRun: boolean
131
+ dryRun: boolean,
106
132
  ): Promise<void> {
107
133
  for (const asset of assets) {
108
134
  const targetPath = path.join(workspaceRoot, asset.targetPath);
@@ -112,7 +138,7 @@ async function restoreScaffoldAssets(
112
138
 
113
139
  if (!dryRun) {
114
140
  await mkdir(path.dirname(targetPath), { recursive: true });
115
- await copyFile(asset.sourcePath, targetPath);
141
+ await Bun.write(targetPath, Bun.file(asset.sourcePath));
116
142
  }
117
143
 
118
144
  changes.push(relativeWorkspacePath(workspaceRoot, targetPath));
@@ -123,7 +149,7 @@ async function restoreFeatureAssets(
123
149
  workspaceRoot: string,
124
150
  assets: readonly StaticFeatureAsset[],
125
151
  changes: string[],
126
- dryRun: boolean
152
+ dryRun: boolean,
127
153
  ): Promise<void> {
128
154
  for (const asset of assets) {
129
155
  const targetPath = path.join(workspaceRoot, asset.targetPath);
@@ -133,7 +159,7 @@ async function restoreFeatureAssets(
133
159
 
134
160
  if (!dryRun) {
135
161
  await mkdir(path.dirname(targetPath), { recursive: true });
136
- await copyFile(asset.sourcePath, targetPath);
162
+ await Bun.write(targetPath, Bun.file(asset.sourcePath));
137
163
  if (asset.executable) {
138
164
  await chmod(targetPath, 0o755);
139
165
  }
@@ -143,7 +169,11 @@ async function restoreFeatureAssets(
143
169
  }
144
170
  }
145
171
 
146
- async function restoreBackendAssets(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
172
+ async function restoreBackendAssets(
173
+ workspaceRoot: string,
174
+ changes: string[],
175
+ dryRun: boolean,
176
+ ): Promise<void> {
147
177
  const assets = await getBackendScaffoldAssets();
148
178
  for (const asset of assets) {
149
179
  const targetPath = path.join(workspaceRoot, asset.targetPath);
@@ -153,7 +183,7 @@ async function restoreBackendAssets(workspaceRoot: string, changes: string[], dr
153
183
 
154
184
  if (!dryRun) {
155
185
  await mkdir(path.dirname(targetPath), { recursive: true });
156
- await copyFile(asset.sourcePath, targetPath);
186
+ await Bun.write(targetPath, Bun.file(asset.sourcePath));
157
187
  }
158
188
 
159
189
  changes.push(relativeWorkspacePath(workspaceRoot, targetPath));
@@ -164,21 +194,21 @@ async function ensureAppImport(
164
194
  workspaceRoot: string,
165
195
  importPath: string,
166
196
  changes: string[],
167
- dryRun: boolean
197
+ dryRun: boolean,
168
198
  ): Promise<void> {
169
199
  const appTsPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.ts');
170
200
  if (!existsSync(appTsPath)) {
171
201
  return;
172
202
  }
173
203
 
174
- const source = await readFile(appTsPath, 'utf8');
204
+ const source = await readTextFile(appTsPath);
175
205
  const updated = ensureSideEffectImport(source, importPath);
176
206
  if (updated === source) {
177
207
  return;
178
208
  }
179
209
 
180
210
  if (!dryRun) {
181
- await writeFile(appTsPath, updated, 'utf8');
211
+ await Bun.write(appTsPath, updated);
182
212
  }
183
213
  changes.push(relativeWorkspacePath(workspaceRoot, appTsPath));
184
214
  }
@@ -187,21 +217,21 @@ async function ensureCssLayerIncludes(
187
217
  workspaceRoot: string,
188
218
  layerName: string,
189
219
  changes: string[],
190
- dryRun: boolean
220
+ dryRun: boolean,
191
221
  ): Promise<void> {
192
222
  const appCssPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.css');
193
223
  if (!existsSync(appCssPath)) {
194
224
  return;
195
225
  }
196
226
 
197
- const source = await readFile(appCssPath, 'utf8');
227
+ const source = await readTextFile(appCssPath);
198
228
  const updated = ensureLayerIncludes(source, layerName);
199
229
  if (updated === source) {
200
230
  return;
201
231
  }
202
232
 
203
233
  if (!dryRun) {
204
- await writeFile(appCssPath, updated, 'utf8');
234
+ await Bun.write(appCssPath, updated);
205
235
  }
206
236
  changes.push(relativeWorkspacePath(workspaceRoot, appCssPath));
207
237
  }
@@ -211,63 +241,72 @@ async function ensureAppCssImport(
211
241
  importPath: string,
212
242
  insertAfterImportPath: string,
213
243
  changes: string[],
214
- dryRun: boolean
244
+ dryRun: boolean,
215
245
  ): Promise<void> {
216
246
  const appCssPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.css');
217
247
  if (!existsSync(appCssPath)) {
218
248
  return;
219
249
  }
220
250
 
221
- const source = await readFile(appCssPath, 'utf8');
251
+ const source = await readTextFile(appCssPath);
222
252
  const updated = ensureImportIncludes(source, importPath, insertAfterImportPath);
223
253
  if (updated === source) {
224
254
  return;
225
255
  }
226
256
 
227
257
  if (!dryRun) {
228
- await writeFile(appCssPath, updated, 'utf8');
258
+ await Bun.write(appCssPath, updated);
229
259
  }
230
260
  changes.push(relativeWorkspacePath(workspaceRoot, appCssPath));
231
261
  }
232
262
 
233
- async function ensureHtmlSearchMode(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
263
+ async function ensureHtmlSearchMode(
264
+ workspaceRoot: string,
265
+ changes: string[],
266
+ dryRun: boolean,
267
+ ): Promise<void> {
234
268
  const appHtmlPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.html');
235
269
  if (!existsSync(appHtmlPath)) {
236
270
  return;
237
271
  }
238
272
 
239
- const source = await readFile(appHtmlPath, 'utf8');
273
+ const source = await readTextFile(appHtmlPath);
240
274
  if (source.includes('data-webstir-search-styles=')) {
241
275
  return;
242
276
  }
243
277
 
244
278
  const updated = source.replace(
245
279
  /<html\b(?![^>]*\bdata-webstir-search-styles=)/i,
246
- '<html data-webstir-search-styles="css"'
280
+ '<html data-webstir-search-styles="css"',
247
281
  );
248
282
  if (updated === source) {
249
283
  return;
250
284
  }
251
285
 
252
286
  if (!dryRun) {
253
- await writeFile(appHtmlPath, updated, 'utf8');
287
+ await Bun.write(appHtmlPath, updated);
254
288
  }
255
289
  changes.push(relativeWorkspacePath(workspaceRoot, appHtmlPath));
256
290
  }
257
291
 
258
- async function ensureBackendTsReference(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
292
+ async function ensureBackendTsReference(
293
+ workspaceRoot: string,
294
+ changes: string[],
295
+ dryRun: boolean,
296
+ ): Promise<void> {
259
297
  const tsconfigPath = path.join(workspaceRoot, 'base.tsconfig.json');
260
298
  if (!existsSync(tsconfigPath)) {
261
299
  return;
262
300
  }
263
301
 
264
- const source = await readFile(tsconfigPath, 'utf8');
302
+ const source = await readTextFile(tsconfigPath);
265
303
  const root = JSON.parse(source) as Record<string, unknown>;
266
304
  const references = Array.isArray(root.references) ? [...root.references] : [];
267
- const exists = references.some((entry) =>
268
- typeof entry === 'object'
269
- && entry !== null
270
- && (entry as Record<string, unknown>).path === 'src/backend'
305
+ const exists = references.some(
306
+ (entry) =>
307
+ typeof entry === 'object' &&
308
+ entry !== null &&
309
+ (entry as Record<string, unknown>).path === 'src/backend',
271
310
  );
272
311
  if (exists) {
273
312
  return;
@@ -278,12 +317,16 @@ async function ensureBackendTsReference(workspaceRoot: string, changes: string[]
278
317
  const updated = `${JSON.stringify(root, null, 2)}\n`;
279
318
 
280
319
  if (!dryRun) {
281
- await writeFile(tsconfigPath, updated, 'utf8');
320
+ await Bun.write(tsconfigPath, updated);
282
321
  }
283
322
  changes.push(relativeWorkspacePath(workspaceRoot, tsconfigPath));
284
323
  }
285
324
 
286
- async function ensureGithubPagesDeployScript(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
325
+ async function ensureGithubPagesDeployScript(
326
+ workspaceRoot: string,
327
+ changes: string[],
328
+ dryRun: boolean,
329
+ ): Promise<void> {
287
330
  const deployScriptPath = path.join(workspaceRoot, 'utils', 'deploy-gh-pages.sh');
288
331
  if (existsSync(deployScriptPath)) {
289
332
  return;
@@ -291,14 +334,18 @@ async function ensureGithubPagesDeployScript(workspaceRoot: string, changes: str
291
334
 
292
335
  if (!dryRun) {
293
336
  await mkdir(path.dirname(deployScriptPath), { recursive: true });
294
- await writeFile(deployScriptPath, renderGithubPagesDeployScript(), 'utf8');
337
+ await Bun.write(deployScriptPath, renderGithubPagesDeployScript());
295
338
  await chmod(deployScriptPath, 0o755);
296
339
  }
297
340
  changes.push(relativeWorkspacePath(workspaceRoot, deployScriptPath));
298
341
  }
299
342
 
300
- async function ensureDeployScriptEntry(packageJsonPath: string, changes: string[], dryRun: boolean): Promise<void> {
301
- const source = await readFile(packageJsonPath, 'utf8');
343
+ async function ensureDeployScriptEntry(
344
+ packageJsonPath: string,
345
+ changes: string[],
346
+ dryRun: boolean,
347
+ ): Promise<void> {
348
+ const source = await readTextFile(packageJsonPath);
302
349
  const root = JSON.parse(source) as RepairPackageJson & Record<string, unknown>;
303
350
  const scripts = root.scripts && typeof root.scripts === 'object' ? { ...root.scripts } : {};
304
351
  if (typeof scripts.deploy === 'string') {
@@ -310,18 +357,22 @@ async function ensureDeployScriptEntry(packageJsonPath: string, changes: string[
310
357
  const updated = `${JSON.stringify(root, null, 2)}\n`;
311
358
 
312
359
  if (!dryRun) {
313
- await writeFile(packageJsonPath, updated, 'utf8');
360
+ await Bun.write(packageJsonPath, updated);
314
361
  }
315
362
  changes.push(path.basename(packageJsonPath));
316
363
  }
317
364
 
318
- async function ensureFrontendConfigBasePath(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
365
+ async function ensureFrontendConfigBasePath(
366
+ workspaceRoot: string,
367
+ changes: string[],
368
+ dryRun: boolean,
369
+ ): Promise<void> {
319
370
  const configPath = path.join(workspaceRoot, 'src', 'frontend', 'frontend.config.json');
320
371
  let root: Record<string, unknown> = {};
321
372
 
322
373
  if (existsSync(configPath)) {
323
374
  try {
324
- root = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
375
+ root = JSON.parse(await readTextFile(configPath)) as Record<string, unknown>;
325
376
  } catch {
326
377
  root = {};
327
378
  }
@@ -338,11 +389,15 @@ async function ensureFrontendConfigBasePath(workspaceRoot: string, changes: stri
338
389
 
339
390
  if (!dryRun) {
340
391
  await mkdir(path.dirname(configPath), { recursive: true });
341
- await writeFile(configPath, updated, 'utf8');
392
+ await Bun.write(configPath, updated);
342
393
  }
343
394
  changes.push(relativeWorkspacePath(workspaceRoot, configPath));
344
395
  }
345
396
 
397
+ async function readTextFile(filePath: string): Promise<string> {
398
+ return await Bun.file(filePath).text();
399
+ }
400
+
346
401
  function ensureSideEffectImport(source: string, importPath: string): string {
347
402
  const escaped = escapeRegExp(importPath);
348
403
  const pattern = new RegExp(`^\\s*import\\s+(['"])${escaped}\\1\\s*;?\\s*$`, 'm');
@@ -360,7 +415,10 @@ function ensureLayerIncludes(css: string, layerName: string): string {
360
415
  return css;
361
416
  }
362
417
 
363
- const layers = match[1].split(',').map((layer) => layer.trim()).filter(Boolean);
418
+ const layers = match[1]
419
+ .split(',')
420
+ .map((layer) => layer.trim())
421
+ .filter(Boolean);
364
422
  if (layers.includes(layerName)) {
365
423
  return css;
366
424
  }
@@ -368,13 +426,18 @@ function ensureLayerIncludes(css: string, layerName: string): string {
368
426
  const updated = [...layers];
369
427
  const utilitiesIndex = updated.indexOf('utilities');
370
428
  const overridesIndex = updated.indexOf('overrides');
371
- const insertIndex = utilitiesIndex >= 0 ? utilitiesIndex : overridesIndex >= 0 ? overridesIndex : updated.length;
429
+ const insertIndex =
430
+ utilitiesIndex >= 0 ? utilitiesIndex : overridesIndex >= 0 ? overridesIndex : updated.length;
372
431
  updated.splice(insertIndex, 0, layerName);
373
432
  const replacement = `@layer ${updated.join(', ')};`;
374
433
  return `${css.slice(0, match.index)}${replacement}${css.slice(match.index + match[0].length)}`;
375
434
  }
376
435
 
377
- function ensureImportIncludes(css: string, importPath: string, insertAfterImportPath: string): string {
436
+ function ensureImportIncludes(
437
+ css: string,
438
+ importPath: string,
439
+ insertAfterImportPath: string,
440
+ ): string {
378
441
  if (css.includes(`@import "${importPath}"`) || css.includes(`@import '${importPath}'`)) {
379
442
  return css;
380
443
  }
@@ -399,6 +462,10 @@ function relativeWorkspacePath(workspaceRoot: string, absolutePath: string): str
399
462
  return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, '/');
400
463
  }
401
464
 
465
+ function normalizeRelativePath(value: string): string {
466
+ return value.split(path.sep).join('/');
467
+ }
468
+
402
469
  function asRecord(value: unknown): Record<string, unknown> {
403
470
  return value && typeof value === 'object' && !Array.isArray(value)
404
471
  ? { ...(value as Record<string, unknown>) }
@@ -0,0 +1,41 @@
1
+ import type { TestManifest } from '@webstir-io/webstir-testing';
2
+
3
+ export type RuntimeFilter = 'frontend' | 'backend' | null;
4
+
5
+ export function normalizeRuntimeFilter(value: string | undefined | null): RuntimeFilter {
6
+ const normalized = (value ?? '').trim().toLowerCase();
7
+ if (!normalized || normalized === 'all') {
8
+ return null;
9
+ }
10
+
11
+ if (normalized === 'frontend' || normalized === 'backend') {
12
+ return normalized;
13
+ }
14
+
15
+ return null;
16
+ }
17
+
18
+ export function applyRuntimeFilter(manifest: TestManifest, runtime: RuntimeFilter): TestManifest {
19
+ if (!runtime) {
20
+ return manifest;
21
+ }
22
+
23
+ return {
24
+ ...manifest,
25
+ modules: manifest.modules.filter((module) => module.runtime === runtime),
26
+ };
27
+ }
28
+
29
+ export function describeRuntimeFilter(
30
+ runtime: RuntimeFilter,
31
+ before: number,
32
+ after: number,
33
+ ): string | null {
34
+ if (!runtime) {
35
+ return null;
36
+ }
37
+
38
+ const skipped = Math.max(before - after, 0);
39
+ const noun = after === 1 ? 'test' : 'tests';
40
+ return `Runtime filter '${runtime}' matched ${after} ${noun} (${skipped} skipped).`;
41
+ }
package/src/runtime.ts CHANGED
@@ -7,7 +7,7 @@ export type ModuleRuntimeMode = 'build' | 'publish' | 'test';
7
7
  export function createWorkspaceRuntimeEnv(
8
8
  workspaceRoot: string,
9
9
  mode: ModuleRuntimeMode,
10
- env: Record<string, string | undefined> = process.env
10
+ env: Record<string, string | undefined> = process.env,
11
11
  ): Record<string, string | undefined> {
12
12
  const binPaths = [
13
13
  ...collectNodeModuleBins(workspaceRoot),
package/src/smoke.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { cp, mkdtemp, rm } from 'node:fs/promises';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
4
 
5
5
  import type { WorkspaceDescriptor } from './types.ts';
6
6
 
7
7
  import { runBackendInspect, type BackendInspectResult } from './backend-inspect.ts';
8
+ import { runDoctor, type DoctorResult } from './doctor.ts';
9
+ import {
10
+ materializeRepoLocalWorkspaceDependencies,
11
+ prepareExternalWorkspaceCopy,
12
+ } from './external-workspace.ts';
8
13
  import { monorepoRoot } from './paths.ts';
9
14
  import { runBuild, type RunBuildOptions } from './build.ts';
10
15
  import { scaffoldWorkspace } from './init.ts';
@@ -18,7 +23,7 @@ export interface RunSmokeOptions {
18
23
  }
19
24
 
20
25
  export interface SmokePhaseResult {
21
- readonly name: 'build' | 'test' | 'publish' | 'backend-inspect';
26
+ readonly name: 'build' | 'test' | 'publish' | 'doctor' | 'backend-inspect';
22
27
  readonly detail: string;
23
28
  }
24
29
 
@@ -68,6 +73,18 @@ export async function runSmoke(options: RunSmokeOptions = {}): Promise<SmokeResu
68
73
  detail: formatBuildDetail(publishResult),
69
74
  });
70
75
 
76
+ const doctorResult = await runDoctor({
77
+ workspaceRoot: workspace.root,
78
+ env,
79
+ });
80
+ if (!doctorResult.healthy) {
81
+ throw new Error('Smoke doctor phase reported issues.');
82
+ }
83
+ phases.push({
84
+ name: 'doctor',
85
+ detail: formatDoctorDetail(doctorResult),
86
+ });
87
+
71
88
  if (workspace.mode === 'api' || workspace.mode === 'full') {
72
89
  const backendInspect = await runBackendInspect({
73
90
  workspaceRoot: workspace.root,
@@ -94,14 +111,15 @@ export async function runSmoke(options: RunSmokeOptions = {}): Promise<SmokeResu
94
111
 
95
112
  function createSmokeEnv(
96
113
  workspaceRoot: string,
97
- env: Record<string, string | undefined> = process.env
114
+ env: Record<string, string | undefined> = process.env,
98
115
  ): Record<string, string | undefined> {
99
116
  if (env.WEBSTIR_BACKEND_TYPECHECK) {
100
117
  return env;
101
118
  }
102
119
 
103
120
  const relativeToRepo = monorepoRoot ? path.relative(monorepoRoot, workspaceRoot) : '../external';
104
- const isExternalWorkspace = !monorepoRoot || relativeToRepo.startsWith('..') || path.isAbsolute(relativeToRepo);
121
+ const isExternalWorkspace =
122
+ !monorepoRoot || relativeToRepo.startsWith('..') || path.isAbsolute(relativeToRepo);
105
123
  if (!isExternalWorkspace) {
106
124
  return env;
107
125
  }
@@ -119,30 +137,40 @@ async function prepareWorkspace(workspaceRoot?: string): Promise<{
119
137
  readonly source?: string;
120
138
  }> {
121
139
  if (workspaceRoot) {
140
+ const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
141
+ const preparedExternalWorkspace = await prepareExternalWorkspaceCopy(
142
+ resolvedWorkspaceRoot,
143
+ 'webstir-smoke-explicit-',
144
+ {
145
+ forceLocalPackages: true,
146
+ },
147
+ );
148
+ if (preparedExternalWorkspace) {
149
+ return {
150
+ workspaceRoot: preparedExternalWorkspace.workspaceRoot,
151
+ cleanupRoot: preparedExternalWorkspace.cleanupRoot,
152
+ usedTempWorkspace: false,
153
+ };
154
+ }
155
+
122
156
  return {
123
- workspaceRoot: path.resolve(workspaceRoot),
157
+ workspaceRoot: resolvedWorkspaceRoot,
124
158
  usedTempWorkspace: false,
125
159
  };
126
160
  }
127
161
 
128
162
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'webstir-smoke-'));
129
163
  const tempWorkspace = path.join(tempRoot, 'full');
130
- let source: string | undefined;
131
-
132
- if (monorepoRoot) {
133
- const sourceRoot = path.join(monorepoRoot, 'examples', 'demos', 'full');
134
- await cp(sourceRoot, tempWorkspace, { recursive: true });
135
- source = sourceRoot;
136
- } else {
137
- await scaffoldWorkspace('full', tempWorkspace, { force: true });
138
- source = 'built-in full template';
139
- }
164
+ await scaffoldWorkspace('full', tempWorkspace, { force: true });
165
+ await materializeRepoLocalWorkspaceDependencies(tempWorkspace, {
166
+ forceLocalPackages: true,
167
+ });
140
168
 
141
169
  return {
142
170
  workspaceRoot: tempWorkspace,
143
171
  cleanupRoot: tempRoot,
144
172
  usedTempWorkspace: true,
145
- source,
173
+ source: 'built-in full template',
146
174
  };
147
175
  }
148
176
 
@@ -159,3 +187,7 @@ function formatTestDetail(result: TestCommandResult): string {
159
187
  function formatBackendInspectDetail(result: BackendInspectResult): string {
160
188
  return `${result.manifest.routes?.length ?? 0} routes, ${result.manifest.jobs?.length ?? 0} jobs`;
161
189
  }
190
+
191
+ function formatDoctorDetail(result: DoctorResult): string {
192
+ return result.healthy ? 'healthy' : `${result.issues.length} issue(s)`;
193
+ }