t44 0.4.0-rc.3

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