t44 0.4.0-rc.10

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 (127) hide show
  1. package/.dco-signatures +9 -0
  2. package/.github/workflows/dco.yaml +12 -0
  3. package/.github/workflows/gordian-open-integrity.yaml +13 -0
  4. package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
  5. package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
  6. package/.o/GordianOpenIntegrity.yaml +25 -0
  7. package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
  8. package/DCO.md +34 -0
  9. package/LICENSE.md +203 -0
  10. package/README.md +185 -0
  11. package/bin/activate +36 -0
  12. package/bin/activate.ts +30 -0
  13. package/bin/postinstall.sh +19 -0
  14. package/bin/shell +27 -0
  15. package/bin/t44 +27 -0
  16. package/caps/ConfigSchemaStruct.ts +55 -0
  17. package/caps/Home.ts +57 -0
  18. package/caps/HomeRegistry.ts +319 -0
  19. package/caps/HomeRegistryFile.ts +144 -0
  20. package/caps/JsonSchemas.ts +220 -0
  21. package/caps/OpenApiSchema.ts +67 -0
  22. package/caps/PackageDescriptor.ts +88 -0
  23. package/caps/ProjectCatalogs.ts +153 -0
  24. package/caps/ProjectDeployment.ts +363 -0
  25. package/caps/ProjectDevelopment.ts +257 -0
  26. package/caps/ProjectPublishing.ts +522 -0
  27. package/caps/ProjectRack.ts +155 -0
  28. package/caps/ProjectRepository.ts +322 -0
  29. package/caps/RootKey.ts +219 -0
  30. package/caps/SigningKey.ts +243 -0
  31. package/caps/WorkspaceCli.ts +442 -0
  32. package/caps/WorkspaceConfig.ts +268 -0
  33. package/caps/WorkspaceConfig.yaml +71 -0
  34. package/caps/WorkspaceConfigFile.ts +807 -0
  35. package/caps/WorkspaceConnection.ts +256 -0
  36. package/caps/WorkspaceEntityConfig.ts +78 -0
  37. package/caps/WorkspaceEntityConfig.v0.ts +77 -0
  38. package/caps/WorkspaceEntityFact.ts +218 -0
  39. package/caps/WorkspaceInfo.ts +595 -0
  40. package/caps/WorkspaceInit.ts +30 -0
  41. package/caps/WorkspaceKey.ts +338 -0
  42. package/caps/WorkspaceModel.ts +373 -0
  43. package/caps/WorkspaceProjects.ts +636 -0
  44. package/caps/WorkspacePrompt.ts +406 -0
  45. package/caps/WorkspaceShell.sh +39 -0
  46. package/caps/WorkspaceShell.ts +104 -0
  47. package/caps/WorkspaceShell.yaml +64 -0
  48. package/caps/WorkspaceShellCli.ts +109 -0
  49. package/caps/WorkspaceTest.ts +167 -0
  50. package/caps/providers/README.md +2 -0
  51. package/caps/providers/bunny.net/ProjectDeployment.ts +327 -0
  52. package/caps/providers/bunny.net/api-pull.test.ts +319 -0
  53. package/caps/providers/bunny.net/api-pull.ts +164 -0
  54. package/caps/providers/bunny.net/api-storage.test.ts +168 -0
  55. package/caps/providers/bunny.net/api-storage.ts +248 -0
  56. package/caps/providers/bunny.net/api.ts +95 -0
  57. package/caps/providers/dynadot.com/ProjectDeployment.ts +202 -0
  58. package/caps/providers/dynadot.com/api-domains.test.ts +224 -0
  59. package/caps/providers/dynadot.com/api-domains.ts +169 -0
  60. package/caps/providers/dynadot.com/api-restful-v1.test.ts +190 -0
  61. package/caps/providers/dynadot.com/api-restful-v1.ts +94 -0
  62. package/caps/providers/dynadot.com/api-restful-v2.test.ts +200 -0
  63. package/caps/providers/dynadot.com/api-restful-v2.ts +94 -0
  64. package/caps/providers/git-scm.com/ProjectPublishing.ts +654 -0
  65. package/caps/providers/github.com/ProjectPublishing.ts +133 -0
  66. package/caps/providers/github.com/api.ts +130 -0
  67. package/caps/providers/npmjs.com/ProjectPublishing.ts +536 -0
  68. package/caps/providers/semver.org/ProjectPublishing.ts +286 -0
  69. package/caps/providers/vercel.com/ProjectDeployment.ts +326 -0
  70. package/caps/providers/vercel.com/api.test.ts +67 -0
  71. package/caps/providers/vercel.com/api.ts +132 -0
  72. package/caps/providers/vercel.com/bun.lock +194 -0
  73. package/caps/providers/vercel.com/package.json +10 -0
  74. package/caps/providers/vercel.com/project.test.ts +108 -0
  75. package/caps/providers/vercel.com/project.ts +150 -0
  76. package/caps/providers/vercel.com/tsconfig.json +28 -0
  77. package/docs/Overview.drawio +248 -0
  78. package/docs/Overview.svg +4 -0
  79. package/examples/01-Lifecycle/main.test.ts +228 -0
  80. package/lib/crypto.ts +53 -0
  81. package/lib/key.ts +369 -0
  82. package/lib/schema-console-renderer.ts +181 -0
  83. package/lib/schema-resolver.ts +349 -0
  84. package/lib/ucan.ts +137 -0
  85. package/package.json +102 -0
  86. package/standalone-rt.ts +121 -0
  87. package/structs/HomeRegistry.ts +55 -0
  88. package/structs/HomeRegistryConfig.ts +60 -0
  89. package/structs/ProjectCatalogsConfig.ts +53 -0
  90. package/structs/ProjectDeploymentConfig.ts +56 -0
  91. package/structs/ProjectDeploymentFact.ts +106 -0
  92. package/structs/ProjectPublishingFact.ts +68 -0
  93. package/structs/ProjectRack.ts +51 -0
  94. package/structs/ProjectRackConfig.ts +56 -0
  95. package/structs/RepositoryOriginDescriptor.ts +51 -0
  96. package/structs/RootKeyConfig.ts +64 -0
  97. package/structs/SigningKeyConfig.ts +64 -0
  98. package/structs/Workspace.ts +56 -0
  99. package/structs/WorkspaceCatalogs.ts +56 -0
  100. package/structs/WorkspaceCliConfig.ts +53 -0
  101. package/structs/WorkspaceConfig.ts +64 -0
  102. package/structs/WorkspaceConfigFile.ts +50 -0
  103. package/structs/WorkspaceConfigFileMeta.ts +70 -0
  104. package/structs/WorkspaceKey.ts +55 -0
  105. package/structs/WorkspaceKeyConfig.ts +56 -0
  106. package/structs/WorkspaceMappingsConfig.ts +56 -0
  107. package/structs/WorkspaceProject.ts +104 -0
  108. package/structs/WorkspaceProjectsConfig.ts +67 -0
  109. package/structs/WorkspacePublishingConfig.ts +65 -0
  110. package/structs/WorkspaceShellConfig.ts +83 -0
  111. package/structs/providers/README.md +2 -0
  112. package/structs/providers/bunny.net/PullZoneFact.ts +55 -0
  113. package/structs/providers/bunny.net/PullZoneListFact.ts +55 -0
  114. package/structs/providers/bunny.net/StorageZoneFact.ts +55 -0
  115. package/structs/providers/bunny.net/StorageZoneListFact.ts +55 -0
  116. package/structs/providers/bunny.net/WorkspaceConnectionConfig.ts +43 -0
  117. package/structs/providers/dynadot.com/DomainFact.ts +46 -0
  118. package/structs/providers/dynadot.com/WorkspaceConnectionConfig.ts +54 -0
  119. package/structs/providers/git-scm.com/ProjectPublishingFact.ts +46 -0
  120. package/structs/providers/github.com/ProjectPublishingFact.ts +46 -0
  121. package/structs/providers/github.com/WorkspaceConnectionConfig.ts +43 -0
  122. package/structs/providers/npmjs.com/ProjectPublishingFact.ts +46 -0
  123. package/structs/providers/vercel.com/ProjectDeploymentFact.ts +55 -0
  124. package/structs/providers/vercel.com/WorkspaceConnectionConfig.ts +49 -0
  125. package/tsconfig.json +28 -0
  126. package/workspace-rt.ts +134 -0
  127. package/workspace.yaml +3 -0
@@ -0,0 +1,256 @@
1
+
2
+ import { join } from 'path'
3
+
4
+ // IMPORTANT: Connection config files contain encrypted credentials.
5
+ // NEVER delete these files programmatically. If decryption fails,
6
+ // log a clear error and exit so the user can investigate. The user must manually delete and re-enter
7
+ // credentials if the workspace key has changed.
8
+
9
+ // Track which connection setup titles and descriptions have been shown
10
+ const shownConnectionTitles = new Set<string>()
11
+ const shownDescriptions = new Set<string>()
12
+
13
+ export async function capsule({
14
+ encapsulate,
15
+ CapsulePropertyTypes,
16
+ makeImportStack
17
+ }: {
18
+ encapsulate: any
19
+ CapsulePropertyTypes: any
20
+ makeImportStack: any
21
+ }) {
22
+ return encapsulate({
23
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
24
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
25
+ '#': {
26
+ Home: {
27
+ type: CapsulePropertyTypes.Mapping,
28
+ value: 't44/caps/Home'
29
+ },
30
+ WorkspaceConfig: {
31
+ type: CapsulePropertyTypes.Mapping,
32
+ value: 't44/caps/WorkspaceConfig'
33
+ },
34
+ WorkspacePrompt: {
35
+ type: CapsulePropertyTypes.Mapping,
36
+ value: 't44/caps/WorkspacePrompt'
37
+ },
38
+ WorkspaceKey: {
39
+ type: CapsulePropertyTypes.Mapping,
40
+ value: 't44/caps/WorkspaceKey'
41
+ },
42
+ HomeRegistry: {
43
+ type: CapsulePropertyTypes.Mapping,
44
+ value: 't44/caps/HomeRegistry'
45
+ },
46
+ JsonSchema: {
47
+ type: CapsulePropertyTypes.Mapping,
48
+ value: 't44/caps/JsonSchemas'
49
+ },
50
+ RegisterSchemas: {
51
+ type: CapsulePropertyTypes.StructInit,
52
+ value: async function (this: any): Promise<void> {
53
+ if (this.schema?.schema) {
54
+ const version = this.schemaMinorVersion || '0'
55
+ await this.JsonSchema.registerSchema(this.capsuleName, this.schema.schema, version)
56
+ }
57
+ }
58
+ },
59
+ getFilepath: {
60
+ type: CapsulePropertyTypes.Function,
61
+ value: async function (this: any): Promise<string> {
62
+ const registryDir = await this.Home.registryDir
63
+ const workspaceConfig = await this.WorkspaceConfig.config
64
+ const workspaceConfigStruct = workspaceConfig?.['#t44/structs/WorkspaceConfig'] || {}
65
+ const workspaceName = workspaceConfigStruct.name
66
+ const connectionType = this.capsuleName.replace(/\//g, '~')
67
+ return join(registryDir, '@t44.sh~t44~caps~WorkspaceConnection', workspaceName, `${connectionType}.json`)
68
+ }
69
+ },
70
+ getRelativeFilepath: {
71
+ type: CapsulePropertyTypes.Function,
72
+ value: async function (this: any): Promise<string> {
73
+ const fullPath = await this.getFilepath()
74
+ return this.Home.relativePath(fullPath)
75
+ }
76
+ },
77
+ getStoredConfig: {
78
+ type: CapsulePropertyTypes.Function,
79
+ value: async function (this: any): Promise<Record<string, any> | null> {
80
+ const { readFile } = await import('fs/promises')
81
+ const filepath = await this.getFilepath()
82
+
83
+ try {
84
+ const content = await readFile(filepath, 'utf-8')
85
+ const parsed = JSON.parse(content)
86
+ const config = parsed.config || {}
87
+
88
+ // Handle legacy encryptedConfig format (migrate to per-value encryption)
89
+ if (parsed.encryptedConfig) {
90
+ const decrypted = await this.WorkspaceKey.decryptString(parsed.encryptedConfig)
91
+ const legacyConfig = JSON.parse(decrypted)
92
+ // Re-save with per-value encryption
93
+ await this.setStoredConfig(legacyConfig)
94
+ return legacyConfig
95
+ }
96
+
97
+ const result: Record<string, any> = {}
98
+ let needsMigration = false
99
+
100
+ for (const [key, value] of Object.entries(config)) {
101
+ if (typeof value === 'string' && value.startsWith('aes-256-gcm:')) {
102
+ // Encrypted value: <algo>:<keyName>-<did>:<enc value>
103
+ // Also supports legacy format: <algo>:<keyName>:<enc value>
104
+ // Use lastIndexOf since DID contains colons but base64 does not
105
+ const lastColon = value.lastIndexOf(':')
106
+ if (lastColon > 'aes-256-gcm:'.length) {
107
+ const encryptedValue = value.substring(lastColon + 1)
108
+ const keyIdentifier = value.substring('aes-256-gcm:'.length, lastColon)
109
+ try {
110
+ const decrypted = await this.WorkspaceKey.decryptString(encryptedValue)
111
+ result[key] = JSON.parse(decrypted)
112
+ } catch (decryptErr: any) {
113
+ const chalk = (await import('chalk')).default
114
+ let currentKeyId = 'unknown'
115
+ try {
116
+ const keyConfig = await this.WorkspaceKey.ensureKey()
117
+ const did = await this.WorkspaceKey.getDid()
118
+ currentKeyId = `${keyConfig.keyName}-${did}`
119
+ } catch { }
120
+ console.error(chalk.red(`\n┌─────────────────────────────────────────────────────────────────┐`))
121
+ console.error(chalk.red(`│ ✗ Connection Credential Decryption Failed │`))
122
+ console.error(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
123
+ console.error(chalk.red(`│ │`))
124
+ console.error(chalk.red(`│ Cannot decrypt '${key}' in ${this.capsuleName}`))
125
+ console.error(chalk.red(`│ │`))
126
+ console.error(chalk.red(`│ This credential was encrypted with a different workspace key. │`))
127
+ console.error(chalk.red(`│ Encrypted with: ${keyIdentifier}`))
128
+ console.error(chalk.red(`│ Current key: ${currentKeyId}`))
129
+ console.error(chalk.red(`│ │`))
130
+ console.error(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
131
+ console.error(chalk.red(`│ To fix, delete the connection config and re-enter credentials: │`))
132
+ console.error(chalk.red(`│ │`))
133
+ console.error(chalk.red(`│ rm ${filepath}`))
134
+ console.error(chalk.red(`│ │`))
135
+ console.error(chalk.red(`└─────────────────────────────────────────────────────────────────┘\n`))
136
+ process.exit(1)
137
+ }
138
+ }
139
+ } else {
140
+ // Plain text value - needs migration
141
+ result[key] = value
142
+ needsMigration = true
143
+ }
144
+ }
145
+
146
+ // Auto-migrate plain text values to encrypted
147
+ if (needsMigration) {
148
+ await this.setStoredConfig(result)
149
+ }
150
+
151
+ return Object.keys(result).length > 0 ? result : null
152
+ } catch (err: any) {
153
+ // If file doesn't exist, that's normal - user hasn't configured this provider yet
154
+ if (err?.code === 'ENOENT') {
155
+ return null
156
+ }
157
+
158
+ // For other errors (permission issues, malformed JSON, etc.), log and exit
159
+ const chalk = (await import('chalk')).default
160
+ console.error(chalk.red(`\n\u2717 Failed to read connection config for '${this.capsuleName}'\n`))
161
+ console.error(chalk.red(` File: ${filepath}`))
162
+ console.error(chalk.red(` Error: ${err?.message || err}\n`))
163
+ process.exit(1)
164
+ }
165
+ }
166
+ },
167
+ setStoredConfig: {
168
+ type: CapsulePropertyTypes.Function,
169
+ value: async function (this: any, config: Record<string, any>): Promise<void> {
170
+ const { mkdir, writeFile } = await import('fs/promises')
171
+ const { dirname } = await import('path')
172
+ const filepath = await this.getFilepath()
173
+ const dir = dirname(filepath)
174
+
175
+ await mkdir(dir, { recursive: true })
176
+
177
+ // Ensure workspace key exists and get key name + DID
178
+ const { keyName } = await this.WorkspaceKey.ensureKey()
179
+ const did = await this.WorkspaceKey.getDid()
180
+
181
+ // Encrypt each value separately with prefix format
182
+ const encryptedConfig: Record<string, string> = {}
183
+ for (const [key, value] of Object.entries(config)) {
184
+ const valueJson = JSON.stringify(value)
185
+ const encrypted = await this.WorkspaceKey.encryptString(valueJson)
186
+ // Format: <algo>:<keyName>-<did>:<enc value>
187
+ encryptedConfig[key] = `aes-256-gcm:${keyName}-${did}:${encrypted}`
188
+ }
189
+
190
+ const output = {
191
+ config: encryptedConfig
192
+ }
193
+
194
+ await writeFile(filepath, JSON.stringify(output, null, 4), { mode: 0o600 })
195
+ }
196
+ },
197
+ getConfigValue: {
198
+ type: CapsulePropertyTypes.Function,
199
+ value: async function (this: any, key: string): Promise<any> {
200
+ const storedConfig = await this.getStoredConfig() || {}
201
+
202
+ if (storedConfig[key] !== undefined) {
203
+ return storedConfig[key]
204
+ }
205
+
206
+ // Value not set, need to prompt user
207
+ const propertySchema = this.schema?.schema?.properties?.[key]
208
+ if (!propertySchema) {
209
+ throw new Error(`No schema defined for config key "${key}" in ${this.capsuleName} connection config`)
210
+ }
211
+
212
+ // Create promptFactId for deduplication
213
+ const promptFactId = `${this.capsuleName}:${key}`
214
+
215
+ // Show title once per capsuleName
216
+ const chalk = (await import('chalk')).default
217
+ if (!shownConnectionTitles.has(this.capsuleName)) {
218
+ console.log(chalk.cyan(`\n🔑 ${this.capsuleName} Connection Setup\n`))
219
+ shownConnectionTitles.add(this.capsuleName)
220
+ }
221
+
222
+ // Show description once per promptFactId
223
+ if (propertySchema.description && !shownDescriptions.has(promptFactId)) {
224
+ console.log(chalk.gray(` ${propertySchema.description}\n`))
225
+ shownDescriptions.add(promptFactId)
226
+ }
227
+
228
+ const value = await this.WorkspacePrompt.input({
229
+ message: `${propertySchema.title || key}:`,
230
+ validate: (input: string) => {
231
+ if (!input || input.trim().length === 0) {
232
+ return `${propertySchema.title || key} cannot be empty`
233
+ }
234
+ return true
235
+ },
236
+ promptFactId
237
+ })
238
+
239
+ // Store the value
240
+ storedConfig[key] = value
241
+ await this.setStoredConfig(storedConfig)
242
+
243
+ console.log(chalk.green(`\n ✓ ${propertySchema.title || key} saved to connection config\n`))
244
+
245
+ return value
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }, {
251
+ importMeta: import.meta,
252
+ importStack: makeImportStack(),
253
+ capsuleName: capsule['#'],
254
+ })
255
+ }
256
+ capsule['#'] = 't44/caps/WorkspaceConnection'
@@ -0,0 +1,78 @@
1
+
2
+ export async function capsule({
3
+ encapsulate,
4
+ CapsulePropertyTypes,
5
+ makeImportStack
6
+ }: {
7
+ encapsulate: any
8
+ CapsulePropertyTypes: any
9
+ makeImportStack: any
10
+ }) {
11
+ return encapsulate({
12
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
13
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
14
+ '#': {
15
+ WorkspaceConfig: {
16
+ type: CapsulePropertyTypes.Mapping,
17
+ value: 't44/caps/WorkspaceConfig'
18
+ },
19
+ JsonSchema: {
20
+ type: CapsulePropertyTypes.Mapping,
21
+ value: 't44/caps/JsonSchemas'
22
+ },
23
+ config: {
24
+ type: CapsulePropertyTypes.GetterFunction,
25
+ value: async function (this: any): Promise<void> {
26
+
27
+ const config = await this.WorkspaceConfig.config
28
+
29
+ const configKey = '#' + this.capsuleName
30
+
31
+ const entityConfig = config[configKey] || undefined
32
+
33
+ return entityConfig
34
+ }
35
+ },
36
+ setConfigValue: {
37
+ type: CapsulePropertyTypes.Function,
38
+ value: async function (this: any, path: string[], value: any): Promise<void> {
39
+
40
+ const configKey = '#' + this.capsuleName
41
+
42
+ const now = new Date().toISOString()
43
+ await this.WorkspaceConfig.ensureEntityTimestamps(
44
+ { entityName: this.capsuleName },
45
+ configKey, now
46
+ )
47
+
48
+ const changed = await this.WorkspaceConfig.setConfigValueForEntity(
49
+ { entityName: this.capsuleName, schema: this.schema },
50
+ [configKey, ...path], value
51
+ )
52
+
53
+ if (changed) {
54
+ await this.WorkspaceConfig.setConfigValueForEntity(
55
+ { entityName: this.capsuleName, schema: this.schema },
56
+ [configKey, 'updatedAt'], new Date().toISOString()
57
+ )
58
+ }
59
+ }
60
+ },
61
+ RegisterSchemas: {
62
+ type: CapsulePropertyTypes.StructInit,
63
+ value: async function (this: any): Promise<void> {
64
+ if (this.schema?.schema) {
65
+ const version = this.schemaMinorVersion || '0'
66
+ await this.JsonSchema.registerSchema(this.capsuleName, this.schema.schema, version)
67
+ }
68
+ }
69
+ },
70
+ }
71
+ }
72
+ }, {
73
+ importMeta: import.meta,
74
+ importStack: makeImportStack(),
75
+ capsuleName: capsule['#'],
76
+ })
77
+ }
78
+ capsule['#'] = 't44/caps/WorkspaceEntityConfig'
@@ -0,0 +1,77 @@
1
+
2
+ export async function capsule({
3
+ encapsulate,
4
+ CapsulePropertyTypes,
5
+ makeImportStack
6
+ }: {
7
+ encapsulate: any
8
+ CapsulePropertyTypes: any
9
+ makeImportStack: any
10
+ }) {
11
+ return encapsulate({
12
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
13
+ '#@stream44.studio/encapsulate/structs/Capsule.v0': {},
14
+ '#': {
15
+ WorkspaceConfig: {
16
+ type: CapsulePropertyTypes.Mapping,
17
+ value: 't44/caps/WorkspaceConfig.v0'
18
+ },
19
+ JsonSchema: {
20
+ type: CapsulePropertyTypes.Mapping,
21
+ value: 't44/caps/JsonSchemas.v0'
22
+ },
23
+ config: {
24
+ type: CapsulePropertyTypes.GetterFunction,
25
+ value: async function (this: any): Promise<void> {
26
+
27
+ const config = await this.WorkspaceConfig.config
28
+
29
+ const configKey = '#' + this.capsuleName
30
+
31
+ const entityConfig = config[configKey] || undefined
32
+
33
+ return entityConfig
34
+ }
35
+ },
36
+ setConfigValue: {
37
+ type: CapsulePropertyTypes.Function,
38
+ value: async function (this: any, path: string[], value: any): Promise<void> {
39
+
40
+ const configKey = '#' + this.capsuleName
41
+
42
+ await this.WorkspaceConfig.setConfigValueForEntity(
43
+ { entityName: this.capsuleName, schema: this.schema },
44
+ [configKey, 'createdAt'], new Date().toISOString(), { ifAbsent: true }
45
+ )
46
+
47
+ const changed = await this.WorkspaceConfig.setConfigValueForEntity(
48
+ { entityName: this.capsuleName, schema: this.schema },
49
+ [configKey, ...path], value
50
+ )
51
+
52
+ if (changed) {
53
+ await this.WorkspaceConfig.setConfigValueForEntity(
54
+ { entityName: this.capsuleName, schema: this.schema },
55
+ [configKey, 'updatedAt'], new Date().toISOString()
56
+ )
57
+ }
58
+ }
59
+ },
60
+ RegisterSchemas: {
61
+ type: CapsulePropertyTypes.StructInit,
62
+ value: async function (this: any): Promise<void> {
63
+ if (this.schema?.schema) {
64
+ const version = this.schemaMinorVersion || '0'
65
+ await this.JsonSchema.registerSchema(this.capsuleName, this.schema.schema, version)
66
+ }
67
+ }
68
+ },
69
+ }
70
+ }
71
+ }, {
72
+ importMeta: import.meta,
73
+ importStack: makeImportStack(),
74
+ capsuleName: capsule['#'],
75
+ })
76
+ }
77
+ capsule['#'] = 't44/caps/WorkspaceEntityConfig.v0'
@@ -0,0 +1,218 @@
1
+
2
+ import { join } from 'path'
3
+ import { mkdir, writeFile, readFile, stat } from 'fs/promises'
4
+
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': {},
18
+ '#': {
19
+ WorkspaceConfig: {
20
+ type: CapsulePropertyTypes.Mapping,
21
+ value: 't44/caps/WorkspaceConfig'
22
+ },
23
+ JsonSchema: {
24
+ type: CapsulePropertyTypes.Mapping,
25
+ value: 't44/caps/JsonSchemas'
26
+ },
27
+ RegisterSchemas: {
28
+ type: CapsulePropertyTypes.StructInit,
29
+ value: async function (this: any): Promise<void> {
30
+ if (this.schema?.schema) {
31
+ const version = this.schemaMinorVersion || '0'
32
+ await this.JsonSchema.registerSchema(this.capsuleName, this.schema.schema, version)
33
+ }
34
+ }
35
+ },
36
+ getFilepath: {
37
+ type: CapsulePropertyTypes.Function,
38
+ value: function (this: any, instanceName: string): string {
39
+ return join(
40
+ this.WorkspaceConfig.workspaceRootDir,
41
+ this.getRelativeFilepath(instanceName)
42
+ )
43
+ }
44
+ },
45
+ getRelativeFilepath: {
46
+ type: CapsulePropertyTypes.Function,
47
+ value: function (this: any, instanceName: string): string {
48
+ return join(
49
+ '.~o',
50
+ 'workspace.foundation',
51
+ capsule['#'].replace(/\//g, '~'),
52
+ this.capsuleName.replace(/\//g, '~'),
53
+ instanceName + '.json'
54
+ )
55
+ }
56
+ },
57
+ get: {
58
+ type: CapsulePropertyTypes.Function,
59
+ value: async function (this: any, instanceName: string, rawFilepaths?: string[]): Promise<{ data: any; stale: boolean } | null> {
60
+ const factFilepath = this.getFilepath(instanceName)
61
+
62
+ try {
63
+ const factStat = await stat(factFilepath)
64
+ const factMtime = factStat.mtimeMs
65
+
66
+ // Check if any raw filepaths are newer than our cached fact
67
+ let stale = false
68
+ if (rawFilepaths && rawFilepaths.length > 0) {
69
+ for (const rawPath of rawFilepaths) {
70
+ const fullRawPath = rawPath.startsWith('/')
71
+ ? rawPath
72
+ : join(this.WorkspaceConfig.workspaceRootDir, rawPath)
73
+ try {
74
+ const rawStat = await stat(fullRawPath)
75
+ if (rawStat.mtimeMs > factMtime) {
76
+ stale = true
77
+ break
78
+ }
79
+ } catch {
80
+ // Raw file doesn't exist, consider stale
81
+ stale = true
82
+ break
83
+ }
84
+ }
85
+ }
86
+
87
+ const content = await readFile(factFilepath, 'utf-8')
88
+ const parsed = JSON.parse(content)
89
+ const { $schema, $id, _ValidationFeedback, ...data } = parsed
90
+
91
+ return { data, stale }
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+ },
97
+ set: {
98
+ type: CapsulePropertyTypes.Function,
99
+ value: async function (this: any, instanceName: string, data: any): Promise<void> {
100
+
101
+ const factDir = join(
102
+ this.WorkspaceConfig.workspaceRootDir,
103
+ '.~o',
104
+ 'workspace.foundation',
105
+ capsule['#'].replace(/\//g, '~'),
106
+ this.capsuleName.replace(/\//g, '~')
107
+ )
108
+
109
+ await mkdir(factDir, { recursive: true })
110
+
111
+ const factFilepath = join(factDir, instanceName + '.json')
112
+
113
+ // Check if schema defines updatedAt or createdAt
114
+ const schemaProperties = this.schema?.schema?.properties || {}
115
+ const hasUpdatedAt = 'updatedAt' in schemaProperties
116
+ const hasCreatedAt = 'createdAt' in schemaProperties
117
+
118
+ // Read existing data to check for changes and preserve timestamps
119
+ let existingData: any = null
120
+ try {
121
+ const existingContent = await readFile(factFilepath, 'utf-8')
122
+ const existingParsed = JSON.parse(existingContent)
123
+ const { $schema, $id, _ValidationFeedback, ...existing } = existingParsed
124
+ existingData = existing
125
+ } catch {
126
+ // File doesn't exist or can't be read
127
+ }
128
+
129
+ // Prepare output data
130
+ const outputData = { ...data }
131
+
132
+ // Track if we're adding missing timestamps
133
+ let addedCreatedAt = false
134
+ let addedUpdatedAt = false
135
+
136
+ // Set createdAt if schema defines it and it's not set
137
+ if (hasCreatedAt && !outputData.createdAt) {
138
+ if (existingData?.createdAt) {
139
+ // Preserve existing createdAt
140
+ outputData.createdAt = existingData.createdAt
141
+ } else {
142
+ // Set new createdAt (file exists but missing timestamp)
143
+ outputData.createdAt = new Date().toISOString()
144
+ addedCreatedAt = true
145
+ }
146
+ }
147
+
148
+ // Set updatedAt if schema defines it
149
+ if (hasUpdatedAt) {
150
+ if (!outputData.updatedAt && existingData?.updatedAt) {
151
+ // Preserve existing updatedAt for comparison
152
+ outputData.updatedAt = existingData.updatedAt
153
+ } else if (!outputData.updatedAt) {
154
+ // Set new updatedAt (file exists but missing timestamp, or new file)
155
+ outputData.updatedAt = new Date().toISOString()
156
+ if (existingData) {
157
+ addedUpdatedAt = true
158
+ }
159
+ }
160
+ }
161
+
162
+ const version = this.schemaMinorVersion || '0'
163
+ const output: Record<string, any> = {
164
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
165
+ $id: this.capsuleName + '.v' + version,
166
+ ...outputData
167
+ }
168
+
169
+ // Check if content has changed
170
+ if (existingData) {
171
+ const existingOutput = {
172
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
173
+ $id: this.capsuleName + '.v' + version,
174
+ ...existingData
175
+ }
176
+ const existingContent = JSON.stringify(existingOutput, null, 4)
177
+ const newContent = JSON.stringify(output, null, 4)
178
+
179
+ if (existingContent === newContent) {
180
+ // Content is identical, skip write
181
+ return
182
+ }
183
+
184
+ // Content changed - update updatedAt if schema defines it and it matches existing
185
+ // But only if we didn't just add it (which means data actually changed)
186
+ if (hasUpdatedAt && !addedUpdatedAt && !addedCreatedAt && outputData.updatedAt === existingData?.updatedAt) {
187
+ output.updatedAt = new Date().toISOString()
188
+ }
189
+ }
190
+
191
+ await writeFile(factFilepath, JSON.stringify(output, null, 4))
192
+ }
193
+ },
194
+ delete: {
195
+ type: CapsulePropertyTypes.Function,
196
+ value: async function (this: any, instanceName: string): Promise<void> {
197
+ const { unlink } = await import('fs/promises')
198
+ const factFilepath = this.getFilepath(instanceName)
199
+
200
+ try {
201
+ await unlink(factFilepath)
202
+ } catch (error: any) {
203
+ // Ignore if file doesn't exist
204
+ if (error.code !== 'ENOENT') {
205
+ throw error
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }, {
213
+ importMeta: import.meta,
214
+ importStack: makeImportStack(),
215
+ capsuleName: capsule['#'],
216
+ })
217
+ }
218
+ capsule['#'] = 't44/caps/WorkspaceEntityFact'