t44 0.4.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.dco-signatures +9 -0
  2. package/.github/workflows/dco.yml +12 -0
  3. package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
  4. package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
  5. package/.o/GordianOpenIntegrity.yaml +25 -0
  6. package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
  7. package/DCO.md +34 -0
  8. package/LICENSE.md +203 -0
  9. package/README.md +183 -0
  10. package/bin/activate +36 -0
  11. package/bin/activate.ts +30 -0
  12. package/bin/postinstall.sh +19 -0
  13. package/bin/shell +27 -0
  14. package/bin/t44 +27 -0
  15. package/caps/ConfigSchemaStruct.ts +55 -0
  16. package/caps/Home.ts +51 -0
  17. package/caps/HomeRegistry.ts +313 -0
  18. package/caps/HomeRegistryFile.ts +144 -0
  19. package/caps/JsonSchemas.ts +220 -0
  20. package/caps/OpenApiSchema.ts +67 -0
  21. package/caps/PackageDescriptor.ts +88 -0
  22. package/caps/ProjectCatalogs.ts +153 -0
  23. package/caps/ProjectDeployment.ts +363 -0
  24. package/caps/ProjectDevelopment.ts +257 -0
  25. package/caps/ProjectPublishing.ts +522 -0
  26. package/caps/ProjectRack.ts +155 -0
  27. package/caps/ProjectRepository.ts +322 -0
  28. package/caps/RootKey.ts +219 -0
  29. package/caps/SigningKey.ts +243 -0
  30. package/caps/WorkspaceCli.ts +442 -0
  31. package/caps/WorkspaceConfig.ts +268 -0
  32. package/caps/WorkspaceConfig.yaml +71 -0
  33. package/caps/WorkspaceConfigFile.ts +799 -0
  34. package/caps/WorkspaceConnection.ts +249 -0
  35. package/caps/WorkspaceEntityConfig.ts +78 -0
  36. package/caps/WorkspaceEntityConfig.v0.ts +77 -0
  37. package/caps/WorkspaceEntityFact.ts +218 -0
  38. package/caps/WorkspaceInfo.ts +595 -0
  39. package/caps/WorkspaceInit.ts +30 -0
  40. package/caps/WorkspaceKey.ts +338 -0
  41. package/caps/WorkspaceModel.ts +373 -0
  42. package/caps/WorkspaceProjects.ts +636 -0
  43. package/caps/WorkspacePrompt.ts +406 -0
  44. package/caps/WorkspaceShell.sh +39 -0
  45. package/caps/WorkspaceShell.ts +104 -0
  46. package/caps/WorkspaceShell.yaml +64 -0
  47. package/caps/WorkspaceShellCli.ts +109 -0
  48. package/caps/WorkspaceTest.ts +167 -0
  49. package/caps/providers/README.md +2 -0
  50. package/caps/providers/bunny.net/ProjectDeployment.ts +327 -0
  51. package/caps/providers/bunny.net/api-pull.test.ts +319 -0
  52. package/caps/providers/bunny.net/api-pull.ts +164 -0
  53. package/caps/providers/bunny.net/api-storage.test.ts +168 -0
  54. package/caps/providers/bunny.net/api-storage.ts +248 -0
  55. package/caps/providers/bunny.net/api.ts +95 -0
  56. package/caps/providers/dynadot.com/ProjectDeployment.ts +202 -0
  57. package/caps/providers/dynadot.com/api-domains.test.ts +224 -0
  58. package/caps/providers/dynadot.com/api-domains.ts +169 -0
  59. package/caps/providers/dynadot.com/api-restful-v1.test.ts +190 -0
  60. package/caps/providers/dynadot.com/api-restful-v1.ts +94 -0
  61. package/caps/providers/dynadot.com/api-restful-v2.test.ts +200 -0
  62. package/caps/providers/dynadot.com/api-restful-v2.ts +94 -0
  63. package/caps/providers/git-scm.com/ProjectPublishing.ts +654 -0
  64. package/caps/providers/github.com/ProjectPublishing.ts +118 -0
  65. package/caps/providers/github.com/api.ts +115 -0
  66. package/caps/providers/npmjs.com/ProjectPublishing.ts +536 -0
  67. package/caps/providers/semver.org/ProjectPublishing.ts +286 -0
  68. package/caps/providers/vercel.com/ProjectDeployment.ts +326 -0
  69. package/caps/providers/vercel.com/api.test.ts +67 -0
  70. package/caps/providers/vercel.com/api.ts +132 -0
  71. package/caps/providers/vercel.com/bun.lock +194 -0
  72. package/caps/providers/vercel.com/package.json +10 -0
  73. package/caps/providers/vercel.com/project.test.ts +108 -0
  74. package/caps/providers/vercel.com/project.ts +150 -0
  75. package/caps/providers/vercel.com/tsconfig.json +28 -0
  76. package/docs/Overview.drawio +248 -0
  77. package/docs/Overview.svg +4 -0
  78. package/lib/crypto.ts +53 -0
  79. package/lib/key.ts +365 -0
  80. package/lib/schema-console-renderer.ts +181 -0
  81. package/lib/schema-resolver.ts +349 -0
  82. package/lib/ucan.ts +137 -0
  83. package/package.json +101 -0
  84. package/structs/HomeRegistry.ts +55 -0
  85. package/structs/HomeRegistryConfig.ts +56 -0
  86. package/structs/ProjectCatalogsConfig.ts +53 -0
  87. package/structs/ProjectDeploymentConfig.ts +56 -0
  88. package/structs/ProjectDeploymentFact.ts +106 -0
  89. package/structs/ProjectPublishingFact.ts +68 -0
  90. package/structs/ProjectRack.ts +51 -0
  91. package/structs/ProjectRackConfig.ts +56 -0
  92. package/structs/RepositoryOriginDescriptor.ts +51 -0
  93. package/structs/RootKeyConfig.ts +64 -0
  94. package/structs/SigningKeyConfig.ts +64 -0
  95. package/structs/Workspace.ts +56 -0
  96. package/structs/WorkspaceCatalogs.ts +56 -0
  97. package/structs/WorkspaceCliConfig.ts +53 -0
  98. package/structs/WorkspaceConfig.ts +64 -0
  99. package/structs/WorkspaceConfigFile.ts +50 -0
  100. package/structs/WorkspaceConfigFileMeta.ts +70 -0
  101. package/structs/WorkspaceKey.ts +55 -0
  102. package/structs/WorkspaceKeyConfig.ts +56 -0
  103. package/structs/WorkspaceMappingsConfig.ts +56 -0
  104. package/structs/WorkspaceProject.ts +104 -0
  105. package/structs/WorkspaceProjectsConfig.ts +67 -0
  106. package/structs/WorkspacePublishingConfig.ts +65 -0
  107. package/structs/WorkspaceShellConfig.ts +83 -0
  108. package/structs/providers/README.md +2 -0
  109. package/structs/providers/bunny.net/PullZoneFact.ts +55 -0
  110. package/structs/providers/bunny.net/PullZoneListFact.ts +55 -0
  111. package/structs/providers/bunny.net/StorageZoneFact.ts +55 -0
  112. package/structs/providers/bunny.net/StorageZoneListFact.ts +55 -0
  113. package/structs/providers/bunny.net/WorkspaceConnectionConfig.ts +43 -0
  114. package/structs/providers/dynadot.com/DomainFact.ts +46 -0
  115. package/structs/providers/dynadot.com/WorkspaceConnectionConfig.ts +54 -0
  116. package/structs/providers/git-scm.com/ProjectPublishingFact.ts +46 -0
  117. package/structs/providers/github.com/ProjectPublishingFact.ts +46 -0
  118. package/structs/providers/github.com/WorkspaceConnectionConfig.ts +43 -0
  119. package/structs/providers/npmjs.com/ProjectPublishingFact.ts +46 -0
  120. package/structs/providers/vercel.com/ProjectDeploymentFact.ts +55 -0
  121. package/structs/providers/vercel.com/WorkspaceConnectionConfig.ts +49 -0
  122. package/tests/01-Lifecycle/main.test.ts +173 -0
  123. package/tsconfig.json +28 -0
  124. package/workspace-rt.ts +134 -0
  125. package/workspace.yaml +3 -0
@@ -0,0 +1,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
+ })