@stream44.studio/t44 0.4.0-rc.24

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 (99) 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/.github/workflows/test.yaml +31 -0
  5. package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
  6. package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
  7. package/.o/GordianOpenIntegrity.yaml +21 -0
  8. package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
  9. package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
  10. package/.repo-identifier +1 -0
  11. package/DCO.md +34 -0
  12. package/LICENSE.txt +186 -0
  13. package/README.md +189 -0
  14. package/bin/activate +36 -0
  15. package/bin/activate.ts +30 -0
  16. package/bin/postinstall.sh +19 -0
  17. package/bin/shell +27 -0
  18. package/bin/t44 +27 -0
  19. package/caps/ConfigSchemaStruct.ts +55 -0
  20. package/caps/Home.ts +57 -0
  21. package/caps/HomeRegistry.ts +319 -0
  22. package/caps/HomeRegistryFile.ts +144 -0
  23. package/caps/JsonSchemas.ts +220 -0
  24. package/caps/OpenApiSchema.ts +67 -0
  25. package/caps/PackageDescriptor.ts +88 -0
  26. package/caps/ProjectCatalogs.ts +153 -0
  27. package/caps/ProjectDeployment.ts +426 -0
  28. package/caps/ProjectDevelopment.ts +257 -0
  29. package/caps/ProjectPublishing.ts +654 -0
  30. package/caps/ProjectPulling.ts +234 -0
  31. package/caps/ProjectRack.ts +155 -0
  32. package/caps/ProjectRepository.ts +332 -0
  33. package/caps/ProjectTest.ts +251 -0
  34. package/caps/ProjectTestLib.ts +257 -0
  35. package/caps/RootKey.ts +219 -0
  36. package/caps/SigningKey.ts +243 -0
  37. package/caps/TaskWorkflow.ts +192 -0
  38. package/caps/WorkspaceCli.ts +448 -0
  39. package/caps/WorkspaceConfig.ts +268 -0
  40. package/caps/WorkspaceConfig.yaml +87 -0
  41. package/caps/WorkspaceConfigFile.ts +902 -0
  42. package/caps/WorkspaceConnection.ts +329 -0
  43. package/caps/WorkspaceEntityConfig.ts +78 -0
  44. package/caps/WorkspaceEntityConfig.v0.ts +77 -0
  45. package/caps/WorkspaceEntityFact.ts +218 -0
  46. package/caps/WorkspaceInfo.ts +619 -0
  47. package/caps/WorkspaceInit.ts +30 -0
  48. package/caps/WorkspaceKey.ts +338 -0
  49. package/caps/WorkspaceModel.ts +373 -0
  50. package/caps/WorkspaceProjects.ts +636 -0
  51. package/caps/WorkspacePrompt.ts +430 -0
  52. package/caps/WorkspaceShell.sh +39 -0
  53. package/caps/WorkspaceShell.ts +104 -0
  54. package/caps/WorkspaceShell.yaml +64 -0
  55. package/caps/WorkspaceShellCli.ts +109 -0
  56. package/caps/patterns/README.md +2 -0
  57. package/caps/patterns/git-scm.com/ProjectPublishing.ts +507 -0
  58. package/caps/patterns/semver.org/ProjectPublishing.ts +458 -0
  59. package/docs/Overview.drawio +248 -0
  60. package/docs/Overview.svg +4 -0
  61. package/examples/01-Lifecycle/main.test.ts +223 -0
  62. package/lib/crypto.ts +53 -0
  63. package/lib/key.ts +381 -0
  64. package/lib/schema-console-renderer.ts +181 -0
  65. package/lib/schema-resolver.ts +349 -0
  66. package/lib/ucan.ts +137 -0
  67. package/package.json +91 -0
  68. package/standalone-rt.test.ts +150 -0
  69. package/standalone-rt.ts +140 -0
  70. package/structs/HomeRegistry.ts +55 -0
  71. package/structs/HomeRegistryConfig.ts +60 -0
  72. package/structs/ProjectCatalogsConfig.ts +53 -0
  73. package/structs/ProjectDeploymentConfig.ts +56 -0
  74. package/structs/ProjectDeploymentFact.ts +106 -0
  75. package/structs/ProjectPublishingConfig.ts +78 -0
  76. package/structs/ProjectPublishingFact.ts +68 -0
  77. package/structs/ProjectPullingConfig.ts +52 -0
  78. package/structs/ProjectRack.ts +51 -0
  79. package/structs/ProjectRackConfig.ts +56 -0
  80. package/structs/RepositoryOriginDescriptor.ts +51 -0
  81. package/structs/RootKeyConfig.ts +64 -0
  82. package/structs/SigningKeyConfig.ts +64 -0
  83. package/structs/Workspace.ts +56 -0
  84. package/structs/WorkspaceCatalogs.ts +56 -0
  85. package/structs/WorkspaceCliConfig.ts +53 -0
  86. package/structs/WorkspaceConfig.ts +64 -0
  87. package/structs/WorkspaceConfigFile.ts +50 -0
  88. package/structs/WorkspaceConfigFileMeta.ts +70 -0
  89. package/structs/WorkspaceKey.ts +55 -0
  90. package/structs/WorkspaceKeyConfig.ts +56 -0
  91. package/structs/WorkspaceMappingsConfig.ts +56 -0
  92. package/structs/WorkspaceProject.ts +104 -0
  93. package/structs/WorkspaceProjectsConfig.ts +67 -0
  94. package/structs/WorkspaceShellConfig.ts +83 -0
  95. package/structs/patterns/README.md +2 -0
  96. package/structs/patterns/git-scm.com/ProjectPublishingFact.ts +46 -0
  97. package/tsconfig.json +33 -0
  98. package/workspace-rt.ts +152 -0
  99. package/workspace.yaml +3 -0
@@ -0,0 +1,109 @@
1
+
2
+ import { Command } from 'commander'
3
+ import { $ } from 'bun'
4
+
5
+ export async function capsule({
6
+ encapsulate,
7
+ CapsulePropertyTypes,
8
+ makeImportStack
9
+ }: {
10
+ encapsulate: any
11
+ CapsulePropertyTypes: any
12
+ makeImportStack: any
13
+ }) {
14
+ return encapsulate({
15
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
16
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
17
+ '#@stream44.studio/t44/structs/WorkspaceCliConfig': {
18
+ as: '$WorkspaceCliConfig'
19
+ },
20
+ '#': {
21
+ WorkspaceConfig: {
22
+ type: CapsulePropertyTypes.Mapping,
23
+ value: '@stream44.studio/t44/caps/WorkspaceConfig'
24
+ },
25
+ shellCommands: {
26
+ type: CapsulePropertyTypes.GetterFunction,
27
+ value: async function (this: any): Promise<object> {
28
+
29
+ const config = await this.WorkspaceConfig.config as any
30
+ const self = this
31
+
32
+ const commands: Record<string, (commandArgs?: any) => Promise<void>> = {}
33
+ for (const commandName in config.shell.commands) {
34
+ const commandConfig = config.shell.commands[commandName]
35
+
36
+ commands[commandName] = async function () {
37
+ throw new Error(`Shell commands cannot be run directly! They must be sourced into the shell.`)
38
+ }
39
+ }
40
+ return commands
41
+ }
42
+ },
43
+ runCli: {
44
+ type: CapsulePropertyTypes.Function,
45
+ value: async function (this: any, argv: string[]): Promise<void> {
46
+
47
+ const config = await this.WorkspaceConfig.config as any
48
+ const cliConfig = await this.$WorkspaceCliConfig.config
49
+ const shellCommands = await this.shellCommands as Record<string, (args?: any) => Promise<void>>
50
+
51
+ const program = new Command()
52
+ .option('--yes', 'Confirm all questions with default values.')
53
+
54
+ for (const commandName in config.shell.commands) {
55
+ const commandConfig = config.shell.commands[commandName]
56
+
57
+ // If this is a cliCommand reference, pull description and arguments from CLI command
58
+ let description = commandConfig.description || ''
59
+ let commandArgs = commandConfig.arguments
60
+ let commandOptions = commandConfig.options
61
+
62
+ if (commandConfig.cliCommand) {
63
+ const cliCommandName = commandConfig.cliCommand
64
+ const cliCommand = cliConfig?.cli?.commands?.[cliCommandName]
65
+ if (cliCommand) {
66
+ description = cliCommand.description || description
67
+ commandArgs = cliCommand.arguments || commandArgs
68
+ commandOptions = cliCommand.options || commandOptions
69
+ }
70
+ }
71
+
72
+ const cmd = program
73
+ .command(commandName)
74
+ .description(description)
75
+
76
+ // Add arguments if defined
77
+ if (commandArgs) {
78
+ for (const argName in commandArgs) {
79
+ const argConfig = commandArgs[argName]
80
+ const argSyntax = argConfig.optional ? `[${argName}]` : `<${argName}>`
81
+ cmd.argument(argSyntax, argConfig.description || '')
82
+ }
83
+ }
84
+
85
+ // Add options if defined
86
+ if (commandOptions) {
87
+ for (const optionName in commandOptions) {
88
+ const optionConfig = commandOptions[optionName]
89
+ cmd.option(`--${optionName}`, optionConfig.description || '')
90
+ }
91
+ }
92
+
93
+ cmd.action(async function (...actionArgs) {
94
+ throw new Error(`Shell commands cannot be run directly! They must be sourced into the shell.`)
95
+ })
96
+ }
97
+
98
+ await program.parseAsync(argv)
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }, {
104
+ importMeta: import.meta,
105
+ importStack: makeImportStack(),
106
+ capsuleName: capsule['#'],
107
+ })
108
+ }
109
+ capsule['#'] = '@stream44.studio/t44/caps/WorkspaceShellCli'
@@ -0,0 +1,2 @@
1
+
2
+ **NOTE: Code in this directory will be relocated under MIT License to a different project in future.**
@@ -0,0 +1,507 @@
1
+
2
+ import { join } from 'path'
3
+ import { mkdir, access, readFile, writeFile } from 'fs/promises'
4
+ import { constants } from 'fs'
5
+ import { $ } from 'bun'
6
+ import chalk from 'chalk'
7
+
8
+ export async function capsule({
9
+ encapsulate,
10
+ CapsulePropertyTypes,
11
+ makeImportStack
12
+ }: {
13
+ encapsulate: any
14
+ CapsulePropertyTypes: any
15
+ makeImportStack: any
16
+ }) {
17
+ // High level API that deals with everything concerning a git repository.
18
+ return encapsulate({
19
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
20
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
21
+ '#@stream44.studio/t44/structs/patterns/git-scm.com/ProjectPublishingFact': {
22
+ as: '$GitFact'
23
+ },
24
+ '#@stream44.studio/t44/structs/ProjectPublishingFact': {
25
+ as: '$StatusFact'
26
+ },
27
+ '#': {
28
+ tags: {
29
+ type: CapsulePropertyTypes.Constant,
30
+ value: ['git'],
31
+ },
32
+ WorkspacePrompt: {
33
+ type: CapsulePropertyTypes.Mapping,
34
+ value: '@stream44.studio/t44/caps/WorkspacePrompt'
35
+ },
36
+ ProjectRepository: {
37
+ type: CapsulePropertyTypes.Mapping,
38
+ value: '@stream44.studio/t44/caps/ProjectRepository'
39
+ },
40
+ ProjectCatalogs: {
41
+ type: CapsulePropertyTypes.Mapping,
42
+ value: '@stream44.studio/t44/caps/ProjectCatalogs'
43
+ },
44
+ prepare: {
45
+ type: CapsulePropertyTypes.Function,
46
+ value: async function (this: any, { config, ctx }: { config: any, ctx: any }) {
47
+
48
+ const originUri = config.config.RepositorySettings.origin
49
+ const authorConfig = config.config?.RepositorySettings?.author
50
+
51
+ console.log(`Preparing git repo '${originUri}' from source '${ctx.repoSourceDir}' ...`)
52
+
53
+ const projectionDir = ctx.publishingApi.getProjectionDir(capsule['#'])
54
+ const stageDir = join(projectionDir, 'stage', originUri.replace(/[\/]/g, '~'))
55
+
56
+ // ── 1. Clone if repository doesn't exist yet ────────────
57
+ let isNewEmptyRepo = false
58
+ const repoExists = await this.ProjectRepository.exists({ rootDir: stageDir })
59
+ if (!repoExists) {
60
+ console.log(`Cloning repository from '${originUri}' ...`)
61
+ const result = await this.ProjectRepository.clone({ originUri, targetDir: stageDir })
62
+ isNewEmptyRepo = result.isNewEmptyRepo
63
+ }
64
+
65
+ // ── 2. Ensure origin remote exists (heal if missing) ────
66
+ const hasOrigin = await this.ProjectRepository.hasRemote({ rootDir: stageDir, name: 'origin' })
67
+ if (!hasOrigin) {
68
+ console.log(`Re-adding missing 'origin' remote ...`)
69
+ await this.ProjectRepository.addRemote({ rootDir: stageDir, name: 'origin', url: originUri })
70
+ }
71
+
72
+ // ── 3. Set local git author ─────────────────────────────
73
+ if (authorConfig?.name) {
74
+ await $`git config user.name ${authorConfig.name}`.cwd(stageDir).quiet()
75
+ }
76
+ if (authorConfig?.email) {
77
+ await $`git config user.email ${authorConfig.email}`.cwd(stageDir).quiet()
78
+ }
79
+
80
+ // ── 4. Determine target branch ──────────────────────────
81
+ const targetBranch = ctx.options.branch
82
+ const effectiveBranch = targetBranch || 'main'
83
+
84
+ // ── 5. Detect empty repo ────────────────────────────────
85
+ const headCheck = await $`git rev-parse HEAD`.cwd(stageDir).quiet().nothrow()
86
+ const isEmptyRepo = isNewEmptyRepo || headCheck.exitCode !== 0
87
+
88
+ // ── 6. Fetch from remote ────────────────────────────────
89
+ // Always fetch so we know the true state of the remote
90
+ // before making any branch decisions. Skip for empty repos
91
+ // that were just created (nothing to fetch).
92
+ if (!isEmptyRepo) {
93
+ await $`git fetch origin`.cwd(stageDir).quiet().nothrow()
94
+ }
95
+
96
+ // ── 7. Clean working tree and sync branch to remote ─────
97
+ // This is the critical section: we must get the local branch
98
+ // to exactly match the remote before rsyncing source files.
99
+ let branchSwitched = false
100
+ if (isEmptyRepo) {
101
+ await $`git checkout -b ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
102
+ console.log(`Initialized branch '${effectiveBranch}' on empty repository`)
103
+ branchSwitched = true
104
+ } else {
105
+ // Discard any uncommitted changes from previous runs
106
+ await $`git checkout .`.cwd(stageDir).quiet().nothrow()
107
+ await $`git clean -fd`.cwd(stageDir).quiet().nothrow()
108
+
109
+ const currentBranch = await this.ProjectRepository.getBranch({ rootDir: stageDir })
110
+
111
+ if (currentBranch !== effectiveBranch) {
112
+ console.log(`Switching from branch '${currentBranch}' to '${effectiveBranch}' ...`)
113
+
114
+ // Check if branch exists locally
115
+ const localBranchCheck = await $`git branch --list ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
116
+ const localBranchExists = localBranchCheck.text().trim().length > 0
117
+
118
+ if (localBranchExists) {
119
+ await $`git checkout ${effectiveBranch}`.cwd(stageDir).quiet()
120
+ } else {
121
+ // Check if branch exists on remote
122
+ const remoteBranchCheck = await $`git ls-remote --heads origin ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
123
+ const remoteBranchExists = remoteBranchCheck.text().trim().length > 0
124
+
125
+ if (remoteBranchExists) {
126
+ await $`git checkout -b ${effectiveBranch} origin/${effectiveBranch}`.cwd(stageDir).quiet()
127
+ } else {
128
+ await $`git checkout -b ${effectiveBranch}`.cwd(stageDir).quiet()
129
+ console.log(`Created new branch '${effectiveBranch}'`)
130
+ }
131
+ }
132
+ branchSwitched = true
133
+ }
134
+
135
+ // Hard-reset local branch to match remote (if remote branch exists).
136
+ // This ensures the local stage repo always starts from the true
137
+ // remote state, regardless of what happened in previous runs.
138
+ const remoteRef = `origin/${effectiveBranch}`
139
+ const remoteRefCheck = await $`git rev-parse --verify ${remoteRef}`.cwd(stageDir).quiet().nothrow()
140
+ if (remoteRefCheck.exitCode === 0) {
141
+ const localHead = (await $`git rev-parse HEAD`.cwd(stageDir).quiet()).text().trim()
142
+ const remoteHead = remoteRefCheck.text().trim()
143
+ if (localHead !== remoteHead) {
144
+ await $`git reset --hard ${remoteRef}`.cwd(stageDir).quiet()
145
+ console.log(`Synced local '${effectiveBranch}' to remote (${remoteHead.slice(0, 8)})`)
146
+ }
147
+ }
148
+
149
+ if (branchSwitched) {
150
+ console.log(`On branch '${effectiveBranch}'`)
151
+ } else if (targetBranch) {
152
+ console.log(`Already on branch '${targetBranch}'`)
153
+ }
154
+ }
155
+
156
+ // ── 8. Rsync source files into stage repo ───────────────
157
+ // Now that the branch is in sync with remote, overlay
158
+ // the workspace source files on top.
159
+ const gitignorePath = join(ctx.repoSourceDir, '.gitignore')
160
+ await this.ProjectRepository.sync({
161
+ rootDir: stageDir,
162
+ sourceDir: ctx.repoSourceDir,
163
+ gitignorePath,
164
+ excludePatterns: ctx.alwaysIgnore || []
165
+ })
166
+
167
+ // ── 9. Security check: .env* files ──────────────────────
168
+ const envFilesResult = await $`find . -name '.env*' -not -path './.git/*'`.cwd(stageDir).quiet().nothrow()
169
+ const envFiles = envFilesResult.text().trim().split('\n').filter(Boolean)
170
+ if (envFiles.length > 0) {
171
+ console.error(chalk.bgRed.white.bold(`\n ██████████████████████████████████████████████████████████`))
172
+ console.error(chalk.bgRed.white.bold(` ██ SECURITY ERROR: .env* FILES DETECTED IN REPOSITORY ██`))
173
+ console.error(chalk.bgRed.white.bold(` ██████████████████████████████████████████████████████████\n`))
174
+ console.error(chalk.red.bold(` The following .env* files were found and may leak sensitive information:`))
175
+ for (const f of envFiles) {
176
+ console.error(chalk.red(` • ${f}`))
177
+ }
178
+ console.error(chalk.red.bold(`\n Add these files to .gitignore before publishing.\n`))
179
+ process.exit(1)
180
+ }
181
+
182
+ // ── 10. Generate files from config ──────────────────────
183
+ // This happens AFTER rsync so generated files are not overwritten
184
+ if (config.config) {
185
+ for (const [key, value] of Object.entries(config.config)) {
186
+ if (key.startsWith('/')) {
187
+ const targetPath = join(stageDir, key)
188
+ const targetDir = join(targetPath, '..')
189
+
190
+ // Check if file already exists
191
+ let fileExists = false
192
+ try {
193
+ await access(targetPath, constants.F_OK)
194
+ fileExists = true
195
+ } catch {
196
+ fileExists = false
197
+ }
198
+
199
+ if (fileExists) {
200
+ console.log(`Overwriting file '${key}' in repository ...`)
201
+ } else {
202
+ console.log(`Creating file '${key}' in repository ...`)
203
+ }
204
+
205
+ // Ensure directory exists
206
+ await mkdir(targetDir, { recursive: true })
207
+
208
+ // Write file content
209
+ const content = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
210
+ await writeFile(targetPath, content, 'utf-8')
211
+ }
212
+ }
213
+ }
214
+
215
+ // ── 11. Handle --dangerously-squash-to-commit ───────────
216
+ let squashedToCommit = false
217
+ const squashToCommit = ctx.options.dangerouslySquashToCommit
218
+ if (squashToCommit) {
219
+ // Resolve the commit hash (supports short refs)
220
+ const resolveResult = await $`git rev-parse ${squashToCommit}`.cwd(stageDir).quiet().nothrow()
221
+ if (resolveResult.exitCode !== 0) {
222
+ throw new Error(`Cannot resolve commit '${squashToCommit}' in stage repo at ${stageDir}. Is the commit hash correct?`)
223
+ }
224
+ const fullHash = resolveResult.text().trim()
225
+
226
+ // Check if HEAD is already at this commit (idempotent — already squashed)
227
+ const headHash = (await $`git rev-parse HEAD`.cwd(stageDir).quiet()).text().trim()
228
+ if (headHash === fullHash) {
229
+ console.log(`Stage repo HEAD is already at ${fullHash.slice(0, 8)} — squash already applied`)
230
+ } else {
231
+ // Verify the commit exists in the history
232
+ const ancestorCheck = await $`git merge-base --is-ancestor ${fullHash} HEAD`.cwd(stageDir).quiet().nothrow()
233
+ if (ancestorCheck.exitCode !== 0) {
234
+ throw new Error(`Commit ${fullHash.slice(0, 8)} is not an ancestor of HEAD in stage repo`)
235
+ }
236
+
237
+ console.log(`Soft-resetting stage repo to commit ${fullHash.slice(0, 8)} ...`)
238
+ await $`git reset --soft ${fullHash}`.cwd(stageDir).quiet()
239
+ console.log(`Stage repo reset to ${fullHash.slice(0, 8)} — all subsequent changes are now staged`)
240
+ squashedToCommit = true
241
+ }
242
+ }
243
+
244
+ // Store metadata for other providers and later steps
245
+ ctx.metadata[capsule['#']] = {
246
+ originUri,
247
+ stageDir,
248
+ isNewEmptyRepo: isEmptyRepo,
249
+ authorConfig,
250
+ providerConfig: config,
251
+ targetBranch,
252
+ branchSwitched,
253
+ squashedToCommit,
254
+ }
255
+ }
256
+ },
257
+ tag: {
258
+ type: CapsulePropertyTypes.Function,
259
+ value: async function (this: any, { config, ctx }: { config: any, ctx: any }) {
260
+
261
+ const myMeta = ctx.metadata[capsule['#']]
262
+ if (!myMeta?.stageDir) return
263
+
264
+ const { stageDir } = myMeta
265
+
266
+ const packageJsonPath = join(ctx.repoSourceDir, 'package.json')
267
+ const packageJsonContent = await readFile(packageJsonPath, 'utf-8')
268
+ const packageJson = JSON.parse(packageJsonContent)
269
+ const version = packageJson.version
270
+ const tag = `v${version}`
271
+
272
+ const headCommit = await this.ProjectRepository.getHeadCommit({ rootDir: stageDir })
273
+
274
+ if (!headCommit) {
275
+ console.log(chalk.gray(` ○ Empty repository, skipping tag (will tag after first commit)\n`))
276
+ return
277
+ }
278
+
279
+ // Check if tag already exists on remote first
280
+ const remoteTag = await this.ProjectRepository.hasRemoteTag({ rootDir: stageDir, tag })
281
+ if (remoteTag.exists) {
282
+ if (remoteTag.commit === headCommit) {
283
+ console.log(chalk.gray(` ○ Tag ${tag} already exists on remote at current commit, skipping\n`))
284
+ return
285
+ }
286
+ console.log(chalk.yellow(`\n Tag ${tag} exists on remote at ${remoteTag.commit!.slice(0, 8)} but HEAD is ${headCommit.slice(0, 8)}\n`))
287
+ const diffText = await this.ProjectRepository.diff({ rootDir: stageDir, from: remoteTag.commit! })
288
+ if (diffText.length > 0) {
289
+ console.log(diffText)
290
+ }
291
+ throw new Error(
292
+ `Git tag '${tag}' already exists on remote but points to a different commit.\n` +
293
+ ` Please bump to a different version before pushing.`
294
+ )
295
+ }
296
+
297
+ // Check if tag already exists locally
298
+ const localTag = await this.ProjectRepository.hasTag({ rootDir: stageDir, tag })
299
+ if (localTag.exists) {
300
+ if (localTag.commit === headCommit) {
301
+ console.log(chalk.gray(` ○ Tag ${tag} already exists at current commit, skipping\n`))
302
+ return
303
+ }
304
+ // Local tag points to a different commit but tag is NOT on remote —
305
+ // this is a stale tag from a previous failed run. Delete and re-tag.
306
+ console.log(chalk.yellow(` ⟳ Local tag ${tag} is stale (at ${localTag.commit!.slice(0, 8)}, HEAD is ${headCommit.slice(0, 8)}) — re-tagging`))
307
+ await $`git tag -d ${tag}`.cwd(stageDir).quiet()
308
+ }
309
+
310
+ await this.ProjectRepository.tag({ rootDir: stageDir, tag })
311
+ console.log(chalk.green(` ✓ Tagged with ${tag}\n`))
312
+ }
313
+ },
314
+ push: {
315
+ type: CapsulePropertyTypes.Function,
316
+ value: async function (this: any, { config, ctx }: { config: any, ctx: any }) {
317
+
318
+ const myMeta = ctx.metadata[capsule['#']]
319
+ if (!myMeta) return
320
+
321
+ const {
322
+ originUri,
323
+ stageDir,
324
+ isNewEmptyRepo,
325
+ branchSwitched,
326
+ squashedToCommit
327
+ } = myMeta
328
+
329
+ const { dangerouslyResetMain, branch: targetBranch } = ctx.options
330
+
331
+ // Check if OI provider already handled the full reset push
332
+ const oiMeta = ctx.metadata['@stream44.studio/t44-blockchaincommons.com/caps/ProjectPublishing']
333
+ if (oiMeta?.handledResetPush) {
334
+ // OI already did the full reset + force push — write facts and return
335
+ const lastCommit = await this.ProjectRepository.getHeadCommit({ rootDir: stageDir })
336
+ const lastCommitMessage = await this.ProjectRepository.getLastCommitMessage({ rootDir: stageDir })
337
+ const branch = await this.ProjectRepository.getBranch({ rootDir: stageDir })
338
+
339
+ const repoFactName = originUri.replace(/[\/]/g, '~')
340
+
341
+ await this.$GitFact.set(repoFactName, {
342
+ origin: originUri,
343
+ branch: branch,
344
+ lastCommit: lastCommit,
345
+ lastCommitMessage: lastCommitMessage,
346
+ pushedAt: new Date().toISOString()
347
+ })
348
+
349
+ await this.$StatusFact.set(repoFactName, {
350
+ projectName: originUri,
351
+ provider: 'git-scm.com',
352
+ status: 'PUBLISHED',
353
+ publicUrl: originUri
354
+ })
355
+
356
+ return
357
+ }
358
+
359
+ if (dangerouslyResetMain) {
360
+ console.log(`Reset mode enabled - will reset repository to initial commit`)
361
+ }
362
+
363
+ // Git add and check for changes
364
+ console.log(`Committing changes ...`)
365
+ let hasNewChanges = await this.ProjectRepository.addAll({ rootDir: stageDir })
366
+
367
+ // Handle reset (works on existing commits, regardless of new changes)
368
+ let shouldReset = false
369
+ if (dangerouslyResetMain) {
370
+ const headCommit = await this.ProjectRepository.getHeadCommit({ rootDir: stageDir })
371
+ const hasExistingCommits = !!headCommit
372
+
373
+ if (hasExistingCommits) {
374
+ shouldReset = await this.WorkspacePrompt.confirm({
375
+ title: '⚠️ WARNING: DESTRUCTIVE OPERATION ⚠️',
376
+ description: [
377
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
378
+ 'Resetting will:',
379
+ ' • Destroy all commit history in the local repository',
380
+ ' • Destroy all commit history on GitHub when force pushed',
381
+ ' • Cannot be undone once pushed to remote',
382
+ '',
383
+ 'This should ONLY be done at the very beginning of a project.',
384
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
385
+ ],
386
+ message: 'Are you absolutely sure you want to reset all commits and destroy the history?',
387
+ defaultValue: false,
388
+ onSuccess: async (confirmed: boolean) => {
389
+ if (confirmed) {
390
+ const chalk = (await import('chalk')).default
391
+ console.log(chalk.cyan(`\nResetting all commits to initial commit ...`))
392
+ } else {
393
+ console.log('\nReset operation cancelled. Pushing without resetting...\n')
394
+ }
395
+ }
396
+ })
397
+ } else {
398
+ shouldReset = true
399
+ }
400
+
401
+ if (shouldReset) {
402
+ await this.ProjectRepository.squashAllCommits({
403
+ rootDir: stageDir,
404
+ message: 'Published using @Stream44 Studio'
405
+ })
406
+ console.log(`Repository reset to initial commit`)
407
+ }
408
+ }
409
+
410
+ // Check if DCO/commit provider already committed
411
+ const dcoMeta = ctx.metadata['@stream44.studio/dco/caps/ProjectPublishing']
412
+ if (dcoMeta?.committed) {
413
+ // DCO provider already committed — use its state
414
+ hasNewChanges = dcoMeta.hasNewChanges
415
+ } else if (!dangerouslyResetMain && hasNewChanges) {
416
+ // No DCO provider committed, and we have changes — do a plain commit
417
+ await this.ProjectRepository.commit({
418
+ rootDir: stageDir,
419
+ message: 'Published using @Stream44 Studio'
420
+ })
421
+ console.log(`New changes committed`)
422
+ } else if (!hasNewChanges) {
423
+ console.log(`No new changes to commit`)
424
+ }
425
+
426
+ // Check if local is ahead of remote
427
+ let localAheadOfRemote = false
428
+ if (!shouldReset && !hasNewChanges && !isNewEmptyRepo) {
429
+ localAheadOfRemote = await this.ProjectRepository.isAheadOfRemote({ rootDir: stageDir, branch: targetBranch })
430
+ }
431
+
432
+ // Push to remote
433
+ if (shouldReset || squashedToCommit) {
434
+ console.log(`Force pushing to remote${squashedToCommit ? ' (squash rewrite)' : ''} ...`)
435
+ await this.ProjectRepository.forcePush({ rootDir: stageDir, branch: targetBranch })
436
+ console.log(`Force pushed to remote`)
437
+ } else if (isNewEmptyRepo || hasNewChanges || localAheadOfRemote || branchSwitched) {
438
+ console.log(`Pushing to remote${targetBranch ? ` (branch: ${targetBranch})` : ''} ...`)
439
+ await this.ProjectRepository.push({ rootDir: stageDir, branch: targetBranch })
440
+ console.log(`Pushed to remote`)
441
+ }
442
+
443
+ // Write fact files
444
+ const lastCommit = await this.ProjectRepository.getHeadCommit({ rootDir: stageDir })
445
+ const lastCommitMessage = await this.ProjectRepository.getLastCommitMessage({ rootDir: stageDir })
446
+ const branch = await this.ProjectRepository.getBranch({ rootDir: stageDir })
447
+
448
+ const repoFactName = originUri.replace(/[\/]/g, '~')
449
+
450
+ await this.$GitFact.set(repoFactName, {
451
+ origin: originUri,
452
+ branch: branch,
453
+ lastCommit: lastCommit,
454
+ lastCommitMessage: lastCommitMessage,
455
+ pushedAt: new Date().toISOString()
456
+ })
457
+
458
+ await this.$StatusFact.set(repoFactName, {
459
+ projectName: originUri,
460
+ provider: 'git-scm.com',
461
+ status: hasNewChanges || shouldReset || localAheadOfRemote || branchSwitched || squashedToCommit ? 'PUBLISHED' : 'READY',
462
+ publicUrl: originUri
463
+ })
464
+
465
+ }
466
+ },
467
+ afterPush: {
468
+ type: CapsulePropertyTypes.Function,
469
+ value: async function (this: any, { config, ctx }: {
470
+ config: any
471
+ ctx: any
472
+ }): Promise<void> {
473
+ const myMeta = ctx.metadata[capsule['#']]
474
+ if (!myMeta?.stageDir) return
475
+
476
+ const branch = await this.ProjectRepository.getBranch({ rootDir: myMeta.stageDir })
477
+ const commit = await this.ProjectRepository.getHeadCommit({ rootDir: myMeta.stageDir })
478
+
479
+ const gitData: Record<string, any> = {
480
+ branches: {},
481
+ }
482
+ if (branch && commit) {
483
+ const branchEntry: Record<string, any> = { commit }
484
+ try {
485
+ const tagResult = await $`git tag --points-at ${commit}`.cwd(myMeta.stageDir).quiet().nothrow()
486
+ const tag = tagResult.text().trim().split('\n').filter(Boolean).pop()
487
+ if (tag) branchEntry.tag = tag
488
+ } catch { }
489
+ gitData.branches[branch] = branchEntry
490
+ }
491
+
492
+ await this.ProjectCatalogs.updateCatalogRepository({
493
+ repoName: ctx.repoName,
494
+ providerKey: '#' + capsule['#'],
495
+ providerData: gitData,
496
+ })
497
+ }
498
+ },
499
+ }
500
+ }
501
+ }, {
502
+ importMeta: import.meta,
503
+ importStack: makeImportStack(),
504
+ capsuleName: capsule['#'],
505
+ })
506
+ }
507
+ capsule['#'] = '@stream44.studio/t44/caps/patterns/git-scm.com/ProjectPublishing'