@stream44.studio/dco 0.3.0-rc.2

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/caps/Dco.ts ADDED
@@ -0,0 +1,269 @@
1
+
2
+ import { join, dirname } from 'path'
3
+ import { readFile, writeFile, access, mkdir, copyFile } from 'fs/promises'
4
+ import { constants } from 'fs'
5
+ import { $ } from 'bun'
6
+
7
+
8
+ // ── Constants ────────────────────────────────────────────────────────
9
+
10
+ const DCO_FILE = 'DCO.md'
11
+ const SIGNATURES_FILE = '.dco-signatures'
12
+ const MARKER_FILE = '.git/.dco-agreed'
13
+
14
+
15
+ // ── Capsule ──────────────────────────────────────────────────────────
16
+
17
+ export async function capsule({
18
+ encapsulate,
19
+ CapsulePropertyTypes,
20
+ makeImportStack
21
+ }: {
22
+ encapsulate: any
23
+ CapsulePropertyTypes: any
24
+ makeImportStack: any
25
+ }) {
26
+
27
+ return encapsulate({
28
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
29
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
30
+ '#': {
31
+
32
+ // ══════════════════════════════════════════════════════
33
+ // sign — Run the DCO signing process
34
+ // ══════════════════════════════════════════════════════
35
+
36
+ sign: {
37
+ type: CapsulePropertyTypes.Function,
38
+ value: async function (this: any, context: {
39
+ repoDir: string
40
+ autoAgree?: boolean
41
+ signingKeyPath?: string
42
+ }) {
43
+ const { repoDir, autoAgree } = context
44
+
45
+ // Verify DCO.md exists
46
+ const dcoPath = join(repoDir, DCO_FILE)
47
+ try {
48
+ await access(dcoPath, constants.F_OK)
49
+ } catch {
50
+ throw new Error(`DCO.md not found in ${repoDir}`)
51
+ }
52
+
53
+ // Resolve commit.sh from this package
54
+ const packageDir = dirname(require.resolve('@stream44.studio/dco/package.json'))
55
+ const dcoScript = join(packageDir, 'dco.sh')
56
+
57
+ const args = ['bash', dcoScript, 'commit']
58
+ if (autoAgree) {
59
+ args.push('--yes-signoff')
60
+ }
61
+ if (context.signingKeyPath) {
62
+ args.push('--signing-key', context.signingKeyPath)
63
+ }
64
+
65
+ const proc = Bun.spawn(args, {
66
+ cwd: repoDir,
67
+ stdin: autoAgree ? 'pipe' : 'inherit',
68
+ stdout: 'inherit',
69
+ stderr: 'inherit',
70
+ })
71
+ const exitCode = await proc.exited
72
+
73
+ if (exitCode !== 0) {
74
+ throw new Error(`DCO signing failed with exit code ${exitCode}`)
75
+ }
76
+
77
+ return { signed: true }
78
+ }
79
+ },
80
+
81
+ // ══════════════════════════════════════════════════════
82
+ // validate — Validate DCO sign-offs on commits
83
+ // ══════════════════════════════════════════════════════
84
+
85
+ validate: {
86
+ type: CapsulePropertyTypes.Function,
87
+ value: async function (this: any, context: {
88
+ repoDir: string
89
+ baseBranch?: string
90
+ headRef?: string
91
+ }) {
92
+ const packageDir = dirname(require.resolve('@stream44.studio/dco/package.json'))
93
+ const dcoScript = join(packageDir, 'dco.sh')
94
+
95
+ const args = ['bash', dcoScript, 'validate']
96
+ if (context.baseBranch) args.push(context.baseBranch)
97
+ if (context.headRef) args.push(context.headRef)
98
+
99
+ const proc = Bun.spawn(args, {
100
+ cwd: context.repoDir,
101
+ stdout: 'pipe',
102
+ stderr: 'pipe',
103
+ })
104
+ const exitCode = await proc.exited
105
+ const stdout = await new Response(proc.stdout).text()
106
+ const stderr = await new Response(proc.stderr).text()
107
+
108
+ return {
109
+ valid: exitCode === 0,
110
+ exitCode,
111
+ stdout,
112
+ stderr,
113
+ }
114
+ }
115
+ },
116
+
117
+ // ══════════════════════════════════════════════════════
118
+ // hasDco — Check if a repository has DCO.md
119
+ // ══════════════════════════════════════════════════════
120
+
121
+ hasDco: {
122
+ type: CapsulePropertyTypes.Function,
123
+ value: async function (this: any, context: {
124
+ repoDir: string
125
+ }) {
126
+ const dcoPath = join(context.repoDir, DCO_FILE)
127
+ try {
128
+ await access(dcoPath, constants.F_OK)
129
+ return true
130
+ } catch {
131
+ return false
132
+ }
133
+ }
134
+ },
135
+
136
+ // ══════════════════════════════════════════════════════
137
+ // isSigned — Check if the current user has signed the DCO
138
+ // ══════════════════════════════════════════════════════
139
+
140
+ isSigned: {
141
+ type: CapsulePropertyTypes.Function,
142
+ value: async function (this: any, context: {
143
+ repoDir: string
144
+ }) {
145
+ const markerPath = join(context.repoDir, MARKER_FILE)
146
+ try {
147
+ await access(markerPath, constants.F_OK)
148
+ const content = await readFile(markerPath, 'utf-8')
149
+ const name = content.match(/^name=(.*)$/m)?.[1] || ''
150
+ const email = content.match(/^email=(.*)$/m)?.[1] || ''
151
+ const date = content.match(/^date=(.*)$/m)?.[1] || ''
152
+ const agreementCommit = content.match(/^agreement_commit=(.*)$/m)?.[1] || ''
153
+ return {
154
+ signed: true,
155
+ name,
156
+ email,
157
+ date,
158
+ agreementCommit,
159
+ }
160
+ } catch {
161
+ return { signed: false }
162
+ }
163
+ }
164
+ },
165
+
166
+ // ══════════════════════════════════════════════════════
167
+ // getSignatures — Read all signatures from .dco-signatures
168
+ // ══════════════════════════════════════════════════════
169
+
170
+ getSignatures: {
171
+ type: CapsulePropertyTypes.Function,
172
+ value: async function (this: any, context: {
173
+ repoDir: string
174
+ }) {
175
+ const sigPath = join(context.repoDir, SIGNATURES_FILE)
176
+ try {
177
+ const content = await readFile(sigPath, 'utf-8')
178
+ const signatures: Array<{
179
+ name: string
180
+ email: string
181
+ signedDate: string
182
+ agreementCommit: string
183
+ agreementChangeDate: string
184
+ }> = []
185
+
186
+ for (const line of content.split('\n')) {
187
+ // Skip empty lines, comments, headers, separator
188
+ if (!line.trim()) continue
189
+ if (line.startsWith('#')) continue
190
+ if (line.startsWith('---')) continue
191
+ if (line.startsWith('This ')) continue
192
+ if (line.startsWith('Each ')) continue
193
+ if (line.startsWith('Format:')) continue
194
+
195
+ // Parse: name <email> | signed: <date> | agreement: <commit> (<date>)
196
+ const nameMatch = line.match(/^(.+?)\s*<(.+?)>/)
197
+ const signedMatch = line.match(/\|\s*signed:\s*(.+?)\s*\|/)
198
+ const agreementMatch = line.match(/\|\s*agreement:\s*([a-f0-9]+)\s*\((.+?)\)/)
199
+
200
+ if (nameMatch) {
201
+ signatures.push({
202
+ name: nameMatch[1].trim(),
203
+ email: nameMatch[2].trim(),
204
+ signedDate: signedMatch?.[1]?.trim() || '',
205
+ agreementCommit: agreementMatch?.[1]?.trim() || '',
206
+ agreementChangeDate: agreementMatch?.[2]?.trim() || '',
207
+ })
208
+ }
209
+ }
210
+
211
+ return { found: true, signatures }
212
+ } catch {
213
+ return { found: false, signatures: [] }
214
+ }
215
+ }
216
+ },
217
+
218
+ // ══════════════════════════════════════════════════════
219
+ // signAndCommit — Full DCO flow for publishing pipelines
220
+ // ══════════════════════════════════════════════════════
221
+
222
+ signAndCommit: {
223
+ type: CapsulePropertyTypes.Function,
224
+ value: async function (this: any, context: {
225
+ repoDir: string
226
+ message: string
227
+ autoAgree?: boolean
228
+ signingKeyPath?: string
229
+ projectSourceDir?: string
230
+ }) {
231
+ const { repoDir, message } = context
232
+
233
+ // 1. Run DCO signing process
234
+ await this.sign({
235
+ repoDir,
236
+ autoAgree: context.autoAgree,
237
+ signingKeyPath: context.signingKeyPath,
238
+ })
239
+
240
+ // 2. Stage all files and commit with --signoff
241
+ await $`git add -A`.cwd(repoDir).quiet()
242
+ if (context.signingKeyPath) {
243
+ await $`git -c gpg.format=ssh -c user.signingkey=${context.signingKeyPath} commit --gpg-sign --signoff -m ${message}`.cwd(repoDir).quiet().nothrow()
244
+ } else {
245
+ await $`git commit --signoff -m ${message}`.cwd(repoDir).quiet().nothrow()
246
+ }
247
+
248
+ // 3. Copy .dco-signatures back to project source if provided
249
+ if (context.projectSourceDir) {
250
+ const stageSigFile = join(repoDir, SIGNATURES_FILE)
251
+ try {
252
+ await access(stageSigFile, constants.F_OK)
253
+ await copyFile(stageSigFile, join(context.projectSourceDir, SIGNATURES_FILE))
254
+ } catch { }
255
+ }
256
+
257
+ return { committed: true }
258
+ }
259
+ },
260
+
261
+ }
262
+ }
263
+ }, {
264
+ importMeta: import.meta,
265
+ importStack: makeImportStack(),
266
+ capsuleName: capsule['#'],
267
+ })
268
+ }
269
+ capsule['#'] = '@stream44.studio/dco/caps/Dco'