trackfw 2.16.0 → 3.1.0
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/README.md +18 -2
- package/package.json +1 -1
- package/src/commands/discover.js +18 -0
- package/src/commands/index.js +2 -0
- package/src/commands/init.js +50 -4
- package/src/commands/integrations.js +75 -4
- package/src/commands/log.js +2 -1
- package/src/commands/note.js +24 -0
- package/src/commands/req.js +7 -1
- package/src/commands/roadmap.js +4 -3
- package/src/commands/ship.js +49 -0
- package/src/config/index.js +5 -2
- package/src/forge/adapter.js +189 -0
- package/src/forge/resolve.js +191 -0
- package/src/generators/adr.js +13 -4
- package/src/generators/date.js +23 -0
- package/src/generators/init.js +133 -8
- package/src/generators/note.js +154 -0
- package/src/generators/req.js +88 -5
- package/src/generators/roadmap.js +96 -20
- package/src/i18n/locales/en-US.json +2 -1
- package/src/i18n/locales/es-ES.json +2 -1
- package/src/i18n/locales/pt-BR.json +2 -1
- package/src/identity/preset.js +22 -0
- package/src/integrations/assets/agents/architect.md +32 -0
- package/src/integrations/assets/agents/backend.md +32 -0
- package/src/integrations/assets/agents/code-quality.md +29 -0
- package/src/integrations/assets/agents/data.md +32 -0
- package/src/integrations/assets/agents/dba.md +32 -0
- package/src/integrations/assets/agents/frontend.md +32 -0
- package/src/integrations/assets/agents/iac.md +46 -0
- package/src/integrations/assets/agents/infra.md +34 -0
- package/src/integrations/assets/agents/qa.md +32 -0
- package/src/integrations/assets/agents/security.md +29 -0
- package/src/integrations/assets/agents/tooling.md +47 -0
- package/src/integrations/assets/agents/ux.md +29 -0
- package/src/integrations/assets/catalog.json +16 -2
- package/src/integrations/assets/skills/architecture.md +66 -0
- package/src/integrations/assets/skills/backend.md +67 -0
- package/src/integrations/assets/skills/code-quality.md +78 -0
- package/src/integrations/assets/skills/data.md +75 -0
- package/src/integrations/assets/skills/dba.md +77 -0
- package/src/integrations/assets/skills/frontend.md +70 -0
- package/src/integrations/assets/skills/iac.md +90 -0
- package/src/integrations/assets/skills/infra.md +62 -0
- package/src/integrations/assets/skills/qa.md +63 -0
- package/src/integrations/assets/skills/security.md +74 -0
- package/src/integrations/assets/skills/tooling.md +80 -0
- package/src/integrations/assets/skills/ux.md +70 -0
- package/src/integrations/render.js +57 -6
- package/src/ship/runner.js +454 -0
- package/src/validator/index.js +321 -111
- package/src/validator/traceid.js +1 -1
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ship/runner.js — Core implementation of `trackfw ship`.
|
|
5
|
+
*
|
|
6
|
+
* All git write operations are injectable for testability.
|
|
7
|
+
* Never passes "add ." or "add -A" to any git executor.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { spawnSync } = require('child_process')
|
|
11
|
+
const fs = require('fs')
|
|
12
|
+
const path = require('path')
|
|
13
|
+
const { load: loadConfig, reset: resetConfig } = require('../config')
|
|
14
|
+
const { resolve: forgeResolve } = require('../forge/resolve')
|
|
15
|
+
const { forgeAdapter } = require('../forge/adapter')
|
|
16
|
+
|
|
17
|
+
// Git subcommands that modify local or remote state.
|
|
18
|
+
// In --dry-run mode these are printed but not executed.
|
|
19
|
+
const GIT_WRITE_COMMANDS = new Set(['commit', 'push', 'fetch'])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* isGitWriteCmd returns true when the first arg is a write-mode git subcommand.
|
|
23
|
+
* @param {string[]} args
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isGitWriteCmd(args) {
|
|
27
|
+
return args.length > 0 && GIT_WRITE_COMMANDS.has(args[0])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* isShipBranch returns true when branch matches feat|fix|refactor/<slug>.
|
|
32
|
+
* @param {string} branch
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isShipBranch(branch) {
|
|
36
|
+
return /^(feat|fix|refactor)\/.+/.test(branch)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* defaultExecGit runs git with the provided args and returns { stdout, error }.
|
|
41
|
+
* @param {string[]} args
|
|
42
|
+
* @returns {{ stdout: string, error: Error|null }}
|
|
43
|
+
*/
|
|
44
|
+
function defaultExecGit(args) {
|
|
45
|
+
const result = spawnSync('git', args, { encoding: 'utf8' })
|
|
46
|
+
if (result.status !== 0) {
|
|
47
|
+
const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
|
|
48
|
+
return { stdout: '', error: new Error(msg) }
|
|
49
|
+
}
|
|
50
|
+
return { stdout: (result.stdout || '').trim(), error: null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* defaultCheckGovernance returns violation messages from the governance gate.
|
|
55
|
+
* Uses validator equivalent: looks for wip roadmap and req link in the filesystem.
|
|
56
|
+
* Returns [] when governance passes.
|
|
57
|
+
* @returns {string[]}
|
|
58
|
+
*/
|
|
59
|
+
function defaultCheckGovernance() {
|
|
60
|
+
return checkShipGovernance()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* checkShipGovernance — hard gate (bypasses config/baseline/lenient).
|
|
65
|
+
* Checks:
|
|
66
|
+
* 1. Current branch has a matching roadmap in wip/
|
|
67
|
+
* 2. WIP roadmaps have a linked REQ
|
|
68
|
+
* @returns {string[]} violation messages
|
|
69
|
+
*/
|
|
70
|
+
function checkShipGovernance() {
|
|
71
|
+
const violations = []
|
|
72
|
+
|
|
73
|
+
// Resolve roadmap dir via config module (single source of truth; default: docs/roadmaps)
|
|
74
|
+
const roadmapDir = resolveRoadmapDir()
|
|
75
|
+
const wipDir = path.join(roadmapDir, 'wip')
|
|
76
|
+
|
|
77
|
+
// Get branch name
|
|
78
|
+
const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
|
|
79
|
+
const branch = branchResult.error ? '' : branchResult.stdout.trim()
|
|
80
|
+
|
|
81
|
+
// Check branch has matching roadmap in wip/
|
|
82
|
+
if (branch && isShipBranch(branch)) {
|
|
83
|
+
const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
|
|
84
|
+
let hasMatch = false
|
|
85
|
+
let wipFiles = []
|
|
86
|
+
|
|
87
|
+
if (fs.existsSync(wipDir)) {
|
|
88
|
+
wipFiles = fs.readdirSync(wipDir).filter(f => f.endsWith('.md'))
|
|
89
|
+
for (const f of wipFiles) {
|
|
90
|
+
if (normalizeBranchSlug(f).includes(slug)) {
|
|
91
|
+
hasMatch = true
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (wipFiles.length === 0) {
|
|
98
|
+
violations.push(
|
|
99
|
+
`branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n` +
|
|
100
|
+
' trackfw req new "<title>"\n' +
|
|
101
|
+
' trackfw roadmap new "<title>"\n' +
|
|
102
|
+
' trackfw roadmap move <name> wip'
|
|
103
|
+
)
|
|
104
|
+
} else if (!hasMatch) {
|
|
105
|
+
violations.push(
|
|
106
|
+
`branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — ` +
|
|
107
|
+
'include the branch slug in the roadmap filename'
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check WIP roadmaps have a linked REQ
|
|
112
|
+
if (hasMatch && fs.existsSync(wipDir)) {
|
|
113
|
+
for (const f of wipFiles) {
|
|
114
|
+
const content = fs.readFileSync(path.join(wipDir, f), 'utf8')
|
|
115
|
+
if (!content.includes('REQ:') && !content.includes('req:')) {
|
|
116
|
+
violations.push(`roadmap "${f}" is in wip but has no linked REQ`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return violations
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* resolveRoadmapDir delegates to config.load() — single source of truth for roadmap_dir.
|
|
127
|
+
* Accepts an optional cwd for testability (passed through to config.load).
|
|
128
|
+
* Default when no trackfw.yaml is present: docs/roadmaps.
|
|
129
|
+
* @param {string} [cwd]
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function resolveRoadmapDir(cwd) {
|
|
133
|
+
return loadConfig(cwd).roadmapDir
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* normalizeBranchSlug converts a string to a lowercase dash-only slug
|
|
138
|
+
* (same algorithm as Go's normalizeBranchSlug).
|
|
139
|
+
* @param {string} value
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function normalizeBranchSlug(value) {
|
|
143
|
+
let out = ''
|
|
144
|
+
let lastDash = false
|
|
145
|
+
for (const ch of value.toLowerCase()) {
|
|
146
|
+
if (/[a-z0-9]/.test(ch)) {
|
|
147
|
+
out += ch
|
|
148
|
+
lastDash = false
|
|
149
|
+
} else if (!lastDash) {
|
|
150
|
+
out += '-'
|
|
151
|
+
lastDash = true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out.replace(/^-|-$/g, '')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* detectPendingSquashMerges warns about remote branches with non-empty diffs vs origin/main.
|
|
159
|
+
* Non-blocking.
|
|
160
|
+
* @param {string} currentBranch
|
|
161
|
+
* @param {function} execGit
|
|
162
|
+
* @param {function} writeln
|
|
163
|
+
*/
|
|
164
|
+
function detectPendingSquashMerges(currentBranch, execGit, writeln) {
|
|
165
|
+
const { stdout, error } = execGit(['branch', '-r', '--no-merged', 'origin/main'])
|
|
166
|
+
if (error || !stdout.trim()) return
|
|
167
|
+
|
|
168
|
+
for (const raw of stdout.split('\n')) {
|
|
169
|
+
const candidate = raw.trim()
|
|
170
|
+
if (!candidate || candidate.includes('HEAD')) continue
|
|
171
|
+
const shortName = candidate.replace(/^origin\//, '')
|
|
172
|
+
if (shortName === currentBranch) continue
|
|
173
|
+
|
|
174
|
+
const { stdout: diff, error: derr } = execGit(['diff', 'origin/main', candidate, '--stat'])
|
|
175
|
+
if (derr) continue
|
|
176
|
+
if (diff.trim()) {
|
|
177
|
+
writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* buildPushArgs returns the push args, adding -u if no upstream is configured.
|
|
184
|
+
* @param {string} branch
|
|
185
|
+
* @param {function} execGit
|
|
186
|
+
* @returns {string[]}
|
|
187
|
+
*/
|
|
188
|
+
function buildPushArgs(branch, execGit) {
|
|
189
|
+
const { error } = execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
|
|
190
|
+
if (error) {
|
|
191
|
+
return ['push', '-u', 'origin', branch]
|
|
192
|
+
}
|
|
193
|
+
return ['push', 'origin', branch]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* defaultExecForgeCLI invokes a forge CLI (gh, glab, az) inheriting stdio.
|
|
198
|
+
* @param {string} name
|
|
199
|
+
* @param {string[]} args
|
|
200
|
+
* @returns {Error|null}
|
|
201
|
+
*/
|
|
202
|
+
function defaultExecForgeCLI(name, args) {
|
|
203
|
+
const result = spawnSync(name, args, { stdio: 'inherit' })
|
|
204
|
+
if (result.status !== 0) {
|
|
205
|
+
return new Error(`${name} exited with ${result.status}`)
|
|
206
|
+
}
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* firstLine returns only the first line of s.
|
|
212
|
+
* @param {string} s
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
function firstLine(s) {
|
|
216
|
+
const idx = s.indexOf('\n')
|
|
217
|
+
return idx >= 0 ? s.slice(0, idx) : s
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* buildForgeCreateArgs appends --title and --body (or --description for azure)
|
|
222
|
+
* to a copy of adapter.cliArgs. Never mutates the original array.
|
|
223
|
+
* @param {object} adapter
|
|
224
|
+
* @param {string} title
|
|
225
|
+
* @param {string} body
|
|
226
|
+
* @returns {string[]}
|
|
227
|
+
*/
|
|
228
|
+
function buildForgeCreateArgs(adapter, title, body) {
|
|
229
|
+
const args = [...adapter.cliArgs, '--title', title]
|
|
230
|
+
if (adapter.forge === 'azure') {
|
|
231
|
+
args.push('--description', body)
|
|
232
|
+
} else {
|
|
233
|
+
args.push('--body', body)
|
|
234
|
+
}
|
|
235
|
+
return args
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* runShip executes the seven-step ship sequence.
|
|
240
|
+
*
|
|
241
|
+
* @param {{ message: string, dryRun: boolean, noPR?: boolean, forge?: string }} opts
|
|
242
|
+
* @param {{ execGit?: function, checkGovernance?: function, writeln?: function,
|
|
243
|
+
* configForge?: string, repoDir?: string,
|
|
244
|
+
* availFn?: function, execForgeCLI?: function }} deps
|
|
245
|
+
* @returns {number} exit code (0 = success)
|
|
246
|
+
*/
|
|
247
|
+
function runShip(opts, deps = {}) {
|
|
248
|
+
const execGit = deps.execGit || defaultExecGit
|
|
249
|
+
const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
|
|
250
|
+
const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
|
|
251
|
+
|
|
252
|
+
// Inner git wrapper: skips write commands in dry-run mode.
|
|
253
|
+
function git(args) {
|
|
254
|
+
if (opts.dryRun && isGitWriteCmd(args)) {
|
|
255
|
+
writeln(`[dry-run] git ${args.join(' ')}`)
|
|
256
|
+
return { stdout: '', error: null }
|
|
257
|
+
}
|
|
258
|
+
return execGit(args)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Step 1: Branch validation ─────────────────────────────────────────────
|
|
262
|
+
const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
|
|
263
|
+
if (branchResult.error) {
|
|
264
|
+
writeln(`error: could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
|
|
265
|
+
return 1
|
|
266
|
+
}
|
|
267
|
+
const branch = branchResult.stdout.trim()
|
|
268
|
+
|
|
269
|
+
if (branch === 'main' || branch === 'master') {
|
|
270
|
+
writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
|
|
271
|
+
return 1
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!isShipBranch(branch)) {
|
|
275
|
+
writeln(
|
|
276
|
+
`error: branch "${branch}" does not match the required pattern feat|fix|refactor/<slug>\n` +
|
|
277
|
+
'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
|
|
278
|
+
)
|
|
279
|
+
return 1
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
writeln(`Branch: ${branch}`)
|
|
283
|
+
|
|
284
|
+
// ─── Step 2: Governance ────────────────────────────────────────────────────
|
|
285
|
+
const violations = checkGovernanceFn()
|
|
286
|
+
if (violations.length > 0) {
|
|
287
|
+
writeln('\nGovernance check failed:')
|
|
288
|
+
for (const v of violations) {
|
|
289
|
+
writeln(` ${v}`)
|
|
290
|
+
}
|
|
291
|
+
writeln('\nCreate the required artifacts before running ship:')
|
|
292
|
+
writeln(' trackfw req new "<title>"')
|
|
293
|
+
writeln(' trackfw roadmap new "<title>"')
|
|
294
|
+
writeln(' trackfw roadmap move <name> wip')
|
|
295
|
+
writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
|
|
296
|
+
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
297
|
+
writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
|
|
298
|
+
writeln("configured — ship always requires REQ + roadmap in wip/.")
|
|
299
|
+
writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
|
|
300
|
+
return 1
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
writeln('Governance: OK')
|
|
304
|
+
|
|
305
|
+
// ─── Step 3: Squash-merge detection ────────────────────────────────────────
|
|
306
|
+
if (opts.dryRun) {
|
|
307
|
+
writeln('[dry-run] git fetch origin --prune')
|
|
308
|
+
} else {
|
|
309
|
+
const { error: fetchErr } = execGit(['fetch', 'origin', '--prune'])
|
|
310
|
+
if (fetchErr) {
|
|
311
|
+
writeln('Warning: could not fetch origin (offline or no remote); skipping squash-merge check.')
|
|
312
|
+
} else {
|
|
313
|
+
detectPendingSquashMerges(branch, execGit, writeln)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── Step 4: Review staged ─────────────────────────────────────────────────
|
|
318
|
+
const { stdout: statusOut } = execGit(['status', '--short'])
|
|
319
|
+
const { stdout: diffStatOut } = execGit(['diff', '--cached', '--stat'])
|
|
320
|
+
|
|
321
|
+
writeln('\n── Staged changes ──────────────────────────────────────')
|
|
322
|
+
if (statusOut) writeln(statusOut)
|
|
323
|
+
if (diffStatOut) writeln(diffStatOut)
|
|
324
|
+
writeln('────────────────────────────────────────────────────────\n')
|
|
325
|
+
|
|
326
|
+
const { stdout: cachedFiles } = execGit(['diff', '--cached', '--name-only'])
|
|
327
|
+
if (!cachedFiles.trim()) {
|
|
328
|
+
writeln(
|
|
329
|
+
'error: nothing is staged — stage your files explicitly before running ship:\n' +
|
|
330
|
+
' git add <file1> <file2> ...\n' +
|
|
331
|
+
"Never use 'git add .' or 'git add -A'"
|
|
332
|
+
)
|
|
333
|
+
return 1
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── Step 5: Commit ────────────────────────────────────────────────────────
|
|
337
|
+
if (!opts.message) {
|
|
338
|
+
writeln(
|
|
339
|
+
'error: commit message is required — use -m:\n' +
|
|
340
|
+
' trackfw ship -m "feat(<scope>): <description>"'
|
|
341
|
+
)
|
|
342
|
+
return 1
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const { error: commitErr } = git(['commit', '-m', opts.message])
|
|
346
|
+
if (commitErr) {
|
|
347
|
+
writeln(`error: git commit failed: ${commitErr.message}`)
|
|
348
|
+
return 1
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!opts.dryRun) {
|
|
352
|
+
writeln(`Committed: ${opts.message}`)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── Step 6: Push ──────────────────────────────────────────────────────────
|
|
356
|
+
const pushArgs = buildPushArgs(branch, execGit)
|
|
357
|
+
const { error: pushErr } = git(pushArgs)
|
|
358
|
+
if (pushErr) {
|
|
359
|
+
writeln(`error: git push failed: ${pushErr.message}`)
|
|
360
|
+
return 1
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!opts.dryRun) {
|
|
364
|
+
writeln(`Pushed: ${branch} → origin/${branch}`)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ─── Step 7: Open PR/MR ────────────────────────────────────────────────────
|
|
368
|
+
const { stdout: remoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
|
|
369
|
+
const remoteURL = (remoteURLRaw || '').trim()
|
|
370
|
+
|
|
371
|
+
let resolution
|
|
372
|
+
try {
|
|
373
|
+
resolution = forgeResolve({
|
|
374
|
+
flagForge: opts.forge || '',
|
|
375
|
+
configForge: deps.configForge || '',
|
|
376
|
+
remoteURL,
|
|
377
|
+
repoDir: deps.repoDir || '',
|
|
378
|
+
})
|
|
379
|
+
} catch (resErr) {
|
|
380
|
+
writeln(`Warning: forge resolution error: ${resErr.message} — open PR/MR manually.`)
|
|
381
|
+
writeln('\nship complete.')
|
|
382
|
+
return 0
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
|
|
386
|
+
writeln(`Forge: ${resolution.forge} (source: ${resolution.source})`)
|
|
387
|
+
|
|
388
|
+
if (opts.noPR) {
|
|
389
|
+
writeln(`--no-pr: skipping ${adapter.noun} creation.`)
|
|
390
|
+
writeln('\nship complete.')
|
|
391
|
+
return 0
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (opts.dryRun) {
|
|
395
|
+
if (!adapter.available && resolution.forge !== 'manual') {
|
|
396
|
+
const url = adapter.fallbackURL(remoteURL, branch)
|
|
397
|
+
if (url) {
|
|
398
|
+
writeln(`[dry-run] ${adapter.noun} CLI (${adapter.cliName}) not available — would open in browser:\n ${url}`)
|
|
399
|
+
} else {
|
|
400
|
+
writeln(`[dry-run] ${adapter.noun} CLI (${adapter.cliName}) not available — would open ${adapter.noun} manually`)
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
writeln(`[dry-run] would open ${adapter.noun} via ${resolution.forge} CLI`)
|
|
404
|
+
}
|
|
405
|
+
return 0
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (resolution.forge === 'manual') {
|
|
409
|
+
writeln(`\nOpen your ${adapter.noun} manually at:\n ${remoteURL}`)
|
|
410
|
+
writeln('\nship complete.')
|
|
411
|
+
return 0
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!adapter.available) {
|
|
415
|
+
const url = adapter.fallbackURL(remoteURL, branch)
|
|
416
|
+
if (url) {
|
|
417
|
+
writeln(`${adapter.noun} CLI (${adapter.cliName}) not available — open in browser:\n ${url}`)
|
|
418
|
+
} else {
|
|
419
|
+
writeln(`${adapter.noun} CLI (${adapter.cliName}) not available — open ${adapter.noun} manually.`)
|
|
420
|
+
}
|
|
421
|
+
writeln('\nship complete.')
|
|
422
|
+
return 0
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// CLI is available — invoke it.
|
|
426
|
+
const title = firstLine(opts.message || '')
|
|
427
|
+
const body = `Branch: ${branch}\n\nCreated by trackfw ship.`
|
|
428
|
+
const cliArgs = buildForgeCreateArgs(adapter, title, body)
|
|
429
|
+
const execForgeCLI = deps.execForgeCLI || defaultExecForgeCLI
|
|
430
|
+
const cliErr = execForgeCLI(adapter.cliName, cliArgs)
|
|
431
|
+
if (cliErr) {
|
|
432
|
+
const url = adapter.fallbackURL(remoteURL, branch)
|
|
433
|
+
writeln(`Warning: ${adapter.noun} CLI failed (${cliErr.message}).`)
|
|
434
|
+
if (url) writeln(`Open in browser:\n ${url}`)
|
|
435
|
+
} else {
|
|
436
|
+
writeln(`${adapter.noun} created.`)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
writeln('\nship complete.')
|
|
440
|
+
return 0
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
module.exports = {
|
|
444
|
+
runShip,
|
|
445
|
+
isShipBranch,
|
|
446
|
+
isGitWriteCmd,
|
|
447
|
+
normalizeBranchSlug,
|
|
448
|
+
checkShipGovernance,
|
|
449
|
+
resolveRoadmapDir,
|
|
450
|
+
resetConfig,
|
|
451
|
+
GIT_WRITE_COMMANDS,
|
|
452
|
+
buildForgeCreateArgs,
|
|
453
|
+
firstLine,
|
|
454
|
+
}
|