@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
@@ -1,12 +1,9 @@
1
- import path from 'node:path';
2
-
3
- import { DevServer, type DevServerAddress } from './dev-server.ts';
1
+ import { startBunSpaFrontendWatch } from './bun-spa-watch.ts';
2
+ import { startBunSsgFrontendWatch } from './bun-ssg-watch.ts';
3
+ import type { DevServerAddress } from './dev-server.ts';
4
4
  import { createStopSignal } from './stop-signal.ts';
5
- import { FrontendWatchDaemonClient } from './watch-daemon-client.ts';
6
- import { collectWatchActions, type StructuredDiagnosticPayload } from './watch-events.ts';
7
5
  import type { WorkspaceDescriptor } from './types.ts';
8
6
  import type { WatchIo, WatchOptions } from './watch.ts';
9
- import { WorkspaceWatcher, type WorkspaceWatchEvent } from './workspace-watcher.ts';
10
7
 
11
8
  export interface FrontendWatchSession {
12
9
  readonly address: DevServerAddress;
@@ -14,31 +11,29 @@ export interface FrontendWatchSession {
14
11
  stop(): Promise<void>;
15
12
  }
16
13
 
17
- interface FrontendWatchSessionOptions extends WatchOptions {
18
- readonly server?: DevServer;
19
- }
14
+ type FrontendWatchSessionOptions = WatchOptions;
20
15
 
21
16
  export async function runFrontendWatch(
22
17
  workspace: WorkspaceDescriptor,
23
18
  options: WatchOptions,
24
- io: WatchIo
19
+ io: WatchIo,
25
20
  ): Promise<void> {
26
21
  const session = await startFrontendWatchSession(workspace, options, io);
27
22
 
28
23
  io.stdout.write(
29
- `[webstir] watch starting\nworkspace: ${workspace.name}\nmode: ${workspace.mode}\nurl: ${session.address.origin}\n`
24
+ `[webstir] watch starting\nworkspace: ${workspace.name}\nmode: ${workspace.mode}\nurl: ${session.address.origin}\n`,
30
25
  );
31
26
 
32
27
  const stopSignal = createStopSignal();
33
28
 
34
29
  try {
35
- const daemonExitCode = await Promise.race([
30
+ const sessionExitCode = await Promise.race([
36
31
  session.waitForExit(),
37
32
  stopSignal.promise.then(() => null),
38
33
  ]);
39
34
 
40
- if (typeof daemonExitCode === 'number' && daemonExitCode !== 0) {
41
- throw new Error(`Frontend watch daemon exited with code ${daemonExitCode}.`);
35
+ if (typeof sessionExitCode === 'number' && sessionExitCode !== 0) {
36
+ throw new Error(`Frontend watch session exited with code ${sessionExitCode}.`);
42
37
  }
43
38
  } finally {
44
39
  stopSignal.dispose();
@@ -49,97 +44,27 @@ export async function runFrontendWatch(
49
44
  export async function startFrontendWatchSession(
50
45
  workspace: WorkspaceDescriptor,
51
46
  options: FrontendWatchSessionOptions,
52
- io: WatchIo
47
+ io: WatchIo,
53
48
  ): Promise<FrontendWatchSession> {
54
- const server = options.server ?? new DevServer({
55
- buildRoot: path.join(workspace.root, 'build', 'frontend'),
56
- host: options.host,
57
- port: options.port,
58
- });
59
- const ownsServer = options.server === undefined;
60
- const address = await server.start();
61
-
62
- let initialBuildReady = false;
63
- const daemon = new FrontendWatchDaemonClient({
64
- workspaceRoot: workspace.root,
65
- verbose: options.verbose,
66
- hmrVerbose: options.hmrVerbose,
67
- env: options.env,
68
- onLine(line) {
69
- io.stdout.write(`${line}\n`);
70
- },
71
- onErrorLine(line) {
72
- io.stderr.write(`${line}\n`);
73
- },
74
- onDiagnostic(payload) {
75
- if (!initialBuildReady && payload.code === 'frontend.watch.pipeline.success') {
76
- initialBuildReady = true;
77
- io.stdout.write(`[webstir] frontend ready at ${address.origin}\n`);
78
- }
79
-
80
- void applyDiagnostic(payload, server);
81
- },
82
- });
83
-
84
- const watcher = new WorkspaceWatcher({
85
- workspaceRoot: workspace.root,
86
- onEvent(event) {
87
- void dispatchWorkspaceEvent(event, daemon);
88
- },
89
- });
90
-
91
- await daemon.start();
92
- await watcher.start();
93
- await daemon.sendStart();
94
-
95
- let stopPromise: Promise<void> | null = null;
96
-
97
- return {
98
- address,
99
- async waitForExit() {
100
- return await daemon.waitForExit();
101
- },
102
- async stop() {
103
- stopPromise ??= (async () => {
104
- await watcher.stop();
105
- await daemon.stop();
106
- if (ownsServer) {
107
- await server.stop();
108
- }
109
- })();
110
-
111
- await stopPromise;
112
- },
113
- };
49
+ return await createFrontendWatchSession(workspace, options, io);
114
50
  }
115
51
 
116
- async function applyDiagnostic(payload: StructuredDiagnosticPayload, server: DevServer): Promise<void> {
117
- const actions = collectWatchActions(payload);
118
- for (const action of actions) {
119
- switch (action.type) {
120
- case 'status':
121
- await server.publishStatus(action.status);
122
- break;
123
- case 'hmr':
124
- await server.publishHotUpdate(action.payload);
125
- break;
126
- case 'reload':
127
- await server.publishReload();
128
- break;
129
- default:
130
- break;
131
- }
132
- }
133
- }
134
-
135
- async function dispatchWorkspaceEvent(
136
- event: WorkspaceWatchEvent,
137
- daemon: FrontendWatchDaemonClient
138
- ): Promise<void> {
139
- if (event.type === 'reload') {
140
- await daemon.sendReload();
141
- return;
52
+ async function createFrontendWatchSession(
53
+ workspace: WorkspaceDescriptor,
54
+ options: FrontendWatchSessionOptions,
55
+ _io: WatchIo,
56
+ ): Promise<FrontendWatchSession> {
57
+ if (workspace.mode === 'ssg') {
58
+ return await startBunSsgFrontendWatch({
59
+ workspaceRoot: workspace.root,
60
+ host: options.host,
61
+ port: options.port,
62
+ });
142
63
  }
143
64
 
144
- await daemon.sendChange(event.path);
65
+ return await startBunSpaFrontendWatch({
66
+ workspaceRoot: workspace.root,
67
+ host: options.host,
68
+ port: options.port,
69
+ });
145
70
  }
package/src/full-watch.ts CHANGED
@@ -1,9 +1,7 @@
1
- import path from 'node:path';
2
1
  import { createServer } from 'node:net';
3
2
 
4
3
  import { startApiWatchSession } from './api-watch.ts';
5
- import { DevServer } from './dev-server.ts';
6
- import { startFrontendWatchSession } from './frontend-watch.ts';
4
+ import { startBunGeneratedFrontendWatch } from './bun-generated-frontend-watch.ts';
7
5
  import { createStopSignal } from './stop-signal.ts';
8
6
  import type { WorkspaceDescriptor } from './types.ts';
9
7
  import type { WatchIo, WatchOptions } from './watch.ts';
@@ -11,34 +9,32 @@ import type { WatchIo, WatchOptions } from './watch.ts';
11
9
  export async function runFullWatch(
12
10
  workspace: WorkspaceDescriptor,
13
11
  options: WatchOptions,
14
- io: WatchIo
12
+ io: WatchIo,
15
13
  ): Promise<void> {
16
14
  const backendPort = await allocateBackendPort();
17
15
  const apiSession = await startApiWatchSession(workspace, { ...options, port: backendPort }, io);
18
- const server = new DevServer({
19
- buildRoot: path.join(workspace.root, 'build', 'frontend'),
20
- host: options.host,
21
- port: options.port,
22
- apiProxyOrigin: apiSession.origin,
23
- });
24
-
25
- let frontendSession: Awaited<ReturnType<typeof startFrontendWatchSession>> | undefined;
16
+ let frontendSession: Awaited<ReturnType<typeof startBunGeneratedFrontendWatch>> | undefined;
26
17
 
27
18
  try {
28
- frontendSession = await startFrontendWatchSession(workspace, { ...options, server }, io);
19
+ frontendSession = await startBunGeneratedFrontendWatch({
20
+ workspaceRoot: workspace.root,
21
+ host: options.host,
22
+ port: options.port,
23
+ apiProxyOrigin: apiSession.origin,
24
+ });
29
25
  io.stdout.write(
30
- `[webstir] watch starting\nworkspace: ${workspace.name}\nmode: ${workspace.mode}\nurl: ${frontendSession.address.origin}\napi: ${apiSession.origin}\n`
26
+ `[webstir] watch starting\nworkspace: ${workspace.name}\nmode: ${workspace.mode}\nurl: ${frontendSession.address.origin}\napi: ${apiSession.origin}\n`,
31
27
  );
32
28
 
33
29
  const stopSignal = createStopSignal();
34
30
  try {
35
- const daemonExitCode = await Promise.race([
31
+ const sessionExitCode = await Promise.race([
36
32
  frontendSession.waitForExit(),
37
33
  stopSignal.promise.then(() => null),
38
34
  ]);
39
35
 
40
- if (typeof daemonExitCode === 'number' && daemonExitCode !== 0) {
41
- throw new Error(`Frontend watch daemon exited with code ${daemonExitCode}.`);
36
+ if (typeof sessionExitCode === 'number' && sessionExitCode !== 0) {
37
+ throw new Error(`Frontend watch session exited with code ${sessionExitCode}.`);
42
38
  }
43
39
  } finally {
44
40
  stopSignal.dispose();
@@ -47,7 +43,6 @@ export async function runFullWatch(
47
43
  if (frontendSession) {
48
44
  await frontendSession.stop();
49
45
  }
50
- await server.stop();
51
46
  await apiSession.stop();
52
47
  }
53
48
  }
package/src/index.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  export * from './add.ts';
2
2
  export * from './add-backend.ts';
3
+ export * from './agent.ts';
3
4
  export * from './backend-inspect.ts';
4
5
  export * from './build-plan.ts';
5
6
  export * from './build.ts';
6
7
  export * from './dev-server.ts';
8
+ export * from './doctor.ts';
7
9
  export * from './enable.ts';
8
10
  export * from './execute.ts';
9
11
  export * from './format.ts';
12
+ export * from './frontend-inspect.ts';
10
13
  export * from './init.ts';
14
+ export * from './inspect.ts';
15
+ export * from './mcp/run-cli-json.ts';
16
+ export * from './mcp/server.ts';
17
+ export * from './operations.ts';
11
18
  export * from './publish.ts';
12
19
  export * from './repair.ts';
13
20
  export * from './refresh.ts';
@@ -22,37 +22,67 @@ export function getRootScaffoldAssets(): readonly ScaffoldAsset[] {
22
22
  createAsset(sharedTemplateRoot, 'Errors.500.html', 'Errors.500.html'),
23
23
  createAsset(sharedTemplateRoot, 'Errors.default.html', 'Errors.default.html'),
24
24
  createAsset(sharedTemplateRoot, 'types.global.d.ts', 'types.global.d.ts'),
25
- createAsset(sharedTemplateRoot, path.join('types', 'global.d.ts'), path.join('types', 'global.d.ts')),
25
+ createAsset(
26
+ sharedTemplateRoot,
27
+ path.join('types', 'global.d.ts'),
28
+ path.join('types', 'global.d.ts'),
29
+ ),
26
30
  ];
27
31
  }
28
32
 
29
- export async function getModeScaffoldAssets(mode: WorkspaceMode): Promise<readonly ScaffoldAsset[]> {
33
+ export async function getModeScaffoldAssets(
34
+ mode: WorkspaceMode,
35
+ ): Promise<readonly ScaffoldAsset[]> {
30
36
  switch (mode) {
31
37
  case 'ssg':
32
38
  return collectModeAssets([
33
- { sourceRoot: path.join(ssgTemplateRoot, 'src', 'frontend'), targetRoot: path.join('src', 'frontend') },
39
+ {
40
+ sourceRoot: path.join(ssgTemplateRoot, 'src', 'frontend'),
41
+ targetRoot: path.join('src', 'frontend'),
42
+ },
34
43
  ]);
35
44
  case 'spa':
36
45
  return collectModeAssets([
37
- { sourceRoot: path.join(spaTemplateRoot, 'src', 'frontend'), targetRoot: path.join('src', 'frontend') },
38
- { sourceRoot: path.join(spaTemplateRoot, 'src', 'shared'), targetRoot: path.join('src', 'shared') },
46
+ {
47
+ sourceRoot: path.join(spaTemplateRoot, 'src', 'frontend'),
48
+ targetRoot: path.join('src', 'frontend'),
49
+ },
50
+ {
51
+ sourceRoot: path.join(spaTemplateRoot, 'src', 'shared'),
52
+ targetRoot: path.join('src', 'shared'),
53
+ },
39
54
  ]);
40
55
  case 'api':
41
56
  return collectModeAssets([
42
- { sourceRoot: path.join(apiTemplateRoot, 'src', 'backend'), targetRoot: path.join('src', 'backend') },
43
- { sourceRoot: path.join(apiTemplateRoot, 'src', 'shared'), targetRoot: path.join('src', 'shared') },
57
+ {
58
+ sourceRoot: path.join(apiTemplateRoot, 'src', 'backend'),
59
+ targetRoot: path.join('src', 'backend'),
60
+ },
61
+ {
62
+ sourceRoot: path.join(apiTemplateRoot, 'src', 'shared'),
63
+ targetRoot: path.join('src', 'shared'),
64
+ },
44
65
  ]);
45
66
  case 'full':
46
67
  return collectModeAssets([
47
- { sourceRoot: path.join(fullTemplateRoot, 'src', 'frontend'), targetRoot: path.join('src', 'frontend') },
48
- { sourceRoot: path.join(fullTemplateRoot, 'src', 'backend'), targetRoot: path.join('src', 'backend') },
49
- { sourceRoot: path.join(fullTemplateRoot, 'src', 'shared'), targetRoot: path.join('src', 'shared') },
68
+ {
69
+ sourceRoot: path.join(fullTemplateRoot, 'src', 'frontend'),
70
+ targetRoot: path.join('src', 'frontend'),
71
+ },
72
+ {
73
+ sourceRoot: path.join(fullTemplateRoot, 'src', 'backend'),
74
+ targetRoot: path.join('src', 'backend'),
75
+ },
76
+ {
77
+ sourceRoot: path.join(fullTemplateRoot, 'src', 'shared'),
78
+ targetRoot: path.join('src', 'shared'),
79
+ },
50
80
  ]);
51
81
  }
52
82
  }
53
83
 
54
84
  async function collectModeAssets(
55
- roots: readonly { sourceRoot: string; targetRoot: string }[]
85
+ roots: readonly { sourceRoot: string; targetRoot: string }[],
56
86
  ): Promise<readonly ScaffoldAsset[]> {
57
87
  const assets: ScaffoldAsset[] = [];
58
88
  for (const root of roots) {
package/src/init.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
@@ -7,15 +7,7 @@ import { getModeScaffoldAssets, getRootScaffoldAssets } from './init-assets.ts';
7
7
  import { monorepoRoot } from './paths.ts';
8
8
  import type { WorkspaceMode } from './types.ts';
9
9
 
10
- const PACKAGE_MANAGER = 'bun@1.3.5';
11
- const REPO_WORKSPACE_PATTERNS = [
12
- 'packages/contracts/*',
13
- 'packages/tooling/*',
14
- 'orchestrators/bun',
15
- 'apps/*',
16
- 'examples/demos/*',
17
- 'examples/demos/ssg/*',
18
- ] as const;
10
+ const PACKAGE_MANAGER = 'bun@1.3.11';
19
11
 
20
12
  const MODE_DESCRIPTIONS: Record<WorkspaceMode, string> = {
21
13
  ssg: 'Static site (SSG) workspace for Webstir.',
@@ -37,15 +29,26 @@ export interface InitResult {
37
29
  readonly changes: readonly string[];
38
30
  }
39
31
 
32
+ interface ScaffoldMetadata {
33
+ readonly packageName?: string;
34
+ readonly description?: string;
35
+ }
36
+
37
+ let repoWorkspacePatternsPromise: Promise<readonly string[]> | undefined;
38
+
40
39
  export async function runInit(options: RunInitOptions): Promise<InitResult> {
41
- const request = parseInitRequest(options.args, options.workspaceRoot, options.cwd ?? process.cwd());
40
+ const request = parseInitRequest(
41
+ options.args,
42
+ options.workspaceRoot,
43
+ options.cwd ?? process.cwd(),
44
+ );
42
45
  return scaffoldWorkspace(request.mode, request.workspaceRoot, { force: false });
43
46
  }
44
47
 
45
48
  export async function scaffoldWorkspace(
46
49
  mode: WorkspaceMode,
47
50
  workspaceRoot: string,
48
- options: { readonly force: boolean }
51
+ options: { readonly force: boolean; readonly metadata?: ScaffoldMetadata },
49
52
  ): Promise<InitResult> {
50
53
  if (existsSync(workspaceRoot) && !options.force && !(await isDirectoryEmpty(workspaceRoot))) {
51
54
  throw new Error(`Refusing to initialize non-empty directory: ${workspaceRoot}`);
@@ -53,34 +56,31 @@ export async function scaffoldWorkspace(
53
56
 
54
57
  await mkdir(workspaceRoot, { recursive: true });
55
58
 
56
- const packageName = resolvePackageName(workspaceRoot);
59
+ const packageName = resolvePackageName(workspaceRoot, options.metadata);
57
60
  const dependencySpecs = await resolveDependencySpecs(workspaceRoot);
58
61
  const changes: string[] = [];
59
62
 
60
63
  for (const asset of getRootScaffoldAssets()) {
61
64
  const targetPath = path.join(workspaceRoot, asset.targetPath);
62
- await mkdir(path.dirname(targetPath), { recursive: true });
63
- await cp(asset.sourcePath, targetPath, { force: true });
65
+ await copyAsset(asset.sourcePath, targetPath);
64
66
  changes.push(toWorkspaceRelative(workspaceRoot, targetPath));
65
67
  }
66
68
 
67
69
  for (const asset of await getModeScaffoldAssets(mode)) {
68
70
  const targetPath = path.join(workspaceRoot, asset.targetPath);
69
- await mkdir(path.dirname(targetPath), { recursive: true });
70
- await cp(asset.sourcePath, targetPath, { force: true });
71
+ await copyAsset(asset.sourcePath, targetPath);
71
72
  changes.push(toWorkspaceRelative(workspaceRoot, targetPath));
72
73
  }
73
74
 
74
75
  const packageJsonPath = path.join(workspaceRoot, 'package.json');
75
- await writeFile(
76
+ await Bun.write(
76
77
  packageJsonPath,
77
- `${JSON.stringify(createPackageJson(mode, workspaceRoot, packageName, dependencySpecs), null, 2)}\n`,
78
- 'utf8'
78
+ `${JSON.stringify(createPackageJson(mode, packageName, dependencySpecs, options.metadata), null, 2)}\n`,
79
79
  );
80
80
  changes.push('package.json');
81
81
 
82
82
  const baseTsconfigPath = path.join(workspaceRoot, 'base.tsconfig.json');
83
- await writeFile(baseTsconfigPath, `${JSON.stringify(createBaseTsconfig(mode), null, 2)}\n`, 'utf8');
83
+ await Bun.write(baseTsconfigPath, `${JSON.stringify(createBaseTsconfig(mode), null, 2)}\n`);
84
84
  changes.push('base.tsconfig.json');
85
85
 
86
86
  return {
@@ -94,13 +94,15 @@ export async function scaffoldWorkspace(
94
94
  function parseInitRequest(
95
95
  args: readonly string[],
96
96
  workspaceOverride: string | undefined,
97
- cwd: string
97
+ cwd: string,
98
98
  ): { readonly mode: WorkspaceMode; readonly workspaceRoot: string } {
99
99
  const [firstArg, secondArg] = args;
100
100
 
101
101
  if (workspaceOverride) {
102
102
  if (!firstArg) {
103
- throw new Error('Usage: webstir init <mode> --workspace <path> or webstir init <mode> <directory>.');
103
+ throw new Error(
104
+ 'Usage: webstir init <mode> --workspace <path> or webstir init <mode> <directory>.',
105
+ );
104
106
  }
105
107
 
106
108
  return {
@@ -128,7 +130,12 @@ function parseInitRequest(
128
130
 
129
131
  function parseWorkspaceMode(value: string): WorkspaceMode {
130
132
  const normalized = value.trim().toLowerCase();
131
- if (normalized === 'ssg' || normalized === 'spa' || normalized === 'api' || normalized === 'full') {
133
+ if (
134
+ normalized === 'ssg' ||
135
+ normalized === 'spa' ||
136
+ normalized === 'api' ||
137
+ normalized === 'full'
138
+ ) {
132
139
  return normalized;
133
140
  }
134
141
 
@@ -149,7 +156,8 @@ async function isRepoWorkspacePath(workspaceRoot: string): Promise<boolean> {
149
156
  return false;
150
157
  }
151
158
 
152
- return REPO_WORKSPACE_PATTERNS.some((pattern) => matchesWorkspacePattern(relative, pattern));
159
+ const repoWorkspacePatterns = await readRepoWorkspacePatterns();
160
+ return repoWorkspacePatterns.some((pattern) => matchesWorkspacePattern(relative, pattern));
153
161
  }
154
162
 
155
163
  async function resolveDependencySpecs(workspaceRoot: string): Promise<Record<string, string>> {
@@ -162,14 +170,18 @@ async function resolveDependencySpecs(workspaceRoot: string): Promise<Record<str
162
170
  }
163
171
 
164
172
  return {
165
- '@webstir-io/webstir-frontend': await readInstalledPackageVersion('@webstir-io/webstir-frontend'),
173
+ '@webstir-io/webstir-frontend': await readInstalledPackageVersion(
174
+ '@webstir-io/webstir-frontend',
175
+ ),
166
176
  '@webstir-io/webstir-backend': await readInstalledPackageVersion('@webstir-io/webstir-backend'),
167
177
  '@webstir-io/webstir-testing': await readInstalledPackageVersion('@webstir-io/webstir-testing'),
168
178
  };
169
179
  }
170
180
 
171
181
  async function readPackageVersion(packageJsonPath: string): Promise<string> {
172
- const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { readonly version?: string };
182
+ const packageJson = JSON.parse(await readTextFile(packageJsonPath)) as {
183
+ readonly version?: string;
184
+ };
173
185
  if (!packageJson.version) {
174
186
  throw new Error(`Missing version in ${packageJsonPath}`);
175
187
  }
@@ -179,9 +191,9 @@ async function readPackageVersion(packageJsonPath: string): Promise<string> {
179
191
 
180
192
  function createPackageJson(
181
193
  mode: WorkspaceMode,
182
- workspaceRoot: string,
183
194
  packageName: string,
184
- dependencySpecs: Record<string, string>
195
+ dependencySpecs: Record<string, string>,
196
+ metadata: ScaffoldMetadata | undefined,
185
197
  ): Record<string, unknown> {
186
198
  const dependencies: Record<string, string> = {
187
199
  '@webstir-io/webstir-testing': dependencySpecs['@webstir-io/webstir-testing'],
@@ -200,7 +212,7 @@ function createPackageJson(
200
212
  version: '1.0.0',
201
213
  private: true,
202
214
  type: 'module',
203
- description: resolveDescription(mode, workspaceRoot),
215
+ description: metadata?.description ?? MODE_DESCRIPTIONS[mode],
204
216
  dependencies,
205
217
  devDependencies: {
206
218
  '@types/node': '^20.0.0',
@@ -255,33 +267,15 @@ function createBaseTsconfig(mode: WorkspaceMode): Record<string, unknown> {
255
267
  sourceMap: true,
256
268
  declaration: false,
257
269
  removeComments: true,
258
- typeRoots: [
259
- './types',
260
- './node_modules/@types',
261
- ],
270
+ typeRoots: ['./types', './node_modules/@types'],
262
271
  inlineSources: true,
263
272
  },
264
273
  };
265
274
  }
266
275
 
267
- function resolveDescription(mode: WorkspaceMode, workspaceRoot: string): string {
268
- const relative = monorepoRoot
269
- ? path.relative(monorepoRoot, workspaceRoot).replaceAll(path.sep, '/')
270
- : '';
271
- if (relative === 'examples/demos/full') {
272
- return 'Webstir frontend defaults and tooling';
273
- }
274
-
275
- return MODE_DESCRIPTIONS[mode];
276
- }
277
-
278
- function resolvePackageName(workspaceRoot: string): string {
279
- const relative = monorepoRoot
280
- ? path.relative(monorepoRoot, workspaceRoot).replaceAll(path.sep, '/')
281
- : '';
282
- const known = getKnownWorkspacePackageName(relative);
283
- if (known) {
284
- return known;
276
+ function resolvePackageName(workspaceRoot: string, metadata: ScaffoldMetadata | undefined): string {
277
+ if (metadata?.packageName?.trim()) {
278
+ return metadata.packageName;
285
279
  }
286
280
 
287
281
  return sanitizePackageName(path.basename(workspaceRoot));
@@ -293,25 +287,12 @@ async function readInstalledPackageVersion(packageName: string): Promise<string>
293
287
  return await readPackageVersion(packageJsonPath);
294
288
  }
295
289
 
296
- function getKnownWorkspacePackageName(relativePath: string): string | undefined {
297
- switch (relativePath) {
298
- case 'examples/demos/spa':
299
- return 'webstir-demo-spa';
300
- case 'examples/demos/api':
301
- return 'webstir-demo-api';
302
- case 'examples/demos/full':
303
- return 'webstir-demo-full';
304
- case 'examples/demos/ssg/base':
305
- return 'webstir-demo-ssg-base';
306
- case 'examples/demos/ssg/site':
307
- return 'webstir-demo-ssg-site';
308
- default:
309
- return undefined;
310
- }
311
- }
312
-
313
290
  function sanitizePackageName(value: string): string {
314
- const normalized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
291
+ const normalized = value
292
+ .trim()
293
+ .toLowerCase()
294
+ .replace(/[^a-z0-9._-]+/g, '-')
295
+ .replace(/^-+|-+$/g, '');
315
296
  return normalized || 'webstir-project';
316
297
  }
317
298
 
@@ -327,7 +308,9 @@ function matchesWorkspacePattern(relativePath: string, pattern: string): boolean
327
308
  return false;
328
309
  }
329
310
 
330
- return patternSegments.every((segment, index) => segment === '*' || segment === relativeSegments[index]);
311
+ return patternSegments.every(
312
+ (segment, index) => segment === '*' || segment === relativeSegments[index],
313
+ );
331
314
  }
332
315
 
333
316
  function uniqueSorted(values: readonly string[]): string[] {
@@ -337,3 +320,34 @@ function uniqueSorted(values: readonly string[]): string[] {
337
320
  function toWorkspaceRelative(workspaceRoot: string, absolutePath: string): string {
338
321
  return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, '/');
339
322
  }
323
+
324
+ async function readRepoWorkspacePatterns(): Promise<readonly string[]> {
325
+ if (!monorepoRoot) {
326
+ return [];
327
+ }
328
+
329
+ repoWorkspacePatternsPromise ??= loadRepoWorkspacePatterns();
330
+ return await repoWorkspacePatternsPromise;
331
+ }
332
+
333
+ async function loadRepoWorkspacePatterns(): Promise<readonly string[]> {
334
+ if (!monorepoRoot) {
335
+ return [];
336
+ }
337
+
338
+ const packageJsonPath = path.join(monorepoRoot, 'package.json');
339
+ const packageJson = JSON.parse(await readTextFile(packageJsonPath)) as {
340
+ readonly workspaces?: readonly string[];
341
+ };
342
+
343
+ return packageJson.workspaces ?? [];
344
+ }
345
+
346
+ async function copyAsset(sourcePath: string, targetPath: string): Promise<void> {
347
+ await mkdir(path.dirname(targetPath), { recursive: true });
348
+ await Bun.write(targetPath, Bun.file(sourcePath));
349
+ }
350
+
351
+ async function readTextFile(filePath: string): Promise<string> {
352
+ return await Bun.file(filePath).text();
353
+ }