t44 0.4.0-rc.10

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