@agilemonster/mcp-server 1.0.16 → 1.0.22
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 +27 -1
- package/bin/mcp.js +0 -0
- package/bin/orchestrator.js +0 -0
- package/bin/worker.js +0 -0
- package/dist/client.d.ts +21 -4
- package/dist/client.js +119 -101
- package/dist/deploy-memory.d.ts +122 -0
- package/dist/deploy-memory.js +192 -0
- package/dist/deploy-profile.d.ts +151 -0
- package/dist/deploy-profile.js +993 -0
- package/dist/deploy-runner.d.ts +144 -0
- package/dist/deploy-runner.js +1108 -0
- package/dist/guidelines.d.ts +1 -1
- package/dist/guidelines.js +6 -0
- package/dist/ide-config.d.ts +44 -0
- package/dist/ide-config.js +250 -0
- package/dist/orchestrator.d.ts +1 -0
- package/dist/orchestrator.js +18 -165
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +58 -0
- package/dist/runtime.d.ts +64 -0
- package/dist/runtime.js +293 -0
- package/dist/server.d.ts +16 -3
- package/dist/server.js +1427 -547
- package/dist/types.d.ts +29 -0
- package/package.json +9 -2
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Small helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function readText(file, maxBytes = 120000) {
|
|
8
|
+
try {
|
|
9
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
10
|
+
return '';
|
|
11
|
+
return fs.readFileSync(file, 'utf8').slice(0, maxBytes);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return '';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function exists(...parts) {
|
|
18
|
+
try {
|
|
19
|
+
return fs.existsSync(path.join(...parts));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function isInside(child, parent) {
|
|
26
|
+
const c = path.resolve(child);
|
|
27
|
+
const p = path.resolve(parent);
|
|
28
|
+
return c === p || c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
|
|
29
|
+
}
|
|
30
|
+
export function normalizeMethod(raw) {
|
|
31
|
+
const v = (raw || '').toLowerCase().trim().replace(/[\s-]+/g, '_');
|
|
32
|
+
if (!v)
|
|
33
|
+
return null;
|
|
34
|
+
if (['docker_compose', 'docker', 'compose', 'docker_compose_up', 'container', 'containers'].includes(v)) {
|
|
35
|
+
return 'docker_compose';
|
|
36
|
+
}
|
|
37
|
+
if (['github_actions', 'github', 'gha', 'actions', 'ci', 'ci_cd', 'cicd', 'github_action'].includes(v)) {
|
|
38
|
+
return 'github_actions';
|
|
39
|
+
}
|
|
40
|
+
if (['deploy_script', 'script', 'ssh', 'sh', 'shell', 'make', 'rsync', 'ftp', 'sftp'].includes(v)) {
|
|
41
|
+
return 'deploy_script';
|
|
42
|
+
}
|
|
43
|
+
if (['cloud_cli', 'cli', 'vercel', 'netlify', 'fly', 'firebase', 'wrangler', 'cloudflare'].includes(v)) {
|
|
44
|
+
return 'cloud_cli';
|
|
45
|
+
}
|
|
46
|
+
if (['git_push', 'push', 'git'].includes(v))
|
|
47
|
+
return 'git_push';
|
|
48
|
+
if (['manual', 'human', 'me'].includes(v))
|
|
49
|
+
return 'manual';
|
|
50
|
+
if (['none', 'skip', 'no', 'off', 'no_deploy'].includes(v))
|
|
51
|
+
return 'none';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Older versions stored descriptions such as "git push origin HEAD (GitHub Actions CI/CD)" in the command
|
|
56
|
+
* field. Those are not commands. (Real commands may contain parentheses, e.g. `TAG=$(git rev-parse HEAD)`.)
|
|
57
|
+
*/
|
|
58
|
+
export function isRunnableCommand(cmd) {
|
|
59
|
+
const c = (cmd || '').trim();
|
|
60
|
+
return c.length > 0 && !/\((GitHub Actions|CI\/CD)[^)]*\)\s*$/i.test(c);
|
|
61
|
+
}
|
|
62
|
+
export function globToRegExp(glob) {
|
|
63
|
+
let re = '';
|
|
64
|
+
for (let i = 0; i < glob.length; i++) {
|
|
65
|
+
const ch = glob[i];
|
|
66
|
+
if (ch === '*') {
|
|
67
|
+
if (glob[i + 1] === '*') {
|
|
68
|
+
re += '.*';
|
|
69
|
+
i++;
|
|
70
|
+
if (glob[i + 1] === '/')
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
re += '[^/]*';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (ch === '[') {
|
|
78
|
+
// character class, as in release/v[0-9]
|
|
79
|
+
const close = glob.indexOf(']', i + 1);
|
|
80
|
+
if (close > i) {
|
|
81
|
+
re += glob.slice(i, close + 1);
|
|
82
|
+
i = close;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
re += '\\[';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if ((ch === '?' || ch === '+') && i > 0) {
|
|
89
|
+
// GitHub filter patterns: `?` = zero or one, `+` = one or more of the preceding character
|
|
90
|
+
re += ch;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
re += ch.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return new RegExp(`^${re}$`);
|
|
97
|
+
}
|
|
98
|
+
function matchesAny(patterns, value) {
|
|
99
|
+
let matched = false;
|
|
100
|
+
for (const raw of patterns) {
|
|
101
|
+
const negate = raw.startsWith('!');
|
|
102
|
+
const pattern = negate ? raw.slice(1) : raw;
|
|
103
|
+
if (globToRegExp(pattern).test(value)) {
|
|
104
|
+
matched = !negate;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return matched;
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// GitHub Actions workflows
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
function stripComment(line) {
|
|
113
|
+
let inSingle = false;
|
|
114
|
+
let inDouble = false;
|
|
115
|
+
for (let i = 0; i < line.length; i++) {
|
|
116
|
+
const ch = line[i];
|
|
117
|
+
if (ch === "'" && !inDouble)
|
|
118
|
+
inSingle = !inSingle;
|
|
119
|
+
else if (ch === '"' && !inSingle)
|
|
120
|
+
inDouble = !inDouble;
|
|
121
|
+
else if (ch === '#' && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
122
|
+
return line.slice(0, i);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return line;
|
|
126
|
+
}
|
|
127
|
+
function unquote(s) {
|
|
128
|
+
return s.trim().replace(/^["']|["']$/g, '').trim();
|
|
129
|
+
}
|
|
130
|
+
function parseFlowList(value) {
|
|
131
|
+
const v = value.trim();
|
|
132
|
+
if (v.startsWith('[')) {
|
|
133
|
+
return v
|
|
134
|
+
.replace(/^\[|\]$/g, '')
|
|
135
|
+
.split(',')
|
|
136
|
+
.map(unquote)
|
|
137
|
+
.filter((s) => s.length > 0);
|
|
138
|
+
}
|
|
139
|
+
return v ? [unquote(v)] : [];
|
|
140
|
+
}
|
|
141
|
+
function indentOf(line) {
|
|
142
|
+
return line.length - line.trimStart().length;
|
|
143
|
+
}
|
|
144
|
+
/** Read a `key:` under an event block as a list, whether written inline (`[a, b]`) or as `- a` lines. */
|
|
145
|
+
function readListKey(block, key) {
|
|
146
|
+
for (let i = 0; i < block.length; i++) {
|
|
147
|
+
const m = block[i].match(new RegExp(`^(\\s*)${key}\\s*:\\s*(.*)$`));
|
|
148
|
+
if (!m)
|
|
149
|
+
continue;
|
|
150
|
+
const baseIndent = m[1].length;
|
|
151
|
+
let inline = m[2].trim();
|
|
152
|
+
if (inline.startsWith('[') && !inline.includes(']')) {
|
|
153
|
+
// flow list spread over several lines
|
|
154
|
+
for (let j = i + 1; j < block.length && !inline.includes(']'); j++)
|
|
155
|
+
inline += ` ${block[j].trim()}`;
|
|
156
|
+
}
|
|
157
|
+
if (inline)
|
|
158
|
+
return parseFlowList(inline);
|
|
159
|
+
const items = [];
|
|
160
|
+
for (let j = i + 1; j < block.length; j++) {
|
|
161
|
+
const line = block[j];
|
|
162
|
+
if (!line.trim())
|
|
163
|
+
continue;
|
|
164
|
+
if (indentOf(line) <= baseIndent && !line.trim().startsWith('-'))
|
|
165
|
+
break;
|
|
166
|
+
const item = line.trim().match(/^-\s*(.+)$/);
|
|
167
|
+
if (item)
|
|
168
|
+
items.push(unquote(item[1]));
|
|
169
|
+
else if (indentOf(line) <= baseIndent)
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
return items;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const DEPLOY_STEP_RE = /appleboy\/(ssh|scp)-action|SamKirkland\/FTP-Deploy-Action|ftp-deploy|easingthemes\/ssh-deploy|burnett01\/rsync-deployments|\brsync\s+-|\bscp\s+-|\bsftp\b|\blftp\b|\bssh\s+(-\S+\s+)*\S+@|aws\s+s3\s+(sync|cp)|firebase\s+deploy|firebase-hosting|vercel\s+(deploy|--prod)|amondnet\/vercel-action|netlify\s+deploy|nwtgck\/actions-netlify|wrangler\s+(deploy|publish)|cloudflare\/(wrangler-action|pages-action)|fly(ctl)?\s+deploy|superfly\/flyctl-actions|docker\s+push|docker\/build-push-action|gcloud\s+(run|app|functions)\s+deploy|kubectl\s+(apply|set|rollout)|helm\s+upgrade|azure\/webapps-deploy|peaceiris\/actions-gh-pages|actions\/deploy-pages|npm\s+publish|\buapi\b|cpanel/i;
|
|
177
|
+
export function parseWorkflow(file, text) {
|
|
178
|
+
const lines = text.split(/\r?\n/).map(stripComment);
|
|
179
|
+
const nameLine = lines.find((l) => /^name\s*:/.test(l));
|
|
180
|
+
const name = nameLine ? unquote(nameLine.replace(/^name\s*:/, '')) : path.basename(file).replace(/\.ya?ml$/i, '');
|
|
181
|
+
const events = [];
|
|
182
|
+
let push = null;
|
|
183
|
+
let dispatch = false;
|
|
184
|
+
let workflowRun = null;
|
|
185
|
+
const onIdx = lines.findIndex((l) => /^(on|"on"|'on'|true)\s*:/.test(l));
|
|
186
|
+
if (onIdx >= 0) {
|
|
187
|
+
const inline = lines[onIdx].replace(/^(on|"on"|'on'|true)\s*:/, '').trim();
|
|
188
|
+
if (inline) {
|
|
189
|
+
if (inline.startsWith('{')) {
|
|
190
|
+
for (const ev of inline.match(/[a-z_]+(?=\s*:)/g) || [])
|
|
191
|
+
events.push(ev);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
events.push(...parseFlowList(inline));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const block = [];
|
|
199
|
+
for (let i = onIdx + 1; i < lines.length; i++) {
|
|
200
|
+
const line = lines[i];
|
|
201
|
+
// `- push` items may sit at column 0 directly under `on:`
|
|
202
|
+
if (line.trim() && indentOf(line) === 0 && !line.startsWith('-'))
|
|
203
|
+
break;
|
|
204
|
+
block.push(line);
|
|
205
|
+
}
|
|
206
|
+
const firstReal = block.find((l) => l.trim().length > 0);
|
|
207
|
+
const eventIndent = firstReal ? indentOf(firstReal) : 2;
|
|
208
|
+
for (let i = 0; i < block.length; i++) {
|
|
209
|
+
const line = block[i];
|
|
210
|
+
if (!line.trim() || indentOf(line) !== eventIndent)
|
|
211
|
+
continue;
|
|
212
|
+
const m = line.trim().match(/^-?\s*([a-z_]+)\s*:?\s*(.*)$/);
|
|
213
|
+
if (!m)
|
|
214
|
+
continue;
|
|
215
|
+
const event = m[1];
|
|
216
|
+
events.push(event);
|
|
217
|
+
let sub = [];
|
|
218
|
+
for (let j = i + 1; j < block.length; j++) {
|
|
219
|
+
if (block[j].trim() && indentOf(block[j]) <= eventIndent)
|
|
220
|
+
break;
|
|
221
|
+
sub.push(block[j]);
|
|
222
|
+
}
|
|
223
|
+
const inlineMap = m[2].trim();
|
|
224
|
+
if (inlineMap.startsWith('{')) {
|
|
225
|
+
// `push: { branches: [main], paths: [src/**] }` -> one key per line so the same reader applies
|
|
226
|
+
sub = inlineMap
|
|
227
|
+
.replace(/^\{|\}$/g, '')
|
|
228
|
+
.split(/,(?![^[]*\])/)
|
|
229
|
+
.map((part) => ` ${part.trim()}`);
|
|
230
|
+
}
|
|
231
|
+
if (event === 'push') {
|
|
232
|
+
const branches = readListKey(sub, 'branches');
|
|
233
|
+
const branchesIgnore = readListKey(sub, 'branches-ignore');
|
|
234
|
+
const tags = readListKey(sub, 'tags');
|
|
235
|
+
push = {
|
|
236
|
+
branches,
|
|
237
|
+
branchesIgnore,
|
|
238
|
+
paths: readListKey(sub, 'paths'),
|
|
239
|
+
pathsIgnore: readListKey(sub, 'paths-ignore'),
|
|
240
|
+
tagsOnly: branches === null && branchesIgnore === null && tags !== null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
else if (event === 'workflow_run') {
|
|
244
|
+
workflowRun = {
|
|
245
|
+
workflows: readListKey(sub, 'workflows') || [],
|
|
246
|
+
branches: readListKey(sub, 'branches'),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (events.includes('push') && !push) {
|
|
253
|
+
push = { branches: null, branchesIgnore: null, paths: null, pathsIgnore: null, tagsOnly: false };
|
|
254
|
+
}
|
|
255
|
+
dispatch = events.includes('workflow_dispatch');
|
|
256
|
+
const label = `${path.basename(file)} ${name}`.toLowerCase().replace(/[-_.]+/g, ' ');
|
|
257
|
+
const negativeName = /\b(preflight|pre flight|audit|dry run|bootstrap|check|backfill|sync|refresh|fix)\b/.test(label);
|
|
258
|
+
const strongName = /\b(deploy|deployment|deploys|publish|cpanel|ship)\b/.test(label);
|
|
259
|
+
const isDeploy = strongName && !/\b(preflight|pre flight|dry run|bootstrap)\b/.test(label)
|
|
260
|
+
? true
|
|
261
|
+
: !negativeName && DEPLOY_STEP_RE.test(text);
|
|
262
|
+
return { file, name, isDeploy, events, push, dispatch, workflowRun };
|
|
263
|
+
}
|
|
264
|
+
export function readWorkflows(projectPath) {
|
|
265
|
+
const dir = path.join(projectPath, '.github', 'workflows');
|
|
266
|
+
const out = [];
|
|
267
|
+
try {
|
|
268
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
269
|
+
if (!/\.ya?ml$/i.test(f))
|
|
270
|
+
continue;
|
|
271
|
+
const text = readText(path.join(dir, f));
|
|
272
|
+
if (text)
|
|
273
|
+
out.push(parseWorkflow(f, text));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch { }
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
function pushMatchesBranch(push, branch) {
|
|
280
|
+
if (push.tagsOnly)
|
|
281
|
+
return false;
|
|
282
|
+
if (push.branches && !matchesAny(push.branches, branch))
|
|
283
|
+
return false;
|
|
284
|
+
if (push.branchesIgnore && matchesAny(push.branchesIgnore, branch))
|
|
285
|
+
return false;
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
/** Does a push to `branch` start this workflow, directly or through a `workflow_run` chain? */
|
|
289
|
+
export function workflowRunsOnPush(wf, branch, all, depth = 0) {
|
|
290
|
+
if (wf.push && pushMatchesBranch(wf.push, branch))
|
|
291
|
+
return true;
|
|
292
|
+
if (wf.workflowRun && depth < 3) {
|
|
293
|
+
if (wf.workflowRun.branches && !matchesAny(wf.workflowRun.branches, branch))
|
|
294
|
+
return false;
|
|
295
|
+
const upstreamNames = wf.workflowRun.workflows.map((w) => w.toLowerCase());
|
|
296
|
+
return all.some((other) => other !== wf &&
|
|
297
|
+
upstreamNames.includes(other.name.toLowerCase()) &&
|
|
298
|
+
workflowRunsOnPush(other, branch, all, depth + 1));
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
/** With `paths:` filters, a commit that touches none of them does not start the workflow. */
|
|
303
|
+
export function pushPathsMatch(push, files) {
|
|
304
|
+
if (!push || files.length === 0)
|
|
305
|
+
return true;
|
|
306
|
+
if (push.paths && !files.some((f) => matchesAny(push.paths, f)))
|
|
307
|
+
return false;
|
|
308
|
+
if (push.pathsIgnore && files.every((f) => matchesAny(push.pathsIgnore, f)))
|
|
309
|
+
return false;
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Docker Compose
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
export const COMPOSE_PS_FORMAT = '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}\t{{.Label "com.docker.compose.project.config_files"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}';
|
|
316
|
+
const COMPOSE_NAME_RE = /^(docker-)?compose([._-][\w.-]+)?\.ya?ml$/i;
|
|
317
|
+
export function findComposeFiles(projectPath) {
|
|
318
|
+
const dirs = ['', 'deploy', 'docker', 'infra', 'infra/docker', 'ops', 'deployment', '.docker', 'compose'];
|
|
319
|
+
try {
|
|
320
|
+
for (const entry of fs.readdirSync(path.join(projectPath, 'docker'), { withFileTypes: true })) {
|
|
321
|
+
if (entry.isDirectory())
|
|
322
|
+
dirs.push(`docker/${entry.name}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch { }
|
|
326
|
+
const found = [];
|
|
327
|
+
for (const dir of dirs) {
|
|
328
|
+
let names = [];
|
|
329
|
+
try {
|
|
330
|
+
names = fs.readdirSync(path.join(projectPath, dir));
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
for (const name of names.sort()) {
|
|
336
|
+
if (!COMPOSE_NAME_RE.test(name))
|
|
337
|
+
continue;
|
|
338
|
+
const rel = dir ? `${dir}/${name}` : name;
|
|
339
|
+
const text = readText(path.join(projectPath, rel));
|
|
340
|
+
const lower = name.toLowerCase();
|
|
341
|
+
const kind = /^(docker-)?compose\.ya?ml$/.test(lower)
|
|
342
|
+
? 'default'
|
|
343
|
+
: /(local|dev|test|override)/.test(lower)
|
|
344
|
+
? 'dev'
|
|
345
|
+
: /(prod|production|staging|server)/.test(lower)
|
|
346
|
+
? 'prod'
|
|
347
|
+
: 'other';
|
|
348
|
+
found.push({ rel, kind, hasBuild: /^\s+build\s*:/m.test(text) });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return found;
|
|
352
|
+
}
|
|
353
|
+
/** Parse the docker ps probe and keep the compose projects that were started from inside `projectPath`. */
|
|
354
|
+
export function parseComposeStacks(psOutput, projectPath) {
|
|
355
|
+
const byProject = new Map();
|
|
356
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
357
|
+
const [projectName, workingDir, configFiles, service, state] = line.split('\t');
|
|
358
|
+
if (!projectName || !workingDir)
|
|
359
|
+
continue;
|
|
360
|
+
const files = (configFiles || '').split(',').map((f) => f.trim()).filter(Boolean);
|
|
361
|
+
const inside = isInside(workingDir, projectPath) || files.some((f) => isInside(f, projectPath));
|
|
362
|
+
if (!inside)
|
|
363
|
+
continue;
|
|
364
|
+
const key = `${projectName}\u0000${workingDir}`;
|
|
365
|
+
let stack = byProject.get(key);
|
|
366
|
+
if (!stack) {
|
|
367
|
+
stack = { projectName, workingDir, configFiles: files, services: [], runningCount: 0 };
|
|
368
|
+
byProject.set(key, stack);
|
|
369
|
+
}
|
|
370
|
+
if (service && !stack.services.includes(service))
|
|
371
|
+
stack.services.push(service);
|
|
372
|
+
if ((state || '').trim().toLowerCase() === 'running')
|
|
373
|
+
stack.runningCount++;
|
|
374
|
+
}
|
|
375
|
+
return Array.from(byProject.values()).sort((a, b) => b.runningCount - a.runningCount);
|
|
376
|
+
}
|
|
377
|
+
export function composeCommand(target, tail = 'up -d --build') {
|
|
378
|
+
const parts = ['docker', 'compose'];
|
|
379
|
+
if (target.projectName)
|
|
380
|
+
parts.push('-p', target.projectName);
|
|
381
|
+
for (const f of target.files)
|
|
382
|
+
parts.push('-f', /\s/.test(f) ? `"${f}"` : f);
|
|
383
|
+
return `${parts.join(' ')} ${tail}`.trim();
|
|
384
|
+
}
|
|
385
|
+
/** Arguments for `docker compose …` matching a target (no shell quoting needed). */
|
|
386
|
+
export function composeArgs(target) {
|
|
387
|
+
const args = ['compose'];
|
|
388
|
+
if (target.projectName)
|
|
389
|
+
args.push('-p', target.projectName);
|
|
390
|
+
for (const f of target.files)
|
|
391
|
+
args.push('-f', f);
|
|
392
|
+
return args;
|
|
393
|
+
}
|
|
394
|
+
let dockerPsCache = null;
|
|
395
|
+
function defaultDockerPs() {
|
|
396
|
+
if (dockerPsCache && Date.now() - dockerPsCache.at < 30000)
|
|
397
|
+
return dockerPsCache.value;
|
|
398
|
+
let value = null;
|
|
399
|
+
try {
|
|
400
|
+
value = execFileSync('docker', ['ps', '-a', '--format', COMPOSE_PS_FORMAT], {
|
|
401
|
+
encoding: 'utf8',
|
|
402
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
403
|
+
timeout: 5000,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
value = null;
|
|
408
|
+
}
|
|
409
|
+
dockerPsCache = { at: Date.now(), value };
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Git
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
function defaultGitInfo(projectPath) {
|
|
416
|
+
const info = { isRepo: false, branch: null, hasRemote: false, remoteUrl: null };
|
|
417
|
+
if (!exists(projectPath, '.git'))
|
|
418
|
+
return info;
|
|
419
|
+
info.isRepo = true;
|
|
420
|
+
const git = (args) => {
|
|
421
|
+
try {
|
|
422
|
+
return execFileSync('git', args, {
|
|
423
|
+
cwd: projectPath,
|
|
424
|
+
encoding: 'utf8',
|
|
425
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
426
|
+
timeout: 4000,
|
|
427
|
+
}).trim();
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return '';
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
info.branch = git(['branch', '--show-current']) || null;
|
|
434
|
+
info.remoteUrl = git(['config', '--get', 'remote.origin.url']) || null;
|
|
435
|
+
info.hasRemote = Boolean(info.remoteUrl) || git(['remote']).length > 0;
|
|
436
|
+
return info;
|
|
437
|
+
}
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Docs, build command, live URL
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
function deployDocFiles(projectPath) {
|
|
442
|
+
const files = [
|
|
443
|
+
'CLAUDE.md',
|
|
444
|
+
'AGENTS.md',
|
|
445
|
+
'MEMORY.md',
|
|
446
|
+
'README.md',
|
|
447
|
+
'.cursorrules',
|
|
448
|
+
'deploy/README.md',
|
|
449
|
+
'deploy/README-DEPLOY.md',
|
|
450
|
+
];
|
|
451
|
+
for (const dir of ['', 'docs', 'deploy']) {
|
|
452
|
+
try {
|
|
453
|
+
for (const name of fs.readdirSync(path.join(projectPath, dir)).sort()) {
|
|
454
|
+
if (/^deploy.*\.md$/i.test(name) || /deploy(ment)?[-_a-z]*\.md$/i.test(name)) {
|
|
455
|
+
const rel = dir ? `${dir}/${name}` : name;
|
|
456
|
+
if (!files.includes(rel))
|
|
457
|
+
files.push(rel);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch { }
|
|
462
|
+
}
|
|
463
|
+
return files.filter((f) => exists(projectPath, f)).slice(0, 10);
|
|
464
|
+
}
|
|
465
|
+
const DEPLOY_LINE_RE = /deploy|ci\/cd|github actions|docker[ -]compose|production|rsync|cpanel|\bssh\b|go(es)? live|release|hosting|workflow/i;
|
|
466
|
+
/** Pull the lines of the project docs that talk about deployment (compact, for the code model and for humans). */
|
|
467
|
+
export function extractDeploymentNotes(projectPath, maxChars = 1800) {
|
|
468
|
+
const out = [];
|
|
469
|
+
const seen = new Set();
|
|
470
|
+
let total = 0;
|
|
471
|
+
for (const rel of deployDocFiles(projectPath)) {
|
|
472
|
+
const text = readText(path.join(projectPath, rel), 60000);
|
|
473
|
+
if (!text)
|
|
474
|
+
continue;
|
|
475
|
+
const picked = [];
|
|
476
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
477
|
+
const line = raw.trim();
|
|
478
|
+
if (line.length < 8 || line.length > 320)
|
|
479
|
+
continue;
|
|
480
|
+
if (!DEPLOY_LINE_RE.test(line))
|
|
481
|
+
continue;
|
|
482
|
+
const key = line.toLowerCase();
|
|
483
|
+
if (seen.has(key))
|
|
484
|
+
continue;
|
|
485
|
+
seen.add(key);
|
|
486
|
+
picked.push(line);
|
|
487
|
+
if (picked.length >= 8)
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
if (picked.length === 0)
|
|
491
|
+
continue;
|
|
492
|
+
const section = `[${rel}]\n${picked.join('\n')}`;
|
|
493
|
+
if (total + section.length > maxChars) {
|
|
494
|
+
out.push(section.slice(0, Math.max(0, maxChars - total)));
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
out.push(section);
|
|
498
|
+
total += section.length;
|
|
499
|
+
}
|
|
500
|
+
return out.join('\n').trim();
|
|
501
|
+
}
|
|
502
|
+
function docHints(projectPath) {
|
|
503
|
+
const hints = { docker_compose: 0, github_actions: 0, deploy_script: 0 };
|
|
504
|
+
for (const rel of deployDocFiles(projectPath)) {
|
|
505
|
+
const text = readText(path.join(projectPath, rel), 60000);
|
|
506
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
507
|
+
const line = raw.toLowerCase();
|
|
508
|
+
if (!/deploy|production|release|live/.test(line))
|
|
509
|
+
continue;
|
|
510
|
+
if (/docker[ -]compose\s+.*\bup\b|recreate the container|docker compose up/.test(line))
|
|
511
|
+
hints.docker_compose++;
|
|
512
|
+
if (/github actions|workflow|ci\/cd|\.github\/workflows/.test(line))
|
|
513
|
+
hints.github_actions++;
|
|
514
|
+
if (/deploy\.sh|make deploy|rsync|\bssh\b|deploy script/.test(line))
|
|
515
|
+
hints.deploy_script++;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return hints;
|
|
519
|
+
}
|
|
520
|
+
export function detectBuildCommand(projectPath) {
|
|
521
|
+
const pkgScript = (rel, script) => {
|
|
522
|
+
try {
|
|
523
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, rel, 'package.json'), 'utf8'));
|
|
524
|
+
return Boolean(pkg?.scripts?.[script]);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
const runner = (rel) => (exists(projectPath, rel, 'pnpm-lock.yaml') ? 'pnpm' : 'npm');
|
|
531
|
+
if (pkgScript('apps/web', 'build'))
|
|
532
|
+
return `cd apps/web && ${runner('apps/web')} run build`;
|
|
533
|
+
if (pkgScript('frontend', 'build'))
|
|
534
|
+
return `cd frontend && ${runner('frontend')} run build`;
|
|
535
|
+
if (pkgScript('', 'build'))
|
|
536
|
+
return `${runner('')} run build`;
|
|
537
|
+
if (exists(projectPath, 'frontend-flutter', 'pubspec.yaml'))
|
|
538
|
+
return 'cd frontend-flutter && flutter analyze';
|
|
539
|
+
if (exists(projectPath, 'pubspec.yaml'))
|
|
540
|
+
return 'flutter analyze';
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
const THIRD_PARTY_HOST_RE = /(^|\.)(github|githubusercontent|gitlab|bitbucket|openai|anthropic|google|googleapis|gstatic|npmjs|figma|stripe|vercel|netlify|cloudflare|amazonaws|apple|microsoft|docker|localhost|example|w3|schema|shields|youtube|twitter|x|linkedin|facebook|slack|discord|sentry|mozilla|wikipedia|stackoverflow|medium|notion|atlassian|flutter|dart|pub|nodejs|reactjs|nextjs|php|laravel|python|readthedocs|jsdelivr|unpkg|cdnjs|fonts|gravatar|telnyx|sendgrid|twilio|paypal|supabase|firebase|firebaseio|herokuapp|fly|render|railway|cpanel)\.[a-z.]+$/i;
|
|
544
|
+
const PLACEHOLDER_HOST_RE = /localhost|127\.0\.0\.1|example\.|your-?(domain|site|app|company)|my-?(domain|site|app)\.|domain\.(com|tld)|changeme|placeholder|\.local\b|\.test\b/i;
|
|
545
|
+
function normToken(s) {
|
|
546
|
+
return s
|
|
547
|
+
.toLowerCase()
|
|
548
|
+
.replace(/^www\./, '')
|
|
549
|
+
.replace(/\.(com|ai|io|app|dev|org|net|co|co\.[a-z]{2}|[a-z]{2})$/i, '')
|
|
550
|
+
.replace(/[^a-z0-9]/g, '');
|
|
551
|
+
}
|
|
552
|
+
/** "app.example.co.za" -> "example": the label a project name can be compared with. */
|
|
553
|
+
export function registrableLabel(host) {
|
|
554
|
+
const h = host.toLowerCase().replace(/^www\./, '');
|
|
555
|
+
const withoutSuffix = /\.(co|com|org|net|ac|gov|edu)\.[a-z]{2}$/.test(h)
|
|
556
|
+
? h.replace(/\.(co|com|org|net|ac|gov|edu)\.[a-z]{2}$/, '')
|
|
557
|
+
: h.replace(/\.[a-z]{2,}$/, '');
|
|
558
|
+
return (withoutSuffix.split('.').pop() || '').replace(/[^a-z0-9]/g, '');
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Best-effort production URL. Returns a URL only when something ties it to this project
|
|
562
|
+
* (an APP_URL-style env value, a doc URL whose host resembles the project/repo name, or a
|
|
563
|
+
* folder literally named like a domain) — never just "the first link in the README".
|
|
564
|
+
*/
|
|
565
|
+
export function inferLiveUrl(projectPath, name, remoteUrl) {
|
|
566
|
+
const tokens = new Set();
|
|
567
|
+
const nameToken = normToken(name.replace(/^nieuw\.games[-_]*/i, ''));
|
|
568
|
+
if (nameToken.length >= 4)
|
|
569
|
+
tokens.add(nameToken);
|
|
570
|
+
if (remoteUrl) {
|
|
571
|
+
const repo = normToken(path.basename(remoteUrl).replace(/\.git$/i, ''));
|
|
572
|
+
if (repo.length >= 4)
|
|
573
|
+
tokens.add(repo);
|
|
574
|
+
}
|
|
575
|
+
for (const envFile of ['.env.production', '.env.example', '.env']) {
|
|
576
|
+
const text = readText(path.join(projectPath, envFile), 20000);
|
|
577
|
+
const m = text.match(/^(?:APP_URL|SITE_URL|PUBLIC_URL|BASE_URL|FRONTEND_URL|NEXT_PUBLIC_(?:SITE|APP|BASE)_URL|VITE_(?:SITE|APP)_URL)\s*=\s*["']?(https:\/\/[a-z0-9.-]+\.[a-z]{2,})/im);
|
|
578
|
+
if (m && !PLACEHOLDER_HOST_RE.test(m[1]))
|
|
579
|
+
return m[1];
|
|
580
|
+
}
|
|
581
|
+
const candidates = new Map();
|
|
582
|
+
for (const doc of ['CLAUDE.md', 'AGENTS.md', 'MEMORY.md', 'README.md']) {
|
|
583
|
+
const text = readText(path.join(projectPath, doc), 20000);
|
|
584
|
+
for (const m of text.matchAll(/https:\/\/([a-z0-9.-]+\.[a-z]{2,})/gi)) {
|
|
585
|
+
const host = m[1].toLowerCase().replace(/[.)]+$/, '');
|
|
586
|
+
if (THIRD_PARTY_HOST_RE.test(host) || PLACEHOLDER_HOST_RE.test(host))
|
|
587
|
+
continue;
|
|
588
|
+
const hostToken = registrableLabel(host);
|
|
589
|
+
if (hostToken.length < 4)
|
|
590
|
+
continue;
|
|
591
|
+
let related = false;
|
|
592
|
+
for (const t of tokens) {
|
|
593
|
+
if (hostToken.includes(t) || t.includes(hostToken))
|
|
594
|
+
related = true;
|
|
595
|
+
}
|
|
596
|
+
if (!related)
|
|
597
|
+
continue;
|
|
598
|
+
candidates.set(host, (candidates.get(host) || 0) + 1);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (candidates.size > 0) {
|
|
602
|
+
// Prefer the site itself (apex / www) over api.* or app.* subdomains, then the most-mentioned host
|
|
603
|
+
const isApex = (h) => h.replace(/^www\./, '').split('.').length === (/\.(co|com|org|net|ac|gov|edu)\.[a-z]{2}$/.test(h) ? 3 : 2);
|
|
604
|
+
const best = Array.from(candidates.entries()).sort((a, b) => Number(isApex(b[0])) - Number(isApex(a[0])) || b[1] - a[1] || a[0].length - b[0].length)[0][0];
|
|
605
|
+
return `https://${best}`;
|
|
606
|
+
}
|
|
607
|
+
const clean = name.toLowerCase().replace(/^nieuw\.games[-_]*/i, '');
|
|
608
|
+
if (/^[a-z0-9-]+\.(com|ai|io|app|dev|org|net)$/i.test(clean))
|
|
609
|
+
return `https://${clean}`;
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
// Candidate gathering + ranking
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
function readExplicitFile(projectPath) {
|
|
616
|
+
try {
|
|
617
|
+
const file = path.join(projectPath, '.agilemonster', 'deploy.json');
|
|
618
|
+
if (!fs.existsSync(file))
|
|
619
|
+
return null;
|
|
620
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
621
|
+
const method = normalizeMethod(parsed?.method);
|
|
622
|
+
if (!method)
|
|
623
|
+
return null;
|
|
624
|
+
return {
|
|
625
|
+
method,
|
|
626
|
+
command: typeof parsed.command === 'string' && parsed.command.trim() ? parsed.command.trim() : null,
|
|
627
|
+
live_url: typeof parsed.live_url === 'string' && parsed.live_url.trim() ? parsed.live_url.trim() : null,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
export function gatherCandidates(projectPath, git, workflows, dockerPs) {
|
|
635
|
+
const candidates = [];
|
|
636
|
+
const branch = git.branch || 'main';
|
|
637
|
+
// 1. GitHub Actions: only workflows that actually deploy, judged against the branch being pushed
|
|
638
|
+
const deployWfs = workflows.filter((w) => w.isDeploy);
|
|
639
|
+
if (deployWfs.length > 0 && git.hasRemote) {
|
|
640
|
+
const onPush = deployWfs.filter((w) => workflowRunsOnPush(w, branch, workflows));
|
|
641
|
+
const dispatchable = deployWfs.filter((w) => w.dispatch);
|
|
642
|
+
if (onPush.length > 0) {
|
|
643
|
+
candidates.push({
|
|
644
|
+
method: 'github_actions',
|
|
645
|
+
command: null,
|
|
646
|
+
score: 90,
|
|
647
|
+
workflows: onPush,
|
|
648
|
+
evidence: onPush.map((w) => `.github/workflows/${w.file} ("${w.name}") deploys on push to \`${branch}\`` +
|
|
649
|
+
(w.push?.paths ? ` when these paths change: ${w.push.paths.slice(0, 4).join(', ')}` : '') +
|
|
650
|
+
(w.workflowRun ? ` (after "${w.workflowRun.workflows.join(', ')}" succeeds)` : '')),
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
else if (dispatchable.length > 0) {
|
|
654
|
+
const wf = dispatchable[0];
|
|
655
|
+
candidates.push({
|
|
656
|
+
method: 'github_actions',
|
|
657
|
+
command: `gh workflow run ${wf.file} --ref ${branch}`,
|
|
658
|
+
score: 55,
|
|
659
|
+
workflows: [wf],
|
|
660
|
+
dispatchOnly: true,
|
|
661
|
+
evidence: [
|
|
662
|
+
`.github/workflows/${wf.file} ("${wf.name}") deploys only when dispatched manually — a push to \`${branch}\` does not trigger it`,
|
|
663
|
+
],
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
const wf = deployWfs[0];
|
|
668
|
+
const branches = wf.push?.branches || wf.workflowRun?.branches || [];
|
|
669
|
+
candidates.push({
|
|
670
|
+
method: 'github_actions',
|
|
671
|
+
command: null,
|
|
672
|
+
score: 40,
|
|
673
|
+
workflows: [wf],
|
|
674
|
+
evidence: [
|
|
675
|
+
`.github/workflows/${wf.file} ("${wf.name}") deploys from ${branches.length ? branches.map((b) => `\`${b}\``).join(', ') : 'another branch'}, but the checked-out branch is \`${branch}\``,
|
|
676
|
+
],
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// 2. Deploy scripts
|
|
681
|
+
const scriptCandidates = [
|
|
682
|
+
['deploy/deploy.sh', './deploy/deploy.sh', 'deploy/deploy.sh'],
|
|
683
|
+
['scripts/deploy.sh', './scripts/deploy.sh', 'scripts/deploy.sh'],
|
|
684
|
+
['deploy.sh', './deploy.sh', 'deploy.sh'],
|
|
685
|
+
['bin/deploy', './bin/deploy', 'bin/deploy'],
|
|
686
|
+
['scripts/deploy.mjs', 'node scripts/deploy.mjs', 'scripts/deploy.mjs'],
|
|
687
|
+
['scripts/deploy.js', 'node scripts/deploy.js', 'scripts/deploy.js'],
|
|
688
|
+
['scripts/deploy.py', 'python3 scripts/deploy.py', 'scripts/deploy.py'],
|
|
689
|
+
];
|
|
690
|
+
for (const [rel, cmd, label] of scriptCandidates) {
|
|
691
|
+
if (!exists(projectPath, rel))
|
|
692
|
+
continue;
|
|
693
|
+
let command = cmd;
|
|
694
|
+
if (rel === 'deploy/deploy.sh' && readText(path.join(projectPath, rel)).includes('--skip-preflight')) {
|
|
695
|
+
command = `${cmd} --skip-preflight`;
|
|
696
|
+
}
|
|
697
|
+
candidates.push({ method: 'deploy_script', command, score: 80, evidence: [`deploy script \`${label}\` exists`] });
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
if (!candidates.some((c) => c.method === 'deploy_script')) {
|
|
701
|
+
if (/^deploy\s*:/m.test(readText(path.join(projectPath, 'Makefile')))) {
|
|
702
|
+
candidates.push({
|
|
703
|
+
method: 'deploy_script',
|
|
704
|
+
command: 'make deploy',
|
|
705
|
+
score: 78,
|
|
706
|
+
evidence: ['Makefile has a `deploy` target'],
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
try {
|
|
711
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8'));
|
|
712
|
+
if (pkg?.scripts?.deploy) {
|
|
713
|
+
candidates.push({
|
|
714
|
+
method: 'deploy_script',
|
|
715
|
+
command: exists(projectPath, 'pnpm-lock.yaml') ? 'pnpm run deploy' : 'npm run deploy',
|
|
716
|
+
score: 76,
|
|
717
|
+
evidence: ['package.json has a `deploy` script'],
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch { }
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// 3. Cloud CLIs
|
|
725
|
+
const cloud = [
|
|
726
|
+
[['wrangler.toml', 'wrangler.jsonc', 'wrangler.json'], 'npx wrangler deploy', 'Cloudflare Wrangler config'],
|
|
727
|
+
[['fly.toml'], 'fly deploy', 'Fly.io config (fly.toml)'],
|
|
728
|
+
[['firebase.json'], 'npx firebase deploy', 'Firebase config (firebase.json)'],
|
|
729
|
+
[['vercel.json'], 'npx vercel --prod', 'Vercel config (vercel.json)'],
|
|
730
|
+
[['netlify.toml'], 'npx netlify deploy --prod', 'Netlify config (netlify.toml)'],
|
|
731
|
+
];
|
|
732
|
+
for (const [files, command, label] of cloud) {
|
|
733
|
+
if (files.some((f) => exists(projectPath, f))) {
|
|
734
|
+
candidates.push({ method: 'cloud_cli', command, score: 70, evidence: [`${label} found`] });
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// 4. Docker Compose: what is actually running beats what merely exists on disk
|
|
739
|
+
const composeFiles = findComposeFiles(projectPath);
|
|
740
|
+
const stacks = dockerPs ? parseComposeStacks(dockerPs, projectPath) : [];
|
|
741
|
+
if (stacks.length > 0) {
|
|
742
|
+
const stackBuilds = (s) => s.configFiles.some((f) => /^\s+build\s*:/m.test(readText(f)));
|
|
743
|
+
// The stack that builds the app from this folder matters more than a bigger stack of prebuilt services
|
|
744
|
+
stacks.sort((a, b) => Number(stackBuilds(b) && b.runningCount > 0) - Number(stackBuilds(a) && a.runningCount > 0) ||
|
|
745
|
+
Number(stackBuilds(b)) - Number(stackBuilds(a)) ||
|
|
746
|
+
b.runningCount - a.runningCount);
|
|
747
|
+
const stack = stacks[0];
|
|
748
|
+
const files = stack.configFiles.map((f) => (isInside(f, projectPath) ? path.relative(projectPath, f) : f));
|
|
749
|
+
const builds = stackBuilds(stack);
|
|
750
|
+
const target = { projectName: stack.projectName, files, running: stack.runningCount > 0 };
|
|
751
|
+
const isRunning = stack.runningCount > 0;
|
|
752
|
+
candidates.push({
|
|
753
|
+
method: 'docker_compose',
|
|
754
|
+
command: composeCommand(target),
|
|
755
|
+
// A stack that is up right now is the deployment; a stopped one is only as strong as a file on disk
|
|
756
|
+
score: builds ? (isRunning ? 75 : 60) : isRunning ? 40 : 25,
|
|
757
|
+
compose: target,
|
|
758
|
+
evidence: [
|
|
759
|
+
`Docker Compose project \`${stack.projectName}\` (${stack.services.length} service${stack.services.length === 1 ? '' : 's'}, ${stack.runningCount} running) was started from ${files.map((f) => `\`${f}\``).join(' + ')}` +
|
|
760
|
+
(builds ? '' : ' — it only runs prebuilt images (no `build:`), so it looks like supporting services rather than the app itself'),
|
|
761
|
+
...(stacks.length > 1
|
|
762
|
+
? [`${stacks.length - 1} other Compose project(s) also run from this folder: ${stacks.slice(1).map((s) => s.projectName).join(', ')}`]
|
|
763
|
+
: []),
|
|
764
|
+
],
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
else if (composeFiles.length > 0) {
|
|
768
|
+
const pick = composeFiles.find((f) => f.kind === 'default' && !f.rel.includes('/')) ||
|
|
769
|
+
composeFiles.find((f) => f.kind === 'default') ||
|
|
770
|
+
composeFiles.find((f) => f.kind === 'dev' || f.kind === 'other') ||
|
|
771
|
+
composeFiles[0];
|
|
772
|
+
const target = { projectName: null, files: pick.rel === 'docker-compose.yml' || pick.rel === 'compose.yaml' || pick.rel === 'compose.yml' || pick.rel === 'docker-compose.yaml' ? [] : [pick.rel], running: false };
|
|
773
|
+
const score = pick.kind === 'prod' ? 20 : pick.hasBuild ? 60 : 25;
|
|
774
|
+
candidates.push({
|
|
775
|
+
method: 'docker_compose',
|
|
776
|
+
command: composeCommand(target),
|
|
777
|
+
score,
|
|
778
|
+
compose: target,
|
|
779
|
+
evidence: [
|
|
780
|
+
`Compose file \`${pick.rel}\` exists, but no containers from it are running on this machine` +
|
|
781
|
+
(pick.kind === 'prod' ? ' (it looks like a server-side production file)' : '') +
|
|
782
|
+
(pick.hasBuild ? '' : ' and it builds nothing from source'),
|
|
783
|
+
],
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
// 5. Plain git push
|
|
787
|
+
if (git.isRepo && git.hasRemote) {
|
|
788
|
+
candidates.push({
|
|
789
|
+
method: 'git_push',
|
|
790
|
+
command: null,
|
|
791
|
+
score: 30,
|
|
792
|
+
evidence: ['git remote configured; no deploy step found beyond pushing'],
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
// Project docs nudge the ranking toward what the humans wrote down
|
|
796
|
+
const hints = docHints(projectPath);
|
|
797
|
+
for (const c of candidates) {
|
|
798
|
+
const n = c.method === 'docker_compose' || c.method === 'github_actions' || c.method === 'deploy_script' ? hints[c.method] : 0;
|
|
799
|
+
if (n > 0) {
|
|
800
|
+
c.score = Math.min(95, c.score + Math.min(10, 4 + n * 2));
|
|
801
|
+
c.evidence.push(`project docs mention this deployment route (${n} reference${n === 1 ? '' : 's'})`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return candidates.sort((a, b) => b.score - a.score);
|
|
805
|
+
}
|
|
806
|
+
function confidenceFor(top, second) {
|
|
807
|
+
if (!top)
|
|
808
|
+
return 'low';
|
|
809
|
+
const margin = top.score - (second?.score || 0);
|
|
810
|
+
if (top.score >= 80 && margin >= 10)
|
|
811
|
+
return 'high';
|
|
812
|
+
if (top.score >= 55)
|
|
813
|
+
return 'medium';
|
|
814
|
+
return 'low';
|
|
815
|
+
}
|
|
816
|
+
/** Methods that repeatedly failed because they do not apply to this project are pushed down the ranking. */
|
|
817
|
+
function applyMemoryPenalties(candidates, memory) {
|
|
818
|
+
if (!memory?.failures)
|
|
819
|
+
return;
|
|
820
|
+
for (const c of candidates) {
|
|
821
|
+
const f = memory.failures[c.method];
|
|
822
|
+
if (f && f.count >= 2 && f.wrong_method) {
|
|
823
|
+
c.score = Math.max(0, c.score - 45);
|
|
824
|
+
c.evidence.push(`failed the last ${f.count} attempts on this machine (${f.last_error?.slice(0, 120) || f.last_kind})`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
828
|
+
}
|
|
829
|
+
export function resolveDeployProfile(projectPath, options = {}) {
|
|
830
|
+
const probes = options.probes || {};
|
|
831
|
+
const git = (probes.git || defaultGitInfo)(projectPath);
|
|
832
|
+
const workflows = readWorkflows(projectPath);
|
|
833
|
+
const dockerPs = probes.dockerPs ? probes.dockerPs() : defaultDockerPs();
|
|
834
|
+
const candidates = gatherCandidates(projectPath, git, workflows, dockerPs);
|
|
835
|
+
applyMemoryPenalties(candidates, options.memory);
|
|
836
|
+
const bound = options.bound || null;
|
|
837
|
+
const memory = options.memory || null;
|
|
838
|
+
const name = path.basename(projectPath);
|
|
839
|
+
const explicitFile = readExplicitFile(projectPath);
|
|
840
|
+
const explicitMemory = memory?.explicit || null;
|
|
841
|
+
// Live URL: explicit > user-set binding > learned > guess
|
|
842
|
+
let liveUrl = null;
|
|
843
|
+
let liveUrlSource = null;
|
|
844
|
+
const boundSource = (bound?.deploy_source || '').toLowerCase();
|
|
845
|
+
if (explicitFile?.live_url || explicitMemory?.live_url) {
|
|
846
|
+
liveUrl = explicitFile?.live_url || explicitMemory?.live_url || null;
|
|
847
|
+
liveUrlSource = 'explicit';
|
|
848
|
+
}
|
|
849
|
+
else if (bound?.live_url && boundSource === 'user') {
|
|
850
|
+
liveUrl = bound.live_url;
|
|
851
|
+
liveUrlSource = 'user';
|
|
852
|
+
}
|
|
853
|
+
else if (memory?.last_success?.live_url) {
|
|
854
|
+
liveUrl = memory.last_success.live_url;
|
|
855
|
+
liveUrlSource = 'learned';
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
liveUrl = inferLiveUrl(projectPath, name, git.remoteUrl);
|
|
859
|
+
liveUrlSource = liveUrl ? 'guessed' : null;
|
|
860
|
+
}
|
|
861
|
+
const base = {
|
|
862
|
+
liveUrl,
|
|
863
|
+
liveUrlSource,
|
|
864
|
+
buildCommand: bound?.build_command || detectBuildCommand(projectPath),
|
|
865
|
+
workflows,
|
|
866
|
+
git,
|
|
867
|
+
deploymentNotes: extractDeploymentNotes(projectPath),
|
|
868
|
+
};
|
|
869
|
+
const candidateFor = (method) => candidates.find((c) => c.method === method);
|
|
870
|
+
const pinned = (method, command, source, why) => {
|
|
871
|
+
const match = candidateFor(method);
|
|
872
|
+
const useCommand = isRunnableCommand(command) ? command.trim() : match?.command || null;
|
|
873
|
+
return {
|
|
874
|
+
...base,
|
|
875
|
+
method,
|
|
876
|
+
command: useCommand,
|
|
877
|
+
source,
|
|
878
|
+
confidence: 'high',
|
|
879
|
+
evidence: [why, ...(match?.evidence || [])],
|
|
880
|
+
// A custom compose command is run as given; otherwise reuse the discovered stack (so only the services
|
|
881
|
+
// affected by a change are rebuilt or restarted, and a changed -p/-f set is picked up).
|
|
882
|
+
compose: method === 'docker_compose' && (!isRunnableCommand(command) || (command || '').trim() === match?.command)
|
|
883
|
+
? match?.compose
|
|
884
|
+
: undefined,
|
|
885
|
+
dispatchOnly: Boolean(match?.dispatchOnly),
|
|
886
|
+
needsConfirmation: false,
|
|
887
|
+
alternatives: candidates.filter((c) => c.method !== method),
|
|
888
|
+
};
|
|
889
|
+
};
|
|
890
|
+
if (explicitFile) {
|
|
891
|
+
return pinned(explicitFile.method, explicitFile.command, 'explicit', 'set in `.agilemonster/deploy.json`');
|
|
892
|
+
}
|
|
893
|
+
if (explicitMemory) {
|
|
894
|
+
const method = normalizeMethod(explicitMemory.method);
|
|
895
|
+
if (method) {
|
|
896
|
+
return pinned(method, explicitMemory.command || null, 'explicit', `recorded for this project${explicitMemory.set_by ? ` by ${explicitMemory.set_by}` : ''}${explicitMemory.notes ? `: ${explicitMemory.notes.slice(0, 160)}` : ''}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (bound && boundSource === 'user') {
|
|
900
|
+
const method = normalizeMethod(bound.deploy_method);
|
|
901
|
+
if (method)
|
|
902
|
+
return pinned(method, bound.deploy_command || null, 'user', 'set by a teammate in Agile Monster');
|
|
903
|
+
}
|
|
904
|
+
// What worked last time, as long as it has not started failing since
|
|
905
|
+
const last = memory?.last_success || null;
|
|
906
|
+
if (last) {
|
|
907
|
+
const method = normalizeMethod(last.method);
|
|
908
|
+
const failing = method ? memory?.failures?.[method] : null;
|
|
909
|
+
const stillPlausible = method && (candidateFor(method) || isRunnableCommand(last.command) || method === 'manual' || method === 'none');
|
|
910
|
+
if (method && stillPlausible && !(failing && failing.count >= 2)) {
|
|
911
|
+
return pinned(method, last.command || null, 'learned', `this is how the last successful deployment ran (${last.at.slice(0, 10)}${last.commit ? `, commit ${last.commit}` : ''})`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (bound && boundSource === 'verified') {
|
|
915
|
+
const method = normalizeMethod(bound.deploy_method);
|
|
916
|
+
if (method && (candidateFor(method) || isRunnableCommand(bound.deploy_command))) {
|
|
917
|
+
return pinned(method, bound.deploy_command || null, 'verified', 'verified by an earlier deployment recorded in Agile Monster');
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const top = candidates[0];
|
|
921
|
+
if (!top) {
|
|
922
|
+
return {
|
|
923
|
+
...base,
|
|
924
|
+
method: 'none',
|
|
925
|
+
command: null,
|
|
926
|
+
source: 'detected',
|
|
927
|
+
confidence: 'low',
|
|
928
|
+
evidence: ['no deployment mechanism found in this project (no deploy workflow, script, cloud config, Compose stack, or git remote)'],
|
|
929
|
+
dispatchOnly: false,
|
|
930
|
+
needsConfirmation: false,
|
|
931
|
+
alternatives: [],
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
const evidence = [...top.evidence];
|
|
935
|
+
const boundMethod = normalizeMethod(bound?.deploy_method);
|
|
936
|
+
if (boundMethod && boundMethod !== top.method) {
|
|
937
|
+
evidence.push(`an earlier automatic guess stored for this board said \`${boundMethod}\`; the evidence in the project now points to \`${top.method}\``);
|
|
938
|
+
}
|
|
939
|
+
const command = top.command || (boundMethod === top.method && isRunnableCommand(bound?.deploy_command) ? bound?.deploy_command : null);
|
|
940
|
+
return {
|
|
941
|
+
...base,
|
|
942
|
+
method: top.method,
|
|
943
|
+
command,
|
|
944
|
+
source: 'detected',
|
|
945
|
+
confidence: confidenceFor(top, candidates[1]),
|
|
946
|
+
evidence,
|
|
947
|
+
compose: top.compose,
|
|
948
|
+
dispatchOnly: Boolean(top.dispatchOnly),
|
|
949
|
+
needsConfirmation: requiresConfirmation(top),
|
|
950
|
+
alternatives: candidates.slice(1),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Pushing to the checked-out branch (and whatever pipeline the repo attached to that) and refreshing local
|
|
955
|
+
* containers are routine. Running a deploy script, a cloud CLI, or manually dispatching a workflow reaches
|
|
956
|
+
* production in ways the repository did not automate — so an unconfirmed guess must be confirmed once.
|
|
957
|
+
*/
|
|
958
|
+
export function requiresConfirmation(candidate) {
|
|
959
|
+
return candidate.method === 'deploy_script' || candidate.method === 'cloud_cli' || Boolean(candidate.dispatchOnly);
|
|
960
|
+
}
|
|
961
|
+
const METHOD_LABELS = {
|
|
962
|
+
github_actions: 'GitHub Actions (push triggers the pipeline)',
|
|
963
|
+
docker_compose: 'local Docker Compose (rebuild and recreate containers)',
|
|
964
|
+
deploy_script: 'deploy script',
|
|
965
|
+
cloud_cli: 'cloud CLI',
|
|
966
|
+
git_push: 'git push only',
|
|
967
|
+
manual: 'manual (a human deploys)',
|
|
968
|
+
none: 'no deployment',
|
|
969
|
+
};
|
|
970
|
+
export function methodLabel(method) {
|
|
971
|
+
return METHOD_LABELS[method] || method;
|
|
972
|
+
}
|
|
973
|
+
/** Compact, human-readable description used in card comments, the code model's context, and MCP tool output. */
|
|
974
|
+
export function describeProfile(profile) {
|
|
975
|
+
const lines = [
|
|
976
|
+
`Deployment method: ${methodLabel(profile.method)}${profile.command ? ` — \`${profile.command}\`` : ''}`,
|
|
977
|
+
`Confidence: ${profile.confidence} (source: ${profile.source})${profile.needsConfirmation ? ' — not confirmed yet: a background agent will ask before running this the first time' : ''}`,
|
|
978
|
+
...profile.evidence.slice(0, 4).map((e) => `- ${e}`),
|
|
979
|
+
];
|
|
980
|
+
if (profile.git.isRepo) {
|
|
981
|
+
lines.push(`Git: branch \`${profile.git.branch || 'detached'}\`${profile.git.hasRemote ? '' : ', no remote'}`);
|
|
982
|
+
}
|
|
983
|
+
if (profile.liveUrl) {
|
|
984
|
+
lines.push(`Live URL: ${profile.liveUrl}${profile.liveUrlSource === 'guessed' ? ' (guess)' : ''}`);
|
|
985
|
+
}
|
|
986
|
+
if (profile.alternatives.length > 0) {
|
|
987
|
+
lines.push(`Other routes found: ${profile.alternatives
|
|
988
|
+
.slice(0, 3)
|
|
989
|
+
.map((a) => `${a.method}${a.command ? ` (\`${a.command}\`)` : ''}`)
|
|
990
|
+
.join('; ')}`);
|
|
991
|
+
}
|
|
992
|
+
return lines.join('\n');
|
|
993
|
+
}
|