trackfw 3.0.0 → 4.0.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 +94 -3
- package/package.json +1 -1
- package/src/commands/barrier.js +358 -0
- package/src/commands/discover.js +18 -0
- package/src/commands/help.js +99 -9
- package/src/commands/index.js +3 -0
- package/src/commands/init.js +40 -2
- 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/commands/update-harness.js +208 -0
- package/src/commands/update.js +206 -72
- 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 +229 -39
- 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 +35 -0
- package/src/integrations/assets/agents/backend.md +32 -0
- package/src/integrations/assets/agents/code-quality.md +32 -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 +32 -0
- package/src/integrations/assets/agents/tooling.md +47 -0
- package/src/integrations/assets/agents/ux.md +32 -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/catalog.js +25 -1
- package/src/integrations/index.js +20 -2
- package/src/integrations/render.js +57 -6
- package/src/lib/update-engine.js +217 -0
- package/src/ship/runner.js +454 -0
- package/src/validator/index.js +323 -111
- package/src/validator/traceid.js +1 -1
package/src/commands/update.js
CHANGED
|
@@ -5,6 +5,12 @@ const fs = require('fs');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const identityStore = require('../identity');
|
|
8
|
+
const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine');
|
|
9
|
+
|
|
10
|
+
// `trackfw update` is project-scoped only — see docs/cli-parity.md,
|
|
11
|
+
// "`trackfw update` vs `trackfw update harness`". It must NEVER touch global
|
|
12
|
+
// state (~/.claude, ~/.codex, etc.). Global artifacts moved to
|
|
13
|
+
// `trackfw update harness` (update-harness.js).
|
|
8
14
|
|
|
9
15
|
function readUpdateConfig(rootDir) {
|
|
10
16
|
const yaml = path.join(rootDir, 'trackfw.yaml');
|
|
@@ -50,99 +56,227 @@ function updateHooksSurgical(cfg, rootDir) {
|
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// codexProjectAgentsTarget — installs/reports the project-scoped Codex
|
|
60
|
+
// agents/skills bundle (catalog-based). Uses IntegrationManager.inspect,
|
|
61
|
+
// which is read-only, to compute state under --dry-run too; only the
|
|
62
|
+
// mutating manager.update() call is skipped when dryRun is true. Kept
|
|
63
|
+
// separate from runFileTarget because IntegrationManager already owns a
|
|
64
|
+
// correct, manifest-aware not-installed/current/outdated/modified state
|
|
65
|
+
// machine — re-deriving it via directory hashing would risk diverging from
|
|
66
|
+
// that source of truth (and would need the .trackfw manifest copied into
|
|
67
|
+
// the simulation, which is unnecessary complexity here).
|
|
68
|
+
function codexProjectAgentsTarget(cwd, identityConfig, { dryRun, installMissing }) {
|
|
69
|
+
const id = 'codex-project-agents';
|
|
70
|
+
const displayPath = '.codex/agents, .agents/skills';
|
|
71
|
+
const detected = fs.existsSync(path.join(cwd, 'AGENTS.md')) || fs.existsSync(path.join(cwd, '.codex'));
|
|
72
|
+
if (!detected) return { id, state: 'missing', path: displayPath };
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const { buildPlans, IntegrationManager } = require('../integrations');
|
|
76
|
+
const manager = new IntegrationManager({ projectRoot: cwd });
|
|
77
|
+
let wroteAny = false;
|
|
78
|
+
for (const kind of ['agents', 'skills']) {
|
|
79
|
+
const plans = buildPlans(kind, { targets: ['codex'], scope: 'project', identity: identityConfig });
|
|
80
|
+
const statuses = manager.inspect(plans);
|
|
81
|
+
const toWrite = plans.filter((_, index) => {
|
|
82
|
+
const state = statuses[index].state;
|
|
83
|
+
return state === 'outdated' || (installMissing && state === 'not-installed');
|
|
84
|
+
});
|
|
85
|
+
if (toWrite.length) {
|
|
86
|
+
wroteAny = true;
|
|
87
|
+
if (!dryRun) manager.update(toWrite);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { id, state: wroteAny ? 'updated' : 'skipped', path: displayPath };
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return { id, state: 'failed', path: displayPath, message: e.message };
|
|
61
93
|
}
|
|
94
|
+
}
|
|
62
95
|
|
|
63
|
-
|
|
96
|
+
// PROJECT_TARGET_IDS — the fixed declared order for `trackfw update`
|
|
97
|
+
// targets. `ci-workflow` and `git-hooks` only appear when the project's
|
|
98
|
+
// trackfw.yaml opted into a CI system / hook framework — see ambiguity
|
|
99
|
+
// note in the ML-6C handoff report about config-conditional target lists.
|
|
100
|
+
const PROJECT_TARGET_IDS = [
|
|
101
|
+
'agent-rules',
|
|
102
|
+
'agent-hooks',
|
|
103
|
+
'codex-project-agents',
|
|
104
|
+
'validate-script',
|
|
105
|
+
'ci-workflow',
|
|
106
|
+
'git-hooks',
|
|
107
|
+
'claude-commands',
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
// buildProjectTargets — `wanted` (nullable) restricts which targets are
|
|
111
|
+
// even computed/applied. This must be enforced HERE, before any apply()
|
|
112
|
+
// runs, not as a post-hoc filter on the returned array: every target's
|
|
113
|
+
// apply() is a real filesystem side effect (outside --dry-run), so
|
|
114
|
+
// filtering afterwards would still have written every unrequested target.
|
|
115
|
+
function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted) {
|
|
64
116
|
const generators = require('../generators/init');
|
|
65
117
|
const discover = require('./discover');
|
|
118
|
+
const hooksGen = require('../generators/hooks');
|
|
119
|
+
const include = (id) => !wanted || wanted.includes(id);
|
|
66
120
|
|
|
67
|
-
|
|
68
|
-
// um identity.json corrompido deve abortar o comando inteiro, nunca cair
|
|
69
|
-
// silenciosamente para os nomes neutros default.
|
|
70
|
-
const identityConfig = identityStore.load(os.homedir());
|
|
121
|
+
const targets = [];
|
|
71
122
|
|
|
72
|
-
|
|
123
|
+
if (include('agent-rules')) targets.push(runFileTarget({
|
|
124
|
+
id: 'agent-rules',
|
|
125
|
+
path: 'CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md, .windsurfrules, .amazonq/developer/guidelines.md, .cursor/rules/trackfw.mdc',
|
|
126
|
+
root: cwd,
|
|
127
|
+
relPaths: ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.github/copilot-instructions.md', '.windsurfrules', '.amazonq/developer/guidelines.md', '.cursor/rules/trackfw.mdc'],
|
|
128
|
+
apply: (root) => generators.injectRulesDetected(root),
|
|
129
|
+
dryRun,
|
|
130
|
+
installMissing,
|
|
131
|
+
}));
|
|
73
132
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
133
|
+
if (include('agent-hooks')) targets.push(runFileTarget({
|
|
134
|
+
id: 'agent-hooks',
|
|
135
|
+
path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh',
|
|
136
|
+
root: cwd,
|
|
137
|
+
relPaths: [
|
|
138
|
+
'.claude/settings.json',
|
|
139
|
+
'.codex/hooks.json',
|
|
140
|
+
'.gemini/settings.json',
|
|
141
|
+
'.kiro/hooks/trackfw-attention.json',
|
|
142
|
+
'.github/hooks/trackfw-attention.json',
|
|
143
|
+
'.cursor/hooks.json',
|
|
144
|
+
'scripts/trackfw-attention-signal.sh',
|
|
145
|
+
'scripts/trackfw-attention-cleanup.sh',
|
|
146
|
+
],
|
|
147
|
+
apply: (root) => {
|
|
148
|
+
hooksGen.injectHooksDetected(root);
|
|
149
|
+
hooksGen.generateAttentionScripts(cfg, root);
|
|
150
|
+
},
|
|
151
|
+
dryRun,
|
|
152
|
+
installMissing,
|
|
153
|
+
}));
|
|
96
154
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
155
|
+
if (include('codex-project-agents')) targets.push(codexProjectAgentsTarget(cwd, identityConfig, { dryRun, installMissing }));
|
|
156
|
+
|
|
157
|
+
if (include('validate-script')) targets.push(runFileTarget({
|
|
158
|
+
id: 'validate-script',
|
|
159
|
+
path: 'scripts/trackfw-validate.sh',
|
|
160
|
+
root: cwd,
|
|
161
|
+
relPaths: ['scripts/trackfw-validate.sh'],
|
|
162
|
+
// Reuses generators/init.js's generateValidateScript — the SAME
|
|
163
|
+
// generator `trackfw init` uses to write this file — not
|
|
164
|
+
// discover.js's writeValidateScript, which produces a different
|
|
165
|
+
// (simpler, non-per-backend) script and made every `update` re-run
|
|
166
|
+
// report "updated" against a project actually already current
|
|
167
|
+
// (ML-6H fix). readUpdateConfig returns raw trackfw.yaml keys
|
|
168
|
+
// (snake_case, e.g. "pkg_manager"); buildValidateScript expects the
|
|
169
|
+
// camelCase shape used by the rest of the init generators.
|
|
170
|
+
apply: (root) => generators.generateValidateScript({
|
|
171
|
+
backend: cfg.backend,
|
|
172
|
+
frontend: cfg.frontend,
|
|
173
|
+
pkgManager: cfg.pkg_manager,
|
|
174
|
+
}, root),
|
|
175
|
+
dryRun,
|
|
176
|
+
installMissing,
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
if (include('ci-workflow') && (cfg.ci === 'github-actions' || cfg.ci === 'github_actions')) {
|
|
180
|
+
targets.push(runFileTarget({
|
|
181
|
+
id: 'ci-workflow',
|
|
182
|
+
path: '.github/workflows/trackfw-validate.yml',
|
|
183
|
+
root: cwd,
|
|
184
|
+
relPaths: ['.github/workflows/trackfw-validate.yml'],
|
|
185
|
+
apply: (root) => discover.writeCIWorkflowForce(root),
|
|
186
|
+
dryRun,
|
|
187
|
+
installMissing,
|
|
188
|
+
}));
|
|
105
189
|
}
|
|
106
190
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
191
|
+
if (include('git-hooks') && (cfg.hooks === 'husky' || cfg.hooks === 'lefthook')) {
|
|
192
|
+
const relPath = cfg.hooks === 'husky' ? '.husky/pre-commit' : 'lefthook.yml';
|
|
193
|
+
targets.push(runFileTarget({
|
|
194
|
+
id: 'git-hooks',
|
|
195
|
+
path: relPath,
|
|
196
|
+
root: cwd,
|
|
197
|
+
relPaths: [relPath],
|
|
198
|
+
apply: (root) => updateHooksSurgical(cfg, root),
|
|
199
|
+
dryRun,
|
|
200
|
+
installMissing,
|
|
201
|
+
}));
|
|
113
202
|
}
|
|
114
203
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
204
|
+
if (include('claude-commands')) targets.push(runFileTarget({
|
|
205
|
+
id: 'claude-commands',
|
|
206
|
+
path: '.claude/commands/trackfw',
|
|
207
|
+
root: cwd,
|
|
208
|
+
relPaths: ['.claude/commands/trackfw'],
|
|
209
|
+
apply: (root) => generators.generateClaudeCommandsForce(root),
|
|
210
|
+
dryRun,
|
|
211
|
+
installMissing,
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
return targets;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const cmd = new Command('update');
|
|
218
|
+
cmd.description('Update trackfw-managed artifacts. Bare form is project-scoped (never touches global state); `update harness` updates the global harness instead.');
|
|
219
|
+
// `[mode]` is a plain positional argument, not a nested commander.Command —
|
|
220
|
+
// see the long comment in update-harness.js:run for why: a real subcommand
|
|
221
|
+
// redeclaring the same --json/--dry-run/--targets/--install-missing flags
|
|
222
|
+
// as this parent silently drops them (commander@12 quirk, confirmed by
|
|
223
|
+
// reproduction). One Command, one Option per flag, branch on `mode` inside
|
|
224
|
+
// a single action — this is the only structure that parses correctly.
|
|
225
|
+
cmd.argument('[mode]', 'Pass "harness" to update the global harness instead of the current project');
|
|
226
|
+
cmd.option('--dry-run', 'Compute and report states without writing anything');
|
|
227
|
+
cmd.option('--json', 'Emit the result document as JSON');
|
|
228
|
+
cmd.option('--targets <ids>', 'Comma-separated subset of target ids');
|
|
229
|
+
cmd.option('--install-missing', 'Allow missing targets to be installed');
|
|
230
|
+
cmd.action((mode, options) => {
|
|
231
|
+
if (mode === 'harness') {
|
|
232
|
+
return require('./update-harness').run(options);
|
|
233
|
+
}
|
|
234
|
+
if (mode) {
|
|
235
|
+
console.error(`✗ Unknown update mode: ${mode} (expected "harness" or no argument)`);
|
|
236
|
+
process.exit(1);
|
|
123
237
|
}
|
|
124
238
|
|
|
125
|
-
|
|
126
|
-
|
|
239
|
+
const cwd = process.cwd();
|
|
240
|
+
const yaml = path.join(cwd, 'trackfw.yaml');
|
|
241
|
+
if (!fs.existsSync(yaml)) {
|
|
242
|
+
console.error('✗ trackfw.yaml não encontrado — execute trackfw init primeiro');
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
127
245
|
|
|
128
|
-
|
|
246
|
+
let wanted;
|
|
129
247
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
248
|
+
const requested = options.targets ? String(options.targets).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
249
|
+
wanted = validateTargets(PROJECT_TARGET_IDS, requested);
|
|
132
250
|
} catch (e) {
|
|
133
|
-
console.
|
|
251
|
+
console.error(`✗ ${e.message}`);
|
|
252
|
+
process.exit(1);
|
|
134
253
|
}
|
|
135
254
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
255
|
+
const cfg = readUpdateConfig(cwd);
|
|
256
|
+
const dryRun = Boolean(options.dryRun);
|
|
257
|
+
const installMissing = Boolean(options.installMissing);
|
|
258
|
+
|
|
259
|
+
// A identidade é carregada uma única vez, fora de qualquer try/catch —
|
|
260
|
+
// um identity.json corrompido deve abortar o comando inteiro, nunca cair
|
|
261
|
+
// silenciosamente para os nomes neutros default.
|
|
262
|
+
const identityConfig = identityStore.load(os.homedir());
|
|
263
|
+
|
|
264
|
+
// With --json, stdout must carry only the result document — apply()
|
|
265
|
+
// functions log human progress lines as a side effect; silence them so a
|
|
266
|
+
// consumer parsing --json output never has to skip preamble noise.
|
|
267
|
+
const targets = options.json
|
|
268
|
+
? silenceConsole(() => buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted))
|
|
269
|
+
: buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted);
|
|
270
|
+
|
|
271
|
+
const doc = buildDocument('project', dryRun, targets);
|
|
272
|
+
|
|
273
|
+
if (options.json) {
|
|
274
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
275
|
+
} else {
|
|
276
|
+
console.log(humanReport('project', dryRun, targets));
|
|
142
277
|
}
|
|
143
278
|
|
|
144
|
-
|
|
145
|
-
require('../generators/init').printArchitectNextSteps(process.cwd())
|
|
279
|
+
if (doc.summary.failed > 0) process.exitCode = 1;
|
|
146
280
|
});
|
|
147
281
|
|
|
148
282
|
module.exports = cmd;
|
package/src/config/index.js
CHANGED
|
@@ -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: '
|
|
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 };
|