t44 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (86) hide show
  1. package/LICENSE.md +203 -0
  2. package/README.md +154 -0
  3. package/bin/activate +36 -0
  4. package/bin/activate.ts +30 -0
  5. package/bin/postinstall.sh +19 -0
  6. package/bin/shell +27 -0
  7. package/bin/t44 +27 -0
  8. package/caps/HomeRegistry.v0.ts +298 -0
  9. package/caps/OpenApiSchema.v0.ts +192 -0
  10. package/caps/ProjectDeployment.v0.ts +363 -0
  11. package/caps/ProjectDevelopment.v0.ts +246 -0
  12. package/caps/ProjectPublishing.v0.ts +307 -0
  13. package/caps/ProjectRack.v0.ts +128 -0
  14. package/caps/WorkspaceCli.v0.ts +391 -0
  15. package/caps/WorkspaceConfig.v0.ts +626 -0
  16. package/caps/WorkspaceConfig.yaml +53 -0
  17. package/caps/WorkspaceConnection.v0.ts +240 -0
  18. package/caps/WorkspaceEntityConfig.v0.ts +64 -0
  19. package/caps/WorkspaceEntityFact.v0.ts +193 -0
  20. package/caps/WorkspaceInfo.v0.ts +554 -0
  21. package/caps/WorkspaceInit.v0.ts +30 -0
  22. package/caps/WorkspaceKey.v0.ts +186 -0
  23. package/caps/WorkspaceProjects.v0.ts +455 -0
  24. package/caps/WorkspacePrompt.v0.ts +396 -0
  25. package/caps/WorkspaceShell.sh +39 -0
  26. package/caps/WorkspaceShell.v0.ts +104 -0
  27. package/caps/WorkspaceShell.yaml +65 -0
  28. package/caps/WorkspaceShellCli.v0.ts +109 -0
  29. package/caps/WorkspaceTest.v0.ts +167 -0
  30. package/caps/providers/LICENSE.md +8 -0
  31. package/caps/providers/README.md +2 -0
  32. package/caps/providers/bunny.net/ProjectDeployment.v0.ts +328 -0
  33. package/caps/providers/bunny.net/api-pull.v0.test.ts +319 -0
  34. package/caps/providers/bunny.net/api-pull.v0.ts +161 -0
  35. package/caps/providers/bunny.net/api-storage.v0.test.ts +168 -0
  36. package/caps/providers/bunny.net/api-storage.v0.ts +245 -0
  37. package/caps/providers/bunny.net/api.v0.ts +95 -0
  38. package/caps/providers/dynadot.com/ProjectDeployment.v0.ts +207 -0
  39. package/caps/providers/dynadot.com/api-domains.v0.test.ts +147 -0
  40. package/caps/providers/dynadot.com/api-domains.v0.ts +137 -0
  41. package/caps/providers/dynadot.com/api.v0.ts +88 -0
  42. package/caps/providers/git-scm.com/ProjectPublishing.v0.ts +231 -0
  43. package/caps/providers/github.com/ProjectPublishing.v0.ts +75 -0
  44. package/caps/providers/github.com/api.v0.ts +90 -0
  45. package/caps/providers/npmjs.com/ProjectPublishing.v0.ts +741 -0
  46. package/caps/providers/vercel.com/ProjectDeployment.v0.ts +339 -0
  47. package/caps/providers/vercel.com/api.v0.test.ts +67 -0
  48. package/caps/providers/vercel.com/api.v0.ts +132 -0
  49. package/caps/providers/vercel.com/bun.lock +194 -0
  50. package/caps/providers/vercel.com/package.json +10 -0
  51. package/caps/providers/vercel.com/project.v0.test.ts +108 -0
  52. package/caps/providers/vercel.com/project.v0.ts +150 -0
  53. package/caps/providers/vercel.com/tsconfig.json +28 -0
  54. package/docs/Overview.drawio +189 -0
  55. package/docs/Overview.svg +4 -0
  56. package/lib/crypto.ts +53 -0
  57. package/lib/openapi.ts +132 -0
  58. package/lib/ucan.ts +137 -0
  59. package/package.json +41 -0
  60. package/structs/HomeRegistryConfig.v0.ts +27 -0
  61. package/structs/ProjectDeploymentConfig.v0.ts +27 -0
  62. package/structs/ProjectDeploymentFact.v0.ts +110 -0
  63. package/structs/ProjectPublishingFact.v0.ts +69 -0
  64. package/structs/ProjectRackConfig.v0.ts +27 -0
  65. package/structs/WorkspaceCliConfig.v0.ts +27 -0
  66. package/structs/WorkspaceConfig.v0.ts +27 -0
  67. package/structs/WorkspaceKeyConfig.v0.ts +27 -0
  68. package/structs/WorkspaceMappings.v0.ts +27 -0
  69. package/structs/WorkspaceProjectsConfig.v0.ts +27 -0
  70. package/structs/WorkspaceRepositories.v0.ts +27 -0
  71. package/structs/WorkspaceShellConfig.v0.ts +45 -0
  72. package/structs/providers/LICENSE.md +8 -0
  73. package/structs/providers/README.md +2 -0
  74. package/structs/providers/bunny.net/ProjectDeploymentFact.v0.ts +41 -0
  75. package/structs/providers/bunny.net/WorkspaceConnectionConfig.v0.ts +42 -0
  76. package/structs/providers/dynadot.com/DomainFact.v0.ts +146 -0
  77. package/structs/providers/dynadot.com/WorkspaceConnectionConfig.v0.ts +41 -0
  78. package/structs/providers/git-scm.com/ProjectPublishingFact.v0.ts +46 -0
  79. package/structs/providers/github.com/ProjectPublishingFact.v0.ts +52 -0
  80. package/structs/providers/github.com/WorkspaceConnectionConfig.v0.ts +42 -0
  81. package/structs/providers/npmjs.com/ProjectPublishingFact.v0.ts +48 -0
  82. package/structs/providers/vercel.com/ProjectDeploymentFact.v0.ts +38 -0
  83. package/structs/providers/vercel.com/WorkspaceConnectionConfig.v0.ts +48 -0
  84. package/tsconfig.json +28 -0
  85. package/workspace-rt.ts +134 -0
  86. package/workspace.yaml +5 -0
@@ -0,0 +1,186 @@
1
+ export async function capsule({
2
+ encapsulate,
3
+ CapsulePropertyTypes,
4
+ makeImportStack
5
+ }: {
6
+ encapsulate: any
7
+ CapsulePropertyTypes: any
8
+ makeImportStack: any
9
+ }) {
10
+ return encapsulate({
11
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
12
+ '#@stream44.studio/encapsulate/structs/Capsule.v0': {},
13
+ '#t44/structs/WorkspaceConfig.v0': {
14
+ as: '$WorkspaceConfig'
15
+ },
16
+ '#t44/structs/WorkspaceKeyConfig.v0': {
17
+ as: '$WorkspaceKeyConfig'
18
+ },
19
+ '#': {
20
+ WorkspacePrompt: {
21
+ type: CapsulePropertyTypes.Mapping,
22
+ value: 't44/caps/WorkspacePrompt.v0'
23
+ },
24
+ HomeRegistry: {
25
+ type: CapsulePropertyTypes.Mapping,
26
+ value: 't44/caps/HomeRegistry.v0'
27
+ },
28
+ ensureKey: {
29
+ type: CapsulePropertyTypes.Function,
30
+ value: async function (this: any): Promise<{ keyName: string; keyPath: string }> {
31
+ const workspaceConfig = await this.$WorkspaceConfig.config
32
+ const keyConfig = await this.$WorkspaceKeyConfig.config
33
+
34
+ // Check if key is already set in config (object format: { name, identifier })
35
+ if (keyConfig?.name && keyConfig?.identifier) {
36
+ const keyExists = await this.HomeRegistry.keyExists(keyConfig.name)
37
+
38
+ if (keyExists) {
39
+ const keyPath = await this.HomeRegistry.getKeyPath(keyConfig.name)
40
+ return { keyName: keyConfig.name, keyPath }
41
+ } else {
42
+ const chalk = (await import('chalk')).default
43
+ const keyPath = await this.HomeRegistry.getKeyPath(keyConfig.name)
44
+ console.log(chalk.yellow(`\n⚠️ Workspace key '${keyConfig.name}' is configured but key file not found at:`))
45
+ console.log(chalk.yellow(` ${keyPath}\n`))
46
+ // Fall through to generate the key
47
+ }
48
+ }
49
+
50
+ let keyName: string
51
+
52
+ const keyConfigStructKey = '#t44/structs/WorkspaceKeyConfig.v0'
53
+ if (!keyConfig?.name) {
54
+ keyName = await this.WorkspacePrompt.setupPrompt({
55
+ title: '🔐 Workspace Key Setup',
56
+ description: [
57
+ `Workspace: ${workspaceConfig?.name || 'unknown'}`,
58
+ `Root: ${workspaceConfig?.rootDir || 'unknown'}`,
59
+ '',
60
+ 'All credentials in this workspace are encrypted with a workspace key.',
61
+ 'This key can be shared across multiple workspaces.',
62
+ '',
63
+ ],
64
+ message: 'Enter a name for the workspace key:',
65
+ defaultValue: 'genesis',
66
+ validate: (input: string) => {
67
+ if (!input || input.trim().length === 0) {
68
+ return 'Key name cannot be empty'
69
+ }
70
+ if (!/^[a-zA-Z0-9_-]+$/.test(input)) {
71
+ return 'Key name can only contain letters, numbers, underscores, and hyphens'
72
+ }
73
+ return true
74
+ },
75
+ configPath: [keyConfigStructKey],
76
+ onSuccess: async () => {
77
+ // Don't write to config yet — we write the full object after key generation
78
+ }
79
+ })
80
+ } else {
81
+ keyName = keyConfig.name
82
+ }
83
+
84
+ // Check if key already exists in registry
85
+ let keyData = await this.HomeRegistry.getKey(keyName)
86
+
87
+ if (!keyData) {
88
+ const chalk = (await import('chalk')).default
89
+ // Generate Ed25519 key pair using UCAN library
90
+ console.log(chalk.cyan(`\n Generating Ed25519 key '${keyName}'...\n`))
91
+
92
+ const { generateKeypair } = await import('../lib/ucan.js')
93
+ const { did, privateKey } = await generateKeypair()
94
+
95
+ keyData = {
96
+ did,
97
+ privateKey,
98
+ createdAt: new Date().toISOString()
99
+ }
100
+
101
+ const keyPath = await this.HomeRegistry.setKey(keyName, keyData)
102
+
103
+ console.log(chalk.green(` ✓ Key generated and saved to:`))
104
+ console.log(chalk.green(` ${keyPath}`))
105
+ console.log(chalk.green(` ✓ DID: ${keyData.did}\n`))
106
+ } else {
107
+ const chalk = (await import('chalk')).default
108
+ const keyPath = await this.HomeRegistry.getKeyPath(keyName)
109
+ console.log(chalk.green(`\n ✓ Using existing key at:`))
110
+ console.log(chalk.green(` ${keyPath}`))
111
+ console.log(chalk.green(` ✓ DID: ${keyData.did}\n`))
112
+ }
113
+
114
+ // Store key as object { name, identifier } in key config struct
115
+ await this.$WorkspaceKeyConfig.setConfigValue(['name'], keyName)
116
+ await this.$WorkspaceKeyConfig.setConfigValue(['identifier'], keyData.did)
117
+
118
+ const keyPath = await this.HomeRegistry.getKeyPath(keyName)
119
+ return { keyName, keyPath }
120
+ }
121
+ },
122
+ getKeyPath: {
123
+ type: CapsulePropertyTypes.Function,
124
+ value: async function (this: any): Promise<string | null> {
125
+ const keyConfig = await this.$WorkspaceKeyConfig.config
126
+
127
+ if (!keyConfig?.name) {
128
+ return null
129
+ }
130
+
131
+ return this.HomeRegistry.getKeyPath(keyConfig.name)
132
+ }
133
+ },
134
+ getKey: {
135
+ type: CapsulePropertyTypes.Function,
136
+ value: async function (this: any): Promise<{ did: string; privateKey: string }> {
137
+ const keyConfig = await this.$WorkspaceKeyConfig.config
138
+
139
+ if (!keyConfig?.name) {
140
+ throw new Error('No workspace key configured. Run ensureKey() first.')
141
+ }
142
+
143
+ const keyData = await this.HomeRegistry.getKey(keyConfig.name)
144
+
145
+ if (!keyData) {
146
+ throw new Error(`Workspace key '${keyConfig.name}' not found in registry. Run ensureKey() first.`)
147
+ }
148
+
149
+ return {
150
+ did: keyData.did,
151
+ privateKey: keyData.privateKey
152
+ }
153
+ }
154
+ },
155
+ getDid: {
156
+ type: CapsulePropertyTypes.Function,
157
+ value: async function (this: any): Promise<string> {
158
+ const { did } = await this.getKey()
159
+ return did
160
+ }
161
+ },
162
+ encryptString: {
163
+ type: CapsulePropertyTypes.Function,
164
+ value: async function (this: any, plaintext: string): Promise<string> {
165
+ const { privateKey } = await this.getKey()
166
+ const { encryptString } = await import('../lib/crypto.js')
167
+ return encryptString(plaintext, privateKey)
168
+ }
169
+ },
170
+ decryptString: {
171
+ type: CapsulePropertyTypes.Function,
172
+ value: async function (this: any, ciphertext: string): Promise<string> {
173
+ const { privateKey } = await this.getKey()
174
+ const { decryptString } = await import('../lib/crypto.js')
175
+ return decryptString(ciphertext, privateKey)
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }, {
181
+ importMeta: import.meta,
182
+ importStack: makeImportStack(),
183
+ capsuleName: capsule['#'],
184
+ })
185
+ }
186
+ capsule['#'] = 't44/caps/WorkspaceKey.v0'
@@ -0,0 +1,455 @@
1
+
2
+ import { join, resolve, relative } from 'path'
3
+ import { readdir, stat } from 'fs/promises'
4
+ import { $ } from 'bun'
5
+
6
+ export async function capsule({
7
+ encapsulate,
8
+ CapsulePropertyTypes,
9
+ makeImportStack
10
+ }: {
11
+ encapsulate: any
12
+ CapsulePropertyTypes: any
13
+ makeImportStack: any
14
+ }) {
15
+ return encapsulate({
16
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
17
+ '#@stream44.studio/encapsulate/structs/Capsule.v0': {},
18
+ '#t44/structs/WorkspaceConfig.v0': {
19
+ as: '$WorkspaceConfig'
20
+ },
21
+ '#t44/structs/WorkspaceProjectsConfig.v0': {
22
+ as: '$WorkspaceProjectsConfig',
23
+ },
24
+ '#t44/structs/ProjectDeploymentConfig.v0': {
25
+ as: '$ProjectDeploymentConfig',
26
+ },
27
+ '#t44/structs/WorkspaceRepositories.v0': {
28
+ as: '$WorkspaceRepositories'
29
+ },
30
+ '#': {
31
+ list: {
32
+ type: CapsulePropertyTypes.GetterFunction,
33
+ value: async function (this: any): Promise<Record<string, { sourceDir: string, deployments: Record<string, any>, repositories: Record<string, any> }>> {
34
+ const workspaceConfig = await this.$WorkspaceConfig.config
35
+ const workspaceRootDir = workspaceConfig?.rootDir
36
+
37
+ if (!workspaceRootDir) {
38
+ throw new Error('Workspace root directory not configured')
39
+ }
40
+
41
+ const configFilepath = join(workspaceRootDir, '.workspace/workspace.yaml')
42
+
43
+ // Read existing projects config
44
+ const projectsConfig = await this.$WorkspaceProjectsConfig.config
45
+ const configuredProjects: Record<string, { sourceDir: string }> = projectsConfig?.projects || {}
46
+
47
+ // Scan workspace root for project directories
48
+ const entries = await readdir(workspaceRootDir, { withFileTypes: true })
49
+ const scannedDirs: string[] = []
50
+
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue
53
+ if (entry.name.startsWith('.')) continue
54
+ if (entry.name === 'node_modules') continue
55
+ if (entry.name === '___') continue
56
+ scannedDirs.push(entry.name)
57
+ }
58
+
59
+ // Pre-fill config with scanned projects that are not yet configured
60
+ for (const dirName of scannedDirs) {
61
+ if (!configuredProjects[dirName]) {
62
+ const sourceDir = `resolve('\${__dirname}/../${dirName}')`
63
+ await this.$WorkspaceProjectsConfig.setConfigValue(['projects', dirName, 'sourceDir'], sourceDir)
64
+ configuredProjects[dirName] = { sourceDir: join(workspaceRootDir, dirName) }
65
+ }
66
+ }
67
+
68
+ // Build projects from config values, validating each
69
+ const projects: Record<string, { sourceDir: string, git: any, deployments: Record<string, any>, repositories: Record<string, any> }> = {}
70
+
71
+ const sortedProjectEntries = Object.entries(configuredProjects).sort(([a], [b]) => a.localeCompare(b))
72
+ for (const [projectName, projectConfig] of sortedProjectEntries) {
73
+ const typedConfig = projectConfig as any
74
+
75
+ if (!typedConfig.sourceDir) {
76
+ throw new Error(
77
+ `Project '${projectName}' has no sourceDir configured.\n` +
78
+ ` Fix in: ${configFilepath}\n` +
79
+ ` Under: '#t44/structs/WorkspaceProjectsConfig.v0' → projects → ${projectName}`
80
+ )
81
+ }
82
+
83
+ const resolvedSourceDir = resolve(typedConfig.sourceDir)
84
+
85
+ try {
86
+ const dirStat = await stat(resolvedSourceDir)
87
+ if (!dirStat.isDirectory()) {
88
+ throw new Error(
89
+ `Project '${projectName}' sourceDir is not a directory: ${resolvedSourceDir}\n` +
90
+ ` Fix in: ${configFilepath}\n` +
91
+ ` Under: '#t44/structs/WorkspaceProjectsConfig.v0' → projects → ${projectName} → sourceDir`
92
+ )
93
+ }
94
+ } catch (err: any) {
95
+ if (err.code === 'ENOENT') {
96
+ throw new Error(
97
+ `Project '${projectName}' sourceDir does not exist: ${resolvedSourceDir}\n` +
98
+ ` Fix in: ${configFilepath}\n` +
99
+ ` Under: '#t44/structs/WorkspaceProjectsConfig.v0' → projects → ${projectName} → sourceDir`
100
+ )
101
+ }
102
+ throw err
103
+ }
104
+
105
+ projects[projectName] = {
106
+ sourceDir: resolvedSourceDir,
107
+ git: typedConfig.git !== undefined ? typedConfig.git : undefined,
108
+ deployments: {},
109
+ repositories: {}
110
+ }
111
+ }
112
+
113
+ // Map deployments to projects
114
+ const deploymentConfig = await this.$ProjectDeploymentConfig.config
115
+ if (deploymentConfig?.deployments) {
116
+ for (const [deploymentName, deploymentAliases] of Object.entries(deploymentConfig.deployments)) {
117
+ const aliases = deploymentAliases as Record<string, any>
118
+ // Find the project by checking sourceDir of any alias
119
+ let mappedProject: string | null = null
120
+ for (const [aliasName, aliasConfig] of Object.entries(aliases)) {
121
+ if (aliasConfig.sourceDir) {
122
+ const resolvedSourceDir = resolve(aliasConfig.sourceDir)
123
+ const relPath = relative(workspaceRootDir, resolvedSourceDir)
124
+ const topDir = relPath.split('/')[0]
125
+ if (projects[topDir]) {
126
+ mappedProject = topDir
127
+ break
128
+ }
129
+ }
130
+ }
131
+
132
+ if (!mappedProject) {
133
+ throw new Error(
134
+ `Deployment '${deploymentName}' does not map to any workspace project.\n` +
135
+ ` Ensure at least one alias has a valid sourceDir pointing to a project directory.\n` +
136
+ ` Known projects: ${Object.keys(projects).join(', ')}\n` +
137
+ ` Fix in: ${configFilepath}`
138
+ )
139
+ }
140
+
141
+ projects[mappedProject].deployments[deploymentName] = aliases
142
+ }
143
+ }
144
+
145
+ // Map repositories to projects
146
+ const repositoriesConfig = await this.$WorkspaceRepositories.config
147
+ if (repositoriesConfig?.repositories) {
148
+ for (const [repoName, repoConfig] of Object.entries(repositoriesConfig.repositories)) {
149
+ const typedConfig = repoConfig as any
150
+ if (typedConfig.sourceDir) {
151
+ const resolvedSourceDir = resolve(typedConfig.sourceDir)
152
+ const relPath = relative(workspaceRootDir, resolvedSourceDir)
153
+ const topDir = relPath.split('/')[0]
154
+ if (!projects[topDir]) {
155
+ throw new Error(
156
+ `Repository '${repoName}' sourceDir '${typedConfig.sourceDir}' does not map to any workspace project '${topDir}'.\n` +
157
+ ` Known projects: ${Object.keys(projects).join(', ')}\n` +
158
+ ` Fix in: ${configFilepath}`
159
+ )
160
+ }
161
+ projects[topDir].repositories[repoName] = typedConfig
162
+ } else {
163
+ throw new Error(
164
+ `Repository '${repoName}' has no sourceDir configured.\n` +
165
+ ` Fix in: ${configFilepath}`
166
+ )
167
+ }
168
+ }
169
+ }
170
+
171
+ return projects
172
+ }
173
+ },
174
+ gatherGitInfo: {
175
+ type: CapsulePropertyTypes.Function,
176
+ value: async function (this: any, { now }: { now?: boolean } = {}): Promise<void> {
177
+ const projects = await this.list
178
+
179
+ for (const [projectName, projectInfo] of Object.entries(projects)) {
180
+ const project = projectInfo as any
181
+ const sourceDir = project.sourceDir
182
+
183
+ // If git info is already in config and --now is not passed, skip
184
+ if (project.git !== undefined && !now) {
185
+ continue
186
+ }
187
+
188
+ // Check if this is a git repo
189
+ try {
190
+ const gitDirCheck = await $`git -C ${sourceDir} rev-parse --git-dir`.quiet().nothrow()
191
+ if (gitDirCheck.exitCode !== 0) {
192
+ // Not a git repo
193
+ if (project.git === undefined) {
194
+ await this.$WorkspaceProjectsConfig.setConfigValue(['projects', projectName, 'git'], false)
195
+ }
196
+ continue
197
+ }
198
+
199
+ // Get first commit hash
200
+ const firstCommitResult = await $`git -C ${sourceDir} rev-list --max-parents=0 HEAD`.quiet().nothrow()
201
+ if (firstCommitResult.exitCode !== 0) {
202
+ // No commits yet
203
+ if (project.git === undefined) {
204
+ await this.$WorkspaceProjectsConfig.setConfigValue(['projects', projectName, 'git'], false)
205
+ }
206
+ continue
207
+ }
208
+
209
+ const firstCommitHash = firstCommitResult.text().trim().split('\n')[0]
210
+
211
+ // Get first commit date (createdAt)
212
+ const createdAtResult = await $`git -C ${sourceDir} show -s --format=%aI ${firstCommitHash}`.quiet().nothrow()
213
+ const createdAt = createdAtResult.exitCode === 0 ? createdAtResult.text().trim() : null
214
+
215
+ // Get first commit author details
216
+ const authorNameResult = await $`git -C ${sourceDir} show -s --format=%an ${firstCommitHash}`.quiet().nothrow()
217
+ const authorEmailResult = await $`git -C ${sourceDir} show -s --format=%ae ${firstCommitHash}`.quiet().nothrow()
218
+ const firstCommitAuthor: Record<string, string> = {}
219
+ if (authorNameResult.exitCode === 0) {
220
+ firstCommitAuthor.name = authorNameResult.text().trim()
221
+ }
222
+ if (authorEmailResult.exitCode === 0) {
223
+ firstCommitAuthor.email = authorEmailResult.text().trim()
224
+ }
225
+
226
+ // Get remotes
227
+ const remotesResult = await $`git -C ${sourceDir} remote -v`.quiet().nothrow()
228
+ const remotes: Record<string, string> = {}
229
+ if (remotesResult.exitCode === 0) {
230
+ const lines = remotesResult.text().trim().split('\n').filter(Boolean)
231
+ for (const line of lines) {
232
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/)
233
+ if (match) {
234
+ remotes[match[1]] = match[2]
235
+ }
236
+ }
237
+ }
238
+
239
+ // If --now is passed, sync remotes between config and git repo
240
+ if (now && project.git && typeof project.git === 'object' && project.git.remotes) {
241
+ for (const [remoteName, remoteUri] of Object.entries(project.git.remotes)) {
242
+ if (!remotes[remoteName]) {
243
+ // Remote exists in config but not in git repo — add it
244
+ const addResult = await $`git -C ${sourceDir} remote add ${remoteName} ${remoteUri as string}`.quiet().nothrow()
245
+ if (addResult.exitCode === 0) {
246
+ remotes[remoteName] = remoteUri as string
247
+ }
248
+ } else if (remotes[remoteName] !== remoteUri) {
249
+ // Remote URL in config differs from git — update git to match config
250
+ const setUrlResult = await $`git -C ${sourceDir} remote set-url ${remoteName} ${remoteUri as string}`.quiet().nothrow()
251
+ if (setUrlResult.exitCode === 0) {
252
+ remotes[remoteName] = remoteUri as string
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ const gitInfo: Record<string, any> = {
259
+ firstCommitHash,
260
+ createdAt,
261
+ firstCommitAuthor,
262
+ remotes
263
+ }
264
+
265
+ await this.$WorkspaceProjectsConfig.setConfigValue(['projects', projectName, 'git'], gitInfo)
266
+ } catch (err: any) {
267
+ // If git commands fail entirely, mark as false
268
+ if (project.git === undefined) {
269
+ await this.$WorkspaceProjectsConfig.setConfigValue(['projects', projectName, 'git'], false)
270
+ }
271
+ }
272
+ }
273
+ }
274
+ },
275
+ resolveMatchingRepositories: {
276
+ type: CapsulePropertyTypes.Function,
277
+ value: async function (this: any, { workspaceProject, repositories }: {
278
+ workspaceProject: string
279
+ repositories: Record<string, any>
280
+ }): Promise<Record<string, any>> {
281
+ const workspaceConfig = await this.$WorkspaceConfig.config
282
+ const workspaceRootDir = workspaceConfig?.rootDir
283
+ const currentDir = process.cwd()
284
+
285
+ let matchingRepositories: Record<string, any> = {}
286
+
287
+ // Strategy 1: Try prefix matching on repository names
288
+ const prefixMatches: string[] = []
289
+ for (const repoName of Object.keys(repositories)) {
290
+ if (repoName.startsWith(workspaceProject)) {
291
+ prefixMatches.push(repoName)
292
+ }
293
+ }
294
+
295
+ if (prefixMatches.length > 1) {
296
+ const chalk = (await import('chalk')).default
297
+ console.log(chalk.red(`\nError: Multiple repositories match prefix '${workspaceProject}':\n`))
298
+ for (const match of prefixMatches) {
299
+ console.log(chalk.gray(` - ${match}`))
300
+ }
301
+ console.log(chalk.red('\nPlease be more specific.\n'))
302
+ throw new Error(`Multiple repositories match prefix: ${workspaceProject}`)
303
+ }
304
+
305
+ if (prefixMatches.length === 1) {
306
+ matchingRepositories[prefixMatches[0]] = repositories[prefixMatches[0]]
307
+ return matchingRepositories
308
+ }
309
+
310
+ // Strategy 2: Try path matching (absolute or relative from current directory)
311
+ let targetPath: string
312
+ if (workspaceProject.startsWith('/')) {
313
+ targetPath = workspaceProject
314
+ } else {
315
+ targetPath = resolve(currentDir, workspaceProject)
316
+ }
317
+
318
+ for (const [repoName, repoConfig] of Object.entries(repositories)) {
319
+ if ((repoConfig as any).sourceDir) {
320
+ const sourceDirPath = resolve((repoConfig as any).sourceDir)
321
+ const rel = relative(targetPath, sourceDirPath)
322
+
323
+ const isWithinOrEqual = rel === '' || !rel.startsWith('..')
324
+
325
+ if (isWithinOrEqual) {
326
+ matchingRepositories[repoName] = repoConfig
327
+ }
328
+ }
329
+ }
330
+
331
+ if (Object.keys(matchingRepositories).length === 0) {
332
+ const chalk = (await import('chalk')).default
333
+ console.log(chalk.red(`\nError: No repositories found matching '${workspaceProject}'.\n`))
334
+ console.log(chalk.gray('Available repositories:'))
335
+ for (const repoName of Object.keys(repositories)) {
336
+ console.log(chalk.gray(` - ${repoName}`))
337
+ }
338
+ console.log('')
339
+ throw new Error(`No repositories found matching: ${workspaceProject}`)
340
+ }
341
+
342
+ return matchingRepositories
343
+ }
344
+ },
345
+ resolveMatchingDeployments: {
346
+ type: CapsulePropertyTypes.Function,
347
+ value: async function (this: any, { workspaceProject, deployments }: {
348
+ workspaceProject: string
349
+ deployments: Record<string, any>
350
+ }): Promise<Record<string, any>> {
351
+ const workspaceConfig = await this.$WorkspaceConfig.config
352
+ const workspaceRootDir = workspaceConfig?.rootDir
353
+ const currentDir = process.cwd()
354
+
355
+ let matchingDeployments: Record<string, any> = {}
356
+
357
+ // Strategy 1: Try prefix matching on project names
358
+ const prefixMatches: string[] = []
359
+ for (const projectName of Object.keys(deployments)) {
360
+ if (projectName.startsWith(workspaceProject)) {
361
+ prefixMatches.push(projectName)
362
+ }
363
+ }
364
+
365
+ if (prefixMatches.length > 1) {
366
+ const chalk = (await import('chalk')).default
367
+ console.log(chalk.red(`\nError: Multiple projects match prefix '${workspaceProject}':\n`))
368
+ for (const match of prefixMatches) {
369
+ console.log(chalk.gray(` - ${match}`))
370
+ }
371
+ console.log(chalk.red('\nPlease be more specific.\n'))
372
+ throw new Error(`Multiple projects match prefix: ${workspaceProject}`)
373
+ }
374
+
375
+ if (prefixMatches.length === 1) {
376
+ matchingDeployments[prefixMatches[0]] = deployments[prefixMatches[0]]
377
+ return matchingDeployments
378
+ }
379
+
380
+ // Strategy 2: Try path matching (absolute or relative from current directory)
381
+ let targetPath: string
382
+ if (workspaceProject.startsWith('/')) {
383
+ targetPath = workspaceProject
384
+ } else {
385
+ targetPath = resolve(currentDir, workspaceProject)
386
+ }
387
+
388
+ for (const [projectName, projectAliases] of Object.entries(deployments)) {
389
+ for (const [alias, aliasConfig] of Object.entries(projectAliases as Record<string, any>)) {
390
+ if (aliasConfig.sourceDir) {
391
+ const sourceDirPath = resolve(aliasConfig.sourceDir)
392
+ const rel = relative(targetPath, sourceDirPath)
393
+
394
+ const isWithinOrEqual = rel === '' || !rel.startsWith('..')
395
+
396
+ if (isWithinOrEqual) {
397
+ if (!matchingDeployments[projectName]) {
398
+ matchingDeployments[projectName] = {}
399
+ }
400
+ matchingDeployments[projectName][alias] = aliasConfig
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ if (Object.keys(matchingDeployments).length > 1) {
407
+ const chalk = (await import('chalk')).default
408
+ const pathMatches = Object.keys(matchingDeployments)
409
+ console.log(chalk.red(`\nError: Multiple projects match path '${workspaceProject}':\n`))
410
+ for (const match of pathMatches) {
411
+ console.log(chalk.gray(` - ${match}`))
412
+ }
413
+ console.log(chalk.red('\nPlease be more specific.\n'))
414
+ throw new Error(`Multiple projects match path: ${workspaceProject}`)
415
+ }
416
+
417
+ if (Object.keys(matchingDeployments).length === 0) {
418
+ const chalk = (await import('chalk')).default
419
+ console.log(chalk.red(`\nError: No deployments found matching '${workspaceProject}'.\n`))
420
+ console.log(chalk.gray('Available projects:'))
421
+ for (const projectName of Object.keys(deployments)) {
422
+ console.log(chalk.gray(` - ${projectName}`))
423
+ }
424
+ console.log('')
425
+ throw new Error(`No deployments found matching: ${workspaceProject}`)
426
+ }
427
+
428
+ return matchingDeployments
429
+ }
430
+ },
431
+ findProjectForPath: {
432
+ type: CapsulePropertyTypes.Function,
433
+ value: async function (this: any, { targetPath }: { targetPath: string }): Promise<string | null> {
434
+ const projects = await this.list
435
+ const resolvedTarget = resolve(targetPath)
436
+
437
+ for (const [projectName, projectInfo] of Object.entries(projects)) {
438
+ const projectDir = (projectInfo as any).sourceDir
439
+ if (resolvedTarget === projectDir || resolvedTarget.startsWith(projectDir + '/')) {
440
+ return projectName
441
+ }
442
+ }
443
+
444
+ return null
445
+ }
446
+ },
447
+ }
448
+ }
449
+ }, {
450
+ importMeta: import.meta,
451
+ importStack: makeImportStack(),
452
+ capsuleName: capsule['#'],
453
+ })
454
+ }
455
+ capsule['#'] = 't44/caps/WorkspaceProjects.v0'