@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.
- package/.dco-signatures +9 -0
- package/.github/workflows/dco.yaml +12 -0
- package/.github/workflows/gordian-open-integrity.yaml +13 -0
- package/.github/workflows/test.yaml +31 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +21 -0
- package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
- package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
- package/.repo-identifier +1 -0
- package/DCO.md +34 -0
- package/LICENSE.txt +186 -0
- package/README.md +189 -0
- package/bin/activate +36 -0
- package/bin/activate.ts +30 -0
- package/bin/postinstall.sh +19 -0
- package/bin/shell +27 -0
- package/bin/t44 +27 -0
- package/caps/ConfigSchemaStruct.ts +55 -0
- package/caps/Home.ts +57 -0
- package/caps/HomeRegistry.ts +319 -0
- package/caps/HomeRegistryFile.ts +144 -0
- package/caps/JsonSchemas.ts +220 -0
- package/caps/OpenApiSchema.ts +67 -0
- package/caps/PackageDescriptor.ts +88 -0
- package/caps/ProjectCatalogs.ts +153 -0
- package/caps/ProjectDeployment.ts +426 -0
- package/caps/ProjectDevelopment.ts +257 -0
- package/caps/ProjectPublishing.ts +654 -0
- package/caps/ProjectPulling.ts +234 -0
- package/caps/ProjectRack.ts +155 -0
- package/caps/ProjectRepository.ts +332 -0
- package/caps/ProjectTest.ts +251 -0
- package/caps/ProjectTestLib.ts +257 -0
- package/caps/RootKey.ts +219 -0
- package/caps/SigningKey.ts +243 -0
- package/caps/TaskWorkflow.ts +192 -0
- package/caps/WorkspaceCli.ts +448 -0
- package/caps/WorkspaceConfig.ts +268 -0
- package/caps/WorkspaceConfig.yaml +87 -0
- package/caps/WorkspaceConfigFile.ts +902 -0
- package/caps/WorkspaceConnection.ts +329 -0
- package/caps/WorkspaceEntityConfig.ts +78 -0
- package/caps/WorkspaceEntityConfig.v0.ts +77 -0
- package/caps/WorkspaceEntityFact.ts +218 -0
- package/caps/WorkspaceInfo.ts +619 -0
- package/caps/WorkspaceInit.ts +30 -0
- package/caps/WorkspaceKey.ts +338 -0
- package/caps/WorkspaceModel.ts +373 -0
- package/caps/WorkspaceProjects.ts +636 -0
- package/caps/WorkspacePrompt.ts +430 -0
- package/caps/WorkspaceShell.sh +39 -0
- package/caps/WorkspaceShell.ts +104 -0
- package/caps/WorkspaceShell.yaml +64 -0
- package/caps/WorkspaceShellCli.ts +109 -0
- package/caps/patterns/README.md +2 -0
- package/caps/patterns/git-scm.com/ProjectPublishing.ts +507 -0
- package/caps/patterns/semver.org/ProjectPublishing.ts +458 -0
- package/docs/Overview.drawio +248 -0
- package/docs/Overview.svg +4 -0
- package/examples/01-Lifecycle/main.test.ts +223 -0
- package/lib/crypto.ts +53 -0
- package/lib/key.ts +381 -0
- package/lib/schema-console-renderer.ts +181 -0
- package/lib/schema-resolver.ts +349 -0
- package/lib/ucan.ts +137 -0
- package/package.json +91 -0
- package/standalone-rt.test.ts +150 -0
- package/standalone-rt.ts +140 -0
- package/structs/HomeRegistry.ts +55 -0
- package/structs/HomeRegistryConfig.ts +60 -0
- package/structs/ProjectCatalogsConfig.ts +53 -0
- package/structs/ProjectDeploymentConfig.ts +56 -0
- package/structs/ProjectDeploymentFact.ts +106 -0
- package/structs/ProjectPublishingConfig.ts +78 -0
- package/structs/ProjectPublishingFact.ts +68 -0
- package/structs/ProjectPullingConfig.ts +52 -0
- package/structs/ProjectRack.ts +51 -0
- package/structs/ProjectRackConfig.ts +56 -0
- package/structs/RepositoryOriginDescriptor.ts +51 -0
- package/structs/RootKeyConfig.ts +64 -0
- package/structs/SigningKeyConfig.ts +64 -0
- package/structs/Workspace.ts +56 -0
- package/structs/WorkspaceCatalogs.ts +56 -0
- package/structs/WorkspaceCliConfig.ts +53 -0
- package/structs/WorkspaceConfig.ts +64 -0
- package/structs/WorkspaceConfigFile.ts +50 -0
- package/structs/WorkspaceConfigFileMeta.ts +70 -0
- package/structs/WorkspaceKey.ts +55 -0
- package/structs/WorkspaceKeyConfig.ts +56 -0
- package/structs/WorkspaceMappingsConfig.ts +56 -0
- package/structs/WorkspaceProject.ts +104 -0
- package/structs/WorkspaceProjectsConfig.ts +67 -0
- package/structs/WorkspaceShellConfig.ts +83 -0
- package/structs/patterns/README.md +2 -0
- package/structs/patterns/git-scm.com/ProjectPublishingFact.ts +46 -0
- package/tsconfig.json +33 -0
- package/workspace-rt.ts +152 -0
- 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'
|