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