@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.
- package/.dco-signatures +9 -0
- package/.github/workflows/dco.yaml +12 -0
- package/.github/workflows/gordian-open-integrity.yaml +13 -0
- package/.github/workflows/test.yaml +31 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +21 -0
- package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
- package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
- package/.repo-identifier +1 -0
- package/DCO.md +34 -0
- package/LICENSE.txt +186 -0
- package/README.md +189 -0
- package/bin/activate +36 -0
- package/bin/activate.ts +30 -0
- package/bin/postinstall.sh +19 -0
- package/bin/shell +27 -0
- package/bin/t44 +27 -0
- package/caps/ConfigSchemaStruct.ts +55 -0
- package/caps/Home.ts +57 -0
- package/caps/HomeRegistry.ts +319 -0
- package/caps/HomeRegistryFile.ts +144 -0
- package/caps/JsonSchemas.ts +220 -0
- package/caps/OpenApiSchema.ts +67 -0
- package/caps/PackageDescriptor.ts +88 -0
- package/caps/ProjectCatalogs.ts +153 -0
- package/caps/ProjectDeployment.ts +426 -0
- package/caps/ProjectDevelopment.ts +257 -0
- package/caps/ProjectPublishing.ts +654 -0
- package/caps/ProjectPulling.ts +234 -0
- package/caps/ProjectRack.ts +155 -0
- package/caps/ProjectRepository.ts +332 -0
- package/caps/ProjectTest.ts +251 -0
- package/caps/ProjectTestLib.ts +257 -0
- package/caps/RootKey.ts +219 -0
- package/caps/SigningKey.ts +243 -0
- package/caps/TaskWorkflow.ts +192 -0
- package/caps/WorkspaceCli.ts +448 -0
- package/caps/WorkspaceConfig.ts +268 -0
- package/caps/WorkspaceConfig.yaml +87 -0
- package/caps/WorkspaceConfigFile.ts +902 -0
- package/caps/WorkspaceConnection.ts +329 -0
- package/caps/WorkspaceEntityConfig.ts +78 -0
- package/caps/WorkspaceEntityConfig.v0.ts +77 -0
- package/caps/WorkspaceEntityFact.ts +218 -0
- package/caps/WorkspaceInfo.ts +619 -0
- package/caps/WorkspaceInit.ts +30 -0
- package/caps/WorkspaceKey.ts +338 -0
- package/caps/WorkspaceModel.ts +373 -0
- package/caps/WorkspaceProjects.ts +636 -0
- package/caps/WorkspacePrompt.ts +430 -0
- package/caps/WorkspaceShell.sh +39 -0
- package/caps/WorkspaceShell.ts +104 -0
- package/caps/WorkspaceShell.yaml +64 -0
- package/caps/WorkspaceShellCli.ts +109 -0
- package/caps/patterns/README.md +2 -0
- package/caps/patterns/git-scm.com/ProjectPublishing.ts +507 -0
- package/caps/patterns/semver.org/ProjectPublishing.ts +458 -0
- package/docs/Overview.drawio +248 -0
- package/docs/Overview.svg +4 -0
- package/examples/01-Lifecycle/main.test.ts +223 -0
- package/lib/crypto.ts +53 -0
- package/lib/key.ts +381 -0
- package/lib/schema-console-renderer.ts +181 -0
- package/lib/schema-resolver.ts +349 -0
- package/lib/ucan.ts +137 -0
- package/package.json +91 -0
- package/standalone-rt.test.ts +150 -0
- package/standalone-rt.ts +140 -0
- package/structs/HomeRegistry.ts +55 -0
- package/structs/HomeRegistryConfig.ts +60 -0
- package/structs/ProjectCatalogsConfig.ts +53 -0
- package/structs/ProjectDeploymentConfig.ts +56 -0
- package/structs/ProjectDeploymentFact.ts +106 -0
- package/structs/ProjectPublishingConfig.ts +78 -0
- package/structs/ProjectPublishingFact.ts +68 -0
- package/structs/ProjectPullingConfig.ts +52 -0
- package/structs/ProjectRack.ts +51 -0
- package/structs/ProjectRackConfig.ts +56 -0
- package/structs/RepositoryOriginDescriptor.ts +51 -0
- package/structs/RootKeyConfig.ts +64 -0
- package/structs/SigningKeyConfig.ts +64 -0
- package/structs/Workspace.ts +56 -0
- package/structs/WorkspaceCatalogs.ts +56 -0
- package/structs/WorkspaceCliConfig.ts +53 -0
- package/structs/WorkspaceConfig.ts +64 -0
- package/structs/WorkspaceConfigFile.ts +50 -0
- package/structs/WorkspaceConfigFileMeta.ts +70 -0
- package/structs/WorkspaceKey.ts +55 -0
- package/structs/WorkspaceKeyConfig.ts +56 -0
- package/structs/WorkspaceMappingsConfig.ts +56 -0
- package/structs/WorkspaceProject.ts +104 -0
- package/structs/WorkspaceProjectsConfig.ts +67 -0
- package/structs/WorkspaceShellConfig.ts +83 -0
- package/structs/patterns/README.md +2 -0
- package/structs/patterns/git-scm.com/ProjectPublishingFact.ts +46 -0
- package/tsconfig.json +33 -0
- package/workspace-rt.ts +152 -0
- 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,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'
|