t44 0.4.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dco-signatures +9 -0
- package/.github/workflows/dco.yml +12 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +25 -0
- package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
- package/DCO.md +34 -0
- package/LICENSE.md +203 -0
- package/README.md +183 -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 +51 -0
- package/caps/HomeRegistry.ts +313 -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 +363 -0
- package/caps/ProjectDevelopment.ts +257 -0
- package/caps/ProjectPublishing.ts +522 -0
- package/caps/ProjectRack.ts +155 -0
- package/caps/ProjectRepository.ts +322 -0
- package/caps/RootKey.ts +219 -0
- package/caps/SigningKey.ts +243 -0
- package/caps/WorkspaceCli.ts +442 -0
- package/caps/WorkspaceConfig.ts +268 -0
- package/caps/WorkspaceConfig.yaml +71 -0
- package/caps/WorkspaceConfigFile.ts +799 -0
- package/caps/WorkspaceConnection.ts +249 -0
- package/caps/WorkspaceEntityConfig.ts +78 -0
- package/caps/WorkspaceEntityConfig.v0.ts +77 -0
- package/caps/WorkspaceEntityFact.ts +218 -0
- package/caps/WorkspaceInfo.ts +595 -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 +406 -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/WorkspaceTest.ts +167 -0
- package/caps/providers/README.md +2 -0
- package/caps/providers/bunny.net/ProjectDeployment.ts +327 -0
- package/caps/providers/bunny.net/api-pull.test.ts +319 -0
- package/caps/providers/bunny.net/api-pull.ts +164 -0
- package/caps/providers/bunny.net/api-storage.test.ts +168 -0
- package/caps/providers/bunny.net/api-storage.ts +248 -0
- package/caps/providers/bunny.net/api.ts +95 -0
- package/caps/providers/dynadot.com/ProjectDeployment.ts +202 -0
- package/caps/providers/dynadot.com/api-domains.test.ts +224 -0
- package/caps/providers/dynadot.com/api-domains.ts +169 -0
- package/caps/providers/dynadot.com/api-restful-v1.test.ts +190 -0
- package/caps/providers/dynadot.com/api-restful-v1.ts +94 -0
- package/caps/providers/dynadot.com/api-restful-v2.test.ts +200 -0
- package/caps/providers/dynadot.com/api-restful-v2.ts +94 -0
- package/caps/providers/git-scm.com/ProjectPublishing.ts +654 -0
- package/caps/providers/github.com/ProjectPublishing.ts +118 -0
- package/caps/providers/github.com/api.ts +115 -0
- package/caps/providers/npmjs.com/ProjectPublishing.ts +536 -0
- package/caps/providers/semver.org/ProjectPublishing.ts +286 -0
- package/caps/providers/vercel.com/ProjectDeployment.ts +326 -0
- package/caps/providers/vercel.com/api.test.ts +67 -0
- package/caps/providers/vercel.com/api.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.test.ts +108 -0
- package/caps/providers/vercel.com/project.ts +150 -0
- package/caps/providers/vercel.com/tsconfig.json +28 -0
- package/docs/Overview.drawio +248 -0
- package/docs/Overview.svg +4 -0
- package/lib/crypto.ts +53 -0
- package/lib/key.ts +365 -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 +101 -0
- package/structs/HomeRegistry.ts +55 -0
- package/structs/HomeRegistryConfig.ts +56 -0
- package/structs/ProjectCatalogsConfig.ts +53 -0
- package/structs/ProjectDeploymentConfig.ts +56 -0
- package/structs/ProjectDeploymentFact.ts +106 -0
- package/structs/ProjectPublishingFact.ts +68 -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/WorkspacePublishingConfig.ts +65 -0
- package/structs/WorkspaceShellConfig.ts +83 -0
- package/structs/providers/README.md +2 -0
- package/structs/providers/bunny.net/PullZoneFact.ts +55 -0
- package/structs/providers/bunny.net/PullZoneListFact.ts +55 -0
- package/structs/providers/bunny.net/StorageZoneFact.ts +55 -0
- package/structs/providers/bunny.net/StorageZoneListFact.ts +55 -0
- package/structs/providers/bunny.net/WorkspaceConnectionConfig.ts +43 -0
- package/structs/providers/dynadot.com/DomainFact.ts +46 -0
- package/structs/providers/dynadot.com/WorkspaceConnectionConfig.ts +54 -0
- package/structs/providers/git-scm.com/ProjectPublishingFact.ts +46 -0
- package/structs/providers/github.com/ProjectPublishingFact.ts +46 -0
- package/structs/providers/github.com/WorkspaceConnectionConfig.ts +43 -0
- package/structs/providers/npmjs.com/ProjectPublishingFact.ts +46 -0
- package/structs/providers/vercel.com/ProjectDeploymentFact.ts +55 -0
- package/structs/providers/vercel.com/WorkspaceConnectionConfig.ts +49 -0
- package/tests/01-Lifecycle/main.test.ts +173 -0
- package/tsconfig.json +28 -0
- package/workspace-rt.ts +134 -0
- package/workspace.yaml +3 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { readFile, writeFile } from 'fs/promises'
|
|
4
|
+
import glob from 'fast-glob'
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
|
|
7
|
+
function detectIndent(content: string): number {
|
|
8
|
+
const match = content.match(/^\{\s*\n([ \t]+)/)
|
|
9
|
+
if (match) {
|
|
10
|
+
return match[1].length
|
|
11
|
+
}
|
|
12
|
+
return 2
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function capsule({
|
|
16
|
+
encapsulate,
|
|
17
|
+
CapsulePropertyTypes,
|
|
18
|
+
makeImportStack
|
|
19
|
+
}: {
|
|
20
|
+
encapsulate: any
|
|
21
|
+
CapsulePropertyTypes: any
|
|
22
|
+
makeImportStack: any
|
|
23
|
+
}) {
|
|
24
|
+
return encapsulate({
|
|
25
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
26
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
27
|
+
'#t44/structs/WorkspacePublishingConfig': {
|
|
28
|
+
as: '$WorkspaceRepositories'
|
|
29
|
+
},
|
|
30
|
+
'#t44/structs/WorkspaceMappingsConfig': {
|
|
31
|
+
as: '$WorkspaceMappings'
|
|
32
|
+
},
|
|
33
|
+
'#': {
|
|
34
|
+
rename: {
|
|
35
|
+
type: CapsulePropertyTypes.Function,
|
|
36
|
+
value: async function (this: any, { dirs, repos }: { dirs: Iterable<string>, repos?: Record<string, any> }) {
|
|
37
|
+
const mappingsConfig = await this.$WorkspaceMappings.config
|
|
38
|
+
const publishingMappings = mappingsConfig?.mappings?.['t44/caps/providers/ProjectPublishing']
|
|
39
|
+
if (publishingMappings?.npm) {
|
|
40
|
+
const npmRenames: Record<string, string> = publishingMappings.npm
|
|
41
|
+
const renameEntries = Object.entries(npmRenames)
|
|
42
|
+
.sort((a, b) => b[0].length - a[0].length)
|
|
43
|
+
|
|
44
|
+
if (renameEntries.length > 0) {
|
|
45
|
+
console.log('[t44] Applying package name renames ...\n')
|
|
46
|
+
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
const files = await glob('**/*', {
|
|
49
|
+
cwd: dir,
|
|
50
|
+
absolute: true,
|
|
51
|
+
onlyFiles: true,
|
|
52
|
+
dot: true
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
try {
|
|
57
|
+
const buffer = await readFile(file)
|
|
58
|
+
|
|
59
|
+
// Detect binary files by checking for null bytes (the standard
|
|
60
|
+
// heuristic used by git, diff, file(1), etc.)
|
|
61
|
+
if (buffer.includes(0x00)) continue
|
|
62
|
+
|
|
63
|
+
let content = buffer.toString('utf-8')
|
|
64
|
+
let modified = false
|
|
65
|
+
|
|
66
|
+
for (const [workspaceName, publicName] of renameEntries) {
|
|
67
|
+
const regex = new RegExp(workspaceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
|
|
68
|
+
const replaced = content.replace(regex, publicName)
|
|
69
|
+
if (replaced !== content) {
|
|
70
|
+
content = replaced
|
|
71
|
+
modified = true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (modified) {
|
|
76
|
+
await writeFile(file, content, 'utf-8')
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// Skip files that can't be read
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Resolve workspace:* dependencies
|
|
87
|
+
if (repos) {
|
|
88
|
+
const repositoriesConfig = await this.$WorkspaceRepositories.config
|
|
89
|
+
const mappingsConfig = await this.$WorkspaceMappings.config
|
|
90
|
+
const npmRenames: Record<string, string> = mappingsConfig?.mappings?.['t44/caps/providers/ProjectPublishing']?.npm || {}
|
|
91
|
+
const { publicNpmPackageNames, workspaceNpmPackageNames, workspacePackageSourceDirs } = await buildWorkspacePackageMaps(repositoriesConfig, npmRenames)
|
|
92
|
+
|
|
93
|
+
console.log('[t44] Resolving workspace dependencies ...\n')
|
|
94
|
+
for (const [repoName, repoSourceDir] of Object.entries(repos)) {
|
|
95
|
+
const packageJsonPath = join(repoSourceDir as string, 'package.json')
|
|
96
|
+
|
|
97
|
+
const packageJsonContent = await readFile(packageJsonPath, 'utf-8')
|
|
98
|
+
const indent = detectIndent(packageJsonContent)
|
|
99
|
+
const packageJson = JSON.parse(packageJsonContent)
|
|
100
|
+
|
|
101
|
+
await updateWorkspaceDependencies(packageJson, workspaceNpmPackageNames, workspacePackageSourceDirs, publicNpmPackageNames)
|
|
102
|
+
|
|
103
|
+
const updatedContent = JSON.stringify(packageJson, null, indent) + '\n'
|
|
104
|
+
if (updatedContent !== packageJsonContent) {
|
|
105
|
+
await writeFile(packageJsonPath, updatedContent, 'utf-8')
|
|
106
|
+
console.log(chalk.green(` ✓ Updated workspace dependencies in ${packageJsonPath}\n`))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
bump: {
|
|
113
|
+
type: CapsulePropertyTypes.Function,
|
|
114
|
+
value: async function (this: any, { config, options }: { config: any, options?: { rc?: boolean, release?: boolean } }) {
|
|
115
|
+
const { rc, release } = options || {}
|
|
116
|
+
|
|
117
|
+
const projectSourceDir = join(config.sourceDir)
|
|
118
|
+
const packageJsonPath = join(projectSourceDir, 'package.json')
|
|
119
|
+
|
|
120
|
+
const packageJsonContent = await readFile(packageJsonPath, 'utf-8')
|
|
121
|
+
const packageJson = JSON.parse(packageJsonContent)
|
|
122
|
+
const currentVersion = packageJson.version
|
|
123
|
+
|
|
124
|
+
let newVersion: string
|
|
125
|
+
|
|
126
|
+
if (release) {
|
|
127
|
+
const rcMatch = currentVersion.match(/^(.+)-rc\.\d+$/)
|
|
128
|
+
if (rcMatch) {
|
|
129
|
+
newVersion = rcMatch[1]
|
|
130
|
+
console.log(chalk.cyan(` Removing RC suffix: ${currentVersion} → ${newVersion}`))
|
|
131
|
+
} else {
|
|
132
|
+
console.log(chalk.yellow(` Version ${currentVersion} has no RC suffix, skipping bump`))
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
} else if (rc) {
|
|
136
|
+
const rcMatch = currentVersion.match(/^(.+)-rc\.(\d+)$/)
|
|
137
|
+
if (rcMatch) {
|
|
138
|
+
const baseVersion = rcMatch[1]
|
|
139
|
+
const rcNumber = parseInt(rcMatch[2], 10)
|
|
140
|
+
newVersion = `${baseVersion}-rc.${rcNumber + 1}`
|
|
141
|
+
console.log(chalk.cyan(` Incrementing RC version: ${currentVersion} → ${newVersion}`))
|
|
142
|
+
} else {
|
|
143
|
+
const versionParts = currentVersion.split('.')
|
|
144
|
+
if (versionParts.length !== 3) {
|
|
145
|
+
throw new Error(`Invalid version format: ${currentVersion}`)
|
|
146
|
+
}
|
|
147
|
+
const [major, minor, patch] = versionParts
|
|
148
|
+
const newMinor = parseInt(minor, 10) + 1
|
|
149
|
+
newVersion = `${major}.${newMinor}.0-rc.1`
|
|
150
|
+
console.log(chalk.cyan(` Bumping minor version and adding RC: ${currentVersion} → ${newVersion}`))
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
console.log(chalk.yellow(` No version bump requested`))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
packageJson.version = newVersion
|
|
158
|
+
const indent = detectIndent(packageJsonContent)
|
|
159
|
+
const updatedContent = JSON.stringify(packageJson, null, indent) + '\n'
|
|
160
|
+
await writeFile(packageJsonPath, updatedContent, 'utf-8')
|
|
161
|
+
|
|
162
|
+
console.log(chalk.green(` ✓ Updated ${packageJsonPath} to version ${newVersion}\n`))
|
|
163
|
+
|
|
164
|
+
return { newVersion }
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}, {
|
|
170
|
+
importMeta: import.meta,
|
|
171
|
+
importStack: makeImportStack(),
|
|
172
|
+
capsuleName: capsule['#'],
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
capsule['#'] = 't44/caps/providers/semver.org/ProjectPublishing'
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async function buildWorkspacePackageMaps(repositoriesConfig: any, npmRenames: Record<string, string>) {
|
|
179
|
+
const publicNpmPackageNames: Record<string, string> = {}
|
|
180
|
+
const workspaceNpmPackageNames: Record<string, string> = {}
|
|
181
|
+
const workspacePackageSourceDirs: Record<string, string> = {}
|
|
182
|
+
|
|
183
|
+
// Build reverse rename map: publicName → workspaceName
|
|
184
|
+
const reverseRenames: Record<string, string> = {}
|
|
185
|
+
for (const [workspaceName, publicName] of Object.entries(npmRenames)) {
|
|
186
|
+
reverseRenames[publicName] = workspaceName
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (repositoriesConfig?.repositories) {
|
|
190
|
+
for (const [repoKey, repoConfig] of Object.entries(repositoriesConfig.repositories as any)) {
|
|
191
|
+
const sourceDir = (repoConfig as any).sourceDir
|
|
192
|
+
if (!sourceDir) continue
|
|
193
|
+
|
|
194
|
+
const packageJsonPath = join(sourceDir, 'package.json')
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const packageJsonContent = await readFile(packageJsonPath, 'utf-8')
|
|
198
|
+
const packageJson = JSON.parse(packageJsonContent)
|
|
199
|
+
const workspacePackageName = packageJson.name
|
|
200
|
+
|
|
201
|
+
// Index source dir by the original workspace package name
|
|
202
|
+
workspacePackageSourceDirs[workspacePackageName] = sourceDir
|
|
203
|
+
|
|
204
|
+
// Also index by the renamed (public) name if a rename mapping exists
|
|
205
|
+
const renamedName = npmRenames[workspacePackageName]
|
|
206
|
+
if (renamedName) {
|
|
207
|
+
workspacePackageSourceDirs[renamedName] = sourceDir
|
|
208
|
+
workspaceNpmPackageNames[renamedName] = workspacePackageName
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// Skip repos without a readable package.json
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const providers = (repoConfig as any).providers || ((repoConfig as any).provider ? [(repoConfig as any).provider] : [])
|
|
215
|
+
|
|
216
|
+
for (const provider of providers) {
|
|
217
|
+
if (provider.capsule === 't44/caps/providers/npmjs.com/ProjectPublishing') {
|
|
218
|
+
try {
|
|
219
|
+
const packageJsonContent = await readFile(packageJsonPath, 'utf-8')
|
|
220
|
+
const packageJson = JSON.parse(packageJsonContent)
|
|
221
|
+
const workspacePackageName = packageJson.name
|
|
222
|
+
const publicPackageName = provider.config.PackageSettings.name
|
|
223
|
+
|
|
224
|
+
publicNpmPackageNames[workspacePackageName] = publicPackageName
|
|
225
|
+
workspaceNpmPackageNames[publicPackageName] = workspacePackageName
|
|
226
|
+
// Also index source dir by the public package name
|
|
227
|
+
workspacePackageSourceDirs[publicPackageName] = sourceDir
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new Error(`Could not read package.json from ${packageJsonPath}: ${error}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { publicNpmPackageNames, workspaceNpmPackageNames, workspacePackageSourceDirs }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function updateWorkspaceDependencies(
|
|
240
|
+
packageJson: any,
|
|
241
|
+
workspaceNpmPackageNames: Record<string, string>,
|
|
242
|
+
workspacePackageSourceDirs: Record<string, string>,
|
|
243
|
+
publicNpmPackageNames: Record<string, string>
|
|
244
|
+
) {
|
|
245
|
+
const dependencyFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
246
|
+
const currentPackageName = packageJson.name
|
|
247
|
+
|
|
248
|
+
for (const depField of dependencyFields) {
|
|
249
|
+
if (packageJson[depField]) {
|
|
250
|
+
const updatedDeps: Record<string, string> = {}
|
|
251
|
+
|
|
252
|
+
for (const [depName, depVersion] of Object.entries(packageJson[depField])) {
|
|
253
|
+
// Skip self-referencing dependencies
|
|
254
|
+
if (depName === currentPackageName) {
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof depVersion === 'string' && depVersion.startsWith('workspace:')) {
|
|
259
|
+
try {
|
|
260
|
+
const workspaceDepName = workspaceNpmPackageNames[depName] || depName
|
|
261
|
+
const depSourceDir = workspacePackageSourceDirs[workspaceDepName]
|
|
262
|
+
|
|
263
|
+
if (!depSourceDir) {
|
|
264
|
+
throw new Error(`Could not find source directory for workspace dependency ${depName} (${workspaceDepName})`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const depPackageJsonPath = join(depSourceDir, 'package.json')
|
|
268
|
+
const depPackageJsonContent = await readFile(depPackageJsonPath, 'utf-8')
|
|
269
|
+
const depPackageJson = JSON.parse(depPackageJsonContent)
|
|
270
|
+
|
|
271
|
+
// Replace workspace package name with public package name
|
|
272
|
+
const publicDepName = publicNpmPackageNames[workspaceDepName] || depName
|
|
273
|
+
updatedDeps[publicDepName] = `^${depPackageJson.version}`
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw new Error(`Could not resolve workspace dependency ${depName}: ${error}`)
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
// Keep non-workspace dependencies as-is
|
|
279
|
+
updatedDeps[depName] = depVersion as string
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
packageJson[depField] = updatedDeps
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import { mkdir, writeFile } from 'fs/promises'
|
|
5
|
+
|
|
6
|
+
export async function capsule({
|
|
7
|
+
encapsulate,
|
|
8
|
+
CapsulePropertyTypes,
|
|
9
|
+
makeImportStack
|
|
10
|
+
}: {
|
|
11
|
+
encapsulate: any
|
|
12
|
+
CapsulePropertyTypes: any
|
|
13
|
+
makeImportStack: any
|
|
14
|
+
}) {
|
|
15
|
+
// High level API that deals with everything concerning deployment of projects.
|
|
16
|
+
// NOTE: The API signatures do NOT match the vercel SDK and this is on purpose.
|
|
17
|
+
// The goal is to move towards a standard 'deployment' API that can be used across providers.
|
|
18
|
+
return encapsulate({
|
|
19
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
20
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
21
|
+
'#t44/structs/providers/vercel.com/ProjectDeploymentFact': {
|
|
22
|
+
as: '$ProjectDeploymentFact'
|
|
23
|
+
},
|
|
24
|
+
'#t44/structs/ProjectDeploymentFact': {
|
|
25
|
+
as: '$StatusFact'
|
|
26
|
+
},
|
|
27
|
+
'#': {
|
|
28
|
+
project: {
|
|
29
|
+
type: CapsulePropertyTypes.Mapping,
|
|
30
|
+
value: './project'
|
|
31
|
+
},
|
|
32
|
+
deploy: {
|
|
33
|
+
type: CapsulePropertyTypes.Function,
|
|
34
|
+
value: async function (this: any, { projectionDir, alias, config }: { projectionDir: string, alias: string, config: any }) {
|
|
35
|
+
|
|
36
|
+
const projectName = config.provider.config.ProjectSettings.name
|
|
37
|
+
const projectSettings = {
|
|
38
|
+
...config.provider.config.ProjectSettings || {},
|
|
39
|
+
name: undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`Ensure project '${projectName}' is created on Vercel ...`)
|
|
43
|
+
|
|
44
|
+
const details = await this.project.ensureCreated({
|
|
45
|
+
name: projectName,
|
|
46
|
+
settings: projectSettings
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
console.log(`Project ID: ${details.id}`)
|
|
50
|
+
|
|
51
|
+
// Set environment variables if configured
|
|
52
|
+
if (config.provider.config.ENV) {
|
|
53
|
+
console.log(`Managing environment variables ...`)
|
|
54
|
+
|
|
55
|
+
// Get existing environment variables
|
|
56
|
+
const team = await this.project.vercel.getDefaultTeam()
|
|
57
|
+
const existingVars = await (await this.project.vercel.vercel).projects.filterProjectEnvs({
|
|
58
|
+
idOrName: details.id,
|
|
59
|
+
slug: team
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const existingVarMap = new Map<string, string>(
|
|
63
|
+
existingVars.envs?.map((env: any) => [env.key as string, env.id as string]) || []
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const configuredKeys = new Set(Object.keys(config.provider.config.ENV))
|
|
67
|
+
|
|
68
|
+
// Delete variables that are no longer defined
|
|
69
|
+
for (const [key, envId] of existingVarMap.entries()) {
|
|
70
|
+
if (!configuredKeys.has(key)) {
|
|
71
|
+
await (await this.project.vercel.vercel).projects.removeProjectEnv({
|
|
72
|
+
idOrName: details.id,
|
|
73
|
+
slug: team,
|
|
74
|
+
id: envId
|
|
75
|
+
})
|
|
76
|
+
console.log(`Deleted environment variable: ${key}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create or update environment variables
|
|
81
|
+
for (const [key, value] of Object.entries(config.provider.config.ENV)) {
|
|
82
|
+
|
|
83
|
+
// If value is a function (jit expression), execute it
|
|
84
|
+
const resolvedValue = typeof value === 'function'
|
|
85
|
+
? await value()
|
|
86
|
+
: value as string
|
|
87
|
+
|
|
88
|
+
if (!existingVarMap.has(key)) {
|
|
89
|
+
// Create new variable
|
|
90
|
+
await (await this.project.vercel.vercel).projects.createProjectEnv({
|
|
91
|
+
idOrName: details.id,
|
|
92
|
+
slug: team,
|
|
93
|
+
requestBody: {
|
|
94
|
+
key,
|
|
95
|
+
value: resolvedValue,
|
|
96
|
+
type: 'encrypted',
|
|
97
|
+
target: ['production', 'preview', 'development']
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
console.log(`Created environment variable: ${key}`, resolvedValue)
|
|
101
|
+
} else {
|
|
102
|
+
// Update existing variable
|
|
103
|
+
const envId = existingVarMap.get(key)!
|
|
104
|
+
await (await this.project.vercel.vercel).projects.editProjectEnv({
|
|
105
|
+
idOrName: details.id,
|
|
106
|
+
slug: team,
|
|
107
|
+
id: envId,
|
|
108
|
+
requestBody: {
|
|
109
|
+
value: resolvedValue,
|
|
110
|
+
type: 'encrypted',
|
|
111
|
+
target: ['production', 'preview', 'development']
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
console.log(`Updated environment variable: ${key}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`Environment variables configured.`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const projectSourceDir = join(config.sourceDir)
|
|
122
|
+
const projectProjectionDir = join(projectionDir, 'projects', projectName)
|
|
123
|
+
|
|
124
|
+
await $`rm -Rf "${projectProjectionDir}" && mkdir -p "${projectProjectionDir}" && rsync -a "${projectSourceDir}/" "${projectProjectionDir}/"`
|
|
125
|
+
|
|
126
|
+
const vercelDir = join(projectProjectionDir, '.vercel')
|
|
127
|
+
await mkdir(vercelDir, { recursive: true })
|
|
128
|
+
|
|
129
|
+
const defaultTeam = await this.project.vercel.getDefaultTeam()
|
|
130
|
+
const projectJson = {
|
|
131
|
+
projectId: details.id,
|
|
132
|
+
orgId: await this.project.vercel.orgIdForName({
|
|
133
|
+
name: defaultTeam
|
|
134
|
+
}),
|
|
135
|
+
projectName
|
|
136
|
+
}
|
|
137
|
+
await writeFile(
|
|
138
|
+
join(vercelDir, 'project.json'),
|
|
139
|
+
JSON.stringify(projectJson, null, 4)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const vercelJsonConfig = config.provider.config['/vercel.json'] || {}
|
|
143
|
+
const vercelJson = {
|
|
144
|
+
framework: vercelJsonConfig.framework,
|
|
145
|
+
installCommand: vercelJsonConfig.installCommand || 'bun install',
|
|
146
|
+
buildCommand: vercelJsonConfig.buildCommand || 'bun run build',
|
|
147
|
+
outputDirectory: vercelJsonConfig.outputDirectory
|
|
148
|
+
}
|
|
149
|
+
await writeFile(
|
|
150
|
+
join(projectProjectionDir, 'vercel.json'),
|
|
151
|
+
JSON.stringify(vercelJson, null, 4)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
console.log(`Deploying to vercel ...`)
|
|
155
|
+
|
|
156
|
+
await $`vercel link --yes --project ${projectJson.projectName}`.cwd(projectProjectionDir)
|
|
157
|
+
await $`vercel deploy --force --target=preview`.cwd(projectProjectionDir)
|
|
158
|
+
// TODO: Add a workspace ID: '--meta WORKSPACE_ID=<id>'
|
|
159
|
+
|
|
160
|
+
console.log(`Deployment to vercel done.`)
|
|
161
|
+
|
|
162
|
+
// Fetch and store project details (automatically saved via $ProjectDeploymentFact)
|
|
163
|
+
await this.project.get({
|
|
164
|
+
name: details.name
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const statusResult = {
|
|
168
|
+
projectName,
|
|
169
|
+
provider: 'vercel.com',
|
|
170
|
+
status: 'READY'
|
|
171
|
+
}
|
|
172
|
+
await this.$StatusFact.set(projectName, statusResult)
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
status: {
|
|
176
|
+
type: CapsulePropertyTypes.Function,
|
|
177
|
+
value: async function (this: any, { config, now, passive, deploymentName }: { config: any; now?: boolean; passive?: boolean; deploymentName?: string }) {
|
|
178
|
+
const projectName = config.provider.config.ProjectSettings.name
|
|
179
|
+
const factName = deploymentName || projectName
|
|
180
|
+
|
|
181
|
+
if (!projectName) {
|
|
182
|
+
return {
|
|
183
|
+
projectName: factName || 'unknown',
|
|
184
|
+
provider: 'vercel.com',
|
|
185
|
+
error: 'No project name configured',
|
|
186
|
+
rawDefinitionFilepaths: []
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Raw fact filepaths that this status depends on
|
|
191
|
+
const rawFilepaths = [
|
|
192
|
+
this.$ProjectDeploymentFact.getRelativeFilepath(projectName)
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
// Try to get cached status if not forcing refresh
|
|
196
|
+
if (!now) {
|
|
197
|
+
const cached = await this.$StatusFact.get(factName, rawFilepaths)
|
|
198
|
+
if (cached) {
|
|
199
|
+
return cached.data
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// In passive mode, don't call the provider if no cache exists
|
|
204
|
+
if (passive) {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const projectDetails = await this.project.get({
|
|
209
|
+
name: projectName
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
if (!projectDetails) {
|
|
213
|
+
const errorResult = {
|
|
214
|
+
projectName: factName,
|
|
215
|
+
provider: 'vercel.com',
|
|
216
|
+
error: 'Project not found',
|
|
217
|
+
rawDefinitionFilepaths: rawFilepaths
|
|
218
|
+
}
|
|
219
|
+
await this.$StatusFact.set(factName, errorResult)
|
|
220
|
+
return errorResult
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const statusTeam = await this.project.vercel.getDefaultTeam()
|
|
224
|
+
const deploymentsResponse = await (await this.project.vercel.vercel).deployments.getDeployments({
|
|
225
|
+
projectId: projectDetails.id,
|
|
226
|
+
teamId: await this.project.vercel.orgIdForName({
|
|
227
|
+
name: statusTeam
|
|
228
|
+
}),
|
|
229
|
+
limit: 1
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const latestDeployment = deploymentsResponse.deployments?.[0]
|
|
233
|
+
|
|
234
|
+
const statusMap: Record<string, string> = {
|
|
235
|
+
'READY': 'READY',
|
|
236
|
+
'BUILDING': 'BUILDING',
|
|
237
|
+
'ERROR': 'ERROR',
|
|
238
|
+
'CANCELED': 'ERROR',
|
|
239
|
+
'QUEUED': 'BUILDING'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const result: Record<string, any> = {
|
|
243
|
+
projectName: factName,
|
|
244
|
+
provider: 'vercel.com',
|
|
245
|
+
status: statusMap[latestDeployment?.readyState] || 'UNKNOWN',
|
|
246
|
+
publicUrl: latestDeployment?.url ? `https://${latestDeployment.url}` : undefined,
|
|
247
|
+
createdAt: latestDeployment?.createdAt ? new Date(latestDeployment.createdAt).toISOString() : undefined,
|
|
248
|
+
updatedAt: latestDeployment?.aliasAssigned ? new Date(latestDeployment.aliasAssigned).toISOString() : undefined,
|
|
249
|
+
providerProjectId: projectDetails.id,
|
|
250
|
+
providerPortalUrl: latestDeployment?.inspectorUrl,
|
|
251
|
+
rawDefinitionFilepaths: rawFilepaths
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await this.$StatusFact.set(factName, result)
|
|
255
|
+
|
|
256
|
+
return result
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
deprovision: {
|
|
260
|
+
type: CapsulePropertyTypes.Function,
|
|
261
|
+
value: async function (this: any, { config }: { config: any }) {
|
|
262
|
+
|
|
263
|
+
const projectName = config.provider.config.ProjectSettings.name
|
|
264
|
+
|
|
265
|
+
console.log(`Deprovisioning project '${projectName}' from Vercel ...`)
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
// Get project details to verify it exists
|
|
269
|
+
const details = await this.project.get({
|
|
270
|
+
name: projectName
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
if (!details) {
|
|
274
|
+
console.log(`Project '${projectName}' not found on Vercel. Nothing to deprovision.`)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log(`Found project ID: ${details.id}`)
|
|
279
|
+
|
|
280
|
+
// Delete the project
|
|
281
|
+
const deprovisionTeam = await this.project.vercel.getDefaultTeam()
|
|
282
|
+
await (await this.project.vercel.vercel).projects.deleteProject({
|
|
283
|
+
idOrName: details.id,
|
|
284
|
+
slug: deprovisionTeam
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
console.log(`Successfully deleted project '${projectName}' from Vercel.`)
|
|
288
|
+
|
|
289
|
+
// Delete fact files
|
|
290
|
+
console.log(`Deleting fact files ...`)
|
|
291
|
+
try {
|
|
292
|
+
await this.$ProjectDeploymentFact.delete(projectName)
|
|
293
|
+
await this.$StatusFact.delete(projectName)
|
|
294
|
+
console.log(`Fact files deleted`)
|
|
295
|
+
} catch (error: any) {
|
|
296
|
+
console.log(`Error deleting fact files: ${error.message}`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
} catch (error: any) {
|
|
300
|
+
if (error.message?.includes('not found') || error.status === 404) {
|
|
301
|
+
console.log(`Project '${projectName}' not found on Vercel. Nothing to deprovision.`)
|
|
302
|
+
|
|
303
|
+
// Still delete fact files even if project not found
|
|
304
|
+
console.log(`Deleting fact files ...`)
|
|
305
|
+
try {
|
|
306
|
+
await this.$ProjectDeploymentFact.delete(projectName)
|
|
307
|
+
await this.$StatusFact.delete(projectName)
|
|
308
|
+
console.log(`Fact files deleted`)
|
|
309
|
+
} catch (factError: any) {
|
|
310
|
+
console.log(`Error deleting fact files: ${factError.message}`)
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
throw error
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}, {
|
|
321
|
+
importMeta: import.meta,
|
|
322
|
+
importStack: makeImportStack(),
|
|
323
|
+
capsuleName: capsule['#'],
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
capsule['#'] = 't44/caps/providers/vercel.com/ProjectDeployment'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env bun test
|
|
2
|
+
|
|
3
|
+
export const testConfig = {
|
|
4
|
+
group: 'vendor',
|
|
5
|
+
runOnAll: false,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
import * as bunTest from 'bun:test'
|
|
9
|
+
import { run } from '@t44/t44/workspace-rt'
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
test: { describe, it, expect },
|
|
13
|
+
vercel
|
|
14
|
+
} = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
|
|
15
|
+
const spine = await encapsulate({
|
|
16
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
17
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
18
|
+
'#': {
|
|
19
|
+
test: {
|
|
20
|
+
type: CapsulePropertyTypes.Mapping,
|
|
21
|
+
value: 't44/caps/WorkspaceTest',
|
|
22
|
+
options: {
|
|
23
|
+
'#': {
|
|
24
|
+
bunTest,
|
|
25
|
+
env: {
|
|
26
|
+
VERCEL_TOKEN: { factReference: 't44/structs/providers/vercel.com/WorkspaceConnectionConfig:apiToken' }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
vercel: {
|
|
32
|
+
type: CapsulePropertyTypes.Mapping,
|
|
33
|
+
value: './api'
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}, {
|
|
38
|
+
importMeta: import.meta,
|
|
39
|
+
importStack: makeImportStack(),
|
|
40
|
+
capsuleName: 't44/caps/providers/vercel.com/api.test'
|
|
41
|
+
})
|
|
42
|
+
return { spine }
|
|
43
|
+
}, async ({ spine, apis }: any) => {
|
|
44
|
+
return apis[spine.capsuleSourceLineRef]
|
|
45
|
+
}, {
|
|
46
|
+
importMeta: import.meta
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('Vercel SDK', function () {
|
|
50
|
+
|
|
51
|
+
it('getTeams()', async function () {
|
|
52
|
+
|
|
53
|
+
const result = await vercel.getTeams()
|
|
54
|
+
|
|
55
|
+
expect(result).toBeObject()
|
|
56
|
+
expect(result.teams).toBeArray()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('getProjects()', async function () {
|
|
60
|
+
|
|
61
|
+
const result = await vercel.getProjects()
|
|
62
|
+
|
|
63
|
+
expect(result).toBeObject()
|
|
64
|
+
expect(result.projects).toBeArray()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
})
|