@vibesdotdev/runtime-environment-bun 0.0.0

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 (86) hide show
  1. package/dist/bun-environment.impl.d.ts +71 -0
  2. package/dist/bun-environment.impl.d.ts.map +1 -0
  3. package/dist/bun-environment.impl.js +130 -0
  4. package/dist/bun-environment.impl.js.map +1 -0
  5. package/dist/bun-loader.impl.d.ts +12 -0
  6. package/dist/bun-loader.impl.d.ts.map +1 -0
  7. package/dist/bun-loader.impl.js +96 -0
  8. package/dist/bun-loader.impl.js.map +1 -0
  9. package/dist/discovery/discovery.assets.consumer.d.ts +13 -0
  10. package/dist/discovery/discovery.assets.consumer.d.ts.map +1 -0
  11. package/dist/discovery/discovery.assets.consumer.js +116 -0
  12. package/dist/discovery/discovery.assets.consumer.js.map +1 -0
  13. package/dist/host/host.consumer.d.ts +12 -0
  14. package/dist/host/host.consumer.d.ts.map +1 -0
  15. package/dist/host/host.consumer.js +13 -0
  16. package/dist/host/host.consumer.js.map +1 -0
  17. package/dist/index.d.ts +49 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +77 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/loaders/fallback-loader.impl.consumer.d.ts +7 -0
  22. package/dist/loaders/fallback-loader.impl.consumer.d.ts.map +1 -0
  23. package/dist/loaders/fallback-loader.impl.consumer.js +63 -0
  24. package/dist/loaders/fallback-loader.impl.consumer.js.map +1 -0
  25. package/dist/package-plugins/candidates.consumer.d.ts +8 -0
  26. package/dist/package-plugins/candidates.consumer.d.ts.map +1 -0
  27. package/dist/package-plugins/candidates.consumer.js +133 -0
  28. package/dist/package-plugins/candidates.consumer.js.map +1 -0
  29. package/dist/package-plugins/import-module.consumer.d.ts +14 -0
  30. package/dist/package-plugins/import-module.consumer.d.ts.map +1 -0
  31. package/dist/package-plugins/import-module.consumer.js +28 -0
  32. package/dist/package-plugins/import-module.consumer.js.map +1 -0
  33. package/dist/package-plugins/index.d.ts +8 -0
  34. package/dist/package-plugins/index.d.ts.map +1 -0
  35. package/dist/package-plugins/index.js +8 -0
  36. package/dist/package-plugins/index.js.map +1 -0
  37. package/dist/package-plugins/installer.consumer.d.ts +14 -0
  38. package/dist/package-plugins/installer.consumer.d.ts.map +1 -0
  39. package/dist/package-plugins/installer.consumer.js +29 -0
  40. package/dist/package-plugins/installer.consumer.js.map +1 -0
  41. package/dist/package-plugins/loader.consumer.d.ts +12 -0
  42. package/dist/package-plugins/loader.consumer.d.ts.map +1 -0
  43. package/dist/package-plugins/loader.consumer.js +59 -0
  44. package/dist/package-plugins/loader.consumer.js.map +1 -0
  45. package/dist/package-plugins/reporting.d.ts +5 -0
  46. package/dist/package-plugins/reporting.d.ts.map +1 -0
  47. package/dist/package-plugins/reporting.js +10 -0
  48. package/dist/package-plugins/reporting.js.map +1 -0
  49. package/dist/package-plugins/resolve.consumer.d.ts +22 -0
  50. package/dist/package-plugins/resolve.consumer.d.ts.map +1 -0
  51. package/dist/package-plugins/resolve.consumer.js +265 -0
  52. package/dist/package-plugins/resolve.consumer.js.map +1 -0
  53. package/dist/process-spawn.impl.d.ts +15 -0
  54. package/dist/process-spawn.impl.d.ts.map +1 -0
  55. package/dist/process-spawn.impl.js +41 -0
  56. package/dist/process-spawn.impl.js.map +1 -0
  57. package/dist/services/runtime-path.impl.d.ts +20 -0
  58. package/dist/services/runtime-path.impl.d.ts.map +1 -0
  59. package/dist/services/runtime-path.impl.js +20 -0
  60. package/dist/services/runtime-path.impl.js.map +1 -0
  61. package/dist/services/self-spawn.consumer.d.ts +55 -0
  62. package/dist/services/self-spawn.consumer.d.ts.map +1 -0
  63. package/dist/services/self-spawn.consumer.js +85 -0
  64. package/dist/services/self-spawn.consumer.js.map +1 -0
  65. package/dist/services/workspace-resolve.d.ts +84 -0
  66. package/dist/services/workspace-resolve.d.ts.map +1 -0
  67. package/dist/services/workspace-resolve.js +107 -0
  68. package/dist/services/workspace-resolve.js.map +1 -0
  69. package/package.json +66 -0
  70. package/src/bun-environment.impl.ts +179 -0
  71. package/src/bun-loader.impl.ts +102 -0
  72. package/src/discovery/discovery.assets.consumer.ts +135 -0
  73. package/src/host/host.consumer.ts +23 -0
  74. package/src/index.ts +92 -0
  75. package/src/loaders/fallback-loader.impl.consumer.ts +57 -0
  76. package/src/package-plugins/candidates.consumer.ts +133 -0
  77. package/src/package-plugins/import-module.consumer.ts +27 -0
  78. package/src/package-plugins/index.ts +8 -0
  79. package/src/package-plugins/installer.consumer.ts +38 -0
  80. package/src/package-plugins/loader.consumer.ts +80 -0
  81. package/src/package-plugins/reporting.ts +13 -0
  82. package/src/package-plugins/resolve.consumer.ts +292 -0
  83. package/src/process-spawn.impl.ts +52 -0
  84. package/src/services/runtime-path.impl.ts +20 -0
  85. package/src/services/self-spawn.consumer.ts +91 -0
  86. package/src/services/workspace-resolve.ts +146 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Plugin resolution — anchored on the project directory.
3
+ *
4
+ * For each request, we ask Bun to resolve the package's plugin entry points
5
+ * from `projectDir`. Bun's resolver follows the standard Node walk: it finds
6
+ * the package in `projectDir/node_modules`, then in parent `node_modules`,
7
+ * and so on. No shadow trees, no on-the-fly bundling.
8
+ */
9
+
10
+ import { dirname, join } from 'path';
11
+ import { existsSync, readFileSync } from 'fs';
12
+ import { pathToFileURL } from 'url';
13
+ import type { RuntimePlugin } from '@vibesdotdev/runtime';
14
+ import type {
15
+ PackagePluginRequest,
16
+ ResolveAttempt,
17
+ ResolvedPluginEntry,
18
+ ResolvePluginResult
19
+ } from '@vibesdotdev/runtime/schemas/package-plugins';
20
+ import {
21
+ exportPluginSpecifiers,
22
+ getPackageName as getPluginPackageName,
23
+ isBarePackageSpecifier,
24
+ pluginFileCandidates
25
+ } from './candidates.consumer';
26
+
27
+ function looksLikePlugin(candidate: unknown): candidate is RuntimePlugin {
28
+ return Boolean(
29
+ candidate &&
30
+ typeof candidate === 'object' &&
31
+ 'id' in candidate &&
32
+ !('kind' in candidate) &&
33
+ ('name' in candidate ||
34
+ 'description' in candidate ||
35
+ 'dependencies' in candidate ||
36
+ 'kinds' in candidate ||
37
+ 'onRegister' in candidate ||
38
+ 'onActivate' in candidate ||
39
+ 'onDispose' in candidate)
40
+ );
41
+ }
42
+
43
+ function resolvePlugin(mod: Record<string, unknown>): RuntimePlugin | null {
44
+ const def = mod.default;
45
+ if (looksLikePlugin(def)) return def;
46
+
47
+ for (const [key, value] of Object.entries(mod)) {
48
+ if (key.toLowerCase().includes('plugin') && looksLikePlugin(value)) {
49
+ return value;
50
+ }
51
+ }
52
+
53
+ const candidates = Object.values(mod).filter(looksLikePlugin);
54
+ return candidates.length === 1 ? candidates[0] : null;
55
+ }
56
+
57
+ /**
58
+ * Specifiers we never load in consumer scope. Cloud plugins target workerd;
59
+ * loading them in a consumer runtime just produces unmet-dep errors because
60
+ * the consumer chain does not include the cloud-only plugin graph. Skip them
61
+ * at the specifier level.
62
+ */
63
+ function isConsumerScopedSpecifier(specifier: string): boolean {
64
+ return !specifier.endsWith('cloud.plugin');
65
+ }
66
+
67
+ export function getPackageName(specifier: string): string {
68
+ return getPluginPackageName(specifier);
69
+ }
70
+
71
+ function isNotFoundMessage(message: string): boolean {
72
+ return (
73
+ message.includes('Cannot find') ||
74
+ message.includes('Could not resolve') ||
75
+ message.includes('Failed to resolve')
76
+ );
77
+ }
78
+
79
+ async function importByPath(path: string): Promise<Record<string, unknown>> {
80
+ return (await import(pathToFileURL(path).href)) as Record<string, unknown>;
81
+ }
82
+
83
+ /**
84
+ * Walk up from a resolved file path to find the package root (the directory
85
+ * containing the matching `package.json`). This is how we discover a package's
86
+ * own filesystem root after `Bun.resolveSync` has already located it via the
87
+ * standard node_modules walk.
88
+ */
89
+ function packageRootFromResolvedPath(resolvedPath: string, packageName: string): string | null {
90
+ let current = dirname(resolvedPath);
91
+ const seen = new Set<string>();
92
+ while (!seen.has(current)) {
93
+ seen.add(current);
94
+ const pkgJsonPath = join(current, 'package.json');
95
+ if (existsSync(pkgJsonPath)) {
96
+ try {
97
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as { name?: string };
98
+ if (pkg.name === packageName) return current;
99
+ } catch {
100
+ // Continue walking past unreadable package.json files.
101
+ }
102
+ }
103
+ const parent = dirname(current);
104
+ if (parent === current) return null;
105
+ current = parent;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Locate a package's root by walking up from `projectDir` and probing each
112
+ * ancestor's `node_modules/<packageName>/package.json`. Used when the package
113
+ * declares no `.` export (so `Bun.resolveSync(packageName, …)` throws) but we
114
+ * still want to find its on-disk root to probe `./plugin` exports and scan
115
+ * for canonical plugin files.
116
+ */
117
+ function findPackageRootByWalkUp(projectDir: string, packageName: string): string | null {
118
+ const segments = packageName.split('/');
119
+ let current = projectDir;
120
+ const seen = new Set<string>();
121
+ while (!seen.has(current)) {
122
+ seen.add(current);
123
+ const candidate = join(current, 'node_modules', ...segments);
124
+ if (existsSync(join(candidate, 'package.json'))) return candidate;
125
+ const parent = dirname(current);
126
+ if (parent === current) return null;
127
+ current = parent;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ type SpecifierAttempt =
133
+ | { kind: 'plugin'; entry: ResolvedPluginEntry; resolvedPath: string }
134
+ | { kind: 'attempt'; attempt: ResolveAttempt; resolvedPath: string | null };
135
+
136
+ async function trySpecifier(
137
+ specifier: string,
138
+ projectDir: string
139
+ ): Promise<SpecifierAttempt> {
140
+ let resolvedPath: string | null = null;
141
+ try {
142
+ resolvedPath = Bun.resolveSync(specifier, projectDir);
143
+ } catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ return {
146
+ kind: 'attempt',
147
+ attempt: { label: specifier, message, notFound: isNotFoundMessage(message) },
148
+ resolvedPath: null
149
+ };
150
+ }
151
+
152
+ try {
153
+ const loaded = await importByPath(resolvedPath);
154
+ const plugin = resolvePlugin(loaded);
155
+ if (plugin) {
156
+ return {
157
+ kind: 'plugin',
158
+ entry: { plugin, specifier, resolvedPath },
159
+ resolvedPath
160
+ };
161
+ }
162
+ return {
163
+ kind: 'attempt',
164
+ attempt: {
165
+ label: specifier,
166
+ message: `Resolved ${specifier}, but it did not export a RuntimePlugin`,
167
+ notFound: false
168
+ },
169
+ resolvedPath
170
+ };
171
+ } catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ // Resolution succeeded but import failed (broken root export, syntax
174
+ // error, etc.). Surface the failure but keep `resolvedPath` so the
175
+ // caller can still discover the package root and probe other entries.
176
+ return {
177
+ kind: 'attempt',
178
+ attempt: { label: specifier, message, notFound: false },
179
+ resolvedPath
180
+ };
181
+ }
182
+ }
183
+
184
+ type FileAttempt =
185
+ | { kind: 'plugin'; entry: ResolvedPluginEntry }
186
+ | { kind: 'attempt'; attempt: ResolveAttempt };
187
+
188
+ async function tryFile(path: string): Promise<FileAttempt> {
189
+ try {
190
+ const loaded = await importByPath(path);
191
+ const plugin = resolvePlugin(loaded);
192
+ if (plugin) {
193
+ return { kind: 'plugin', entry: { plugin, specifier: path, resolvedPath: path } };
194
+ }
195
+ return {
196
+ kind: 'attempt',
197
+ attempt: {
198
+ label: path,
199
+ message: `Resolved ${path}, but it did not export a RuntimePlugin`,
200
+ notFound: false
201
+ }
202
+ };
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ return { kind: 'attempt', attempt: { label: path, message, notFound: isNotFoundMessage(message) } };
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Resolve all plugins exported by a package — the primary plugin (`./plugin`
211
+ * or the bare specifier) plus any sibling surface plugins (`./<x>.plugin`,
212
+ * e.g. `./cli.plugin`). Cloud-scoped specifiers are excluded in consumer
213
+ * resolution. Plugins are deduplicated by `plugin.id` (preserving first occurrence)
214
+ * to handle index-barrel re-exports.
215
+ */
216
+ export async function resolvePluginModule(
217
+ request: PackagePluginRequest
218
+ ): Promise<ResolvePluginResult | { error: string; missing: boolean }> {
219
+ const { id, projectDir } = request;
220
+ const attempts: ResolveAttempt[] = [];
221
+ const packageName = getPluginPackageName(id);
222
+ const bareSpecifier = isBarePackageSpecifier(id);
223
+
224
+ const collected = new Map<string, ResolvedPluginEntry>();
225
+ const addEntry = (entry: ResolvedPluginEntry) => {
226
+ if (!collected.has(entry.plugin.id)) collected.set(entry.plugin.id, entry);
227
+ };
228
+
229
+ // Try the literal request first. Even if it doesn't expose a plugin, the
230
+ // resolved path tells us where the package lives so we can probe its plugin
231
+ // entry points and on-disk plugin files.
232
+ const initial = await trySpecifier(id, projectDir);
233
+ let initialResolvedPath: string | null = null;
234
+ if (initial.kind === 'plugin') {
235
+ addEntry(initial.entry);
236
+ initialResolvedPath = initial.resolvedPath;
237
+ } else {
238
+ attempts.push(initial.attempt);
239
+ initialResolvedPath = initial.resolvedPath;
240
+ }
241
+
242
+ let packageRoot: string | null = null;
243
+ if (bareSpecifier) {
244
+ if (initialResolvedPath) {
245
+ packageRoot = packageRootFromResolvedPath(initialResolvedPath, packageName);
246
+ }
247
+ if (!packageRoot) {
248
+ packageRoot = findPackageRootByWalkUp(projectDir, packageName);
249
+ }
250
+ }
251
+
252
+ if (bareSpecifier && packageRoot) {
253
+ for (const specifier of exportPluginSpecifiers(packageName, packageRoot)) {
254
+ if (specifier === id) continue;
255
+ if (!isConsumerScopedSpecifier(specifier)) continue;
256
+ const candidate = await trySpecifier(specifier, projectDir);
257
+ if (candidate.kind === 'plugin') {
258
+ addEntry(candidate.entry);
259
+ } else {
260
+ attempts.push(candidate.attempt);
261
+ }
262
+ }
263
+
264
+ // Filesystem-discovered plugin files are only used as a fallback when no
265
+ // explicit plugin export resolved. They are scope-agnostic but we keep
266
+ // them as a last resort to preserve historic discovery behavior.
267
+ if (collected.size === 0) {
268
+ for (const file of pluginFileCandidates(packageRoot, packageName)) {
269
+ const candidate = await tryFile(file);
270
+ if (candidate.kind === 'plugin') {
271
+ addEntry(candidate.entry);
272
+ } else {
273
+ attempts.push(candidate.attempt);
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ if (collected.size > 0) {
280
+ return {
281
+ plugins: Array.from(collected.values()),
282
+ resolvedFrom: projectDir
283
+ };
284
+ }
285
+
286
+ const lastMeaningful = [...attempts].reverse().find((attempt) => !attempt.notFound);
287
+ const allNotFound = attempts.length > 0 && attempts.every((attempt) => attempt.notFound);
288
+ return {
289
+ error: lastMeaningful?.message ?? `Unable to resolve plugin ${id} from ${projectDir}`,
290
+ missing: allNotFound
291
+ };
292
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Bun Process Spawn Implementation
3
+ *
4
+ * Consumer-only process spawning backed by Bun.spawn().
5
+ * Only available when hardware === 'consumer'.
6
+ */
7
+
8
+ import type {
9
+ ProcessSpawnImplementation,
10
+ ProcessSpawnOptions,
11
+ ProcessHandle
12
+ } from '@vibesdotdev/runtime/kinds/process-spawn';
13
+ import type { ProcessSpawnDescriptor } from '@vibesdotdev/runtime/kinds/process-spawn';
14
+
15
+ export class BunProcessSpawn implements ProcessSpawnImplementation {
16
+ readonly id: string;
17
+ readonly descriptor: ProcessSpawnDescriptor;
18
+
19
+ constructor() {
20
+ this.id = 'process-spawn-bun';
21
+ this.descriptor = {
22
+ id: 'process-spawn-bun',
23
+ kind: 'process/spawn',
24
+ name: 'Bun Process Spawn',
25
+ description: 'Consumer-only process spawning via Bun.spawn()',
26
+ hardware: ['consumer'],
27
+ };
28
+ }
29
+
30
+ async spawn(options: ProcessSpawnOptions): Promise<ProcessHandle> {
31
+ const proc = Bun.spawn([options.command, ...(options.args ?? [])], {
32
+ cwd: options.cwd,
33
+ env: options.env ?? (process.env as Record<string, string>),
34
+ stdout: 'pipe',
35
+ stderr: 'pipe',
36
+ });
37
+
38
+ const exited = proc.exited.then((code) => ({
39
+ code,
40
+ signal: null as string | null,
41
+ }));
42
+
43
+ return {
44
+ pid: proc.pid,
45
+ stdout: proc.stdout as ProcessHandle['stdout'],
46
+ stderr: proc.stderr as ProcessHandle['stderr'],
47
+ stdin: null,
48
+ exited,
49
+ kill: (signal?: string) => proc.kill(signal as Parameters<typeof proc.kill>[0]),
50
+ };
51
+ }
52
+ }
@@ -0,0 +1,20 @@
1
+ import { homedir, tmpdir } from 'node:os';
2
+ import { join, resolve, dirname, basename, extname, isAbsolute, normalize } from 'node:path';
3
+
4
+ /**
5
+ * Bun / Node implementation of low-level path utilities.
6
+ * This is the source of truth for path operations on this host.
7
+ *
8
+ * The shape matches RuntimePath from @vibesdotdev/paths (the contract lives there).
9
+ */
10
+ export const bunRuntimePath = {
11
+ join,
12
+ resolve,
13
+ dirname,
14
+ basename,
15
+ extname,
16
+ isAbsolute,
17
+ normalize,
18
+ homedir,
19
+ tmpdir,
20
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Self-spawn argv resolution.
3
+ *
4
+ * Lets a hosted process (PM daemon launcher, an autoloop child, etc.)
5
+ * compute the command line that re-launches the *currently running*
6
+ * vibes binary so a fresh subprocess can run additional commands.
7
+ *
8
+ * Two host shapes are supported, and the bin entry's `import.meta.url`
9
+ * is the only reliable signal for distinguishing them:
10
+ *
11
+ * - **Compiled binary** (`bun build --compile`): `import.meta.url`
12
+ * starts with `file:///$bunfs/` (Bun's virtual mount for the
13
+ * bundled root). `process.execPath` is the standalone binary
14
+ * itself, so the spawn argv is just `[execPath]`.
15
+ *
16
+ * - **Source mode** (`bun run vibes.ts`): `import.meta.url` is a
17
+ * real `file://` path to the .ts entry. `process.execPath` is bun;
18
+ * `process.argv[1]` is the entry script. Spawn argv is
19
+ * `[bun, 'run', '--bun', entry]`.
20
+ *
21
+ * Bin entries call `declareSelfSpawn(import.meta.url)` once at
22
+ * startup. Consumers (e.g. PM client) call `readSelfSpawnArgv()`.
23
+ *
24
+ * Embedded hosts (a SvelteKit app importing PM at runtime) never call
25
+ * `declareSelfSpawn`. Consumers receive `null` and decide their own
26
+ * fallback policy (PM, for instance, falls back to scanning the
27
+ * workspace for a CLI source entry — useful in monorepo dev, throws
28
+ * with a clear message otherwise).
29
+ */
30
+
31
+ const COMPILED_BINARY_URL_PREFIX = 'file:///$bunfs/';
32
+ const SELF_SPAWN_ENV_VAR = 'VIBES_SELF_SPAWN_ARGV';
33
+
34
+ export type SelfSpawnSource = 'compiled-binary' | 'bun-source';
35
+
36
+ export interface SelfSpawnArgv {
37
+ argv: string[];
38
+ source: SelfSpawnSource;
39
+ }
40
+
41
+ /**
42
+ * Compute the self-spawn argv from the host's `import.meta.url`.
43
+ *
44
+ * Returns `null` when the host shape can't be determined (e.g. source
45
+ * mode where `process.argv[1]` is missing). Callers fall back.
46
+ */
47
+ export function computeSelfSpawnArgv(binImportMetaUrl: string): SelfSpawnArgv | null {
48
+ if (binImportMetaUrl.startsWith(COMPILED_BINARY_URL_PREFIX)) {
49
+ return { argv: [process.execPath], source: 'compiled-binary' };
50
+ }
51
+ const entry = process.argv[1];
52
+ if (entry && entry.length > 0) {
53
+ return {
54
+ argv: [process.execPath, 'run', '--bun', entry],
55
+ source: 'bun-source'
56
+ };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Publish the host's self-spawn argv as a JSON env var so subprocesses
63
+ * and other in-process consumers can re-launch the same binary shape.
64
+ *
65
+ * Idempotent — if the env var is already set (e.g. inherited from a
66
+ * parent vibes process), the original launch shape is preserved.
67
+ */
68
+ export function declareSelfSpawn(binImportMetaUrl: string): void {
69
+ if (process.env[SELF_SPAWN_ENV_VAR]) return;
70
+ const computed = computeSelfSpawnArgv(binImportMetaUrl);
71
+ if (!computed) return;
72
+ process.env[SELF_SPAWN_ENV_VAR] = JSON.stringify(computed.argv);
73
+ }
74
+
75
+ /**
76
+ * Read the published self-spawn argv. Returns `null` when no host has
77
+ * declared one or the value is malformed.
78
+ */
79
+ export function readSelfSpawnArgv(): string[] | null {
80
+ const raw = process.env[SELF_SPAWN_ENV_VAR];
81
+ if (!raw) return null;
82
+ try {
83
+ const parsed = JSON.parse(raw) as unknown;
84
+ if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
85
+ return parsed as string[];
86
+ }
87
+ } catch {
88
+ // malformed env value — treat as not declared
89
+ }
90
+ return null;
91
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Unified Workspace Root Resolution
3
+ *
4
+ * Single source of truth for resolving the workspace root path.
5
+ * This consolidates the various fallback patterns used across the codebase.
6
+ *
7
+ * Priority order (highest to lowest):
8
+ * 1. Explicit override (e.g., MCP request header)
9
+ * 2. Runtime context workspacePath
10
+ * 3. Environment config workspacePath
11
+ * 4. CLI invocation cwd
12
+ * 5. process.cwd() fallback
13
+ *
14
+ * NOTE: This module lives in @vibesdotdev/runtime (not workspace) to avoid
15
+ * a circular dependency between runtime and workspace. The workspace package
16
+ * re-exports from here for backward compatibility.
17
+ */
18
+
19
+ type WorkspaceSourceKey = 'override' | 'runtimeContext' | 'environment' | 'cliCwd' | 'cwd';
20
+
21
+ function normalizeCandidate(path: string | undefined): string | undefined {
22
+ if (typeof path !== 'string') return undefined;
23
+ const trimmed = path.trim();
24
+ return trimmed.length > 0 ? trimmed : undefined;
25
+ }
26
+
27
+ function readStringField(
28
+ value: object | null | undefined,
29
+ key: 'workspacePath' | 'cwd'
30
+ ): string | undefined {
31
+ if (!value) return undefined;
32
+ const candidate = (value as Record<'workspacePath' | 'cwd', string | undefined>)[key];
33
+ return typeof candidate === 'string' ? candidate : undefined;
34
+ }
35
+
36
+ export interface WorkspaceSources {
37
+ override?: string;
38
+ runtimeContext?: string;
39
+ environment?: string;
40
+ cliCwd?: string;
41
+ cwd?: string;
42
+ }
43
+
44
+ export interface WorkspaceResolutionResult {
45
+ path: string;
46
+ source: WorkspaceSourceKey | 'processCwd';
47
+ }
48
+
49
+ export interface WorkspaceResolutionContext {
50
+ workspacePath?: string;
51
+ environment?: object | null;
52
+ cli?: object | null;
53
+ }
54
+
55
+ const RESOLUTION_ORDER: readonly WorkspaceSourceKey[] = [
56
+ 'override',
57
+ 'runtimeContext',
58
+ 'environment',
59
+ 'cliCwd',
60
+ 'cwd'
61
+ ];
62
+
63
+ /**
64
+ * Resolve workspace root path from multiple sources with priority fallback.
65
+ *
66
+ * @param sources - Object containing potential workspace path sources
67
+ * @returns The resolved workspace path (never undefined)
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const workspace = resolveWorkspaceRoot({
72
+ * override: request.workspacePath,
73
+ * runtimeContext: context.workspacePath,
74
+ * environment: context.environment?.workspacePath,
75
+ * cliCwd: context.cli?.cwd
76
+ * });
77
+ * ```
78
+ */
79
+ export function resolveWorkspaceRoot(sources: WorkspaceSources = {}): string {
80
+ const { path } = resolveWorkspaceRootWithSource(sources);
81
+ return path;
82
+ }
83
+
84
+ /**
85
+ * Resolve workspace root path with source information for debugging.
86
+ *
87
+ * @param sources - Object containing potential workspace path sources
88
+ * @returns Object with resolved path and source that provided it
89
+ */
90
+ export function resolveWorkspaceRootWithSource(
91
+ sources: WorkspaceSources = {}
92
+ ): WorkspaceResolutionResult {
93
+ for (const source of RESOLUTION_ORDER) {
94
+ const path = normalizeCandidate(sources[source]);
95
+ if (path) {
96
+ return { path, source };
97
+ }
98
+ }
99
+
100
+ const fallback = sources.cwd || process.cwd();
101
+ return { path: fallback, source: 'processCwd' };
102
+ }
103
+
104
+ // Context Extraction Helpers
105
+
106
+ /**
107
+ * Extract workspace sources from a RuntimeContext-like object.
108
+ * Handles the various shapes of context objects in the codebase.
109
+ *
110
+ * @param context - Runtime context object with optional workspace-related fields
111
+ * @param override - Optional explicit override (e.g., from MCP request)
112
+ * @returns WorkspaceSources for use with resolveWorkspaceRoot
113
+ */
114
+ export function extractWorkspaceSources(
115
+ context: WorkspaceResolutionContext,
116
+ override?: string
117
+ ): WorkspaceSources {
118
+ return {
119
+ override: normalizeCandidate(override),
120
+ runtimeContext: normalizeCandidate(context.workspacePath),
121
+ environment: normalizeCandidate(readStringField(context.environment, 'workspacePath')),
122
+ cliCwd: normalizeCandidate(readStringField(context.cli, 'cwd'))
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Convenience function: Extract sources and resolve in one step.
128
+ *
129
+ * @param context - Runtime context object
130
+ * @param override - Optional explicit override
131
+ * @returns Resolved workspace path
132
+ */
133
+ export function resolveWorkspaceFromContext(
134
+ context: WorkspaceResolutionContext,
135
+ override?: string
136
+ ): string {
137
+ return resolveWorkspaceRoot(extractWorkspaceSources(context, override));
138
+ }
139
+
140
+ /**
141
+ * Resolve the current workspace as an object with rootPath.
142
+ * Convenience wrapper for callers that expect `{ rootPath: string }`.
143
+ */
144
+ export function resolveCurrentWorkspace(): { rootPath: string } {
145
+ return { rootPath: resolveWorkspaceRoot() };
146
+ }