@stream44.studio/t44 0.4.0-rc.24

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 (99) hide show
  1. package/.dco-signatures +9 -0
  2. package/.github/workflows/dco.yaml +12 -0
  3. package/.github/workflows/gordian-open-integrity.yaml +13 -0
  4. package/.github/workflows/test.yaml +31 -0
  5. package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
  6. package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
  7. package/.o/GordianOpenIntegrity.yaml +21 -0
  8. package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
  9. package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
  10. package/.repo-identifier +1 -0
  11. package/DCO.md +34 -0
  12. package/LICENSE.txt +186 -0
  13. package/README.md +189 -0
  14. package/bin/activate +36 -0
  15. package/bin/activate.ts +30 -0
  16. package/bin/postinstall.sh +19 -0
  17. package/bin/shell +27 -0
  18. package/bin/t44 +27 -0
  19. package/caps/ConfigSchemaStruct.ts +55 -0
  20. package/caps/Home.ts +57 -0
  21. package/caps/HomeRegistry.ts +319 -0
  22. package/caps/HomeRegistryFile.ts +144 -0
  23. package/caps/JsonSchemas.ts +220 -0
  24. package/caps/OpenApiSchema.ts +67 -0
  25. package/caps/PackageDescriptor.ts +88 -0
  26. package/caps/ProjectCatalogs.ts +153 -0
  27. package/caps/ProjectDeployment.ts +426 -0
  28. package/caps/ProjectDevelopment.ts +257 -0
  29. package/caps/ProjectPublishing.ts +654 -0
  30. package/caps/ProjectPulling.ts +234 -0
  31. package/caps/ProjectRack.ts +155 -0
  32. package/caps/ProjectRepository.ts +332 -0
  33. package/caps/ProjectTest.ts +251 -0
  34. package/caps/ProjectTestLib.ts +257 -0
  35. package/caps/RootKey.ts +219 -0
  36. package/caps/SigningKey.ts +243 -0
  37. package/caps/TaskWorkflow.ts +192 -0
  38. package/caps/WorkspaceCli.ts +448 -0
  39. package/caps/WorkspaceConfig.ts +268 -0
  40. package/caps/WorkspaceConfig.yaml +87 -0
  41. package/caps/WorkspaceConfigFile.ts +902 -0
  42. package/caps/WorkspaceConnection.ts +329 -0
  43. package/caps/WorkspaceEntityConfig.ts +78 -0
  44. package/caps/WorkspaceEntityConfig.v0.ts +77 -0
  45. package/caps/WorkspaceEntityFact.ts +218 -0
  46. package/caps/WorkspaceInfo.ts +619 -0
  47. package/caps/WorkspaceInit.ts +30 -0
  48. package/caps/WorkspaceKey.ts +338 -0
  49. package/caps/WorkspaceModel.ts +373 -0
  50. package/caps/WorkspaceProjects.ts +636 -0
  51. package/caps/WorkspacePrompt.ts +430 -0
  52. package/caps/WorkspaceShell.sh +39 -0
  53. package/caps/WorkspaceShell.ts +104 -0
  54. package/caps/WorkspaceShell.yaml +64 -0
  55. package/caps/WorkspaceShellCli.ts +109 -0
  56. package/caps/patterns/README.md +2 -0
  57. package/caps/patterns/git-scm.com/ProjectPublishing.ts +507 -0
  58. package/caps/patterns/semver.org/ProjectPublishing.ts +458 -0
  59. package/docs/Overview.drawio +248 -0
  60. package/docs/Overview.svg +4 -0
  61. package/examples/01-Lifecycle/main.test.ts +223 -0
  62. package/lib/crypto.ts +53 -0
  63. package/lib/key.ts +381 -0
  64. package/lib/schema-console-renderer.ts +181 -0
  65. package/lib/schema-resolver.ts +349 -0
  66. package/lib/ucan.ts +137 -0
  67. package/package.json +91 -0
  68. package/standalone-rt.test.ts +150 -0
  69. package/standalone-rt.ts +140 -0
  70. package/structs/HomeRegistry.ts +55 -0
  71. package/structs/HomeRegistryConfig.ts +60 -0
  72. package/structs/ProjectCatalogsConfig.ts +53 -0
  73. package/structs/ProjectDeploymentConfig.ts +56 -0
  74. package/structs/ProjectDeploymentFact.ts +106 -0
  75. package/structs/ProjectPublishingConfig.ts +78 -0
  76. package/structs/ProjectPublishingFact.ts +68 -0
  77. package/structs/ProjectPullingConfig.ts +52 -0
  78. package/structs/ProjectRack.ts +51 -0
  79. package/structs/ProjectRackConfig.ts +56 -0
  80. package/structs/RepositoryOriginDescriptor.ts +51 -0
  81. package/structs/RootKeyConfig.ts +64 -0
  82. package/structs/SigningKeyConfig.ts +64 -0
  83. package/structs/Workspace.ts +56 -0
  84. package/structs/WorkspaceCatalogs.ts +56 -0
  85. package/structs/WorkspaceCliConfig.ts +53 -0
  86. package/structs/WorkspaceConfig.ts +64 -0
  87. package/structs/WorkspaceConfigFile.ts +50 -0
  88. package/structs/WorkspaceConfigFileMeta.ts +70 -0
  89. package/structs/WorkspaceKey.ts +55 -0
  90. package/structs/WorkspaceKeyConfig.ts +56 -0
  91. package/structs/WorkspaceMappingsConfig.ts +56 -0
  92. package/structs/WorkspaceProject.ts +104 -0
  93. package/structs/WorkspaceProjectsConfig.ts +67 -0
  94. package/structs/WorkspaceShellConfig.ts +83 -0
  95. package/structs/patterns/README.md +2 -0
  96. package/structs/patterns/git-scm.com/ProjectPublishingFact.ts +46 -0
  97. package/tsconfig.json +33 -0
  98. package/workspace-rt.ts +152 -0
  99. package/workspace.yaml +3 -0
@@ -0,0 +1,426 @@
1
+
2
+ import { join, dirname } from 'path'
3
+ import { readFile, access } from 'fs/promises'
4
+ import { constants } from 'fs'
5
+ import chalk from 'chalk'
6
+
7
+ // ── Provider Lifecycle ─────────────────────────────────────────────
8
+ // Each deployment provider capsule exposes a standard interface:
9
+ //
10
+ // deploy — deploy the project to the provider
11
+ // deprovision — remove the project from the provider
12
+ // status — query deployment status (supports passive/cached mode)
13
+ //
14
+ // Every method receives { config, ... } where config contains the
15
+ // alias-level config with a .provider entry for the specific provider.
16
+ //
17
+ // The orchestrator dynamically loads provider capsules via importCapsule
18
+ // so no hard-coded provider mappings are needed.
19
+
20
+ export async function capsule({
21
+ encapsulate,
22
+ CapsulePropertyTypes,
23
+ makeImportStack
24
+ }: {
25
+ encapsulate: any
26
+ CapsulePropertyTypes: any
27
+ makeImportStack: any
28
+ }) {
29
+ return encapsulate({
30
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
31
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
32
+ '#@stream44.studio/t44/structs/ProjectDeploymentConfig': {
33
+ as: '$ProjectDeploymentConfig',
34
+ },
35
+ '#@stream44.studio/t44/structs/WorkspaceConfig': {
36
+ as: '$WorkspaceConfig'
37
+ },
38
+ '#': {
39
+ WorkspacePrompt: {
40
+ type: CapsulePropertyTypes.Mapping,
41
+ value: '@stream44.studio/t44/caps/WorkspacePrompt'
42
+ },
43
+ WorkspaceProjects: {
44
+ type: CapsulePropertyTypes.Mapping,
45
+ value: '@stream44.studio/t44/caps/WorkspaceProjects'
46
+ },
47
+ run: {
48
+ type: CapsulePropertyTypes.Function,
49
+ value: async function (this: any, { args }: any): Promise<void> {
50
+
51
+ let { projectSelector, deprovision, yes } = args
52
+
53
+ // ── Dynamic provider loader ──────────────────────────
54
+ const providerCache = new Map<string, any>()
55
+ const getProvider = async (uri: string) => {
56
+ const cleanUri = uri.startsWith('#') ? uri.substring(1) : uri
57
+ if (!providerCache.has(cleanUri)) {
58
+ const { api } = await this.self.importCapsule({ uri: cleanUri })
59
+ providerCache.set(cleanUri, api)
60
+ }
61
+ return providerCache.get(cleanUri)!
62
+ }
63
+
64
+ // ── Projection dir helper ────────────────────────────
65
+ const workspaceConfig = await this.$WorkspaceConfig.config
66
+ const getProjectionDir = (capsuleName: string) => join(
67
+ workspaceConfig.rootDir,
68
+ '.~o/workspace.foundation/@t44.sh~t44~caps~ProjectDeployment',
69
+ capsuleName.replace(/\//g, '~')
70
+ )
71
+
72
+ // ── Config helpers ────────────────────────────────────
73
+ const resolveProviders = (aliasConfig: any): any[] =>
74
+ aliasConfig.providers || (aliasConfig.provider ? [aliasConfig.provider] : [])
75
+
76
+ const extractProviderName = (capsulePath: string): string => {
77
+ const match = capsulePath.match(/t44-([^/]+)\//)
78
+ return match ? match[1] : 'unknown'
79
+ }
80
+
81
+ // ── Helper: call a lifecycle method on all providers for an alias ──
82
+ const callProvidersForAlias = async (
83
+ step: 'deploy' | 'deprovision' | 'status',
84
+ aliasConfig: any,
85
+ ctx: { alias: string; projectName: string },
86
+ ) => {
87
+ const providers = resolveProviders(aliasConfig)
88
+ for (const providerConfig of providers) {
89
+ const provider = await getProvider(providerConfig.capsule)
90
+ if (typeof provider[step] !== 'function') continue
91
+
92
+ const config = { ...aliasConfig, provider: providerConfig }
93
+
94
+ if (step === 'deploy') {
95
+ await provider.deploy({
96
+ alias: ctx.alias,
97
+ config,
98
+ projectionDir: getProjectionDir(providerConfig.capsule),
99
+ workspaceProjectName: ctx.projectName,
100
+ })
101
+ } else if (step === 'deprovision') {
102
+ await provider.deprovision({ config })
103
+ }
104
+ }
105
+ }
106
+
107
+ // ══════════════════════════════════════════════════════
108
+ // STEP 1: Load config & resolve matching deployments
109
+ // ══════════════════════════════════════════════════════
110
+ const deploymentConfig = await this.$ProjectDeploymentConfig.config
111
+
112
+ if (!deploymentConfig?.deployments) {
113
+ throw new Error('No deployments configuration found')
114
+ }
115
+
116
+ let matchingDeployments: Record<string, any> = {}
117
+
118
+ if (!projectSelector) {
119
+ const selected = await selectProjectInteractively.call(this, {
120
+ deploymentConfig,
121
+ deprovision,
122
+ getProvider,
123
+ resolveProviders,
124
+ extractProviderName,
125
+ })
126
+ if (!selected) return
127
+ matchingDeployments = selected
128
+ } else {
129
+ matchingDeployments = await this.WorkspaceProjects.resolveMatchingDeployments({
130
+ workspaceProject: projectSelector,
131
+ deployments: deploymentConfig.deployments
132
+ })
133
+ }
134
+
135
+ // ══════════════════════════════════════════════════════
136
+ // STEP 2: Deploy or deprovision each matching project
137
+ // ══════════════════════════════════════════════════════
138
+ for (const [projectName, projectConfig] of Object.entries(matchingDeployments)) {
139
+ const actionText = deprovision ? 'Deprovisioning' : 'Deploying'
140
+ console.log(`\n=> ${actionText} project '${projectName}' ...\n`)
141
+
142
+ const orderedAliases = orderAliasesByDependencies(projectConfig)
143
+
144
+ // ── Deprovision confirmation ─────────────────────
145
+ if (deprovision) {
146
+ if (!yes) {
147
+ const confirmed = await confirmDeprovision.call(this, { projectName, orderedAliases })
148
+ if (confirmed === 'skip') continue
149
+ if (confirmed === 'abort') return
150
+ } else {
151
+ console.log(chalk.red(`\n✓ Auto-confirmed with --yes flag. Proceeding with deprovisioning...\n`))
152
+ }
153
+ }
154
+
155
+ // For deprovision, reverse the order to handle dependencies correctly
156
+ const aliasesToProcess = deprovision ? [...orderedAliases].reverse() : orderedAliases
157
+
158
+ // ── Process each alias ───────────────────────────
159
+ for (const alias of aliasesToProcess) {
160
+ const step = deprovision ? 'deprovision' : 'deploy'
161
+ const stepText = deprovision ? 'Deprovisioning' : 'Deploying'
162
+ console.log(`\n=> ${stepText} provider project alias '${alias}' for workspace project '${projectName}' ...\n`)
163
+
164
+ // ── Build step (deploy only) ──────────────────
165
+ if (!deprovision) {
166
+ const aliasConfig = projectConfig[alias]
167
+ if (aliasConfig.sourceDir) {
168
+ await runBuildIfAvailable(aliasConfig.sourceDir)
169
+ }
170
+ }
171
+
172
+ await callProvidersForAlias(step, projectConfig[alias], { alias, projectName })
173
+
174
+ console.log(`\n<= ${stepText} of provider project alias '${alias}' for workspace project '${projectName}' done.\n`)
175
+ }
176
+
177
+ console.log(`\n<= Project '${projectName}' ${deprovision ? 'deprovisioning' : 'deployment'} complete.\n`)
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }, {
184
+ importMeta: import.meta,
185
+ importStack: makeImportStack(),
186
+ capsuleName: capsule['#'],
187
+ })
188
+ }
189
+ capsule['#'] = '@stream44.studio/t44/caps/ProjectDeployment'
190
+
191
+
192
+ // ── Interactive project selection ────────────────────────────────────
193
+ async function selectProjectInteractively(
194
+ this: any,
195
+ { deploymentConfig, deprovision, getProvider, resolveProviders, extractProviderName }: {
196
+ deploymentConfig: any
197
+ deprovision: boolean
198
+ getProvider: (uri: string) => Promise<any>
199
+ resolveProviders: (aliasConfig: any) => any[]
200
+ extractProviderName: (capsulePath: string) => string
201
+ }
202
+ ): Promise<Record<string, any> | null> {
203
+ const allProjects = Object.keys(deploymentConfig.deployments)
204
+
205
+ if (allProjects.length === 0) {
206
+ throw new Error('No deployments configured')
207
+ }
208
+
209
+ const actionText = deprovision ? 'deprovision' : 'deploy'
210
+ console.log(chalk.cyan(`\nPick a project to ${actionText}. You will be asked for necessary credentials as needed.\n`))
211
+
212
+ const choices: Array<{ name: string; value: string }> = []
213
+
214
+ for (const projectName of allProjects) {
215
+ const projectAliases = deploymentConfig.deployments[projectName]
216
+ const aliasNames = Object.keys(projectAliases)
217
+ const firstAlias = aliasNames[0]
218
+ const aliasConfig = projectAliases[firstAlias]
219
+ const providers = resolveProviders(aliasConfig)
220
+
221
+ const providerName = extractProviderName(providers[0]?.capsule || '')
222
+
223
+ // Check deployment status across all providers that support it
224
+ let statusText = ''
225
+ let isDeployed = false
226
+ try {
227
+ let status: any
228
+ for (const providerConfig of providers) {
229
+ const config = { ...aliasConfig, provider: providerConfig }
230
+ const provider = await getProvider(providerConfig.capsule)
231
+ if (typeof provider.status === 'function') {
232
+ status = await provider.status({ config, passive: true })
233
+ }
234
+ if (status && !status.error) break
235
+ }
236
+
237
+ if (!status || status?.error) {
238
+ statusText = chalk.gray('not deployed')
239
+ } else if (status?.status === 'READY') {
240
+ isDeployed = true
241
+ statusText = chalk.green('deployed') + formatDuration(status)
242
+ } else if (status?.status) {
243
+ statusText = chalk.gray(status.status.toLowerCase())
244
+ } else {
245
+ statusText = chalk.gray('not deployed')
246
+ }
247
+ } catch {
248
+ statusText = chalk.gray('not deployed')
249
+ }
250
+
251
+ // When deprovisioning, only show deployed projects
252
+ if (deprovision && !isDeployed) continue
253
+
254
+ choices.push({
255
+ name: `${chalk.white(projectName)} ${chalk.cyan(`[${providerName}]`)} ${chalk.gray(`[${aliasNames.join(', ')}]`)} ${statusText}`,
256
+ value: projectName
257
+ })
258
+ }
259
+
260
+ if (choices.length === 0) {
261
+ console.log(chalk.gray('No deployed projects found.\n'))
262
+ return null
263
+ }
264
+
265
+ try {
266
+ const selectedProject = await this.WorkspacePrompt.select({
267
+ message: `Select a project:`,
268
+ choices
269
+ })
270
+ return { [selectedProject]: deploymentConfig.deployments[selectedProject] }
271
+ } catch (error: any) {
272
+ if (error.message?.includes('SIGINT') || error.message?.includes('force closed')) {
273
+ console.log(chalk.red('\nABORTED\n'))
274
+ return null
275
+ }
276
+ throw error
277
+ }
278
+ }
279
+
280
+
281
+ // ── Deprovision confirmation prompt ──────────────────────────────────
282
+ async function confirmDeprovision(
283
+ this: any,
284
+ { projectName, orderedAliases }: { projectName: string; orderedAliases: string[] }
285
+ ): Promise<'ok' | 'skip' | 'abort'> {
286
+ const aliasNames = orderedAliases.join(', ')
287
+ console.log(chalk.red(`\n⚠️ WARNING: You are about to DELETE all deployments for project '${projectName}':\n`))
288
+ console.log(chalk.red(` Aliases: ${aliasNames}`))
289
+ console.log(chalk.red(`\n ⚠️ THIS ACTION IS IRREVERSIBLE ⚠️\n`))
290
+
291
+ try {
292
+ const confirmation = await this.WorkspacePrompt.input({
293
+ message: chalk.red(`To confirm deletion, type the project name exactly: "${projectName}"`),
294
+ defaultValue: ''
295
+ })
296
+
297
+ if (confirmation !== projectName) {
298
+ console.log(chalk.red('\n⚠️ ABORTED\n'))
299
+ return 'skip'
300
+ }
301
+
302
+ console.log(chalk.red(`\n✓ Confirmation received. Proceeding with deprovisioning...\n`))
303
+ return 'ok'
304
+ } catch (error: any) {
305
+ if (error.message?.includes('SIGINT') || error.message?.includes('force closed')) {
306
+ console.log(chalk.red('\n\nABORTED\n'))
307
+ return 'abort'
308
+ }
309
+ throw error
310
+ }
311
+ }
312
+
313
+
314
+ // ── Duration formatting for status display ───────────────────────────
315
+ function formatDuration(status: any): string {
316
+ if (!status.createdAt && !status.updatedAt) return ''
317
+
318
+ const deployedDate = new Date(status.updatedAt || status.createdAt)
319
+ const now = new Date()
320
+ const diffMs = now.getTime() - deployedDate.getTime()
321
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
322
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
323
+ const diffMinutes = Math.floor(diffMs / (1000 * 60))
324
+
325
+ if (diffDays > 0) return chalk.gray(` (${diffDays}d ago)`)
326
+ if (diffHours > 0) return chalk.gray(` (${diffHours}h ago)`)
327
+ if (diffMinutes > 0) return chalk.gray(` (${diffMinutes}m ago)`)
328
+ return chalk.gray(' (just now)')
329
+ }
330
+
331
+
332
+ // ── Build step: find closest package.json with build script ───────
333
+ async function runBuildIfAvailable(sourceDir: string): Promise<void> {
334
+ // Walk up from sourceDir to find the closest package.json with a build script
335
+ let currentDir = sourceDir
336
+
337
+ // Normalize: if sourceDir ends with a known build output folder, start from parent
338
+ const buildOutputFolders = ['dist', 'build', 'out', '.next', '.output']
339
+ const lastSegment = sourceDir.split('/').pop()
340
+ if (lastSegment && buildOutputFolders.includes(lastSegment)) {
341
+ currentDir = dirname(sourceDir)
342
+ }
343
+
344
+ // Walk up looking for package.json with build script
345
+ const maxDepth = 5
346
+ for (let i = 0; i < maxDepth; i++) {
347
+ const pkgPath = join(currentDir, 'package.json')
348
+ try {
349
+ await access(pkgPath, constants.F_OK)
350
+ const pkgContent = await readFile(pkgPath, 'utf-8')
351
+ const pkg = JSON.parse(pkgContent)
352
+
353
+ if (pkg.scripts?.build) {
354
+ console.log(chalk.cyan(`Building ${pkg.name || currentDir} ...`))
355
+ console.log(chalk.gray(` Directory: ${currentDir}`))
356
+ console.log(chalk.gray(` Script: ${pkg.scripts.build}\n`))
357
+
358
+ const proc = Bun.spawn(['bun', 'run', 'build'], {
359
+ cwd: currentDir,
360
+ stdin: 'inherit',
361
+ stdout: 'inherit',
362
+ stderr: 'inherit'
363
+ })
364
+
365
+ const exitCode = await proc.exited
366
+ if (exitCode !== 0) {
367
+ throw new Error(`Build failed with exit code ${exitCode}`)
368
+ }
369
+
370
+ console.log(chalk.green(`Build complete.\n`))
371
+ return
372
+ }
373
+ } catch (err: any) {
374
+ // If it's our own error (build failed), rethrow
375
+ if (err.message?.includes('Build failed')) {
376
+ throw err
377
+ }
378
+ // Otherwise, package.json doesn't exist or isn't valid, continue walking up
379
+ }
380
+
381
+ const parentDir = dirname(currentDir)
382
+ if (parentDir === currentDir) {
383
+ // Reached filesystem root
384
+ break
385
+ }
386
+ currentDir = parentDir
387
+ }
388
+
389
+ // No build script found - that's okay, just skip
390
+ }
391
+
392
+
393
+ function orderAliasesByDependencies(deploymentConfig: Record<string, any>): string[] {
394
+ const aliases = Object.keys(deploymentConfig)
395
+ const ordered: string[] = []
396
+ const visited = new Set<string>()
397
+ const visiting = new Set<string>()
398
+
399
+ function visit(alias: string): void {
400
+ if (visited.has(alias)) return
401
+
402
+ if (visiting.has(alias)) {
403
+ throw new Error(`Circular dependency detected involving alias: ${alias}`)
404
+ }
405
+
406
+ visiting.add(alias)
407
+
408
+ const depends = deploymentConfig[alias].depends || []
409
+ for (const dep of depends) {
410
+ if (!deploymentConfig[dep]) {
411
+ throw new Error(`Dependency '${dep}' not found for alias '${alias}'`)
412
+ }
413
+ visit(dep)
414
+ }
415
+
416
+ visiting.delete(alias)
417
+ visited.add(alias)
418
+ ordered.push(alias)
419
+ }
420
+
421
+ for (const alias of aliases) {
422
+ visit(alias)
423
+ }
424
+
425
+ return ordered
426
+ }
@@ -0,0 +1,257 @@
1
+
2
+ import { join, resolve, relative } from 'path'
3
+ import { readdir, readFile, access } from 'fs/promises'
4
+ import { constants } from 'fs'
5
+ import { $ } from 'bun'
6
+ import chalk from 'chalk'
7
+
8
+ export async function capsule({
9
+ encapsulate,
10
+ CapsulePropertyTypes,
11
+ makeImportStack
12
+ }: {
13
+ encapsulate: any
14
+ CapsulePropertyTypes: any
15
+ makeImportStack: any
16
+ }) {
17
+ return encapsulate({
18
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
19
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
20
+ '#': {
21
+ WorkspaceConfig: {
22
+ type: CapsulePropertyTypes.Mapping,
23
+ value: '@stream44.studio/t44/caps/WorkspaceConfig'
24
+ },
25
+ WorkspaceProjects: {
26
+ type: CapsulePropertyTypes.Mapping,
27
+ value: '@stream44.studio/t44/caps/WorkspaceProjects'
28
+ },
29
+ WorkspacePrompt: {
30
+ type: CapsulePropertyTypes.Mapping,
31
+ value: '@stream44.studio/t44/caps/WorkspacePrompt'
32
+ },
33
+ run: {
34
+ type: CapsulePropertyTypes.Function,
35
+ value: async function (this: any, { args }: any): Promise<void> {
36
+
37
+ const { projectSelector } = args
38
+
39
+ const projects = await this.WorkspaceProjects.list
40
+
41
+ // Discover all dev scripts across projects and their packages
42
+ const devTargets: Array<{
43
+ label: string
44
+ type: 'project' | 'package'
45
+ projectName: string
46
+ packageName?: string
47
+ dir: string
48
+ script: string
49
+ }> = []
50
+
51
+ for (const [projectName, projectInfo] of Object.entries(projects)) {
52
+ const sourceDir = (projectInfo as any).sourceDir
53
+ if (!sourceDir) continue
54
+
55
+ // Check project root for dev script
56
+ const projectPkgPath = join(sourceDir, 'package.json')
57
+ try {
58
+ await access(projectPkgPath, constants.F_OK)
59
+ const pkgContent = await readFile(projectPkgPath, 'utf-8')
60
+ const pkg = JSON.parse(pkgContent)
61
+ if (pkg.scripts?.dev) {
62
+ devTargets.push({
63
+ label: projectName,
64
+ type: 'project',
65
+ projectName,
66
+ dir: sourceDir,
67
+ script: pkg.scripts.dev
68
+ })
69
+ }
70
+ } catch { }
71
+
72
+ // Check packages/* for dev scripts
73
+ const packagesDir = join(sourceDir, 'packages')
74
+ try {
75
+ await access(packagesDir, constants.F_OK)
76
+ const packageEntries = await readdir(packagesDir, { withFileTypes: true })
77
+ for (const entry of packageEntries) {
78
+ if (!entry.isDirectory()) continue
79
+ const pkgDir = join(packagesDir, entry.name)
80
+ const pkgJsonPath = join(pkgDir, 'package.json')
81
+ try {
82
+ await access(pkgJsonPath, constants.F_OK)
83
+ const pkgContent = await readFile(pkgJsonPath, 'utf-8')
84
+ const pkg = JSON.parse(pkgContent)
85
+ if (pkg.scripts?.dev) {
86
+ devTargets.push({
87
+ label: `${projectName}/packages/${entry.name}`,
88
+ type: 'package',
89
+ projectName,
90
+ packageName: entry.name,
91
+ dir: pkgDir,
92
+ script: pkg.scripts.dev
93
+ })
94
+ }
95
+ } catch { }
96
+ }
97
+ } catch { }
98
+ }
99
+
100
+ if (devTargets.length === 0) {
101
+ console.log(chalk.yellow('\nNo projects or packages with a "dev" script found.\n'))
102
+ return
103
+ }
104
+
105
+ // Sort alphabetically by label
106
+ devTargets.sort((a, b) => a.label.localeCompare(b.label))
107
+
108
+ let selectedTarget: typeof devTargets[0]
109
+
110
+ if (projectSelector) {
111
+ // Match by project name, package path, package name, or resolved path
112
+ const resolvedSelector = resolve(process.cwd(), projectSelector)
113
+
114
+ const matches = devTargets.filter(t => {
115
+ // Name-based matching
116
+ if (t.label === projectSelector ||
117
+ t.label.startsWith(projectSelector) ||
118
+ t.projectName === projectSelector ||
119
+ t.packageName === projectSelector) {
120
+ return true
121
+ }
122
+ // Path-based matching: resolved selector matches or contains the target dir
123
+ const resolvedDir = resolve(t.dir)
124
+ if (resolvedDir === resolvedSelector || resolvedDir.startsWith(resolvedSelector + '/')) {
125
+ return true
126
+ }
127
+ return false
128
+ })
129
+
130
+ if (matches.length === 0) {
131
+ console.log(chalk.red(`\nNo dev script found matching '${projectSelector}'.\n`))
132
+ console.log(chalk.gray('Available targets:'))
133
+ for (const t of devTargets) {
134
+ const typeTag = t.type === 'project'
135
+ ? chalk.cyan('[project]')
136
+ : chalk.magenta('[package]')
137
+ console.log(chalk.gray(` - ${t.label} ${typeTag}`))
138
+ }
139
+ console.log('')
140
+ return
141
+ }
142
+
143
+ if (matches.length > 1) {
144
+ // Prefer exact match over substring matches
145
+ const exactMatch = matches.find(t =>
146
+ t.label === projectSelector ||
147
+ t.projectName === projectSelector ||
148
+ t.packageName === projectSelector
149
+ )
150
+ if (exactMatch) {
151
+ matches.length = 0
152
+ matches.push(exactMatch)
153
+ } else {
154
+ console.log(chalk.red(`\nMultiple dev targets match '${projectSelector}':\n`))
155
+ for (const m of matches) {
156
+ console.log(chalk.gray(` - ${m.label}`))
157
+ }
158
+ console.log(chalk.red('\nPlease be more specific.\n'))
159
+ return
160
+ }
161
+ }
162
+
163
+ selectedTarget = matches[0]
164
+ } else {
165
+ // Interactive picker
166
+ console.log(chalk.cyan('\nSelect a dev server to run:\n'))
167
+
168
+ const choices: Array<{ name: string; value: number }> = []
169
+
170
+ for (let i = 0; i < devTargets.length; i++) {
171
+ const t = devTargets[i]
172
+ const typeTag = t.type === 'project'
173
+ ? chalk.cyan('[project]')
174
+ : chalk.magenta('[package]')
175
+ const scriptPreview = chalk.gray(t.script)
176
+
177
+ choices.push({
178
+ name: `${chalk.white(t.label)} ${typeTag} ${scriptPreview}`,
179
+ value: i
180
+ })
181
+ }
182
+
183
+ try {
184
+ const selectedIndex = await this.WorkspacePrompt.select({
185
+ message: 'Select dev target:',
186
+ choices,
187
+ pageSize: 20
188
+ })
189
+ selectedTarget = devTargets[selectedIndex]
190
+ } catch (error: any) {
191
+ if (error.message?.includes('SIGINT') || error.message?.includes('force closed')) {
192
+ console.log(chalk.red('\nABORTED\n'))
193
+ return
194
+ }
195
+ throw error
196
+ }
197
+ }
198
+
199
+ // Run the dev script interactively
200
+ const typeTag = selectedTarget.type === 'project'
201
+ ? chalk.cyan('[project]')
202
+ : chalk.magenta('[package]')
203
+
204
+ console.log(chalk.green(`\n=> Starting dev server for ${selectedTarget.label} ${typeTag}\n`))
205
+ console.log(chalk.gray(` Directory: ${selectedTarget.dir}`))
206
+ console.log(chalk.gray(` Script: ${selectedTarget.script}\n`))
207
+
208
+ // Check if node_modules exists; if not, check for dependencies and run bun install
209
+ const nodeModulesDir = join(selectedTarget.dir, 'node_modules')
210
+ let needsInstall = false
211
+ try {
212
+ await access(nodeModulesDir, constants.F_OK)
213
+ } catch {
214
+ // node_modules missing — check if package.json has dependencies
215
+ const pkgPath = join(selectedTarget.dir, 'package.json')
216
+ try {
217
+ const pkgContent = await readFile(pkgPath, 'utf-8')
218
+ const pkg = JSON.parse(pkgContent)
219
+ if ((pkg.dependencies && Object.keys(pkg.dependencies).length > 0) ||
220
+ (pkg.devDependencies && Object.keys(pkg.devDependencies).length > 0)) {
221
+ needsInstall = true
222
+ }
223
+ } catch { }
224
+ }
225
+
226
+ if (needsInstall) {
227
+ console.log(chalk.yellow(` Installing dependencies ...\n`))
228
+ const installProc = Bun.spawn(['bun', 'install'], {
229
+ cwd: selectedTarget.dir,
230
+ stdin: 'inherit',
231
+ stdout: 'inherit',
232
+ stderr: 'inherit'
233
+ })
234
+ await installProc.exited
235
+ console.log('')
236
+ }
237
+
238
+ // Use Bun.spawn for full interactive mode (stdin/stdout/stderr passthrough)
239
+ const proc = Bun.spawn(['bun', 'run', 'dev'], {
240
+ cwd: selectedTarget.dir,
241
+ stdin: 'inherit',
242
+ stdout: 'inherit',
243
+ stderr: 'inherit'
244
+ })
245
+
246
+ await proc.exited
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }, {
252
+ importMeta: import.meta,
253
+ importStack: makeImportStack(),
254
+ capsuleName: capsule['#'],
255
+ })
256
+ }
257
+ capsule['#'] = '@stream44.studio/t44/caps/ProjectDevelopment'