trackfw 3.0.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.
Files changed (52) hide show
  1. package/README.md +9 -2
  2. package/package.json +1 -1
  3. package/src/commands/discover.js +18 -0
  4. package/src/commands/index.js +2 -0
  5. package/src/commands/init.js +40 -2
  6. package/src/commands/log.js +2 -1
  7. package/src/commands/note.js +24 -0
  8. package/src/commands/req.js +7 -1
  9. package/src/commands/roadmap.js +4 -3
  10. package/src/commands/ship.js +49 -0
  11. package/src/config/index.js +5 -2
  12. package/src/forge/adapter.js +189 -0
  13. package/src/forge/resolve.js +191 -0
  14. package/src/generators/adr.js +13 -4
  15. package/src/generators/date.js +23 -0
  16. package/src/generators/init.js +105 -3
  17. package/src/generators/note.js +154 -0
  18. package/src/generators/req.js +88 -5
  19. package/src/generators/roadmap.js +96 -20
  20. package/src/i18n/locales/en-US.json +2 -1
  21. package/src/i18n/locales/es-ES.json +2 -1
  22. package/src/i18n/locales/pt-BR.json +2 -1
  23. package/src/identity/preset.js +22 -0
  24. package/src/integrations/assets/agents/architect.md +32 -0
  25. package/src/integrations/assets/agents/backend.md +32 -0
  26. package/src/integrations/assets/agents/code-quality.md +29 -0
  27. package/src/integrations/assets/agents/data.md +32 -0
  28. package/src/integrations/assets/agents/dba.md +32 -0
  29. package/src/integrations/assets/agents/frontend.md +32 -0
  30. package/src/integrations/assets/agents/iac.md +46 -0
  31. package/src/integrations/assets/agents/infra.md +34 -0
  32. package/src/integrations/assets/agents/qa.md +32 -0
  33. package/src/integrations/assets/agents/security.md +29 -0
  34. package/src/integrations/assets/agents/tooling.md +47 -0
  35. package/src/integrations/assets/agents/ux.md +29 -0
  36. package/src/integrations/assets/catalog.json +16 -2
  37. package/src/integrations/assets/skills/architecture.md +66 -0
  38. package/src/integrations/assets/skills/backend.md +67 -0
  39. package/src/integrations/assets/skills/code-quality.md +78 -0
  40. package/src/integrations/assets/skills/data.md +75 -0
  41. package/src/integrations/assets/skills/dba.md +77 -0
  42. package/src/integrations/assets/skills/frontend.md +70 -0
  43. package/src/integrations/assets/skills/iac.md +90 -0
  44. package/src/integrations/assets/skills/infra.md +62 -0
  45. package/src/integrations/assets/skills/qa.md +63 -0
  46. package/src/integrations/assets/skills/security.md +74 -0
  47. package/src/integrations/assets/skills/tooling.md +80 -0
  48. package/src/integrations/assets/skills/ux.md +70 -0
  49. package/src/integrations/render.js +57 -6
  50. package/src/ship/runner.js +454 -0
  51. package/src/validator/index.js +321 -111
  52. package/src/validator/traceid.js +1 -1
package/README.md CHANGED
@@ -128,6 +128,10 @@ trackfw validate
128
128
 
129
129
  # 6. See what is in flight
130
130
  trackfw status
131
+
132
+ # 7. Governed commit + push + open PR/MR (feat/fix/refactor branches only)
133
+ git add -p # stage your changes explicitly
134
+ trackfw ship -m "feat(auth): add login flow"
131
135
  ```
132
136
 
133
137
  ---
@@ -146,6 +150,7 @@ trackfw status
146
150
  | `trackfw roadmap move <name> <state>` | Move roadmap between states |
147
151
  | `trackfw roadmap list` | List all roadmaps grouped by state |
148
152
  | `trackfw validate` | Check governance consistency (use as CI gate) |
153
+ | `trackfw ship -m "msg"` | Governed `git commit + push + open PR/MR` — enforces branch pattern and governance gate; resolves forge (GitHub/GitLab/Bitbucket/Azure) automatically or via `--forge`; falls back to a browser URL when the forge CLI is absent |
149
154
  | `trackfw context` | Print a structured summary of the project's governance state (REQs, Roadmaps, ADRs with counts and statuses) |
150
155
  | `trackfw serve` | Start a local governance dashboard (no cloud, no accounts) |
151
156
  | `trackfw status` | Show wip, blocked, REQs waiting on ADRs |
@@ -372,14 +377,16 @@ operation could delete artifacts from the user's home directory. `list` never
372
377
  prompts and always assumes `global` unless `--scope` is given, so it reports
373
378
  the same destinations `install` actually wrote to.
374
379
 
375
- The 10 roles installed for each tool: **architect · backend · frontend · qa · infra · security · code-quality · dba · ux · data**
380
+ The **12 roles** installed for each tool: **architect · backend · frontend · qa · infra · security · code-quality · dba · ux · data · iac · tooling**
381
+
382
+ The **17 skills** cover governance process (governance, implement, plan, release, review) and technical specialties (backend-skill, code-quality-skill, data-skill, dba-skill, frontend-skill, iac-skill, infra-skill, qa-skill, security-skill, tooling-skill, ux-skill, vault-skill).
376
383
 
377
384
  ---
378
385
 
379
386
  ## Agent identity — give your agents a name
380
387
 
381
388
  By default the agents are functional and impersonal: `trackfw-architect`,
382
- `trackfw-backend`, … Agent identity lets you name all ten, pick how they
389
+ `trackfw-backend`, … Agent identity lets you name all twelve, pick how they
383
390
  address you, and call them by name.
384
391
 
385
392
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, and Amazon Q.",
5
5
  "keywords": [
6
6
  "cli",
@@ -4,6 +4,7 @@ const { Command } = require('commander');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { execSync } = require('child_process');
7
+ const { resolve: resolveForge } = require('../forge/resolve');
7
8
 
8
9
  function scan(rootDir) {
9
10
  const r = {
@@ -20,6 +21,7 @@ function scan(rootDir) {
20
21
  governanceScore: 0,
21
22
  hookFramework: 'none',
22
23
  ciSystem: 'none',
24
+ forge: '',
23
25
  };
24
26
 
25
27
  // trackfw.yaml
@@ -108,6 +110,18 @@ function scan(rootDir) {
108
110
  r.ciSystem = 'none';
109
111
  }
110
112
 
113
+ // Forge detection — reuse forge/resolve.js (no duplicate parse).
114
+ // gitRemoteURL returns '' on any error; CI detection is filesystem-based.
115
+ try {
116
+ const { resolveFromRepo } = require('../forge/resolve');
117
+ const res = resolveFromRepo('', '', rootDir);
118
+ if (res.source !== 'none') {
119
+ r.forge = res.forge;
120
+ }
121
+ } catch (_) {
122
+ // forge detection is best-effort; never block scan on it
123
+ }
124
+
111
125
  r.governanceScore = calcScore(r);
112
126
  return r;
113
127
  }
@@ -146,6 +160,10 @@ function generateYAML(r) {
146
160
  out += `hooks: ${r.hookFramework}\n`;
147
161
  out += `ci: ${r.ciSystem}\n`;
148
162
 
163
+ if (r.forge) {
164
+ out += `forge: ${r.forge}\n`;
165
+ }
166
+
149
167
  return out;
150
168
  }
151
169
 
@@ -29,6 +29,8 @@ function createProgram() {
29
29
  program.addCommand(require('./version'))
30
30
  program.addCommand(require('./agents'))
31
31
  program.addCommand(require('./skills'))
32
+ program.addCommand(require('./note'))
33
+ program.addCommand(require('./ship'))
32
34
 
33
35
  const { createServeCommand } = require('./serve')
34
36
  program.addCommand(createServeCommand())
@@ -10,6 +10,7 @@ const cmd = new Command('init')
10
10
  cmd.description(t('init.description'))
11
11
  cmd.option('--ai-tools <tools>', 'Comma-separated AI tools to configure (claude,codex,gemini,antigravity,cursor,copilot,windsurf,amazonq,kiro)', '')
12
12
  cmd.option('--identity-preset <preset>', `Agent identity preset: none, neutral, ${identityStore.presetNames().join(', ')}`, 'none')
13
+ cmd.option('--forge <value>', 'Forge platform: github, gitlab, bitbucket, azure', '')
13
14
  cmd.action(async (options, command) => {
14
15
  const os = require('os')
15
16
  const path = require('path')
@@ -29,6 +30,14 @@ cmd.action(async (options, command) => {
29
30
  }
30
31
  }
31
32
 
33
+ // --forge: validate unconditionally above the non-TTY early return so that
34
+ // an invalid value fails loudly in CI instead of silently no-op'ing.
35
+ const forgeChanged = command.getOptionValueSource('forge') === 'cli'
36
+ if (forgeChanged && options.forge) {
37
+ const { validateForge } = require('../forge/resolve')
38
+ validateForge(options.forge) // throws on invalid value
39
+ }
40
+
32
41
  // Pula o wizard de identidade inteiramente quando a flag foi passada
33
42
  // explicitamente (já tratado acima) ou quando um arquivo de identidade já
34
43
  // existe — re-executar init nunca deve sobrescrever silenciosamente uma
@@ -45,6 +54,7 @@ cmd.action(async (options, command) => {
45
54
  pkgManager: 'npm',
46
55
  hooks: 'none',
47
56
  ci: 'none',
57
+ forge: forgeChanged ? options.forge : '',
48
58
  }
49
59
  await generators.scaffold(cfg)
50
60
  const aiTools = String(options.aiTools || '').split(',').map(tool => tool.trim()).filter(Boolean)
@@ -62,7 +72,17 @@ cmd.action(async (options, command) => {
62
72
 
63
73
  const { input, select, checkbox } = require('@inquirer/prompts')
64
74
 
65
- let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, aiTools, requireReqInCommit, scope
75
+ // Detect forge from the current dir to prefill the wizard default.
76
+ const { resolveFromRepo: resolveForgeFromRepo } = require('../forge/resolve')
77
+ let detectedForge = ''
78
+ if (!forgeChanged) {
79
+ try {
80
+ const res = resolveForgeFromRepo('', '', process.cwd())
81
+ if (res.source !== 'none') detectedForge = res.forge
82
+ } catch (_) {}
83
+ }
84
+
85
+ let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, forgeValue, aiTools, requireReqInCommit, scope
66
86
 
67
87
  try {
68
88
  projectName = await input({
@@ -163,6 +183,22 @@ cmd.action(async (options, command) => {
163
183
  ],
164
184
  })
165
185
 
186
+ if (!forgeChanged) {
187
+ forgeValue = await select({
188
+ message: t('init.prompt.forge'),
189
+ default: detectedForge,
190
+ choices: [
191
+ { name: 'Auto-detect (omit key)', value: '' },
192
+ { name: 'GitHub', value: 'github' },
193
+ { name: 'GitLab', value: 'gitlab' },
194
+ { name: 'Bitbucket', value: 'bitbucket' },
195
+ { name: 'Azure DevOps', value: 'azure' },
196
+ ],
197
+ })
198
+ } else {
199
+ forgeValue = options.forge
200
+ }
201
+
166
202
  requireReqInCommit = false
167
203
  if (hooks !== 'none') {
168
204
  const { confirm: confirmPrompt } = require('@inquirer/prompts')
@@ -202,6 +238,7 @@ cmd.action(async (options, command) => {
202
238
  pkgManager: 'npm',
203
239
  hooks: 'none',
204
240
  ci: 'none',
241
+ forge: forgeChanged ? options.forge : '',
205
242
  }
206
243
  await generators.scaffold(cfg)
207
244
  console.log(`\n${t('init.success')}`)
@@ -229,6 +266,7 @@ cmd.action(async (options, command) => {
229
266
  pkgManager: 'npm',
230
267
  hooks: 'none',
231
268
  ci: 'none',
269
+ forge: forgeChanged ? options.forge : '',
232
270
  })
233
271
  console.log(`\n${t('init.success')}`)
234
272
  require('../generators/init').printArchitectNextSteps(process.cwd())
@@ -236,7 +274,7 @@ cmd.action(async (options, command) => {
236
274
  }
237
275
  }
238
276
 
239
- const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci, requireReqInCommit }
277
+ const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci, forge: forgeValue || '', requireReqInCommit }
240
278
  await generators.scaffold(cfg)
241
279
 
242
280
  for (const tool of (aiTools || [])) await generators.installIntegrationTarget(tool, process.cwd(), scope)
@@ -3,13 +3,14 @@ const { Command } = require('commander')
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const { t } = require('../i18n')
6
+ const config = require('../config')
6
7
 
7
8
  const cmd = new Command('log')
8
9
  cmd.description(t('log.description'))
9
10
  cmd.option('--tail <n>', t('log.tail'), '20')
10
11
  cmd.action(async (opts) => {
11
12
  const tail = parseInt(opts.tail, 10)
12
- const logPath = path.join('docs', 'roadmaps', '.trackfw-log')
13
+ const logPath = path.join(config.load().roadmapDir, '.trackfw-log')
13
14
 
14
15
  if (!fs.existsSync(logPath)) {
15
16
  console.log(t('log.empty'))
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { newNote } = require('../generators/note')
5
+
6
+ function createNoteCommand() {
7
+ const note = new Command('note').description('Manage vault knowledge notes')
8
+
9
+ note
10
+ .command('new <title>')
11
+ .description('Create a new knowledge note in vault/notes/')
12
+ .action((title) => {
13
+ try {
14
+ newNote(title)
15
+ } catch (err) {
16
+ console.error(`error: ${err.message}`)
17
+ process.exit(1)
18
+ }
19
+ })
20
+
21
+ return note
22
+ }
23
+
24
+ module.exports = createNoteCommand()
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const { Command } = require('commander')
3
- const { listREQs } = require('../generators/req')
3
+ const { listREQs, moveREQ } = require('../generators/req')
4
4
  const { t } = require('../i18n')
5
5
 
6
6
  const cmd = new Command('req')
@@ -67,4 +67,10 @@ cmd.command('list')
67
67
  listREQs(require('../config').load().reqDir)
68
68
  })
69
69
 
70
+ cmd.command('move <name> <status>')
71
+ .description('Update a REQ status in place')
72
+ .action(async (name, status) => {
73
+ moveREQ(name, status)
74
+ })
75
+
70
76
  module.exports = cmd
@@ -8,15 +8,16 @@ cmd.description(t('roadmap.description'))
8
8
 
9
9
  cmd.command('new')
10
10
  .description(t('roadmap.new.description'))
11
- .option('-t, --title <title>', 'Roadmap title')
11
+ .argument('[title]', 'Roadmap title')
12
+ .option('-t, --title <title>', 'Roadmap title (alias)')
12
13
  .option('-r, --req <path>', 'Path to the linked REQ')
13
14
  .option('--from-req <path>', 'Generate roadmap with ML stubs from REQ acceptance criteria')
14
- .action(async (opts) => {
15
+ .action(async (titleArg, opts) => {
15
16
  if (opts.fromReq) {
16
17
  newRoadmapFromReq(opts.fromReq)
17
18
  return
18
19
  }
19
- const title = opts.title || 'New Roadmap'
20
+ const title = titleArg || opts.title || 'New Roadmap'
20
21
  const reqPath = opts.req || ''
21
22
  newRoadmap(title, reqPath)
22
23
  })
@@ -0,0 +1,49 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { runShip } = require('../ship/runner')
5
+ const config = require('../config')
6
+
7
+ function createShipCommand() {
8
+ const cmd = new Command('ship')
9
+ cmd
10
+ .description(
11
+ 'trackfw ship runs a governed delivery sequence:\n\n' +
12
+ ' 1. Validates branch name — must match feat|fix|refactor/<slug>\n' +
13
+ ' 2. Validates governance — REQ + roadmap in wip/ must exist\n' +
14
+ ' (hard gate: not affected by lenient mode or per-rule severity)\n' +
15
+ ' 3. Detects pending squash-merges in other branches (advisory only)\n' +
16
+ ' 4. Reviews what is staged (git status --short + git diff --cached --stat)\n' +
17
+ ' 5. Commits with Conventional Commits format (-m is required)\n' +
18
+ ' 6. Pushes to origin (adds -u if no upstream is configured yet)\n' +
19
+ ' 7. Opens PR/MR via the resolved forge CLI (or prints URL if CLI is absent)\n\n' +
20
+ "Stage your files explicitly before running ship.\n" +
21
+ "This command never executes 'git add .' or 'git add -A'."
22
+ )
23
+ .option('-m, --message <msg>', 'Commit message (Conventional Commits format required)')
24
+ .option('--dry-run', 'Print what would be done without executing write commands', false)
25
+ .option('--no-pr', 'Skip PR/MR creation after push')
26
+ .option('--forge <forge>', 'Override forge detection (github, gitlab, bitbucket, azure)', '')
27
+ .action((options) => {
28
+ const cfg = config.load()
29
+ const exitCode = runShip(
30
+ {
31
+ message: options.message || '',
32
+ dryRun: options.dryRun || false,
33
+ noPR: options.pr === false,
34
+ forge: options.forge || '',
35
+ },
36
+ {
37
+ configForge: cfg.forge || '',
38
+ repoDir: '.',
39
+ }
40
+ )
41
+ if (exitCode !== 0) {
42
+ process.exit(exitCode)
43
+ }
44
+ })
45
+
46
+ return cmd
47
+ }
48
+
49
+ module.exports = createShipCommand()
@@ -26,9 +26,11 @@ function defaults() {
26
26
  lenientUntil: '',
27
27
  wipLimit: 1,
28
28
  wipBySquad: false,
29
+ staleWipDays: 7,
29
30
  requireReqInCommit: false,
30
31
  strictCiPaths: false,
31
32
  traceIdField: '',
33
+ forge: '',
32
34
  // NOVOS campos:
33
35
  linkFields: {
34
36
  req: ['REQ:'],
@@ -42,7 +44,7 @@ function defaults() {
42
44
  wip_limit: 'error',
43
45
  stale_wip: 'warning',
44
46
  adr_orphan: 'warning',
45
- ref_targets_exist: 'warning',
47
+ ref_targets_exist: 'error',
46
48
  folder_status: 'warning',
47
49
  filename_uniqueness: 'error',
48
50
  blocked_by_draft_adr: 'error',
@@ -191,9 +193,11 @@ function parse(content, cfg) {
191
193
  case 'lenient_until': cfg.lenientUntil = val; break;
192
194
  case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
193
195
  case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
196
+ case 'stale_wip_days': { const n = parseInt(val, 10); if (n > 0) cfg.staleWipDays = n; break; }
194
197
  case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
195
198
  case 'strict_ci_paths': cfg.strictCiPaths = val === 'true'; break;
196
199
  case 'trace_id_field': cfg.traceIdField = val.replace(/^["']|["']$/g, ''); break;
200
+ case 'forge': cfg.forge = val.replace(/^["']|["']$/g, ''); break;
197
201
  case 'link_fields': inLinkFields = true; break;
198
202
  case 'acceptance_markers': inAcceptanceMarkers = true; acceptanceMarkers = []; break;
199
203
  case 'rules': inRules = true; rules = {}; break;
@@ -208,4 +212,3 @@ const NAMESPACING_FLAT = 'flat';
208
212
  const NAMESPACING_BY_AGENT = 'by_agent';
209
213
 
210
214
  module.exports = { load, reset, defaults, expandPath, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
211
-
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Verificação de disponibilidade de CLI — scan puro no PATH sem subprocess
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Verifica se um executável está disponível no PATH, respeitando
12
+ * TRACKFW_DISABLE_EXTERNAL_COMMANDS (mesmo padrão de discover.go).
13
+ * @param {string} name
14
+ * @returns {boolean}
15
+ */
16
+ function _defaultAvailFn(name) {
17
+ if (!name) return false;
18
+ if (process.env.TRACKFW_DISABLE_EXTERNAL_COMMANDS === '1') return false;
19
+
20
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
21
+ const isWindows = process.platform === 'win32';
22
+ const exts = isWindows
23
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
24
+ : [''];
25
+
26
+ for (const dir of dirs) {
27
+ for (const ext of exts) {
28
+ try {
29
+ const candidate = path.join(dir, name + ext);
30
+ fs.accessSync(candidate, fs.constants.F_OK | fs.constants.X_OK);
31
+ return true;
32
+ } catch (_) {
33
+ // continua
34
+ }
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Conversão de remote URL → base HTTPS
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Converte qualquer formato de remote git (git@, ssh://, https://, http://)
46
+ * para URL base HTTPS sem .git.
47
+ *
48
+ * Normalização especial para Azure SSH:
49
+ * git@ssh.dev.azure.com:v3/org/project/repo → https://dev.azure.com/org/project/_git/repo
50
+ *
51
+ * @param {string} rawURL
52
+ * @param {string} forge
53
+ * @returns {string}
54
+ */
55
+ function remoteHTTPSBase(rawURL, forge) {
56
+ if (!rawURL) return '';
57
+ rawURL = rawURL.trim();
58
+ if (!rawURL) return '';
59
+
60
+ let host = '';
61
+ let pathStr = '';
62
+
63
+ if (rawURL.startsWith('git@')) {
64
+ // git@host:path
65
+ const rest = rawURL.slice(4);
66
+ const colonIdx = rest.indexOf(':');
67
+ if (colonIdx < 0) return '';
68
+ host = rest.slice(0, colonIdx).toLowerCase();
69
+ pathStr = rest.slice(colonIdx + 1);
70
+
71
+ } else if (rawURL.startsWith('ssh://')) {
72
+ // ssh://[user@]host/path
73
+ let rest = rawURL.slice(6);
74
+ const atIdx = rest.indexOf('@');
75
+ if (atIdx >= 0) rest = rest.slice(atIdx + 1);
76
+ const slashIdx = rest.indexOf('/');
77
+ if (slashIdx < 0) {
78
+ host = rest.toLowerCase();
79
+ } else {
80
+ host = rest.slice(0, slashIdx).toLowerCase();
81
+ pathStr = rest.slice(slashIdx + 1);
82
+ }
83
+
84
+ } else if (rawURL.startsWith('https://') || rawURL.startsWith('http://')) {
85
+ let rest = rawURL.replace(/^https?:\/\//, '');
86
+ const atIdx = rest.indexOf('@');
87
+ if (atIdx >= 0) rest = rest.slice(atIdx + 1);
88
+ const slashIdx = rest.indexOf('/');
89
+ if (slashIdx < 0) {
90
+ host = rest.toLowerCase();
91
+ } else {
92
+ host = rest.slice(0, slashIdx).toLowerCase();
93
+ pathStr = rest.slice(slashIdx + 1);
94
+ }
95
+
96
+ } else {
97
+ return '';
98
+ }
99
+
100
+ pathStr = pathStr.replace(/\.git$/, '').replace(/^\/|\/$/g, '');
101
+
102
+ // Normalização Azure SSH
103
+ if (forge === 'azure' && host === 'ssh.dev.azure.com') {
104
+ host = 'dev.azure.com';
105
+ pathStr = pathStr.replace(/^v3\//, '');
106
+ const parts = pathStr.split('/');
107
+ if (parts.length === 3) {
108
+ pathStr = `${parts[0]}/${parts[1]}/_git/${parts[2]}`;
109
+ }
110
+ }
111
+
112
+ if (!pathStr) return `https://${host}`;
113
+ return `https://${host}/${pathStr}`;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Adapter
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * @typedef {Object} Adapter
122
+ * @property {string} forge
123
+ * @property {string} noun - "Pull Request" ou "Merge Request"
124
+ * @property {string} cliName - nome do executável (vazio = sem CLI)
125
+ * @property {string[]} cliArgs - argumentos para criar PR/MR
126
+ * @property {boolean} available - false quando CLI ausente
127
+ * @property {function(string, string): string} fallbackURL
128
+ */
129
+
130
+ /**
131
+ * Retorna o Adapter para o forge informado.
132
+ *
133
+ * @param {string} forge
134
+ * @param {function(string): boolean} [availFn] - injeta verificação de CLI (para testes)
135
+ * @returns {Adapter}
136
+ */
137
+ function forgeAdapter(forge, availFn) {
138
+ if (typeof availFn !== 'function') {
139
+ availFn = _defaultAvailFn;
140
+ }
141
+
142
+ let noun = 'Pull Request';
143
+ let cliName = '';
144
+ let cliArgs = [];
145
+ let available = false;
146
+
147
+ switch (forge) {
148
+ case 'github':
149
+ cliName = 'gh';
150
+ cliArgs = ['pr', 'create'];
151
+ available = availFn('gh');
152
+ break;
153
+ case 'gitlab':
154
+ noun = 'Merge Request';
155
+ cliName = 'glab';
156
+ cliArgs = ['mr', 'create'];
157
+ available = availFn('glab');
158
+ break;
159
+ case 'azure':
160
+ cliName = 'az';
161
+ cliArgs = ['repos', 'pr', 'create'];
162
+ available = availFn('az');
163
+ break;
164
+ case 'bitbucket':
165
+ // Bitbucket não possui CLI — nunca chama availFn
166
+ break;
167
+ default:
168
+ // forge desconhecido: sem CLI, sem disponibilidade
169
+ break;
170
+ }
171
+
172
+ const adapter = { forge, noun, cliName, cliArgs, available };
173
+
174
+ adapter.fallbackURL = function (remoteURL, branch) {
175
+ const base = remoteHTTPSBase(remoteURL, forge);
176
+ if (!base) return '';
177
+ switch (forge) {
178
+ case 'github': return `${base}/compare/${branch}?expand=1`;
179
+ case 'gitlab': return `${base}/-/merge_requests/new?merge_request[source_branch]=${branch}`;
180
+ case 'bitbucket': return `${base}/pull-requests/new?source=${branch}`;
181
+ case 'azure': return `${base}/pullrequestcreate?sourceRef=${branch}`;
182
+ default: return '';
183
+ }
184
+ };
185
+
186
+ return adapter;
187
+ }
188
+
189
+ module.exports = { forgeAdapter, remoteHTTPSBase };