@reyemtech/stack-upgrade 0.4.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/package.json +25 -0
- package/src/config.js +38 -0
- package/src/credentials.js +115 -0
- package/src/docker.js +108 -0
- package/src/github.js +137 -0
- package/src/index.js +144 -0
- package/src/kubectl.js +260 -0
- package/src/pod-name.js +27 -0
- package/src/prompts.js +85 -0
- package/src/stacks.js +36 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reyemtech/stack-upgrade",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "CLI to launch Stack Upgrade Agents via Docker or Kubernetes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"stack-upgrade": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@clack/prompts": "^0.10",
|
|
14
|
+
"picocolors": "^1.1"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/ReyemTech/stack-upgrade.git",
|
|
22
|
+
"directory": "cli"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT"
|
|
25
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.stack-upgrade');
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load persisted config from ~/.stack-upgrade/config.json.
|
|
10
|
+
* @returns {Record<string, any>}
|
|
11
|
+
*/
|
|
12
|
+
export function loadConfig() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Save config to ~/.stack-upgrade/config.json (merges with existing).
|
|
22
|
+
* @param {Record<string, any>} updates
|
|
23
|
+
*/
|
|
24
|
+
export function saveConfig(updates) {
|
|
25
|
+
const existing = loadConfig();
|
|
26
|
+
const merged = { ...existing, ...updates };
|
|
27
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
28
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get a single config value.
|
|
33
|
+
* @param {string} key
|
|
34
|
+
* @returns {any}
|
|
35
|
+
*/
|
|
36
|
+
export function getConfig(key) {
|
|
37
|
+
return loadConfig()[key];
|
|
38
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import * as p from '@clack/prompts';
|
|
6
|
+
import { getConfig, saveConfig } from './config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Auto-detect Claude credentials.
|
|
10
|
+
* Priority: saved config > credentials.json > env vars > prompt
|
|
11
|
+
* @param {{ promptIfMissing?: boolean }} options
|
|
12
|
+
* @returns {{ type: 'oauth' | 'apikey', value: string, source: string } | null}
|
|
13
|
+
*/
|
|
14
|
+
export async function detectClaudeCredentials({ promptIfMissing = true } = {}) {
|
|
15
|
+
// 1. Saved config (~/.stack-upgrade/config.json)
|
|
16
|
+
const saved = getConfig('claudeCredentials');
|
|
17
|
+
if (saved?.value) {
|
|
18
|
+
return {
|
|
19
|
+
type: saved.type,
|
|
20
|
+
value: saved.value,
|
|
21
|
+
source: '~/.stack-upgrade/config.json',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. ~/.claude/.credentials.json
|
|
26
|
+
try {
|
|
27
|
+
const credPath = join(homedir(), '.claude', '.credentials.json');
|
|
28
|
+
const raw = await readFile(credPath, 'utf-8');
|
|
29
|
+
const creds = JSON.parse(raw);
|
|
30
|
+
const oauth = creds?.claudeAiOauth;
|
|
31
|
+
if (oauth?.accessToken && oauth?.expiresAt > Date.now()) {
|
|
32
|
+
return {
|
|
33
|
+
type: 'oauth',
|
|
34
|
+
value: oauth.accessToken,
|
|
35
|
+
source: '~/.claude/.credentials.json',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// File doesn't exist or is invalid — continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. CLAUDE_CODE_OAUTH_TOKEN env var
|
|
43
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
44
|
+
return {
|
|
45
|
+
type: 'oauth',
|
|
46
|
+
value: process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
47
|
+
source: 'CLAUDE_CODE_OAUTH_TOKEN env var',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 4. ANTHROPIC_API_KEY env var
|
|
52
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
53
|
+
return {
|
|
54
|
+
type: 'apikey',
|
|
55
|
+
value: process.env.ANTHROPIC_API_KEY,
|
|
56
|
+
source: 'ANTHROPIC_API_KEY env var',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 5. Prompt user (or return null if not allowed)
|
|
61
|
+
if (!promptIfMissing) return null;
|
|
62
|
+
|
|
63
|
+
const hasClaude = (() => {
|
|
64
|
+
try { execFileSync('which', ['claude'], { stdio: 'ignore' }); return true; } catch { return false; }
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
const method = await p.select({
|
|
68
|
+
message: 'Claude credentials not found. How do you want to authenticate?',
|
|
69
|
+
options: [
|
|
70
|
+
...(hasClaude ? [{ value: 'setup-token', label: 'Run claude setup-token (recommended)', hint: 'opens browser login' }] : []),
|
|
71
|
+
{ value: 'oauth', label: 'Paste OAuth token manually' },
|
|
72
|
+
{ value: 'apikey', label: 'Paste API key (Anthropic)' },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
if (p.isCancel(method)) process.exit(0);
|
|
76
|
+
|
|
77
|
+
let result;
|
|
78
|
+
|
|
79
|
+
if (method === 'setup-token') {
|
|
80
|
+
p.log.info('Launching claude setup-token...');
|
|
81
|
+
try {
|
|
82
|
+
const output = execFileSync('claude', ['setup-token'], {
|
|
83
|
+
encoding: 'utf-8',
|
|
84
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const match = output.match(/\b(sk-ant-oat\S+)/);
|
|
88
|
+
if (match) {
|
|
89
|
+
p.log.success('Token captured from setup-token');
|
|
90
|
+
result = { type: 'oauth', value: match[1], source: 'claude setup-token' };
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// fall through
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!result) {
|
|
97
|
+
p.log.warn('Could not capture token automatically. Please paste it below.');
|
|
98
|
+
const token = await p.password({ message: 'Paste the OAuth token from above:' });
|
|
99
|
+
if (p.isCancel(token)) process.exit(0);
|
|
100
|
+
result = { type: 'oauth', value: token, source: 'claude setup-token (manual paste)' };
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
const value = await p.password({
|
|
104
|
+
message: method === 'oauth' ? 'Paste your OAuth token:' : 'Paste your API key:',
|
|
105
|
+
});
|
|
106
|
+
if (p.isCancel(value)) process.exit(0);
|
|
107
|
+
result = { type: method, value, source: 'manual input' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Save to config for next time
|
|
111
|
+
saveConfig({ claudeCredentials: { type: result.type, value: result.value } });
|
|
112
|
+
p.log.success('Credentials saved to ~/.stack-upgrade/config.json');
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
package/src/docker.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import { deriveName } from './pod-name.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if Docker is available.
|
|
9
|
+
*/
|
|
10
|
+
export function hasDocker() {
|
|
11
|
+
try {
|
|
12
|
+
execFileSync('docker', ['info'], { stdio: 'ignore' });
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pull a Docker image.
|
|
21
|
+
* @param {string} image
|
|
22
|
+
*/
|
|
23
|
+
function pullImage(image) {
|
|
24
|
+
const pullSpinner = p.spinner();
|
|
25
|
+
pullSpinner.start(`Pulling ${image}...`);
|
|
26
|
+
try {
|
|
27
|
+
execFileSync('docker', ['pull', image], { stdio: 'ignore' });
|
|
28
|
+
pullSpinner.stop('Pulled latest image');
|
|
29
|
+
} catch {
|
|
30
|
+
pullSpinner.stop('Using cached image (pull failed)');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Launch a single Docker container for an upgrade.
|
|
36
|
+
* @returns {{ containerName: string, outputDir: string }}
|
|
37
|
+
*/
|
|
38
|
+
function startContainer({ repoUrl, targetVersion, push, suffix, ghToken, claudeCreds, image, stack, envKey }) {
|
|
39
|
+
const containerName = deriveName(repoUrl, stack, targetVersion, suffix);
|
|
40
|
+
const repoShort = repoUrl.replace(/.*[:/]/, '').replace(/\.git$/, '');
|
|
41
|
+
const outputDir = resolve(process.cwd(), 'output', repoShort);
|
|
42
|
+
|
|
43
|
+
const args = ['run', '--rm', '-d', '--name', containerName];
|
|
44
|
+
|
|
45
|
+
const env = {
|
|
46
|
+
REPO_URL: repoUrl,
|
|
47
|
+
[envKey]: targetVersion,
|
|
48
|
+
GIT_PUSH: push ? 'true' : 'false',
|
|
49
|
+
GH_TOKEN: ghToken,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (suffix) env.BRANCH_SUFFIX = suffix;
|
|
53
|
+
|
|
54
|
+
if (claudeCreds.type === 'oauth') {
|
|
55
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = claudeCreds.value;
|
|
56
|
+
} else {
|
|
57
|
+
env.ANTHROPIC_API_KEY = claudeCreds.value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [key, val] of Object.entries(env)) {
|
|
61
|
+
if (val) args.push('-e', `${key}=${val}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
args.push('-v', `${outputDir}:/output`);
|
|
65
|
+
args.push(image);
|
|
66
|
+
|
|
67
|
+
execFileSync('docker', args, { stdio: 'ignore' });
|
|
68
|
+
|
|
69
|
+
return { containerName, outputDir };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Launch one or more upgrades as local Docker containers.
|
|
74
|
+
* @param {Array<object>} upgrades - array of upgrade configs
|
|
75
|
+
*/
|
|
76
|
+
export async function launchDocker(upgrades) {
|
|
77
|
+
// Pull unique images
|
|
78
|
+
const images = [...new Set(upgrades.map((u) => u.image))];
|
|
79
|
+
for (const image of images) {
|
|
80
|
+
pullImage(image);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Start all containers
|
|
84
|
+
const launched = [];
|
|
85
|
+
const launchSpinner = p.spinner();
|
|
86
|
+
launchSpinner.start(`Starting ${upgrades.length} ${upgrades.length === 1 ? 'container' : 'containers'}...`);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
for (const upgrade of upgrades) {
|
|
90
|
+
const result = startContainer(upgrade);
|
|
91
|
+
launched.push({ ...upgrade, ...result });
|
|
92
|
+
}
|
|
93
|
+
launchSpinner.stop(`${launched.length} ${launched.length === 1 ? 'container' : 'containers'} started`);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
launchSpinner.stop('Failed to start container');
|
|
96
|
+
p.log.error(err.message);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Summary
|
|
101
|
+
const lines = launched.map((l) => [
|
|
102
|
+
`${pc.bold(l.containerName)}:`,
|
|
103
|
+
` Logs: docker logs -f ${l.containerName}`,
|
|
104
|
+
` Output: ${l.outputDir}/`,
|
|
105
|
+
].join('\n'));
|
|
106
|
+
|
|
107
|
+
p.note(lines.join('\n\n'), `${launched.length} ${launched.length === 1 ? 'upgrade' : 'upgrades'} running!`);
|
|
108
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import { detectStack } from './stacks.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the GitHub auth token from `gh auth token`.
|
|
7
|
+
* @returns {string|null}
|
|
8
|
+
*/
|
|
9
|
+
export function getGhToken() {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('gh', ['auth', 'token'], { encoding: 'utf-8' }).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the authenticated GitHub username.
|
|
19
|
+
* @returns {string|null}
|
|
20
|
+
*/
|
|
21
|
+
export function getGhUser() {
|
|
22
|
+
try {
|
|
23
|
+
const out = execFileSync(
|
|
24
|
+
'gh', ['api', '/user', '--jq', '.login'],
|
|
25
|
+
{ encoding: 'utf-8' },
|
|
26
|
+
);
|
|
27
|
+
return out.trim();
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* List PHP repos the user has access to, detect stack + version.
|
|
35
|
+
* @returns {Promise<Array<{ name: string, url: string, stack: string, stackName: string, version: string }>>}
|
|
36
|
+
*/
|
|
37
|
+
export async function discoverRepos() {
|
|
38
|
+
// Fetch all PHP repos (owner + collaborator)
|
|
39
|
+
let repos;
|
|
40
|
+
try {
|
|
41
|
+
const out = execFileSync('gh', [
|
|
42
|
+
'api', '/user/repos',
|
|
43
|
+
'--paginate',
|
|
44
|
+
'--jq', '.[] | select(.language == "PHP") | .full_name',
|
|
45
|
+
], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
46
|
+
repos = out.trim().split('\n').filter(Boolean);
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (repos.length === 0) return [];
|
|
52
|
+
|
|
53
|
+
// Check each repo for known stacks (parallel, with concurrency limit)
|
|
54
|
+
const results = [];
|
|
55
|
+
const batchSize = 10;
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < repos.length; i += batchSize) {
|
|
58
|
+
const batch = repos.slice(i, i + batchSize);
|
|
59
|
+
const promises = batch.map(async (fullName) => {
|
|
60
|
+
try {
|
|
61
|
+
const out = execFileSync('gh', [
|
|
62
|
+
'api', `/repos/${fullName}/contents/composer.json`,
|
|
63
|
+
'--jq', '.content',
|
|
64
|
+
], { encoding: 'utf-8' });
|
|
65
|
+
|
|
66
|
+
const content = Buffer.from(out.trim(), 'base64').toString('utf-8');
|
|
67
|
+
const composer = JSON.parse(content);
|
|
68
|
+
const detected = detectStack(composer);
|
|
69
|
+
if (!detected) return null;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
name: fullName,
|
|
73
|
+
url: `https://github.com/${fullName}.git`,
|
|
74
|
+
stack: detected.stack,
|
|
75
|
+
stackName: detected.stack.charAt(0).toUpperCase() + detected.stack.slice(1),
|
|
76
|
+
version: detected.version,
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const batchResults = await Promise.all(promises);
|
|
84
|
+
results.push(...batchResults.filter(Boolean));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prompt user to select a repo or enter manually.
|
|
92
|
+
* @param {Array} repos
|
|
93
|
+
* @returns {Promise<{ name: string, url: string, stack: string, version: string }>}
|
|
94
|
+
*/
|
|
95
|
+
export async function selectRepo(repos) {
|
|
96
|
+
if (repos.length === 0) {
|
|
97
|
+
const url = await p.text({
|
|
98
|
+
message: 'Enter the repository URL:',
|
|
99
|
+
placeholder: 'https://github.com/org/repo.git',
|
|
100
|
+
validate: (v) => {
|
|
101
|
+
if (!v) return 'URL is required';
|
|
102
|
+
if (!v.includes('github.com')) return 'Must be a GitHub URL';
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
if (p.isCancel(url)) process.exit(0);
|
|
106
|
+
|
|
107
|
+
return { name: url.replace(/.*github\.com[:/]/, '').replace(/\.git$/, ''), url, stack: 'laravel', version: 'unknown' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const options = repos.map((r) => ({
|
|
111
|
+
value: r,
|
|
112
|
+
label: r.name,
|
|
113
|
+
hint: `${r.stackName} ${r.version}`,
|
|
114
|
+
}));
|
|
115
|
+
options.push({ value: 'manual', label: 'Enter URL manually' });
|
|
116
|
+
|
|
117
|
+
const choice = await p.select({
|
|
118
|
+
message: 'Which repo do you want to upgrade?',
|
|
119
|
+
options,
|
|
120
|
+
});
|
|
121
|
+
if (p.isCancel(choice)) process.exit(0);
|
|
122
|
+
|
|
123
|
+
if (choice === 'manual') {
|
|
124
|
+
const url = await p.text({
|
|
125
|
+
message: 'Enter the repository URL:',
|
|
126
|
+
placeholder: 'https://github.com/org/repo.git',
|
|
127
|
+
validate: (v) => {
|
|
128
|
+
if (!v) return 'URL is required';
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (p.isCancel(url)) process.exit(0);
|
|
132
|
+
|
|
133
|
+
return { name: url.replace(/.*github\.com[:/]/, '').replace(/\.git$/, ''), url, stack: 'laravel', version: 'unknown' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return choice;
|
|
137
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { getGhToken, getGhUser, discoverRepos, selectRepo } from './github.js';
|
|
6
|
+
import { detectClaudeCredentials } from './credentials.js';
|
|
7
|
+
import { askRunTarget, askTargetVersion, askPush, askSuffix, askAddAnother } from './prompts.js';
|
|
8
|
+
import { hasDocker, launchDocker } from './docker.js';
|
|
9
|
+
import { hasKubectl, launchKubernetes } from './kubectl.js';
|
|
10
|
+
import { getConfig, saveConfig } from './config.js';
|
|
11
|
+
import { getStack, STACKS } from './stacks.js';
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
p.intro(pc.bold('Stack Upgrade Agent'));
|
|
15
|
+
|
|
16
|
+
// --- Prerequisites (auto-detectable) ---
|
|
17
|
+
const preSpinner = p.spinner();
|
|
18
|
+
preSpinner.start('Checking prerequisites...');
|
|
19
|
+
|
|
20
|
+
let ghToken = getGhToken();
|
|
21
|
+
if (!ghToken) ghToken = getConfig('ghToken') || null;
|
|
22
|
+
const ghUser = ghToken ? getGhUser() : null;
|
|
23
|
+
const claudeAutoDetect = await detectClaudeCredentials({ promptIfMissing: false });
|
|
24
|
+
|
|
25
|
+
if (ghUser) {
|
|
26
|
+
p.log.message(`${pc.green('\u2713')} GitHub CLI authenticated (${ghUser})`);
|
|
27
|
+
} else {
|
|
28
|
+
p.log.message(`${pc.yellow('!')} GitHub CLI not authenticated (will prompt for repo URL)`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
preSpinner.stop('Prerequisites checked');
|
|
32
|
+
|
|
33
|
+
// Claude credentials — prompt after spinner if not auto-detected
|
|
34
|
+
let claudeCreds;
|
|
35
|
+
if (claudeAutoDetect) {
|
|
36
|
+
p.log.message(`${pc.green('\u2713')} Claude credentials found (${claudeAutoDetect.source})`);
|
|
37
|
+
claudeCreds = claudeAutoDetect;
|
|
38
|
+
} else {
|
|
39
|
+
claudeCreds = await detectClaudeCredentials({ promptIfMissing: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Save GH token if we got one from `gh auth`
|
|
43
|
+
if (ghToken && !getConfig('ghToken')) {
|
|
44
|
+
saveConfig({ ghToken });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Run target ---
|
|
48
|
+
const dockerAvailable = hasDocker();
|
|
49
|
+
const kubectlAvailable = hasKubectl();
|
|
50
|
+
const savedTarget = getConfig('runTarget');
|
|
51
|
+
|
|
52
|
+
let target;
|
|
53
|
+
if (savedTarget && ((savedTarget === 'docker' && dockerAvailable) || (savedTarget === 'kubernetes' && kubectlAvailable))) {
|
|
54
|
+
target = savedTarget;
|
|
55
|
+
p.log.message(`${pc.green('\u2713')} Run target: ${savedTarget === 'docker' ? 'Local Docker' : 'Kubernetes'} (saved)`);
|
|
56
|
+
} else {
|
|
57
|
+
target = await askRunTarget({ hasDocker: dockerAvailable, hasKubectl: kubectlAvailable });
|
|
58
|
+
saveConfig({ runTarget: target });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Repo discovery ---
|
|
62
|
+
let repos = [];
|
|
63
|
+
if (ghToken) {
|
|
64
|
+
const scanSpinner = p.spinner();
|
|
65
|
+
scanSpinner.start('Scanning your repos...');
|
|
66
|
+
repos = await discoverRepos();
|
|
67
|
+
scanSpinner.stop(`Found ${repos.length} ${repos.length === 1 ? 'repo' : 'repos'}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Shared config ---
|
|
71
|
+
const push = await askPush();
|
|
72
|
+
const suffix = await askSuffix();
|
|
73
|
+
|
|
74
|
+
// --- Multi-upgrade loop ---
|
|
75
|
+
const upgrades = [];
|
|
76
|
+
|
|
77
|
+
// eslint-disable-next-line no-constant-condition
|
|
78
|
+
while (true) {
|
|
79
|
+
const repo = await selectRepo(repos);
|
|
80
|
+
|
|
81
|
+
// Resolve stack
|
|
82
|
+
const stackConfig = getStack(repo.stack);
|
|
83
|
+
if (!stackConfig) {
|
|
84
|
+
p.log.warn(`Unknown stack: ${repo.stack}. Defaulting to Laravel.`);
|
|
85
|
+
}
|
|
86
|
+
const stack = stackConfig || getStack('laravel');
|
|
87
|
+
|
|
88
|
+
const targetVersion = await askTargetVersion(stack.versionLabel);
|
|
89
|
+
|
|
90
|
+
const branchName = suffix
|
|
91
|
+
? `${stack.branchPrefix}-${targetVersion}-${suffix}`
|
|
92
|
+
: `${stack.branchPrefix}-${targetVersion}`;
|
|
93
|
+
|
|
94
|
+
upgrades.push({
|
|
95
|
+
repoUrl: repo.url,
|
|
96
|
+
repoName: repo.name,
|
|
97
|
+
targetVersion,
|
|
98
|
+
push,
|
|
99
|
+
suffix: suffix || '',
|
|
100
|
+
ghToken,
|
|
101
|
+
claudeCreds,
|
|
102
|
+
image: stack.image,
|
|
103
|
+
stack: repo.stack,
|
|
104
|
+
stackName: stack.name,
|
|
105
|
+
envKey: stack.envKey,
|
|
106
|
+
branchName,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const another = await askAddAnother();
|
|
110
|
+
if (!another) break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Confirmation ---
|
|
114
|
+
const summaryLines = upgrades.map((u, i) =>
|
|
115
|
+
` ${i + 1}. ${u.repoName} ${u.stackName} → ${u.targetVersion} ${target === 'docker' ? 'Local Docker' : 'Kubernetes'}`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
p.note([
|
|
119
|
+
...summaryLines,
|
|
120
|
+
'',
|
|
121
|
+
`Push+PR: ${push ? 'yes' : 'no'}${suffix ? ` Suffix: ${suffix}` : ''}`,
|
|
122
|
+
].join('\n'), `Ready to launch ${upgrades.length} ${upgrades.length === 1 ? 'upgrade' : 'upgrades'}`);
|
|
123
|
+
|
|
124
|
+
const confirm = await p.confirm({ message: 'Launch now?' });
|
|
125
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
126
|
+
p.cancel('Aborted.');
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Launch ---
|
|
131
|
+
if (target === 'docker') {
|
|
132
|
+
await launchDocker(upgrades);
|
|
133
|
+
} else {
|
|
134
|
+
await launchKubernetes(upgrades);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
p.outro('Done!');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main().catch((err) => {
|
|
141
|
+
if (err.message?.includes('User force closed')) process.exit(0);
|
|
142
|
+
p.cancel(err.message);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
package/src/kubectl.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import { deriveName } from './pod-name.js';
|
|
5
|
+
|
|
6
|
+
const SECRET_NAME = 'upgrade-agent';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if kubectl is available.
|
|
10
|
+
*/
|
|
11
|
+
export function hasKubectl() {
|
|
12
|
+
try {
|
|
13
|
+
execFileSync('kubectl', ['version', '--client'], { stdio: 'ignore' });
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* List available k8s contexts.
|
|
22
|
+
* @returns {string[]}
|
|
23
|
+
*/
|
|
24
|
+
function listContexts() {
|
|
25
|
+
try {
|
|
26
|
+
const out = execFileSync('kubectl', ['config', 'get-contexts', '-o', 'name'], { encoding: 'utf-8' });
|
|
27
|
+
return out.trim().split('\n').filter(Boolean);
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get current k8s context.
|
|
35
|
+
* @returns {string|null}
|
|
36
|
+
*/
|
|
37
|
+
function currentContext() {
|
|
38
|
+
try {
|
|
39
|
+
return execFileSync('kubectl', ['config', 'current-context'], { encoding: 'utf-8' }).trim();
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List namespaces in the current context.
|
|
47
|
+
* @returns {string[]}
|
|
48
|
+
*/
|
|
49
|
+
function listNamespaces() {
|
|
50
|
+
try {
|
|
51
|
+
const out = execFileSync('kubectl', ['get', 'namespaces', '-o', 'jsonpath={.items[*].metadata.name}'], { encoding: 'utf-8' });
|
|
52
|
+
return out.trim().split(/\s+/).filter(Boolean);
|
|
53
|
+
} catch {
|
|
54
|
+
return ['default'];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Prompt for k8s context selection.
|
|
60
|
+
* @returns {Promise<string>}
|
|
61
|
+
*/
|
|
62
|
+
async function selectContext() {
|
|
63
|
+
const contexts = listContexts();
|
|
64
|
+
if (contexts.length === 0) {
|
|
65
|
+
p.cancel('No Kubernetes contexts found. Run kubectl config to set one up.');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (contexts.length === 1) {
|
|
70
|
+
p.log.info(`Using context: ${contexts[0]}`);
|
|
71
|
+
return contexts[0];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const current = currentContext();
|
|
75
|
+
const options = contexts.map((c) => ({
|
|
76
|
+
value: c,
|
|
77
|
+
label: c,
|
|
78
|
+
hint: c === current ? 'current' : undefined,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
const choice = await p.select({
|
|
82
|
+
message: 'Kubernetes context',
|
|
83
|
+
options,
|
|
84
|
+
});
|
|
85
|
+
if (p.isCancel(choice)) process.exit(0);
|
|
86
|
+
return choice;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Prompt for namespace selection.
|
|
91
|
+
* @returns {Promise<string>}
|
|
92
|
+
*/
|
|
93
|
+
async function selectNamespace() {
|
|
94
|
+
const namespaces = listNamespaces();
|
|
95
|
+
const common = ['default', 'upgrades'];
|
|
96
|
+
const options = [];
|
|
97
|
+
|
|
98
|
+
for (const ns of common) {
|
|
99
|
+
if (namespaces.includes(ns)) {
|
|
100
|
+
options.push({ value: ns, label: ns });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
options.push({ value: '__other', label: 'Other' });
|
|
104
|
+
|
|
105
|
+
const choice = await p.select({
|
|
106
|
+
message: 'Kubernetes namespace',
|
|
107
|
+
options,
|
|
108
|
+
});
|
|
109
|
+
if (p.isCancel(choice)) process.exit(0);
|
|
110
|
+
|
|
111
|
+
if (choice === '__other') {
|
|
112
|
+
const ns = await p.text({
|
|
113
|
+
message: 'Namespace name:',
|
|
114
|
+
validate: (v) => { if (!v) return 'Required'; },
|
|
115
|
+
});
|
|
116
|
+
if (p.isCancel(ns)) process.exit(0);
|
|
117
|
+
return ns;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return choice;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ensure the upgrade-agent secret exists in the namespace.
|
|
125
|
+
*/
|
|
126
|
+
function ensureSecret(namespace, ghToken, claudeCreds) {
|
|
127
|
+
try {
|
|
128
|
+
execFileSync('kubectl', ['get', 'secret', SECRET_NAME, '-n', namespace], { stdio: 'ignore' });
|
|
129
|
+
p.log.info(`Secret "${SECRET_NAME}" exists in namespace "${namespace}"`);
|
|
130
|
+
return;
|
|
131
|
+
} catch {
|
|
132
|
+
// Secret doesn't exist — create it
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
p.log.info(`Creating secret "${SECRET_NAME}" in namespace "${namespace}"...`);
|
|
136
|
+
|
|
137
|
+
const args = [
|
|
138
|
+
'create', 'secret', 'generic', SECRET_NAME,
|
|
139
|
+
'-n', namespace,
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
if (ghToken) {
|
|
143
|
+
args.push(`--from-literal=GH_TOKEN=${ghToken}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (claudeCreds.type === 'oauth') {
|
|
147
|
+
args.push(`--from-literal=CLAUDE_CODE_OAUTH_TOKEN=${claudeCreds.value}`);
|
|
148
|
+
} else {
|
|
149
|
+
args.push(`--from-literal=ANTHROPIC_API_KEY=${claudeCreds.value}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
execFileSync('kubectl', args, { stdio: 'ignore' });
|
|
153
|
+
p.log.success(`Secret created (Claude: ${claudeCreds.type}, GitHub: ${ghToken ? 'yes' : 'no'})`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Launch a single pod for an upgrade.
|
|
158
|
+
*/
|
|
159
|
+
function startPod({ namespace, repoUrl, targetVersion, push, suffix, ghToken, claudeCreds, image, stack, envKey }) {
|
|
160
|
+
const podName = deriveName(repoUrl, stack, targetVersion, suffix);
|
|
161
|
+
|
|
162
|
+
const envVars = [
|
|
163
|
+
{ name: 'REPO_URL', value: repoUrl },
|
|
164
|
+
{ name: envKey, value: targetVersion },
|
|
165
|
+
{ name: 'GIT_PUSH', value: push ? 'true' : 'false' },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
if (suffix) envVars.push({ name: 'BRANCH_SUFFIX', value: suffix });
|
|
169
|
+
|
|
170
|
+
// Secret-backed env vars
|
|
171
|
+
const secretEnvs = ['GH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|
172
|
+
for (const key of secretEnvs) {
|
|
173
|
+
envVars.push({
|
|
174
|
+
name: key,
|
|
175
|
+
valueFrom: { secretKeyRef: { name: SECRET_NAME, key, optional: true } },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const overrides = JSON.stringify({
|
|
180
|
+
spec: {
|
|
181
|
+
containers: [{
|
|
182
|
+
name: podName,
|
|
183
|
+
image,
|
|
184
|
+
env: envVars,
|
|
185
|
+
resources: {
|
|
186
|
+
requests: { cpu: '1', memory: '2Gi' },
|
|
187
|
+
limits: { cpu: '2', memory: '4Gi' },
|
|
188
|
+
},
|
|
189
|
+
}],
|
|
190
|
+
restartPolicy: 'Never',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
execFileSync('kubectl', [
|
|
195
|
+
'run', podName,
|
|
196
|
+
`--namespace=${namespace}`,
|
|
197
|
+
`--image=${image}`,
|
|
198
|
+
'--restart=Never',
|
|
199
|
+
`--overrides=${overrides}`,
|
|
200
|
+
], { stdio: 'ignore' });
|
|
201
|
+
|
|
202
|
+
return podName;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Launch one or more upgrades as Kubernetes pods.
|
|
207
|
+
* @param {Array<object>} upgrades - array of upgrade configs
|
|
208
|
+
*/
|
|
209
|
+
export async function launchKubernetes(upgrades) {
|
|
210
|
+
const context = await selectContext();
|
|
211
|
+
execFileSync('kubectl', ['config', 'use-context', context], { stdio: 'ignore' });
|
|
212
|
+
|
|
213
|
+
const namespace = await selectNamespace();
|
|
214
|
+
|
|
215
|
+
// Ensure secret (uses first upgrade's creds — they're shared)
|
|
216
|
+
const { ghToken, claudeCreds } = upgrades[0];
|
|
217
|
+
ensureSecret(namespace, ghToken, claudeCreds);
|
|
218
|
+
|
|
219
|
+
// Launch all pods
|
|
220
|
+
const launched = [];
|
|
221
|
+
const launchSpinner = p.spinner();
|
|
222
|
+
launchSpinner.start(`Launching ${upgrades.length} ${upgrades.length === 1 ? 'pod' : 'pods'}...`);
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
for (const upgrade of upgrades) {
|
|
226
|
+
const podName = startPod({ ...upgrade, namespace });
|
|
227
|
+
launched.push({ ...upgrade, podName });
|
|
228
|
+
}
|
|
229
|
+
launchSpinner.stop(`${launched.length} ${launched.length === 1 ? 'pod' : 'pods'} created`);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
launchSpinner.stop('Failed to launch pod');
|
|
232
|
+
p.log.error(err.message);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Wait for pods to start
|
|
237
|
+
const waitSpinner = p.spinner();
|
|
238
|
+
waitSpinner.start('Waiting for pods to start...');
|
|
239
|
+
for (const { podName } of launched) {
|
|
240
|
+
try {
|
|
241
|
+
execFileSync('kubectl', [
|
|
242
|
+
'wait', '--for=jsonpath={.status.phase}=Running', `pod/${podName}`,
|
|
243
|
+
'-n', namespace, '--timeout=120s',
|
|
244
|
+
], { stdio: 'ignore' });
|
|
245
|
+
} catch {
|
|
246
|
+
// May still be pulling image
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
waitSpinner.stop('Pods running');
|
|
250
|
+
|
|
251
|
+
// Summary
|
|
252
|
+
const lines = launched.map((l) => [
|
|
253
|
+
`${pc.bold(l.podName)}:`,
|
|
254
|
+
` Logs: kubectl logs -f ${l.podName} -n ${namespace}`,
|
|
255
|
+
` Status: kubectl get pod ${l.podName} -n ${namespace}`,
|
|
256
|
+
` Clean: kubectl delete pod ${l.podName} -n ${namespace}`,
|
|
257
|
+
].join('\n'));
|
|
258
|
+
|
|
259
|
+
p.note(lines.join('\n\n'), `${launched.length} ${launched.length === 1 ? 'pod' : 'pods'} launched!`);
|
|
260
|
+
}
|
package/src/pod-name.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a container/pod name from repo + stack + version + suffix.
|
|
3
|
+
* K8s constraints: lowercase, alphanumeric + dashes, max 63 chars.
|
|
4
|
+
* @param {string} repoUrl
|
|
5
|
+
* @param {string} stack - stack key (e.g., 'laravel')
|
|
6
|
+
* @param {string} targetVersion
|
|
7
|
+
* @param {string} [suffix]
|
|
8
|
+
*/
|
|
9
|
+
export function deriveName(repoUrl, stack, targetVersion, suffix) {
|
|
10
|
+
const repoShort = repoUrl
|
|
11
|
+
.replace(/.*[:/]/, '')
|
|
12
|
+
.replace(/\.git$/, '')
|
|
13
|
+
.toLowerCase();
|
|
14
|
+
|
|
15
|
+
const prefix = stack.charAt(0); // 'l' for laravel, 'r' for react, etc.
|
|
16
|
+
let name = `upgrade-${repoShort}-${prefix}${targetVersion}`;
|
|
17
|
+
if (suffix) {
|
|
18
|
+
name += `-${suffix}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return name
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
24
|
+
.replace(/-+/g, '-')
|
|
25
|
+
.replace(/-$/, '')
|
|
26
|
+
.slice(0, 63);
|
|
27
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prompt for target version.
|
|
5
|
+
* @param {string} [label='Target version']
|
|
6
|
+
* @returns {Promise<string>}
|
|
7
|
+
*/
|
|
8
|
+
export async function askTargetVersion(label = 'Target version') {
|
|
9
|
+
const version = await p.text({
|
|
10
|
+
message: label,
|
|
11
|
+
placeholder: '12',
|
|
12
|
+
validate: (v) => {
|
|
13
|
+
if (!v) return 'Version is required';
|
|
14
|
+
if (!/^\d+$/.test(v)) return 'Enter just the major version number (e.g., 12)';
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
if (p.isCancel(version)) process.exit(0);
|
|
18
|
+
return version;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Prompt for push + PR preference.
|
|
23
|
+
* @returns {Promise<boolean>}
|
|
24
|
+
*/
|
|
25
|
+
export async function askPush() {
|
|
26
|
+
const push = await p.confirm({
|
|
27
|
+
message: 'Push branch and open PR on completion?',
|
|
28
|
+
initialValue: true,
|
|
29
|
+
});
|
|
30
|
+
if (p.isCancel(push)) process.exit(0);
|
|
31
|
+
return push;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Prompt for optional branch suffix.
|
|
36
|
+
* @returns {Promise<string>}
|
|
37
|
+
*/
|
|
38
|
+
export async function askSuffix() {
|
|
39
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
40
|
+
const suffix = await p.text({
|
|
41
|
+
message: 'Branch suffix (optional)',
|
|
42
|
+
placeholder: today,
|
|
43
|
+
defaultValue: '',
|
|
44
|
+
});
|
|
45
|
+
if (p.isCancel(suffix)) process.exit(0);
|
|
46
|
+
return suffix;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prompt for run target (Docker or Kubernetes).
|
|
51
|
+
* @param {{ hasDocker: boolean, hasKubectl: boolean }} available
|
|
52
|
+
* @returns {Promise<'docker' | 'kubernetes'>}
|
|
53
|
+
*/
|
|
54
|
+
export async function askRunTarget({ hasDocker, hasKubectl }) {
|
|
55
|
+
const options = [];
|
|
56
|
+
if (hasDocker) options.push({ value: 'docker', label: 'Local Docker' });
|
|
57
|
+
if (hasKubectl) options.push({ value: 'kubernetes', label: 'Kubernetes cluster' });
|
|
58
|
+
|
|
59
|
+
if (options.length === 0) {
|
|
60
|
+
p.cancel('Neither docker nor kubectl found in PATH. Install one to continue.');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (options.length === 1) return options[0].value;
|
|
65
|
+
|
|
66
|
+
const target = await p.select({
|
|
67
|
+
message: 'Where should the upgrade run?',
|
|
68
|
+
options,
|
|
69
|
+
});
|
|
70
|
+
if (p.isCancel(target)) process.exit(0);
|
|
71
|
+
return target;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Prompt to add another upgrade to the queue.
|
|
76
|
+
* @returns {Promise<boolean>}
|
|
77
|
+
*/
|
|
78
|
+
export async function askAddAnother() {
|
|
79
|
+
const another = await p.confirm({
|
|
80
|
+
message: 'Add another upgrade?',
|
|
81
|
+
initialValue: false,
|
|
82
|
+
});
|
|
83
|
+
if (p.isCancel(another)) process.exit(0);
|
|
84
|
+
return another;
|
|
85
|
+
}
|
package/src/stacks.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const STACKS = {
|
|
2
|
+
laravel: {
|
|
3
|
+
name: 'Laravel',
|
|
4
|
+
image: 'ghcr.io/reyemtech/laravel-upgrade-agent:latest',
|
|
5
|
+
detect: (composer) => composer?.require?.['laravel/framework'],
|
|
6
|
+
versionLabel: 'Target Laravel version',
|
|
7
|
+
branchPrefix: 'upgrade/laravel',
|
|
8
|
+
envKey: 'TARGET_LARAVEL',
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Look up a stack by key.
|
|
14
|
+
* @param {string} key
|
|
15
|
+
* @returns {object|null}
|
|
16
|
+
*/
|
|
17
|
+
export function getStack(key) {
|
|
18
|
+
return STACKS[key] || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect which stack a composer.json belongs to.
|
|
23
|
+
* @param {object} composer - parsed composer.json
|
|
24
|
+
* @returns {{ stack: string, version: string } | null}
|
|
25
|
+
*/
|
|
26
|
+
export function detectStack(composer) {
|
|
27
|
+
for (const [key, stack] of Object.entries(STACKS)) {
|
|
28
|
+
const dep = stack.detect(composer);
|
|
29
|
+
if (dep) {
|
|
30
|
+
const match = dep.match(/(\d+)/);
|
|
31
|
+
const version = match ? `${match[1]}.x` : dep;
|
|
32
|
+
return { stack: key, version };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|