quicklify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +25 -0
- package/.github/workflows/ci.yml +42 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +82 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/base.d.ts +11 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +2 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/digitalocean.d.ts +14 -0
- package/dist/providers/digitalocean.d.ts.map +1 -0
- package/dist/providers/digitalocean.js +29 -0
- package/dist/providers/digitalocean.js.map +1 -0
- package/dist/providers/hetzner.d.ts +15 -0
- package/dist/providers/hetzner.d.ts.map +1 -0
- package/dist/providers/hetzner.js +73 -0
- package/dist/providers/hetzner.js.map +1 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cloudInit.d.ts +2 -0
- package/dist/utils/cloudInit.d.ts.map +1 -0
- package/dist/utils/cloudInit.js +30 -0
- package/dist/utils/cloudInit.js.map +1 -0
- package/dist/utils/logger.d.ts +11 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +31 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/prompts.d.ts +5 -0
- package/dist/utils/prompts.d.ts.map +1 -0
- package/dist/utils/prompts.js +77 -0
- package/dist/utils/prompts.js.map +1 -0
- package/jest.config.cjs +30 -0
- package/package.json +41 -0
- package/src/commands/init.ts +101 -0
- package/src/index.ts +18 -0
- package/src/providers/base.ts +11 -0
- package/src/providers/digitalocean.ts +37 -0
- package/src/providers/hetzner.ts +85 -0
- package/src/types/index.ts +36 -0
- package/src/utils/cloudInit.ts +29 -0
- package/src/utils/logger.ts +37 -0
- package/src/utils/prompts.ts +84 -0
- package/tests/__mocks__/axios.ts +14 -0
- package/tests/__mocks__/chalk.ts +15 -0
- package/tests/__mocks__/inquirer.ts +5 -0
- package/tests/__mocks__/ora.ts +16 -0
- package/tests/e2e/init.test.ts +190 -0
- package/tests/integration/hetzner.test.ts +274 -0
- package/tests/unit/cloudInit.test.ts +53 -0
- package/tests/unit/logger.test.ts +76 -0
- package/tests/unit/prompts.test.ts +220 -0
- package/tests/unit/validators.test.ts +63 -0
- package/tsconfig.json +19 -0
- package/tsconfig.test.json +10 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface Region {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
location: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ServerSize {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
vcpu: number;
|
|
11
|
+
ram: number;
|
|
12
|
+
disk: number;
|
|
13
|
+
price: string;
|
|
14
|
+
recommended?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ServerConfig {
|
|
18
|
+
name: string;
|
|
19
|
+
size: string;
|
|
20
|
+
region: string;
|
|
21
|
+
cloudInit: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ServerResult {
|
|
25
|
+
id: string;
|
|
26
|
+
ip: string;
|
|
27
|
+
status: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DeploymentConfig {
|
|
31
|
+
provider: string;
|
|
32
|
+
apiToken: string;
|
|
33
|
+
region: string;
|
|
34
|
+
serverSize: string;
|
|
35
|
+
serverName: string;
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function getCoolifyCloudInit(serverName: string): string {
|
|
2
|
+
return `#!/bin/bash
|
|
3
|
+
set -e
|
|
4
|
+
|
|
5
|
+
echo "=================================="
|
|
6
|
+
echo "Quicklify Auto-Installer"
|
|
7
|
+
echo "Server: ${serverName}"
|
|
8
|
+
echo "=================================="
|
|
9
|
+
|
|
10
|
+
# Update system
|
|
11
|
+
echo "Updating system packages..."
|
|
12
|
+
apt-get update -y
|
|
13
|
+
|
|
14
|
+
# Install Coolify
|
|
15
|
+
echo "Installing Coolify..."
|
|
16
|
+
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
|
17
|
+
|
|
18
|
+
# Wait for services
|
|
19
|
+
echo "Waiting for Coolify services to start..."
|
|
20
|
+
sleep 30
|
|
21
|
+
|
|
22
|
+
echo "=================================="
|
|
23
|
+
echo "Coolify installation completed!"
|
|
24
|
+
echo "=================================="
|
|
25
|
+
echo ""
|
|
26
|
+
echo "Please wait 2-3 more minutes for Coolify to fully initialize."
|
|
27
|
+
echo "Then access your instance at: https://YOUR_SERVER_IP:8000"
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora, { type Ora } from "ora";
|
|
3
|
+
|
|
4
|
+
export const logger = {
|
|
5
|
+
info: (message: string) => {
|
|
6
|
+
console.log(chalk.blue("ℹ"), message);
|
|
7
|
+
},
|
|
8
|
+
|
|
9
|
+
success: (message: string) => {
|
|
10
|
+
console.log(chalk.green("✔"), message);
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
error: (message: string) => {
|
|
14
|
+
console.log(chalk.red("✖"), message);
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
warning: (message: string) => {
|
|
18
|
+
console.log(chalk.yellow("⚠"), message);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
title: (message: string) => {
|
|
22
|
+
console.log();
|
|
23
|
+
console.log(chalk.bold.cyan(message));
|
|
24
|
+
console.log();
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
step: (message: string) => {
|
|
28
|
+
console.log(chalk.gray("→"), message);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function createSpinner(text: string): Ora {
|
|
33
|
+
return ora({
|
|
34
|
+
text,
|
|
35
|
+
color: "cyan",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import type { CloudProvider } from "../providers/base.js";
|
|
3
|
+
import type { DeploymentConfig } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
export async function getDeploymentConfig(provider: CloudProvider): Promise<DeploymentConfig> {
|
|
6
|
+
const answers = await inquirer.prompt([
|
|
7
|
+
{
|
|
8
|
+
type: "password",
|
|
9
|
+
name: "apiToken",
|
|
10
|
+
message: `Enter your ${provider.displayName} API token:`,
|
|
11
|
+
validate: (input: string) => {
|
|
12
|
+
if (!input || input.trim().length === 0) {
|
|
13
|
+
return "API token is required";
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
type: "list",
|
|
20
|
+
name: "region",
|
|
21
|
+
message: "Select region:",
|
|
22
|
+
choices: provider.getRegions().map((r) => ({
|
|
23
|
+
name: `${r.name} (${r.location})`,
|
|
24
|
+
value: r.id,
|
|
25
|
+
})),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: "list",
|
|
29
|
+
name: "size",
|
|
30
|
+
message: "Select server size:",
|
|
31
|
+
choices: provider.getServerSizes().map((s) => ({
|
|
32
|
+
name: `${s.name} - ${s.vcpu} vCPU, ${s.ram}GB RAM - ${s.price}${s.recommended ? " ⭐ Recommended" : ""}`,
|
|
33
|
+
value: s.id,
|
|
34
|
+
})),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "input",
|
|
38
|
+
name: "serverName",
|
|
39
|
+
message: "Server name:",
|
|
40
|
+
default: "coolify-server",
|
|
41
|
+
validate: (input: string) => {
|
|
42
|
+
if (!input || input.trim().length === 0) {
|
|
43
|
+
return "Server name is required";
|
|
44
|
+
}
|
|
45
|
+
if (!/^[a-z0-9-]+$/.test(input)) {
|
|
46
|
+
return "Server name must contain only lowercase letters, numbers, and hyphens";
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
provider: provider.name,
|
|
55
|
+
apiToken: answers.apiToken.trim(),
|
|
56
|
+
region: answers.region,
|
|
57
|
+
serverSize: answers.size,
|
|
58
|
+
serverName: answers.serverName.trim(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function confirmDeployment(config: DeploymentConfig, provider: CloudProvider): Promise<boolean> {
|
|
63
|
+
const region = provider.getRegions().find((r) => r.id === config.region);
|
|
64
|
+
const size = provider.getServerSizes().find((s) => s.id === config.serverSize);
|
|
65
|
+
|
|
66
|
+
console.log("\nDeployment Summary:");
|
|
67
|
+
console.log(` Provider: ${provider.displayName}`);
|
|
68
|
+
console.log(` Region: ${region?.name} (${region?.location})`);
|
|
69
|
+
console.log(` Size: ${size?.name} - ${size?.vcpu} vCPU, ${size?.ram}GB RAM`);
|
|
70
|
+
console.log(` Price: ${size?.price}`);
|
|
71
|
+
console.log(` Server Name: ${config.serverName}`);
|
|
72
|
+
console.log();
|
|
73
|
+
|
|
74
|
+
const { confirm } = await inquirer.prompt([
|
|
75
|
+
{
|
|
76
|
+
type: "confirm",
|
|
77
|
+
name: "confirm",
|
|
78
|
+
message: "Proceed with deployment?",
|
|
79
|
+
default: true,
|
|
80
|
+
},
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
return confirm;
|
|
84
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const identity = (s: string) => s;
|
|
2
|
+
|
|
3
|
+
const bold = Object.assign(identity, { cyan: identity });
|
|
4
|
+
|
|
5
|
+
const chalk = {
|
|
6
|
+
blue: identity,
|
|
7
|
+
green: identity,
|
|
8
|
+
red: identity,
|
|
9
|
+
yellow: identity,
|
|
10
|
+
gray: identity,
|
|
11
|
+
cyan: identity,
|
|
12
|
+
bold,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default chalk;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const createMockSpinner = () => {
|
|
2
|
+
const spinner: any = {
|
|
3
|
+
text: '',
|
|
4
|
+
color: 'cyan',
|
|
5
|
+
};
|
|
6
|
+
spinner.start = jest.fn(() => spinner);
|
|
7
|
+
spinner.succeed = jest.fn(() => spinner);
|
|
8
|
+
spinner.fail = jest.fn(() => spinner);
|
|
9
|
+
spinner.stop = jest.fn(() => spinner);
|
|
10
|
+
return spinner;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ora = jest.fn(() => createMockSpinner());
|
|
14
|
+
|
|
15
|
+
export default ora;
|
|
16
|
+
export { createMockSpinner };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import { initCommand } from '../../src/commands/init';
|
|
4
|
+
|
|
5
|
+
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
6
|
+
const mockedInquirer = inquirer as jest.Mocked<typeof inquirer>;
|
|
7
|
+
|
|
8
|
+
describe('initCommand E2E', () => {
|
|
9
|
+
let consoleSpy: jest.SpyInstance;
|
|
10
|
+
let processExitSpy: jest.SpyInstance;
|
|
11
|
+
const originalSetTimeout = global.setTimeout;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
15
|
+
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
|
|
18
|
+
// Make all setTimeout calls resolve instantly for test speed
|
|
19
|
+
global.setTimeout = ((fn: Function) => {
|
|
20
|
+
fn();
|
|
21
|
+
return 0;
|
|
22
|
+
}) as any;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
consoleSpy.mockRestore();
|
|
27
|
+
processExitSpy.mockRestore();
|
|
28
|
+
global.setTimeout = originalSetTimeout;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should complete full deployment flow successfully', async () => {
|
|
32
|
+
// Mock: user fills config + confirms
|
|
33
|
+
mockedInquirer.prompt
|
|
34
|
+
.mockResolvedValueOnce({
|
|
35
|
+
apiToken: 'valid-token',
|
|
36
|
+
region: 'nbg1',
|
|
37
|
+
size: 'cax11',
|
|
38
|
+
serverName: 'coolify-test',
|
|
39
|
+
})
|
|
40
|
+
.mockResolvedValueOnce({ confirm: true });
|
|
41
|
+
|
|
42
|
+
// Mock: validateToken succeeds
|
|
43
|
+
mockedAxios.get
|
|
44
|
+
.mockResolvedValueOnce({ data: { servers: [] } })
|
|
45
|
+
// Mock: getServerStatus returns running immediately
|
|
46
|
+
.mockResolvedValueOnce({ data: { server: { status: 'running' } } });
|
|
47
|
+
|
|
48
|
+
// Mock: createServer succeeds
|
|
49
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
50
|
+
data: {
|
|
51
|
+
server: {
|
|
52
|
+
id: 123,
|
|
53
|
+
public_net: { ipv4: { ip: '1.2.3.4' } },
|
|
54
|
+
status: 'initializing',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await initCommand();
|
|
60
|
+
|
|
61
|
+
// Verify API calls were made
|
|
62
|
+
expect(mockedAxios.get).toHaveBeenCalled();
|
|
63
|
+
expect(mockedAxios.post).toHaveBeenCalled();
|
|
64
|
+
|
|
65
|
+
// Verify success output contains IP
|
|
66
|
+
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c.join(' ')).join('\n');
|
|
67
|
+
expect(allOutput).toContain('1.2.3.4');
|
|
68
|
+
|
|
69
|
+
// Verify process.exit was NOT called (success path)
|
|
70
|
+
expect(processExitSpy).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should abort when user cancels deployment', async () => {
|
|
74
|
+
mockedInquirer.prompt
|
|
75
|
+
.mockResolvedValueOnce({
|
|
76
|
+
apiToken: 'valid-token',
|
|
77
|
+
region: 'nbg1',
|
|
78
|
+
size: 'cax11',
|
|
79
|
+
serverName: 'coolify-test',
|
|
80
|
+
})
|
|
81
|
+
.mockResolvedValueOnce({ confirm: false });
|
|
82
|
+
|
|
83
|
+
await initCommand();
|
|
84
|
+
|
|
85
|
+
// No API calls should be made after cancellation
|
|
86
|
+
expect(mockedAxios.get).not.toHaveBeenCalled();
|
|
87
|
+
expect(mockedAxios.post).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should stop on invalid API token', async () => {
|
|
91
|
+
mockedInquirer.prompt
|
|
92
|
+
.mockResolvedValueOnce({
|
|
93
|
+
apiToken: 'bad-token',
|
|
94
|
+
region: 'nbg1',
|
|
95
|
+
size: 'cax11',
|
|
96
|
+
serverName: 'coolify-test',
|
|
97
|
+
})
|
|
98
|
+
.mockResolvedValueOnce({ confirm: true });
|
|
99
|
+
|
|
100
|
+
// Mock: validateToken fails
|
|
101
|
+
mockedAxios.get.mockRejectedValueOnce(new Error('Unauthorized'));
|
|
102
|
+
|
|
103
|
+
await initCommand();
|
|
104
|
+
|
|
105
|
+
// createServer should never be called
|
|
106
|
+
expect(mockedAxios.post).not.toHaveBeenCalled();
|
|
107
|
+
expect(processExitSpy).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should handle server creation failure', async () => {
|
|
111
|
+
mockedInquirer.prompt
|
|
112
|
+
.mockResolvedValueOnce({
|
|
113
|
+
apiToken: 'valid-token',
|
|
114
|
+
region: 'nbg1',
|
|
115
|
+
size: 'cax11',
|
|
116
|
+
serverName: 'coolify-test',
|
|
117
|
+
})
|
|
118
|
+
.mockResolvedValueOnce({ confirm: true });
|
|
119
|
+
|
|
120
|
+
// Mock: validateToken succeeds
|
|
121
|
+
mockedAxios.get.mockResolvedValueOnce({ data: { servers: [] } });
|
|
122
|
+
|
|
123
|
+
// Mock: createServer fails
|
|
124
|
+
mockedAxios.post.mockRejectedValueOnce({
|
|
125
|
+
response: { data: { error: { message: 'insufficient_funds' } } },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await initCommand();
|
|
129
|
+
|
|
130
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should handle server boot timeout', async () => {
|
|
134
|
+
mockedInquirer.prompt
|
|
135
|
+
.mockResolvedValueOnce({
|
|
136
|
+
apiToken: 'valid-token',
|
|
137
|
+
region: 'nbg1',
|
|
138
|
+
size: 'cax11',
|
|
139
|
+
serverName: 'coolify-test',
|
|
140
|
+
})
|
|
141
|
+
.mockResolvedValueOnce({ confirm: true });
|
|
142
|
+
|
|
143
|
+
// Mock: validateToken succeeds
|
|
144
|
+
mockedAxios.get
|
|
145
|
+
.mockResolvedValueOnce({ data: { servers: [] } });
|
|
146
|
+
|
|
147
|
+
// Mock: createServer succeeds
|
|
148
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
149
|
+
data: {
|
|
150
|
+
server: {
|
|
151
|
+
id: 456,
|
|
152
|
+
public_net: { ipv4: { ip: '5.6.7.8' } },
|
|
153
|
+
status: 'initializing',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Mock: getServerStatus always returns 'initializing' (never reaches 'running')
|
|
159
|
+
mockedAxios.get.mockResolvedValue({
|
|
160
|
+
data: { server: { status: 'initializing' } },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await initCommand();
|
|
164
|
+
|
|
165
|
+
// Should NOT call process.exit (it returns early, not throws)
|
|
166
|
+
// Server creation was attempted
|
|
167
|
+
expect(mockedAxios.post).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should handle network error during deployment', async () => {
|
|
171
|
+
mockedInquirer.prompt
|
|
172
|
+
.mockResolvedValueOnce({
|
|
173
|
+
apiToken: 'valid-token',
|
|
174
|
+
region: 'nbg1',
|
|
175
|
+
size: 'cax11',
|
|
176
|
+
serverName: 'coolify-test',
|
|
177
|
+
})
|
|
178
|
+
.mockResolvedValueOnce({ confirm: true });
|
|
179
|
+
|
|
180
|
+
// Mock: validateToken succeeds
|
|
181
|
+
mockedAxios.get.mockResolvedValueOnce({ data: { servers: [] } });
|
|
182
|
+
|
|
183
|
+
// Mock: createServer network error
|
|
184
|
+
mockedAxios.post.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
185
|
+
|
|
186
|
+
await initCommand();
|
|
187
|
+
|
|
188
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
189
|
+
});
|
|
190
|
+
});
|