t44 0.2.0-rc.1

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.

Potentially problematic release.


This version of t44 might be problematic. Click here for more details.

Files changed (86) hide show
  1. package/LICENSE.md +203 -0
  2. package/README.md +154 -0
  3. package/bin/activate +36 -0
  4. package/bin/activate.ts +30 -0
  5. package/bin/postinstall.sh +19 -0
  6. package/bin/shell +27 -0
  7. package/bin/t44 +27 -0
  8. package/caps/HomeRegistry.v0.ts +298 -0
  9. package/caps/OpenApiSchema.v0.ts +192 -0
  10. package/caps/ProjectDeployment.v0.ts +363 -0
  11. package/caps/ProjectDevelopment.v0.ts +246 -0
  12. package/caps/ProjectPublishing.v0.ts +307 -0
  13. package/caps/ProjectRack.v0.ts +128 -0
  14. package/caps/WorkspaceCli.v0.ts +391 -0
  15. package/caps/WorkspaceConfig.v0.ts +626 -0
  16. package/caps/WorkspaceConfig.yaml +53 -0
  17. package/caps/WorkspaceConnection.v0.ts +240 -0
  18. package/caps/WorkspaceEntityConfig.v0.ts +64 -0
  19. package/caps/WorkspaceEntityFact.v0.ts +193 -0
  20. package/caps/WorkspaceInfo.v0.ts +554 -0
  21. package/caps/WorkspaceInit.v0.ts +30 -0
  22. package/caps/WorkspaceKey.v0.ts +186 -0
  23. package/caps/WorkspaceProjects.v0.ts +455 -0
  24. package/caps/WorkspacePrompt.v0.ts +396 -0
  25. package/caps/WorkspaceShell.sh +39 -0
  26. package/caps/WorkspaceShell.v0.ts +104 -0
  27. package/caps/WorkspaceShell.yaml +65 -0
  28. package/caps/WorkspaceShellCli.v0.ts +109 -0
  29. package/caps/WorkspaceTest.v0.ts +167 -0
  30. package/caps/providers/LICENSE.md +8 -0
  31. package/caps/providers/README.md +2 -0
  32. package/caps/providers/bunny.net/ProjectDeployment.v0.ts +328 -0
  33. package/caps/providers/bunny.net/api-pull.v0.test.ts +319 -0
  34. package/caps/providers/bunny.net/api-pull.v0.ts +161 -0
  35. package/caps/providers/bunny.net/api-storage.v0.test.ts +168 -0
  36. package/caps/providers/bunny.net/api-storage.v0.ts +245 -0
  37. package/caps/providers/bunny.net/api.v0.ts +95 -0
  38. package/caps/providers/dynadot.com/ProjectDeployment.v0.ts +207 -0
  39. package/caps/providers/dynadot.com/api-domains.v0.test.ts +147 -0
  40. package/caps/providers/dynadot.com/api-domains.v0.ts +137 -0
  41. package/caps/providers/dynadot.com/api.v0.ts +88 -0
  42. package/caps/providers/git-scm.com/ProjectPublishing.v0.ts +231 -0
  43. package/caps/providers/github.com/ProjectPublishing.v0.ts +75 -0
  44. package/caps/providers/github.com/api.v0.ts +90 -0
  45. package/caps/providers/npmjs.com/ProjectPublishing.v0.ts +741 -0
  46. package/caps/providers/vercel.com/ProjectDeployment.v0.ts +339 -0
  47. package/caps/providers/vercel.com/api.v0.test.ts +67 -0
  48. package/caps/providers/vercel.com/api.v0.ts +132 -0
  49. package/caps/providers/vercel.com/bun.lock +194 -0
  50. package/caps/providers/vercel.com/package.json +10 -0
  51. package/caps/providers/vercel.com/project.v0.test.ts +108 -0
  52. package/caps/providers/vercel.com/project.v0.ts +150 -0
  53. package/caps/providers/vercel.com/tsconfig.json +28 -0
  54. package/docs/Overview.drawio +189 -0
  55. package/docs/Overview.svg +4 -0
  56. package/lib/crypto.ts +53 -0
  57. package/lib/openapi.ts +132 -0
  58. package/lib/ucan.ts +137 -0
  59. package/package.json +41 -0
  60. package/structs/HomeRegistryConfig.v0.ts +27 -0
  61. package/structs/ProjectDeploymentConfig.v0.ts +27 -0
  62. package/structs/ProjectDeploymentFact.v0.ts +110 -0
  63. package/structs/ProjectPublishingFact.v0.ts +69 -0
  64. package/structs/ProjectRackConfig.v0.ts +27 -0
  65. package/structs/WorkspaceCliConfig.v0.ts +27 -0
  66. package/structs/WorkspaceConfig.v0.ts +27 -0
  67. package/structs/WorkspaceKeyConfig.v0.ts +27 -0
  68. package/structs/WorkspaceMappings.v0.ts +27 -0
  69. package/structs/WorkspaceProjectsConfig.v0.ts +27 -0
  70. package/structs/WorkspaceRepositories.v0.ts +27 -0
  71. package/structs/WorkspaceShellConfig.v0.ts +45 -0
  72. package/structs/providers/LICENSE.md +8 -0
  73. package/structs/providers/README.md +2 -0
  74. package/structs/providers/bunny.net/ProjectDeploymentFact.v0.ts +41 -0
  75. package/structs/providers/bunny.net/WorkspaceConnectionConfig.v0.ts +42 -0
  76. package/structs/providers/dynadot.com/DomainFact.v0.ts +146 -0
  77. package/structs/providers/dynadot.com/WorkspaceConnectionConfig.v0.ts +41 -0
  78. package/structs/providers/git-scm.com/ProjectPublishingFact.v0.ts +46 -0
  79. package/structs/providers/github.com/ProjectPublishingFact.v0.ts +52 -0
  80. package/structs/providers/github.com/WorkspaceConnectionConfig.v0.ts +42 -0
  81. package/structs/providers/npmjs.com/ProjectPublishingFact.v0.ts +48 -0
  82. package/structs/providers/vercel.com/ProjectDeploymentFact.v0.ts +38 -0
  83. package/structs/providers/vercel.com/WorkspaceConnectionConfig.v0.ts +48 -0
  84. package/tsconfig.json +28 -0
  85. package/workspace-rt.ts +134 -0
  86. package/workspace.yaml +5 -0
@@ -0,0 +1,626 @@
1
+
2
+ import * as yaml from 'js-yaml'
3
+ import { readFile, writeFile, access } from 'fs/promises';
4
+ import { join, resolve } 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.v0': {},
20
+ '#t44/structs/WorkspaceConfig.v0': {
21
+ as: '$WorkspaceConfig'
22
+ },
23
+ '#': {
24
+ workspaceRootDir: {
25
+ type: CapsulePropertyTypes.Literal,
26
+ value: undefined
27
+ },
28
+ workspaceConfigFilepath: {
29
+ type: CapsulePropertyTypes.Literal,
30
+ value: '.workspace/workspace.yaml'
31
+ },
32
+ WorkspacePrompt: {
33
+ type: CapsulePropertyTypes.Mapping,
34
+ value: 't44/caps/WorkspacePrompt.v0'
35
+ },
36
+ HomeRegistry: {
37
+ type: CapsulePropertyTypes.Mapping,
38
+ value: 't44/caps/HomeRegistry.v0'
39
+ },
40
+ config: {
41
+ type: CapsulePropertyTypes.GetterFunction,
42
+ value: async function (this: any): Promise<object> {
43
+
44
+ const configPath = join(this.workspaceRootDir, this.workspaceConfigFilepath);
45
+
46
+ const { config } = await loadConfigWithExtends(configPath, this.workspaceRootDir)
47
+
48
+ // Get struct configs from the loaded config (avoid circular dependency by not calling struct.config)
49
+ const workspaceConfigStructKey = '#t44/structs/WorkspaceConfig.v0'
50
+ const cliConfigStructKey = '#t44/structs/WorkspaceCliConfig.v0'
51
+ const workspaceConfigStruct = config[workspaceConfigStructKey] || {}
52
+ const cliConfigStruct = config[cliConfigStructKey] || {}
53
+
54
+ // Validate javascript.api.workspaceDir from CLI config struct
55
+ if (resolve(cliConfigStruct?.javascript?.api?.workspaceDir) !== this.workspaceRootDir) {
56
+ throw new Error(`javascript.api.workspaceDir '${cliConfigStruct?.javascript?.api?.workspaceDir}' in '${configPath}' does not match expected this.workspaceRootDir '${this.workspaceRootDir}'!`)
57
+ }
58
+
59
+ // Check rootDir - validate if set, set if not
60
+ if (workspaceConfigStruct.rootDir) {
61
+ if (workspaceConfigStruct.rootDir !== this.workspaceRootDir) {
62
+ throw new Error(`rootDir '${workspaceConfigStruct.rootDir}' does not match expected '${this.workspaceRootDir}'!`)
63
+ }
64
+ } else {
65
+ await this.$WorkspaceConfig.setConfigValue(['rootDir'], this.workspaceRootDir)
66
+ }
67
+
68
+ // Check rootConfigFilepath - validate if set, set if not
69
+ if (workspaceConfigStruct.rootConfigFilepath) {
70
+ if (workspaceConfigStruct.rootConfigFilepath !== this.workspaceConfigFilepath) {
71
+ throw new Error(`rootConfigFilepath '${workspaceConfigStruct.rootConfigFilepath}' does not match expected '${this.workspaceConfigFilepath}'!`)
72
+ }
73
+ } else {
74
+ await this.$WorkspaceConfig.setConfigValue(['rootConfigFilepath'], this.workspaceConfigFilepath)
75
+ }
76
+
77
+ // Check workspace name - prompt if not set
78
+ if (!workspaceConfigStruct.name) {
79
+ const { basename } = await import('path')
80
+
81
+ let workspaceName: string | undefined
82
+
83
+ while (!workspaceName) {
84
+ const candidateName = await this.WorkspacePrompt.setupPrompt({
85
+ title: '🏢 Workspace Name Setup',
86
+ description: 'A workspace holds some or all projects registered in a Project Rack.',
87
+ message: 'Enter a name for this workspace:',
88
+ defaultValue: basename(this.workspaceRootDir),
89
+ validate: (input: string) => {
90
+ if (!input || input.trim().length === 0) {
91
+ return 'Workspace name cannot be empty'
92
+ }
93
+ if (!/^[a-zA-Z0-9_-]+$/.test(input)) {
94
+ return 'Workspace name can only contain letters, numbers, underscores, and hyphens'
95
+ }
96
+ return true
97
+ },
98
+ configPath: [workspaceConfigStructKey, 'name'],
99
+ onSuccess: async () => {
100
+ // Don't write to config yet — we need to validate first
101
+ }
102
+ })
103
+
104
+ // Check if a workspace with this name already exists in the registry
105
+ const existingData = await this.HomeRegistry.getWorkspace(candidateName)
106
+
107
+ if (existingData) {
108
+ if (existingData.workspaceRootDir === this.workspaceRootDir) {
109
+ // Same directory — adopt existing identity
110
+ const chalk = (await import('chalk')).default
111
+ console.log(chalk.green(`\n ✓ Found existing workspace identity for "${candidateName}" in this directory.`))
112
+ console.log(chalk.green(` Adopting existing identity.\n`))
113
+
114
+ await this.$WorkspaceConfig.setConfigValue(['name'], candidateName)
115
+ workspaceConfigStruct.name = candidateName
116
+
117
+ // Adopt the existing identifier
118
+ await this.$WorkspaceConfig.setConfigValue(['identifier'], existingData.did)
119
+ workspaceConfigStruct.identifier = existingData.did
120
+
121
+ console.log(chalk.green(` ✓ DID: ${existingData.did}\n`))
122
+ workspaceName = candidateName
123
+ } else {
124
+ // Different directory — warn and prompt
125
+ const chalk = (await import('chalk')).default
126
+ const registryPath = await this.HomeRegistry.getWorkspacePath(candidateName)
127
+ console.log(chalk.yellow(`\n ⚠ A workspace named "${candidateName}" already exists at:`))
128
+ console.log(chalk.white(` ${registryPath}`))
129
+ console.log('')
130
+ console.log(chalk.yellow(` It is currently connected to:`))
131
+ console.log(chalk.white(` ${existingData.workspaceRootDir}`))
132
+ console.log('')
133
+ console.log(chalk.yellow(` You are trying to set up a workspace with the same name in:`))
134
+ console.log(chalk.white(` ${this.workspaceRootDir}\n`))
135
+ console.log(chalk.yellow(` A workspace can only be connected to one directory.`))
136
+ console.log('')
137
+
138
+ const confirmed = await this.WorkspacePrompt.confirm({
139
+ message: `Disconnect "${candidateName}" from "${existingData.workspaceRootDir}" and connect it to "${this.workspaceRootDir}" instead?`,
140
+ defaultValue: false
141
+ })
142
+
143
+ if (confirmed) {
144
+ // Update registry with new rootDir
145
+ existingData.workspaceRootDir = this.workspaceRootDir
146
+ await this.HomeRegistry.setWorkspace(candidateName, existingData)
147
+
148
+ await this.$WorkspaceConfig.setConfigValue(['name'], candidateName)
149
+ workspaceConfigStruct.name = candidateName
150
+
151
+ // Adopt the existing identifier
152
+ await this.$WorkspaceConfig.setConfigValue(['identifier'], existingData.did)
153
+ workspaceConfigStruct.identifier = existingData.did
154
+
155
+ console.log(chalk.green(`\n ✓ Workspace "${candidateName}" reconnected to this directory.`))
156
+ console.log(chalk.green(` ✓ DID: ${existingData.did}\n`))
157
+ workspaceName = candidateName
158
+ } else {
159
+ console.log(chalk.gray(`\n Please choose a different workspace name.\n`))
160
+ // Loop again to re-prompt
161
+ }
162
+ }
163
+ } else {
164
+ // Name is available — commit it
165
+ await this.$WorkspaceConfig.setConfigValue(['name'], candidateName)
166
+ workspaceConfigStruct.name = candidateName
167
+ workspaceName = candidateName
168
+ }
169
+ }
170
+ }
171
+
172
+ // Check workspace identifier - generate if not set
173
+ if (!workspaceConfigStruct.identifier) {
174
+ const chalk = (await import('chalk')).default
175
+
176
+ const workspaceName = workspaceConfigStruct.name
177
+
178
+ console.log(chalk.cyan('\n🔑 Workspace Identifier Setup\n'))
179
+ console.log(chalk.gray(' Generating unique workspace identifier...\n'))
180
+
181
+ // Generate Ed25519 key pair for workspace identifier
182
+ const { generateKeypair } = await import('../lib/ucan.js')
183
+ const { did, privateKey } = await generateKeypair()
184
+
185
+ // Store in registry
186
+ const identifierData = {
187
+ did,
188
+ privateKey,
189
+ createdAt: new Date().toISOString(),
190
+ workspaceRootDir: this.workspaceRootDir,
191
+ }
192
+
193
+ const identifierPath = await this.HomeRegistry.setWorkspace(workspaceName, identifierData)
194
+
195
+ // Update config with workspace identifier (DID)
196
+ await this.$WorkspaceConfig.setConfigValue(['identifier'], did)
197
+
198
+ console.log(chalk.green(` ✓ Workspace identifier generated and saved to:`))
199
+ console.log(chalk.green(` ${identifierPath}`))
200
+ console.log(chalk.green(` ✓ DID: ${did}\n`))
201
+ }
202
+
203
+ return config
204
+ }
205
+ },
206
+ configTree: {
207
+ type: CapsulePropertyTypes.GetterFunction,
208
+ value: async function (this: any): Promise<any> {
209
+ const configPath = join(this.workspaceRootDir, this.workspaceConfigFilepath);
210
+ const { configTree } = await loadConfigWithExtends(configPath, this.workspaceRootDir)
211
+ return configTree
212
+ }
213
+ },
214
+ setConfigValue: {
215
+ type: CapsulePropertyTypes.Function,
216
+ value: async function (this: any, path: string[], value: any, options?: { ifAbsent?: boolean }): Promise<boolean> {
217
+ const configPath = join(this.workspaceRootDir, this.workspaceConfigFilepath)
218
+ const configContent = await readFile(configPath, 'utf-8')
219
+ const config = yaml.load(configContent) as any || {}
220
+
221
+ const existingValue = getAtPath(config, path)
222
+
223
+ // If ifAbsent is set, only write if the key doesn't exist yet
224
+ if (options?.ifAbsent && existingValue !== undefined) {
225
+ return false
226
+ }
227
+
228
+ // Check if value at path is already identical — skip write if unchanged
229
+ if (deepEqual(existingValue, value)) {
230
+ return false
231
+ }
232
+
233
+ // Set value at path using lodash-style set
234
+ setAtPath(config, path, value)
235
+
236
+ // Write back to file
237
+ const updatedContent = yaml.dump(config, {
238
+ indent: 2,
239
+ lineWidth: -1,
240
+ noRefs: true,
241
+ sortKeys: false
242
+ })
243
+ await writeFile(configPath, updatedContent)
244
+ return true
245
+ }
246
+ },
247
+ }
248
+ }
249
+ }, {
250
+ importMeta: import.meta,
251
+ importStack: makeImportStack(),
252
+ capsuleName: capsule['#'],
253
+ })
254
+ }
255
+ capsule['#'] = 't44/caps/WorkspaceConfig.v0'
256
+
257
+ function resolveExtendPath(extendPath: string, configDir: string, workspaceRequire: NodeRequire): string {
258
+ if (extendPath.startsWith('.')) {
259
+ return resolve(configDir, extendPath)
260
+ } else {
261
+ // For module paths, we need to resolve the package directory first
262
+ // because require.resolve() only works for JS modules, not .yaml files
263
+ // Split the path into package name and file path
264
+ // Handle scoped packages like t44/workspace.yaml
265
+ let packageName: string
266
+ let filePath: string
267
+
268
+ if (extendPath.startsWith('@')) {
269
+ // Scoped package: @scope/package/file/path.yaml
270
+ const parts = extendPath.split('/')
271
+ packageName = `${parts[0]}/${parts[1]}` // @scope/package
272
+ filePath = parts.slice(2).join('/') // file/path.yaml
273
+ } else {
274
+ // Regular package: package/file/path.yaml
275
+ const parts = extendPath.split('/')
276
+ packageName = parts[0]
277
+ filePath = parts.slice(1).join('/')
278
+ }
279
+
280
+ // Resolve the package's package.json to get its directory
281
+ const packageJsonPath = workspaceRequire.resolve(`${packageName}/package.json`)
282
+ const packageDir = join(packageJsonPath, '..')
283
+
284
+ // Resolve the file path within the package
285
+ return resolve(packageDir, filePath)
286
+ }
287
+ }
288
+
289
+ async function loadConfigWithExtends(configPath: string, workspaceRootDir: string): Promise<{ config: any, configTree: any }> {
290
+ const loadedConfigs: any[] = []
291
+ const mainConfigDir = join(resolve(configPath), '..')
292
+ let configTree: any = null
293
+
294
+ // Create a require function relative to workspace root for module resolution
295
+ const workspaceRequire = createRequire(join(workspaceRootDir, 'package.json'))
296
+
297
+ async function loadConfigRecursive(currentPath: string, referencedFrom?: string, chain: string[] = []): Promise<any> {
298
+ const absolutePath = resolve(currentPath)
299
+
300
+ // Check if this file is already in the current chain (circular reference)
301
+ if (chain.includes(absolutePath)) {
302
+ throw new Error(`Circular extends detected: ${absolutePath}\nChain: ${chain.join(' -> ')} -> ${absolutePath}`)
303
+ }
304
+
305
+ // Add to current chain
306
+ const currentChain = [...chain, absolutePath]
307
+
308
+ // Check if file exists before attempting to read
309
+ try {
310
+ await access(absolutePath)
311
+ } catch (error) {
312
+ const errorLines = [
313
+ '',
314
+ chalk.bold.red('✗ Configuration File Not Found'),
315
+ '',
316
+ chalk.gray(' Missing file:'),
317
+ chalk.red(` ${absolutePath}`),
318
+ ''
319
+ ]
320
+
321
+ if (referencedFrom) {
322
+ errorLines.push(
323
+ chalk.gray(' Referenced from:'),
324
+ chalk.yellow(` ${referencedFrom}`),
325
+ ''
326
+ )
327
+ }
328
+
329
+ if (currentChain.length > 0) {
330
+ errorLines.push(
331
+ chalk.gray(' Configuration chain:'),
332
+ ...currentChain.map((path, idx) =>
333
+ chalk.cyan(` ${idx + 1}. ${path}`)
334
+ ),
335
+ chalk.red(` ${currentChain.length + 1}. ${absolutePath} `) + chalk.bold.red('← MISSING'),
336
+ ''
337
+ )
338
+ }
339
+
340
+ // Determine which file to tell user to fix
341
+ const fileToFix = referencedFrom || (currentChain.length > 0 ? currentChain[currentChain.length - 1] : 'your workspace.yaml')
342
+
343
+ errorLines.push(
344
+ chalk.bold.white(' Action Required:'),
345
+ chalk.white(' • Create the missing file, or'),
346
+ chalk.white(' • Fix the \'extends\' path in:'),
347
+ chalk.yellow(` ${fileToFix}`),
348
+ ''
349
+ )
350
+
351
+ const err = new Error(errorLines.join('\n'))
352
+ err.stack = '' // Remove stack trace
353
+ throw err
354
+ }
355
+
356
+ let configContent = await readFile(absolutePath, 'utf-8')
357
+ const configDir = join(absolutePath, '..')
358
+
359
+ // Replace ${__dirname} with the directory of the current config file
360
+ configContent = configContent.replaceAll('${__dirname}', configDir)
361
+
362
+ // Replace all resolve('...') patterns with resolved paths
363
+ const resolvePattern = /resolve\(['"]([^'"]+)['"]\)/g
364
+ const matches = Array.from(configContent.matchAll(resolvePattern))
365
+ for (const m of matches) {
366
+ const fullMatch = m[0]
367
+ const pathArg = m[1]
368
+ const resolvedPath = resolve(pathArg)
369
+ configContent = configContent.replace(fullMatch, resolvedPath)
370
+ }
371
+
372
+ const config = yaml.load(configContent) as any
373
+
374
+ // Check for deprecated top-level deployments property
375
+ if (config.deployments) {
376
+ throw new Error(`Top-level 'deployments' property found in '${absolutePath}'. This format is deprecated. Please move your deployments configuration under the '#t44/structs/ProjectDeploymentConfig.v0' key. See documentation for the new format.`)
377
+ }
378
+
379
+ // Check for deprecated top-level cli property
380
+ if (config.cli) {
381
+ throw new Error(`Top-level 'cli' property found in '${absolutePath}'. This format is deprecated. Please move your cli configuration under the '#t44/structs/WorkspaceCliConfig.v0' key. See documentation for the new format.`)
382
+ }
383
+
384
+ // Check for deprecated top-level shell property
385
+ if (config.shell) {
386
+ throw new Error(`Top-level 'shell' property found in '${absolutePath}'. This format is deprecated. Please move your shell configuration under the '#t44/structs/WorkspaceShellConfig.v0' key. See documentation for the new format.`)
387
+ }
388
+
389
+ // Check for deprecated top-level env property
390
+ if (config.env) {
391
+ throw new Error(`Top-level 'env' property found in '${absolutePath}'. This format is deprecated. Please move your env configuration under the '#t44/structs/WorkspaceShellConfig.v0' key. See documentation for the new format.`)
392
+ }
393
+
394
+ // Check for deprecated top-level javascript property
395
+ if (config.javascript) {
396
+ throw new Error(`Top-level 'javascript' property found in '${absolutePath}'. This format is deprecated. Please move your javascript configuration under the '#t44/structs/WorkspaceCliConfig.v0' key. See documentation for the new format.`)
397
+ }
398
+
399
+ // Check for deprecated top-level workspace property
400
+ if (config.workspace) {
401
+ throw new Error(`Top-level 'workspace' property found in '${absolutePath}'. This format is deprecated. Please move your workspace configuration under the '#t44/structs/WorkspaceConfig.v0' key. See documentation for the new format.`)
402
+ }
403
+
404
+ // Check for deprecated top-level repositories property
405
+ if (config.repositories) {
406
+ throw new Error(`Top-level 'repositories' property found in '${absolutePath}'. This format is deprecated. Please move your repositories configuration under the '#t44/structs/WorkspaceRepositories.v0' key. See documentation for the new format.`)
407
+ }
408
+
409
+ // Check for deprecated top-level mappings property
410
+ if (config.mappings) {
411
+ throw new Error(`Top-level 'mappings' property found in '${absolutePath}'. This format is deprecated. Please move your mappings configuration under the '#t44/structs/WorkspaceMappings.v0' key. See documentation for the new format.`)
412
+ }
413
+
414
+ // Validate that only 'extends' is allowed as a top-level property, all others must start with '#'
415
+ for (const key of Object.keys(config)) {
416
+ if (key !== 'extends' && !key.startsWith('#')) {
417
+ 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 '#'.`)
418
+ }
419
+ }
420
+
421
+ // Build tree node
422
+ const treeNode: any = {
423
+ path: absolutePath,
424
+ extends: []
425
+ }
426
+
427
+ // Process extends first (parent configs)
428
+ if (config.extends && Array.isArray(config.extends)) {
429
+ for (const extendPath of config.extends) {
430
+ // Always use configDir for resolution - it's the directory of the file containing the extends
431
+ const resolvedExtendPath = resolveExtendPath(extendPath, configDir, workspaceRequire)
432
+ const childNode = await loadConfigRecursive(resolvedExtendPath, absolutePath, currentChain)
433
+ // Store the original extends value for display
434
+ childNode.extendsValue = extendPath
435
+ treeNode.extends.push(childNode)
436
+ }
437
+ }
438
+
439
+ // Remove extends key and push current config (child overrides parent)
440
+ delete config.extends
441
+ loadedConfigs.push(config)
442
+
443
+ return treeNode
444
+ }
445
+
446
+ configTree = await loadConfigRecursive(configPath)
447
+
448
+ // Merge configs: parent configs first, then child configs override
449
+ let mergedConfig = {} as any
450
+ for (const config of loadedConfigs) {
451
+ mergedConfig = deepMerge(mergedConfig, config)
452
+ }
453
+
454
+ // Ensure workspace directory paths are set correctly based on main config location
455
+ // This overrides any inherited values from parent configs
456
+ const expectedWorkspaceDir = resolve(mainConfigDir, '..')
457
+
458
+ // Set javascript.api.workspaceDir in the CLI config struct
459
+ const cliConfigKey = '#t44/structs/WorkspaceCliConfig.v0'
460
+ if (!mergedConfig[cliConfigKey]) mergedConfig[cliConfigKey] = {}
461
+ if (!mergedConfig[cliConfigKey].javascript) mergedConfig[cliConfigKey].javascript = {}
462
+ if (!mergedConfig[cliConfigKey].javascript.api) mergedConfig[cliConfigKey].javascript.api = {}
463
+ mergedConfig[cliConfigKey].javascript.api.workspaceDir = expectedWorkspaceDir
464
+
465
+ // Set F_WORKSPACE_DIR in the shell config struct
466
+ const shellConfigKey = '#t44/structs/WorkspaceShellConfig.v0'
467
+ if (!mergedConfig[shellConfigKey]) mergedConfig[shellConfigKey] = {}
468
+ if (!mergedConfig[shellConfigKey].env) mergedConfig[shellConfigKey].env = {}
469
+ if (!mergedConfig[shellConfigKey].env.force) mergedConfig[shellConfigKey].env.force = {}
470
+ mergedConfig[shellConfigKey].env.force.F_WORKSPACE_DIR = expectedWorkspaceDir
471
+
472
+ // Set workspaceRootDir and workspaceConfigFilepath in the workspace config struct
473
+ const workspaceConfigStructKey = '#t44/structs/WorkspaceConfig.v0'
474
+ const expectedConfigFilepath = '.workspace/workspace.yaml'
475
+ if (!mergedConfig[workspaceConfigStructKey]) mergedConfig[workspaceConfigStructKey] = {}
476
+
477
+ // Validate or set workspaceRootDir
478
+ if (mergedConfig[workspaceConfigStructKey].workspaceRootDir) {
479
+ if (resolve(mergedConfig[workspaceConfigStructKey].workspaceRootDir) !== expectedWorkspaceDir) {
480
+ throw new Error(`workspaceRootDir '${mergedConfig[workspaceConfigStructKey].workspaceRootDir}' does not match expected '${expectedWorkspaceDir}'`)
481
+ }
482
+ } else {
483
+ mergedConfig[workspaceConfigStructKey].workspaceRootDir = expectedWorkspaceDir
484
+ }
485
+
486
+ // Validate or set workspaceConfigFilepath
487
+ if (mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath) {
488
+ if (mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath !== expectedConfigFilepath) {
489
+ throw new Error(`workspaceConfigFilepath '${mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath}' does not match expected '${expectedConfigFilepath}'`)
490
+ }
491
+ } else {
492
+ mergedConfig[workspaceConfigStructKey].workspaceConfigFilepath = expectedConfigFilepath
493
+ }
494
+
495
+ mergedConfig = await processJitExpressions(mergedConfig, configPath)
496
+
497
+ return { config: mergedConfig, configTree }
498
+ }
499
+
500
+ function jitJoin(...parts: string[]): string {
501
+ return parts.join('')
502
+ }
503
+
504
+ async function jitPick(configDir: string, filepath: string, path: string): Promise<string> {
505
+ const resolvedPath = resolve(configDir, filepath)
506
+ const content = await readFile(resolvedPath, 'utf-8')
507
+ const data = JSON.parse(content)
508
+
509
+ const parts = path.split('.')
510
+ let result: any = data
511
+
512
+ for (const part of parts) {
513
+ const arrayMatch = part.match(/^(.+)\[(\d+)\]$/)
514
+ if (arrayMatch) {
515
+ const [, key, index] = arrayMatch
516
+ result = result[key][parseInt(index)]
517
+ } else {
518
+ result = result[part]
519
+ }
520
+
521
+ if (result === undefined) {
522
+ throw new Error(`Path '${path}' not found in '${filepath}'`)
523
+ }
524
+ }
525
+
526
+ return result
527
+ }
528
+
529
+ async function processJitExpressions(config: any, configPath: string): Promise<any> {
530
+ const configDir = join(configPath, '..')
531
+
532
+ async function processValue(value: any): Promise<any> {
533
+ if (typeof value === 'string' && value.startsWith('jit(')) {
534
+ const expression = value.slice(4, -1)
535
+ return createJitFunction(expression, configDir)
536
+ }
537
+ if (Array.isArray(value)) {
538
+ return Promise.all(value.map(processValue))
539
+ }
540
+ if (typeof value === 'object' && value !== null) {
541
+ const result: any = {}
542
+ for (const [k, v] of Object.entries(value)) {
543
+ result[k] = await processValue(v)
544
+ }
545
+ return result
546
+ }
547
+ return value
548
+ }
549
+
550
+ return processValue(config)
551
+ }
552
+
553
+ function createJitFunction(expression: string, configDir: string): () => Promise<string> {
554
+ return async () => {
555
+ const join = jitJoin
556
+ const pick = async (filepath: string, path: string) => jitPick(configDir, filepath, path)
557
+
558
+ // Replace pick() calls with await pick() to ensure promises are resolved
559
+ const awaitedExpression = expression.replace(/pick\(/g, 'await pick(')
560
+
561
+ const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor
562
+ const fn = new AsyncFunction('join', 'pick', `return ${awaitedExpression}`)
563
+ return await fn(join, pick)
564
+ }
565
+ }
566
+
567
+ function getAtPath(obj: any, path: string[]): any {
568
+ let current = obj
569
+ for (const key of path) {
570
+ if (current == null || typeof current !== 'object') return undefined
571
+ current = current[key]
572
+ }
573
+ return current
574
+ }
575
+
576
+ function deepEqual(a: any, b: any): boolean {
577
+ if (a === b) return true
578
+ if (a == null || b == null) return false
579
+ if (typeof a !== typeof b) return false
580
+ if (typeof a !== 'object') return false
581
+ if (Array.isArray(a) !== Array.isArray(b)) return false
582
+ const keysA = Object.keys(a)
583
+ const keysB = Object.keys(b)
584
+ if (keysA.length !== keysB.length) return false
585
+ for (const key of keysA) {
586
+ if (!deepEqual(a[key], b[key])) return false
587
+ }
588
+ return true
589
+ }
590
+
591
+ function setAtPath(obj: any, path: string[], value: any): void {
592
+ let current = obj
593
+ for (let i = 0; i < path.length - 1; i++) {
594
+ const key = path[i]
595
+ if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
596
+ current[key] = {}
597
+ }
598
+ current = current[key]
599
+ }
600
+ current[path[path.length - 1]] = value
601
+ }
602
+
603
+ function deepMerge(target: any, source: any): any {
604
+ if (Array.isArray(source)) {
605
+ return source
606
+ }
607
+
608
+ if (typeof source !== 'object' || source === null) {
609
+ return source
610
+ }
611
+
612
+ const result = { ...target }
613
+
614
+ for (const key in source) {
615
+ if (source.hasOwnProperty(key)) {
616
+ if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
617
+ result[key] = deepMerge(result[key] || {}, source[key])
618
+ } else {
619
+ result[key] = source[key]
620
+ }
621
+ }
622
+ }
623
+
624
+ return result
625
+ }
626
+
@@ -0,0 +1,53 @@
1
+
2
+ extends:
3
+ - "./WorkspaceShell.yaml"
4
+
5
+ "#t44/structs/WorkspaceCliConfig.v0":
6
+ javascript:
7
+ api:
8
+ workspaceDir: resolve('${__dirname}/..')
9
+ cli:
10
+ commands:
11
+ activate:
12
+ capsule: "t44/caps/WorkspaceShell.v0"
13
+ description: "Generate environment variables for sourcing into shells."
14
+ deploy:
15
+ capsule: "t44/caps/ProjectDeployment.v0"
16
+ description: "Deploy a project to a provider."
17
+ arguments:
18
+ projectSelector:
19
+ optional: true
20
+ description: "Name of a top-level project directory in the workspace. If not specified, deploys all projects."
21
+ options:
22
+ deprovision:
23
+ description: "Delete the project from the provider instead of deploying it"
24
+ push:
25
+ capsule: "t44/caps/ProjectPublishing.v0"
26
+ description: "Publish local committed code to a remote service."
27
+ arguments:
28
+ projectSelector:
29
+ optional: true
30
+ description: "Name of a top-level project directory in the workspace. If not specified, pushes all repositories."
31
+ options:
32
+ rc:
33
+ description: "Release candidate mode"
34
+ release:
35
+ description: "Remove release candidate suffix and publish release version"
36
+ dangerously-reset-main:
37
+ description: "Reset the git repository and force push to remote"
38
+ dev:
39
+ capsule: "t44/caps/ProjectDevelopment.v0"
40
+ description: "Run a project or package dev server."
41
+ arguments:
42
+ projectSelector:
43
+ optional: true
44
+ description: "Name of a project or package to run the dev script for."
45
+ init:
46
+ capsule: "t44/caps/WorkspaceInit.v0"
47
+ description: "Initialize a new Terminal 44 workspace."
48
+ info:
49
+ capsule: "t44/caps/WorkspaceInfo.v0"
50
+ description: "Display information about the workspace."
51
+ options:
52
+ full:
53
+ description: "Show full details instead of compact view"