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.
- package/LICENSE.md +203 -0
- package/README.md +154 -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/HomeRegistry.v0.ts +298 -0
- package/caps/OpenApiSchema.v0.ts +192 -0
- package/caps/ProjectDeployment.v0.ts +363 -0
- package/caps/ProjectDevelopment.v0.ts +246 -0
- package/caps/ProjectPublishing.v0.ts +307 -0
- package/caps/ProjectRack.v0.ts +128 -0
- package/caps/WorkspaceCli.v0.ts +391 -0
- package/caps/WorkspaceConfig.v0.ts +626 -0
- package/caps/WorkspaceConfig.yaml +53 -0
- package/caps/WorkspaceConnection.v0.ts +240 -0
- package/caps/WorkspaceEntityConfig.v0.ts +64 -0
- package/caps/WorkspaceEntityFact.v0.ts +193 -0
- package/caps/WorkspaceInfo.v0.ts +554 -0
- package/caps/WorkspaceInit.v0.ts +30 -0
- package/caps/WorkspaceKey.v0.ts +186 -0
- package/caps/WorkspaceProjects.v0.ts +455 -0
- package/caps/WorkspacePrompt.v0.ts +396 -0
- package/caps/WorkspaceShell.sh +39 -0
- package/caps/WorkspaceShell.v0.ts +104 -0
- package/caps/WorkspaceShell.yaml +65 -0
- package/caps/WorkspaceShellCli.v0.ts +109 -0
- package/caps/WorkspaceTest.v0.ts +167 -0
- package/caps/providers/LICENSE.md +8 -0
- package/caps/providers/README.md +2 -0
- package/caps/providers/bunny.net/ProjectDeployment.v0.ts +328 -0
- package/caps/providers/bunny.net/api-pull.v0.test.ts +319 -0
- package/caps/providers/bunny.net/api-pull.v0.ts +161 -0
- package/caps/providers/bunny.net/api-storage.v0.test.ts +168 -0
- package/caps/providers/bunny.net/api-storage.v0.ts +245 -0
- package/caps/providers/bunny.net/api.v0.ts +95 -0
- package/caps/providers/dynadot.com/ProjectDeployment.v0.ts +207 -0
- package/caps/providers/dynadot.com/api-domains.v0.test.ts +147 -0
- package/caps/providers/dynadot.com/api-domains.v0.ts +137 -0
- package/caps/providers/dynadot.com/api.v0.ts +88 -0
- package/caps/providers/git-scm.com/ProjectPublishing.v0.ts +231 -0
- package/caps/providers/github.com/ProjectPublishing.v0.ts +75 -0
- package/caps/providers/github.com/api.v0.ts +90 -0
- package/caps/providers/npmjs.com/ProjectPublishing.v0.ts +741 -0
- package/caps/providers/vercel.com/ProjectDeployment.v0.ts +339 -0
- package/caps/providers/vercel.com/api.v0.test.ts +67 -0
- package/caps/providers/vercel.com/api.v0.ts +132 -0
- package/caps/providers/vercel.com/bun.lock +194 -0
- package/caps/providers/vercel.com/package.json +10 -0
- package/caps/providers/vercel.com/project.v0.test.ts +108 -0
- package/caps/providers/vercel.com/project.v0.ts +150 -0
- package/caps/providers/vercel.com/tsconfig.json +28 -0
- package/docs/Overview.drawio +189 -0
- package/docs/Overview.svg +4 -0
- package/lib/crypto.ts +53 -0
- package/lib/openapi.ts +132 -0
- package/lib/ucan.ts +137 -0
- package/package.json +41 -0
- package/structs/HomeRegistryConfig.v0.ts +27 -0
- package/structs/ProjectDeploymentConfig.v0.ts +27 -0
- package/structs/ProjectDeploymentFact.v0.ts +110 -0
- package/structs/ProjectPublishingFact.v0.ts +69 -0
- package/structs/ProjectRackConfig.v0.ts +27 -0
- package/structs/WorkspaceCliConfig.v0.ts +27 -0
- package/structs/WorkspaceConfig.v0.ts +27 -0
- package/structs/WorkspaceKeyConfig.v0.ts +27 -0
- package/structs/WorkspaceMappings.v0.ts +27 -0
- package/structs/WorkspaceProjectsConfig.v0.ts +27 -0
- package/structs/WorkspaceRepositories.v0.ts +27 -0
- package/structs/WorkspaceShellConfig.v0.ts +45 -0
- package/structs/providers/LICENSE.md +8 -0
- package/structs/providers/README.md +2 -0
- package/structs/providers/bunny.net/ProjectDeploymentFact.v0.ts +41 -0
- package/structs/providers/bunny.net/WorkspaceConnectionConfig.v0.ts +42 -0
- package/structs/providers/dynadot.com/DomainFact.v0.ts +146 -0
- package/structs/providers/dynadot.com/WorkspaceConnectionConfig.v0.ts +41 -0
- package/structs/providers/git-scm.com/ProjectPublishingFact.v0.ts +46 -0
- package/structs/providers/github.com/ProjectPublishingFact.v0.ts +52 -0
- package/structs/providers/github.com/WorkspaceConnectionConfig.v0.ts +42 -0
- package/structs/providers/npmjs.com/ProjectPublishingFact.v0.ts +48 -0
- package/structs/providers/vercel.com/ProjectDeploymentFact.v0.ts +38 -0
- package/structs/providers/vercel.com/WorkspaceConnectionConfig.v0.ts +48 -0
- package/tsconfig.json +28 -0
- package/workspace-rt.ts +134 -0
- 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"
|