@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,902 @@
1
+
2
+ import * as yaml from 'js-yaml'
3
+ import { readFile, writeFile, access } from 'fs/promises';
4
+ import { join, resolve, relative, dirname } from 'path'
5
+ import chalk from 'chalk'
6
+
7
+ export async function capsule({
8
+ encapsulate,
9
+ CapsulePropertyTypes,
10
+ makeImportStack
11
+ }: {
12
+ encapsulate: any
13
+ CapsulePropertyTypes: any
14
+ makeImportStack: any
15
+ }) {
16
+ return encapsulate({
17
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
18
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
19
+ '#@stream44.studio/t44/structs/WorkspaceConfigFile': {
20
+ as: '$ConfigFile'
21
+ },
22
+ '#': {
23
+ WorkspaceConfig: {
24
+ type: CapsulePropertyTypes.Mapping,
25
+ value: '@stream44.studio/t44/caps/WorkspaceConfig'
26
+ },
27
+ JsonSchema: {
28
+ type: CapsulePropertyTypes.Mapping,
29
+ value: '@stream44.studio/t44/caps/JsonSchemas'
30
+ },
31
+ RegisterSchemas: {
32
+ type: CapsulePropertyTypes.StructInit,
33
+ value: async function (this: any): Promise<void> {
34
+ const schema = this.$ConfigFile?.schema?.schema
35
+ const capsuleName = this.$ConfigFile?.capsuleName
36
+ if (schema && capsuleName) {
37
+ const version = this.$ConfigFile?.schemaMinorVersion || '0'
38
+ await this.JsonSchema.registerSchema(capsuleName, schema, version)
39
+ }
40
+ }
41
+ },
42
+ loadConfig: {
43
+ type: CapsulePropertyTypes.Function,
44
+ value: async function (this: any, rootConfigPath: string, workspaceRootDir: string): Promise<{ config: any, configTree: any }> {
45
+ return loadConfigWithExtends(rootConfigPath, workspaceRootDir)
46
+ }
47
+ },
48
+ _struct_readConfigFromFile: {
49
+ type: CapsulePropertyTypes.Function,
50
+ value: async function (this: any, configPath: string): Promise<any> {
51
+ const absolutePath = resolve(configPath)
52
+ const content = await readFile(absolutePath, 'utf-8')
53
+ const parsed = yaml.load(content) as any || {}
54
+
55
+ // Already schema-wrapped — return config (strip $schema for internal use)
56
+ if (parsed && parsed.$schema) {
57
+ const config = { ...parsed }
58
+ delete config.$schema
59
+ if (ensureEntityTimestamps(config)) {
60
+ await this.$ConfigFile._struct_writeConfigToFile(configPath, config)
61
+ }
62
+ return config
63
+ }
64
+
65
+ // Raw config file — wrap with schema and save to upgrade it
66
+ const config = parsed
67
+ const schema = this.$ConfigFile.schema
68
+ const capsuleName = this.$ConfigFile.capsuleName
69
+
70
+ ensureEntityTimestamps(config)
71
+
72
+ if (schema?.wrapWithSchema) {
73
+ const schemaFilePath = schema.resolveSchemaFilePath?.(capsuleName)
74
+ const schemaRef = schemaFilePath ? relative(dirname(absolutePath), schemaFilePath) : undefined
75
+ const output = schema.wrapWithSchema(config, schemaRef)
76
+
77
+ const wrapped = yaml.dump(output, {
78
+ indent: 2,
79
+ lineWidth: 200,
80
+ noRefs: true,
81
+ sortKeys: false,
82
+ quotingType: '"',
83
+ forceQuotes: false
84
+ })
85
+ await writeFile(absolutePath, wrapped)
86
+ }
87
+
88
+ return config
89
+ }
90
+ },
91
+ _struct_writeConfigToFile: {
92
+ type: CapsulePropertyTypes.Function,
93
+ value: async function (this: any, configPath: string, config: any): Promise<void> {
94
+ const absolutePath = resolve(configPath)
95
+ const schemaName = 'WorkspaceConfigFile'
96
+ const schema = this.$ConfigFile.schema
97
+ const capsuleName = this.$ConfigFile.capsuleName
98
+
99
+ // Validate against schema (use capsuleName as that's the key in JsonSchemas.schemas)
100
+ const validationFeedback = await schema.validate(capsuleName, config)
101
+ if (validationFeedback.errors.length > 0) {
102
+ console.error(schema.formatValidationFeedback(validationFeedback, {
103
+ filePath: absolutePath,
104
+ schemaRef: `${capsuleName}#/$defs/${schemaName}`
105
+ }))
106
+ process.exit(1)
107
+ }
108
+
109
+ // Build schema-wrapped output
110
+ const schemaFilePath = schema.resolveSchemaFilePath?.(capsuleName)
111
+ const schemaRef = schemaFilePath ? relative(dirname(absolutePath), schemaFilePath) : undefined
112
+ const output = schema.wrapWithSchema(config, schemaRef)
113
+
114
+ // Write as YAML with schema wrapper
115
+ const content = yaml.dump(output, {
116
+ indent: 2,
117
+ lineWidth: 200,
118
+ noRefs: true,
119
+ sortKeys: false,
120
+ quotingType: '"',
121
+ forceQuotes: false
122
+ })
123
+ await writeFile(absolutePath, content)
124
+ }
125
+ },
126
+ readConfigFile: {
127
+ type: CapsulePropertyTypes.Function,
128
+ value: async function (this: any, configPath: string): Promise<any> {
129
+ return this.$ConfigFile._struct_readConfigFromFile(configPath)
130
+ }
131
+ },
132
+ writeConfigFile: {
133
+ type: CapsulePropertyTypes.Function,
134
+ value: async function (this: any, configPath: string, config: any): Promise<void> {
135
+ return this.$ConfigFile._struct_writeConfigToFile(configPath, config)
136
+ }
137
+ },
138
+ setConfigValue: {
139
+ type: CapsulePropertyTypes.Function,
140
+ value: async function (this: any, configPath: string, path: string[], value: any, options?: { ifAbsent?: boolean }): Promise<boolean> {
141
+ const config = await this.$ConfigFile._struct_readConfigFromFile(configPath)
142
+
143
+ const existingValue = getAtPath(config, path)
144
+
145
+ // If ifAbsent is set, only write if the key doesn't exist yet
146
+ if (options?.ifAbsent && existingValue !== undefined) {
147
+ return false
148
+ }
149
+
150
+ // Check if value at path is already identical — skip write if unchanged
151
+ if (deepEqual(existingValue, value)) {
152
+ return false
153
+ }
154
+
155
+ // Set value at path
156
+ setAtPath(config, path, value)
157
+
158
+ await this.$ConfigFile._struct_writeConfigToFile(configPath, config)
159
+ return true
160
+ }
161
+ },
162
+ setConfigValueForEntity: {
163
+ type: CapsulePropertyTypes.Function,
164
+ value: async function (this: any, configPath: string, entity: { entityName: string, schema: any }, path: string[], value: any, options?: { ifAbsent?: boolean }): Promise<boolean> {
165
+ const config = await this.$ConfigFile._struct_readConfigFromFile(configPath)
166
+
167
+ const existingValue = getAtPath(config, path)
168
+
169
+ // If ifAbsent is set, only write if the key doesn't exist yet
170
+ if (options?.ifAbsent && existingValue !== undefined) {
171
+ return false
172
+ }
173
+
174
+ // Check if value at path is already identical — skip write if unchanged
175
+ if (deepEqual(existingValue, value)) {
176
+ return false
177
+ }
178
+
179
+ // Set value at path in memory
180
+ setAtPath(config, path, value)
181
+
182
+ // Validate entity config block against schema before writing
183
+ const configKey = '#' + entity.entityName
184
+ const entityConfig = config[configKey]
185
+ if (entityConfig && entity.schema?.validate && entity.schema?.schema) {
186
+ const feedback = await entity.schema.validate(entity.entityName, entityConfig)
187
+ if (feedback.errors.length > 0) {
188
+ const errorDetails = feedback.errors.map((e: any) => {
189
+ if (e.validationErrors?.length) {
190
+ return e.validationErrors.map((ve: any) =>
191
+ ` ${ve.path || '/'}: ${ve.message}`
192
+ ).join('\n')
193
+ }
194
+ return ` ${e.message}`
195
+ }).join('\n')
196
+
197
+ throw new Error(
198
+ `Entity config validation failed for "${entity.entityName}":\n${errorDetails}\n` +
199
+ ` Path: ${JSON.stringify(path)}\n` +
200
+ ` Value: ${JSON.stringify(value)}`
201
+ )
202
+ }
203
+ }
204
+
205
+ await this.$ConfigFile._struct_writeConfigToFile(configPath, config)
206
+ return true
207
+ }
208
+ },
209
+ getConfigValue: {
210
+ type: CapsulePropertyTypes.Function,
211
+ value: async function (this: any, configPath: string, path: string[]): Promise<any> {
212
+ const config = await this.$ConfigFile._struct_readConfigFromFile(configPath)
213
+ return getAtPath(config, path)
214
+ }
215
+ },
216
+ }
217
+ }
218
+ }, {
219
+ importMeta: import.meta,
220
+ importStack: makeImportStack(),
221
+ capsuleName: capsule['#'],
222
+ })
223
+ }
224
+ capsule['#'] = '@stream44.studio/t44/caps/WorkspaceConfigFile'
225
+
226
+ function resolveExtendPath(extendPath: string, configDir: string): string {
227
+ if (extendPath.startsWith('.')) {
228
+ return resolve(configDir, extendPath)
229
+ } else {
230
+ // For module paths, we need to resolve the package directory first
231
+ // because require.resolve() only works for JS modules, not .yaml files
232
+ // Split the path into package name and file path
233
+ // Handle scoped packages like @stream44.studio/t44/workspace.yaml
234
+ let packageName: string
235
+ let filePath: string
236
+
237
+ if (extendPath.startsWith('@')) {
238
+ // Scoped package: @scope/package/file/path.yaml
239
+ const parts = extendPath.split('/')
240
+ packageName = `${parts[0]}/${parts[1]}` // @scope/package
241
+ filePath = parts.slice(2).join('/') // file/path.yaml
242
+ } else {
243
+ // Regular package: package/file/path.yaml
244
+ const parts = extendPath.split('/')
245
+ packageName = parts[0]
246
+ filePath = parts.slice(1).join('/')
247
+ }
248
+
249
+ // Walk up from configDir looking for self-package or node_modules/<packageName>
250
+ const { existsSync, readFileSync } = require('fs')
251
+ let searchDir = configDir
252
+ let packageDir = ''
253
+ while (searchDir !== dirname(searchDir)) {
254
+ // Check if this directory's package.json matches the requested package (self-package resolution)
255
+ const selfPjPath = join(searchDir, 'package.json')
256
+ if (existsSync(selfPjPath)) {
257
+ try {
258
+ const pj = JSON.parse(readFileSync(selfPjPath, 'utf-8'))
259
+ if (pj.name === packageName) {
260
+ packageDir = searchDir
261
+ break
262
+ }
263
+ } catch { }
264
+ }
265
+
266
+ // Check node_modules
267
+ const candidate = join(searchDir, 'node_modules', packageName)
268
+ if (existsSync(join(candidate, 'package.json'))) {
269
+ packageDir = candidate
270
+ break
271
+ }
272
+ searchDir = dirname(searchDir)
273
+ }
274
+ if (!packageDir) {
275
+ throw new Error(`Cannot resolve package '${packageName}' from '${configDir}'. Ensure it is installed in node_modules.`)
276
+ }
277
+
278
+ // Resolve the file path within the package
279
+ return resolve(packageDir, filePath)
280
+ }
281
+ }
282
+
283
+ async function loadConfigWithExtends(configPath: string, workspaceRootDir: string): Promise<{ config: any, configTree: any, entitySources: Map<string, { path: string, line: number }[]> }> {
284
+ const loadedConfigs: { path: string, config: any, rawContent: string }[] = []
285
+ const mainConfigDir = join(resolve(configPath), '..')
286
+ let configTree: any = null
287
+
288
+ async function loadConfigRecursive(currentPath: string, referencedFrom?: string, chain: string[] = []): Promise<any> {
289
+ const absolutePath = resolve(currentPath)
290
+
291
+ // Check if this file is already in the current chain (circular reference)
292
+ if (chain.includes(absolutePath)) {
293
+ throw new Error(`Circular extends detected: ${absolutePath}\nChain: ${chain.join(' -> ')} -> ${absolutePath}`)
294
+ }
295
+
296
+ // Add to current chain
297
+ const currentChain = [...chain, absolutePath]
298
+
299
+ // Check if file exists before attempting to read
300
+ try {
301
+ await access(absolutePath)
302
+ } catch (error) {
303
+ const errorLines = [
304
+ '',
305
+ chalk.bold.red('✗ Configuration File Not Found'),
306
+ '',
307
+ chalk.gray(' Missing file:'),
308
+ chalk.red(` ${absolutePath}`),
309
+ ''
310
+ ]
311
+
312
+ if (referencedFrom) {
313
+ errorLines.push(
314
+ chalk.gray(' Referenced from:'),
315
+ chalk.yellow(` ${referencedFrom}`),
316
+ ''
317
+ )
318
+ }
319
+
320
+ if (currentChain.length > 0) {
321
+ errorLines.push(
322
+ chalk.gray(' Configuration chain:'),
323
+ ...currentChain.map((path, idx) =>
324
+ chalk.cyan(` ${idx + 1}. ${path}`)
325
+ ),
326
+ chalk.red(` ${currentChain.length + 1}. ${absolutePath} `) + chalk.bold.red('← MISSING'),
327
+ ''
328
+ )
329
+ }
330
+
331
+ // Determine which file to tell user to fix
332
+ const fileToFix = referencedFrom || (currentChain.length > 0 ? currentChain[currentChain.length - 1] : 'your workspace.yaml')
333
+
334
+ errorLines.push(
335
+ chalk.bold.white(' Action Required:'),
336
+ chalk.white(' • Create the missing file, or'),
337
+ chalk.white(' • Fix the \'extends\' path in:'),
338
+ chalk.yellow(` ${fileToFix}`),
339
+ ''
340
+ )
341
+
342
+ const err = new Error(errorLines.join('\n'))
343
+ err.stack = '' // Remove stack trace
344
+ throw err
345
+ }
346
+
347
+ let rawContent = await readFile(absolutePath, 'utf-8')
348
+ const configDir = join(absolutePath, '..')
349
+
350
+ const isTrace = process.argv.includes('--trace')
351
+ if (isTrace) {
352
+ console.log(chalk.gray(` [trace] Loading YAML config: ${absolutePath}`))
353
+ }
354
+
355
+ // Check if file is already schema-wrapped or raw
356
+ let rawParsed: any
357
+ try {
358
+ rawParsed = yaml.load(rawContent) as any
359
+ } catch (yamlError: any) {
360
+ console.error('')
361
+ console.error(chalk.bgRed.white.bold(' YAML Parse Error '))
362
+ console.error('')
363
+ console.error(chalk.red.bold(' File: ') + chalk.yellow(absolutePath))
364
+ if (referencedFrom) {
365
+ console.error(chalk.red.bold(' Referenced from: ') + chalk.yellow(referencedFrom))
366
+ }
367
+ if (yamlError.mark) {
368
+ console.error(chalk.red.bold(' Line: ') + chalk.white(yamlError.mark.line + 1) + chalk.red.bold(' Column: ') + chalk.white(yamlError.mark.column + 1))
369
+ }
370
+ if (yamlError.reason) {
371
+ console.error(chalk.red.bold(' Reason: ') + chalk.white(yamlError.reason))
372
+ }
373
+ if (yamlError.mark?.snippet) {
374
+ console.error('')
375
+ console.error(chalk.gray(' ' + yamlError.mark.snippet.split('\n').join('\n ')))
376
+ }
377
+ console.error('')
378
+ console.error(chalk.red.bold(' === Raw file content (start) ==='))
379
+ const rawLines = rawContent.split('\n')
380
+ for (let i = 0; i < rawLines.length; i++) {
381
+ console.error(chalk.gray(` ${String(i + 1).padStart(4)} | ${rawLines[i]}`))
382
+ }
383
+ console.error(chalk.red.bold(' === Raw file content (end) ==='))
384
+ console.error('')
385
+ process.exit(1)
386
+ }
387
+ const isInsideNodeModules = absolutePath.includes('/node_modules/')
388
+ let isWrapped = !!(rawParsed && rawParsed.$schema)
389
+ let needsRewrite = false
390
+
391
+ // Ensure createdAt/updatedAt timestamps on all entity configs in this file
392
+ const rawConfig = rawParsed
393
+ if (rawConfig && typeof rawConfig === 'object' && ensureEntityTimestamps(rawConfig)) {
394
+ needsRewrite = true
395
+ }
396
+
397
+ // Migrate old schema envelope format to new format (relative $schema, no $defs)
398
+ if (isWrapped) {
399
+ const expectedSchemaRef = resolveSchemaRef(absolutePath, '@stream44.studio/t44/structs/WorkspaceConfigFile', workspaceRootDir)
400
+ if (rawParsed.$schema !== expectedSchemaRef) {
401
+ rawParsed.$schema = expectedSchemaRef
402
+ needsRewrite = true
403
+ }
404
+ if (rawParsed.$defs) {
405
+ delete rawParsed.$defs
406
+ needsRewrite = true
407
+ }
408
+ }
409
+
410
+ // Migrate old WorkspaceConfigFile wrapper format to flat format
411
+ if (isWrapped && rawParsed.WorkspaceConfigFile) {
412
+ const inner = rawParsed.WorkspaceConfigFile
413
+ const schemaRef = resolveSchemaRef(absolutePath, '@stream44.studio/t44/structs/WorkspaceConfigFile', workspaceRootDir)
414
+ const output: Record<string, any> = {}
415
+ if (schemaRef) {
416
+ output.$schema = schemaRef
417
+ }
418
+ Object.assign(output, inner)
419
+
420
+ const wrapped = yaml.dump(output, {
421
+ indent: 2,
422
+ lineWidth: 200,
423
+ noRefs: true,
424
+ sortKeys: false,
425
+ quotingType: '"',
426
+ forceQuotes: false
427
+ })
428
+ if (!isInsideNodeModules) {
429
+ await writeFile(absolutePath, wrapped)
430
+ }
431
+ rawContent = wrapped
432
+ needsRewrite = false
433
+ }
434
+
435
+ // Auto-wrap raw config files with schema envelope
436
+ if (!isWrapped && rawParsed && typeof rawParsed === 'object') {
437
+ const schemaRef = resolveSchemaRef(absolutePath, '@stream44.studio/t44/structs/WorkspaceConfigFile', workspaceRootDir)
438
+ const output: Record<string, any> = {}
439
+ if (schemaRef) {
440
+ output.$schema = schemaRef
441
+ }
442
+ Object.assign(output, rawParsed)
443
+
444
+ const wrapped = yaml.dump(output, {
445
+ indent: 2,
446
+ lineWidth: 200,
447
+ noRefs: true,
448
+ sortKeys: false,
449
+ quotingType: '"',
450
+ forceQuotes: false
451
+ })
452
+ if (!isInsideNodeModules) {
453
+ await writeFile(absolutePath, wrapped)
454
+ }
455
+ needsRewrite = false
456
+ isWrapped = true
457
+ }
458
+
459
+ // Write back timestamps to already-wrapped files that didn't need schema wrapping
460
+ if (needsRewrite && isWrapped) {
461
+ const rewritten = yaml.dump(rawParsed, {
462
+ indent: 2,
463
+ lineWidth: 200,
464
+ noRefs: true,
465
+ sortKeys: false,
466
+ quotingType: '"',
467
+ forceQuotes: false
468
+ })
469
+ if (!isInsideNodeModules) {
470
+ await writeFile(absolutePath, rewritten)
471
+ }
472
+ rawContent = rewritten
473
+ }
474
+
475
+ // Apply variable substitutions for runtime use
476
+ let configContent = rawContent
477
+ configContent = configContent.replaceAll('${__dirname}', configDir)
478
+
479
+ const resolvePattern = /resolve\(['"]([^'"]+)['"]\)/g
480
+ const matches = Array.from(configContent.matchAll(resolvePattern))
481
+ for (const m of matches) {
482
+ const fullMatch = m[0]
483
+ const pathArg = m[1]
484
+ const resolvedPath = resolve(pathArg)
485
+ configContent = configContent.replace(fullMatch, resolvedPath)
486
+ }
487
+
488
+ let config: any
489
+ try {
490
+ config = yaml.load(configContent) as any
491
+ } catch (yamlError: any) {
492
+ console.error('')
493
+ console.error(chalk.bgRed.white.bold(' YAML Parse Error (after variable substitution) '))
494
+ console.error('')
495
+ console.error(chalk.red.bold(' File: ') + chalk.yellow(absolutePath))
496
+ if (referencedFrom) {
497
+ console.error(chalk.red.bold(' Referenced from: ') + chalk.yellow(referencedFrom))
498
+ }
499
+ if (yamlError.mark) {
500
+ console.error(chalk.red.bold(' Line: ') + chalk.white(yamlError.mark.line + 1) + chalk.red.bold(' Column: ') + chalk.white(yamlError.mark.column + 1))
501
+ }
502
+ if (yamlError.reason) {
503
+ console.error(chalk.red.bold(' Reason: ') + chalk.white(yamlError.reason))
504
+ }
505
+ if (yamlError.mark?.snippet) {
506
+ console.error('')
507
+ console.error(chalk.gray(' ' + yamlError.mark.snippet.split('\n').join('\n ')))
508
+ }
509
+ console.error('')
510
+ console.error(chalk.red.bold(' === Raw content being parsed (start) ==='))
511
+ const contentLines = configContent.split('\n')
512
+ for (let i = 0; i < contentLines.length; i++) {
513
+ console.error(chalk.gray(` ${String(i + 1).padStart(4)} | ${contentLines[i]}`))
514
+ }
515
+ console.error(chalk.red.bold(' === Raw content being parsed (end) ==='))
516
+ console.error('')
517
+ process.exit(1)
518
+ }
519
+
520
+ // Strip $schema for processing
521
+ if (config && config.$schema) {
522
+ delete config.$schema
523
+ }
524
+ // Strip $id if present
525
+ if (config && config.$id) {
526
+ delete config.$id
527
+ }
528
+ // Strip $defs if present (legacy)
529
+ if (config && config.$defs) {
530
+ delete config.$defs
531
+ }
532
+
533
+ // Check for deprecated top-level deployments property
534
+ if (config.deployments) {
535
+ throw new Error(`Top-level 'deployments' property found in '${absolutePath}'. This format is deprecated. Please move your deployments configuration under the '#@stream44.studio/t44/structs/ProjectDeploymentConfig' key. See documentation for the new format.`)
536
+ }
537
+
538
+ // Check for deprecated top-level cli property
539
+ if (config.cli) {
540
+ throw new Error(`Top-level 'cli' property found in '${absolutePath}'. This format is deprecated. Please move your cli configuration under the '#@stream44.studio/t44/structs/WorkspaceCliConfig' key. See documentation for the new format.`)
541
+ }
542
+
543
+ // Check for deprecated top-level shell property
544
+ if (config.shell) {
545
+ throw new Error(`Top-level 'shell' property found in '${absolutePath}'. This format is deprecated. Please move your shell configuration under the '#@stream44.studio/t44/structs/WorkspaceShellConfig' key. See documentation for the new format.`)
546
+ }
547
+
548
+ // Check for deprecated top-level env property
549
+ if (config.env) {
550
+ throw new Error(`Top-level 'env' property found in '${absolutePath}'. This format is deprecated. Please move your env configuration under the '#@stream44.studio/t44/structs/WorkspaceShellConfig' key. See documentation for the new format.`)
551
+ }
552
+
553
+ // Check for deprecated top-level javascript property
554
+ if (config.javascript) {
555
+ throw new Error(`Top-level 'javascript' property found in '${absolutePath}'. This format is deprecated. Please move your javascript configuration under the '#@stream44.studio/t44/structs/WorkspaceCliConfig' key. See documentation for the new format.`)
556
+ }
557
+
558
+ // Check for deprecated top-level workspace property
559
+ if (config.workspace) {
560
+ throw new Error(`Top-level 'workspace' property found in '${absolutePath}'. This format is deprecated. Please move your workspace configuration under the '#@stream44.studio/t44/structs/WorkspaceConfig' key. See documentation for the new format.`)
561
+ }
562
+
563
+ // Check for deprecated top-level repositories property
564
+ if (config.repositories) {
565
+ throw new Error(`Top-level 'repositories' property found in '${absolutePath}'. This format is deprecated. Please move your repositories configuration under the '#@stream44.studio/t44/structs/ProjectPublishingConfig' key. See documentation for the new format.`)
566
+ }
567
+
568
+ // Check for deprecated top-level mappings property
569
+ if (config.mappings) {
570
+ throw new Error(`Top-level 'mappings' property found in '${absolutePath}'. This format is deprecated. Please move your mappings configuration under the '#@stream44.studio/t44/structs/WorkspaceMappingsConfig' key. See documentation for the new format.`)
571
+ }
572
+
573
+ // Validate that only 'extends' is allowed as a top-level property, all others must start with '#'
574
+ for (const key of Object.keys(config)) {
575
+ if (key !== 'extends' && !key.startsWith('#')) {
576
+ throw new Error(`Invalid top-level property '${key}' found in '${absolutePath}'. Only 'extends' is allowed as a top-level property. All other configuration must be nested under a struct key starting with '#'.`)
577
+ }
578
+ }
579
+
580
+ // Build tree node
581
+ const treeNode: any = {
582
+ path: absolutePath,
583
+ extends: []
584
+ }
585
+
586
+ // Process extends first (parent configs)
587
+ if (config.extends && Array.isArray(config.extends)) {
588
+ for (const extendPath of config.extends) {
589
+ // Always use configDir for resolution - it's the directory of the file containing the extends
590
+ const resolvedExtendPath = resolveExtendPath(extendPath, configDir)
591
+ const childNode = await loadConfigRecursive(resolvedExtendPath, absolutePath, currentChain)
592
+ // Store the original extends value for display
593
+ childNode.extendsValue = extendPath
594
+ treeNode.extends.push(childNode)
595
+ }
596
+ }
597
+
598
+ // Remove extends key and push current config (child overrides parent)
599
+ delete config.extends
600
+ loadedConfigs.push({ path: absolutePath, config, rawContent: configContent })
601
+
602
+ return treeNode
603
+ }
604
+
605
+ configTree = await loadConfigRecursive(configPath)
606
+
607
+ // Build entitySources: track which files define each entity key with line numbers
608
+ const entitySources = new Map<string, { path: string, line: number }[]>()
609
+ for (const { path: filePath, config: fileConfig, rawContent: fileRawContent } of loadedConfigs) {
610
+ if (fileConfig && typeof fileConfig === 'object') {
611
+ // Pre-compute line numbers for entity keys by scanning raw content
612
+ const lines = fileRawContent.split('\n')
613
+ const keyLineMap = new Map<string, number>()
614
+ for (let i = 0; i < lines.length; i++) {
615
+ const trimmed = lines[i].trimStart()
616
+ if (trimmed.startsWith("'#") || trimmed.startsWith('"#')) {
617
+ // Quoted key like '#@t44.sh/...': or "#@t44.sh/...":
618
+ const match = trimmed.match(/^['"]([^'"]+)['"]\s*:/)
619
+ if (match) keyLineMap.set(match[1], i + 1)
620
+ }
621
+ }
622
+
623
+ for (const key of Object.keys(fileConfig)) {
624
+ if (key.startsWith('#')) {
625
+ if (!entitySources.has(key)) {
626
+ entitySources.set(key, [])
627
+ }
628
+ entitySources.get(key)!.push({ path: filePath, line: keyLineMap.get(key) || 1 })
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ // Reverse each entity's sources so root config file comes first
635
+ for (const [, sources] of entitySources) {
636
+ sources.reverse()
637
+ }
638
+
639
+ // Merge configs: parent configs first, then child configs override
640
+ let mergedConfig = {} as any
641
+ for (const { config } of loadedConfigs) {
642
+ mergedConfig = deepMerge(mergedConfig, config)
643
+ }
644
+
645
+ // Ensure workspace directory paths are set correctly based on main config location
646
+ // This overrides any inherited values from parent configs
647
+ const expectedWorkspaceDir = resolve(mainConfigDir, '..')
648
+
649
+ // Set javascript.api.workspaceDir in the CLI config struct
650
+ const cliConfigKey = '#@stream44.studio/t44/structs/WorkspaceCliConfig'
651
+ if (!mergedConfig[cliConfigKey]) mergedConfig[cliConfigKey] = {}
652
+ if (!mergedConfig[cliConfigKey].javascript) mergedConfig[cliConfigKey].javascript = {}
653
+ if (!mergedConfig[cliConfigKey].javascript.api) mergedConfig[cliConfigKey].javascript.api = {}
654
+ mergedConfig[cliConfigKey].javascript.api.workspaceDir = expectedWorkspaceDir
655
+
656
+ // Set F_WORKSPACE_DIR in the shell config struct
657
+ const shellConfigKey = '#@stream44.studio/t44/structs/WorkspaceShellConfig'
658
+ if (!mergedConfig[shellConfigKey]) mergedConfig[shellConfigKey] = {}
659
+ if (!mergedConfig[shellConfigKey].env) mergedConfig[shellConfigKey].env = {}
660
+ if (!mergedConfig[shellConfigKey].env.force) mergedConfig[shellConfigKey].env.force = {}
661
+ mergedConfig[shellConfigKey].env.force.F_WORKSPACE_DIR = expectedWorkspaceDir
662
+
663
+ // Set workspaceRootDir and workspaceConfigFilepath in the workspace config struct
664
+ const workspaceConfigStructKey = '#@stream44.studio/t44/structs/WorkspaceConfig'
665
+ const expectedConfigFilepath = '.workspace/workspace.yaml'
666
+ if (!mergedConfig[workspaceConfigStructKey]) mergedConfig[workspaceConfigStructKey] = {}
667
+
668
+ // Validate or set workspaceRootDir
669
+ if (mergedConfig[workspaceConfigStructKey].workspaceRootDir) {
670
+ if (resolve(mergedConfig[workspaceConfigStructKey].workspaceRootDir) !== expectedWorkspaceDir) {
671
+ throw new Error(`workspaceRootDir '${mergedConfig[workspaceConfigStructKey].workspaceRootDir}' does not match expected '${expectedWorkspaceDir}'`)
672
+ }
673
+ } else {
674
+ mergedConfig[workspaceConfigStructKey].workspaceRootDir = expectedWorkspaceDir
675
+ }
676
+
677
+ // Validate or set workspaceConfigFilepath
678
+ if (mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath) {
679
+ if (mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath !== expectedConfigFilepath) {
680
+ throw new Error(`workspaceConfigFilepath '${mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath}' does not match expected '${expectedConfigFilepath}'`)
681
+ }
682
+ } else {
683
+ mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath = expectedConfigFilepath
684
+ }
685
+
686
+ mergedConfig = await processJitExpressions(mergedConfig, configPath)
687
+
688
+ // Write metadata cache files for each loaded config file with entity line numbers
689
+ await writeConfigMetadataCache(loadedConfigs, workspaceRootDir)
690
+
691
+ return { config: mergedConfig, configTree, entitySources }
692
+ }
693
+
694
+ async function writeConfigMetadataCache(
695
+ loadedConfigs: Array<{ path: string, config: any, rawContent: string }>,
696
+ workspaceRootDir: string
697
+ ): Promise<void> {
698
+ const { mkdir, writeFile } = await import('fs/promises')
699
+ const metaCacheDir = join(workspaceRootDir, '.~o', 'workspace.foundation', '@t44.sh~t44~caps~WorkspaceEntityFact', '@t44.sh~t44~structs~WorkspaceConfigFileMeta')
700
+
701
+ await mkdir(metaCacheDir, { recursive: true })
702
+
703
+ for (const { path: filePath, config: fileConfig, rawContent: fileRawContent } of loadedConfigs) {
704
+ if (!fileConfig || typeof fileConfig !== 'object') continue
705
+
706
+ const relPath = relative(workspaceRootDir, filePath)
707
+ const cacheFileName = relPath.replace(/\//g, '~').replace(/\\/g, '~') + '.json'
708
+
709
+ // Extract entity line numbers
710
+ const lines = fileRawContent.split('\n')
711
+ const entities: Record<string, { line: number, data: any }> = {}
712
+
713
+ for (let i = 0; i < lines.length; i++) {
714
+ const trimmed = lines[i].trimStart()
715
+ if (trimmed.startsWith("'#") || trimmed.startsWith('"#')) {
716
+ const match = trimmed.match(/^['"]([^'"]+)['"]\s*:/)
717
+ if (match) {
718
+ const entityKey = match[1]
719
+ if (fileConfig[entityKey]) {
720
+ entities[entityKey] = {
721
+ line: i + 1,
722
+ data: fileConfig[entityKey]
723
+ }
724
+ }
725
+ }
726
+ }
727
+ }
728
+
729
+ const metadata = {
730
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
731
+ $id: '@stream44.studio/t44/structs/WorkspaceConfigFileMeta.v0',
732
+ filePath,
733
+ relPath,
734
+ entities,
735
+ updatedAt: new Date().toISOString()
736
+ }
737
+
738
+ await writeFile(join(metaCacheDir, cacheFileName), JSON.stringify(metadata, null, 4))
739
+ }
740
+ }
741
+
742
+ function jitJoin(...parts: string[]): string {
743
+ return parts.join('')
744
+ }
745
+
746
+ async function jitPick(configDir: string, filepath: string, path: string): Promise<string> {
747
+ const resolvedPath = resolve(configDir, filepath)
748
+ const content = await readFile(resolvedPath, 'utf-8')
749
+ const data = JSON.parse(content)
750
+
751
+ const parts = path.split('.')
752
+ let result: any = data
753
+
754
+ for (const part of parts) {
755
+ const arrayMatch = part.match(/^(.+)\[(\d+)\]$/)
756
+ if (arrayMatch) {
757
+ const [, key, index] = arrayMatch
758
+ result = result[key][parseInt(index)]
759
+ } else {
760
+ result = result[part]
761
+ }
762
+
763
+ if (result === undefined) {
764
+ throw new Error(`Path '${path}' not found in '${filepath}'`)
765
+ }
766
+ }
767
+
768
+ return result
769
+ }
770
+
771
+ async function processJitExpressions(config: any, configPath: string): Promise<any> {
772
+ const configDir = join(configPath, '..')
773
+
774
+ async function processValue(value: any): Promise<any> {
775
+ if (typeof value === 'string' && value.startsWith('jit(')) {
776
+ const expression = value.slice(4, -1)
777
+ return createJitFunction(expression, configDir)
778
+ }
779
+ if (Array.isArray(value)) {
780
+ return Promise.all(value.map(processValue))
781
+ }
782
+ if (typeof value === 'object' && value !== null) {
783
+ const result: any = {}
784
+ for (const [k, v] of Object.entries(value)) {
785
+ result[k] = await processValue(v)
786
+ }
787
+ return result
788
+ }
789
+ return value
790
+ }
791
+
792
+ return processValue(config)
793
+ }
794
+
795
+ function createJitFunction(expression: string, configDir: string): () => Promise<string> {
796
+ return async () => {
797
+ const join = jitJoin
798
+ const pick = async (filepath: string, path: string) => jitPick(configDir, filepath, path)
799
+
800
+ // Replace pick() calls with await pick() to ensure promises are resolved
801
+ const awaitedExpression = expression.replace(/pick\(/g, 'await pick(')
802
+
803
+ const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor
804
+ const fn = new AsyncFunction('join', 'pick', `return ${awaitedExpression}`)
805
+ return await fn(join, pick)
806
+ }
807
+ }
808
+
809
+ function getAtPath(obj: any, path: string[]): any {
810
+ let current = obj
811
+ for (const key of path) {
812
+ if (current == null || typeof current !== 'object') return undefined
813
+ current = current[key]
814
+ }
815
+ return current
816
+ }
817
+
818
+ function deepEqual(a: any, b: any): boolean {
819
+ if (a === b) return true
820
+ if (a == null || b == null) return false
821
+ if (typeof a !== typeof b) return false
822
+ if (typeof a !== 'object') return false
823
+ if (Array.isArray(a) !== Array.isArray(b)) return false
824
+ const keysA = Object.keys(a)
825
+ const keysB = Object.keys(b)
826
+ if (keysA.length !== keysB.length) return false
827
+ for (const key of keysA) {
828
+ if (!deepEqual(a[key], b[key])) return false
829
+ }
830
+ return true
831
+ }
832
+
833
+ function setAtPath(obj: any, path: string[], value: any): void {
834
+ let current = obj
835
+ for (let i = 0; i < path.length - 1; i++) {
836
+ const key = path[i]
837
+ if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
838
+ current[key] = {}
839
+ }
840
+ current = current[key]
841
+ }
842
+ current[path[path.length - 1]] = value
843
+ }
844
+
845
+ function resolveSchemaRef(dataFilePath: string, capsuleName: string, workspaceRootDir: string): string | undefined {
846
+ const jsonSchemaDir = join(
847
+ workspaceRootDir,
848
+ '.~o',
849
+ 'workspace.foundation',
850
+ '@t44.sh~t44~caps~JsonSchemas'
851
+ )
852
+ const schemaFilename = capsuleName.replace(/\//g, '~') + '.json'
853
+ const schemaFilePath = join(jsonSchemaDir, schemaFilename)
854
+ return relative(dirname(dataFilePath), schemaFilePath)
855
+ }
856
+
857
+ function ensureEntityTimestamps(config: any): boolean {
858
+ if (!config || typeof config !== 'object') return false
859
+ let changed = false
860
+ const now = new Date().toISOString()
861
+ for (const key of Object.keys(config)) {
862
+ if (!key.startsWith('#')) continue
863
+ const entity = config[key]
864
+ if (!entity || typeof entity !== 'object') continue
865
+ if (!entity.createdAt && !entity.updatedAt) {
866
+ entity.createdAt = now
867
+ entity.updatedAt = now
868
+ changed = true
869
+ } else if (!entity.createdAt) {
870
+ entity.createdAt = entity.updatedAt
871
+ changed = true
872
+ } else if (!entity.updatedAt) {
873
+ entity.updatedAt = entity.createdAt
874
+ changed = true
875
+ }
876
+ }
877
+ return changed
878
+ }
879
+
880
+ function deepMerge(target: any, source: any): any {
881
+ if (Array.isArray(source)) {
882
+ return source
883
+ }
884
+
885
+ if (typeof source !== 'object' || source === null) {
886
+ return source
887
+ }
888
+
889
+ const result = { ...target }
890
+
891
+ for (const key in source) {
892
+ if (source.hasOwnProperty(key)) {
893
+ if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
894
+ result[key] = deepMerge(result[key] || {}, source[key])
895
+ } else {
896
+ result[key] = source[key]
897
+ }
898
+ }
899
+ }
900
+
901
+ return result
902
+ }