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.
Files changed (63) hide show
  1. package/.claude/settings.local.json +25 -0
  2. package/.github/workflows/ci.yml +42 -0
  3. package/LICENSE +21 -0
  4. package/README.md +305 -0
  5. package/dist/commands/init.d.ts +2 -0
  6. package/dist/commands/init.d.ts.map +1 -0
  7. package/dist/commands/init.js +82 -0
  8. package/dist/commands/init.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +14 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/providers/base.d.ts +11 -0
  14. package/dist/providers/base.d.ts.map +1 -0
  15. package/dist/providers/base.js +2 -0
  16. package/dist/providers/base.js.map +1 -0
  17. package/dist/providers/digitalocean.d.ts +14 -0
  18. package/dist/providers/digitalocean.d.ts.map +1 -0
  19. package/dist/providers/digitalocean.js +29 -0
  20. package/dist/providers/digitalocean.js.map +1 -0
  21. package/dist/providers/hetzner.d.ts +15 -0
  22. package/dist/providers/hetzner.d.ts.map +1 -0
  23. package/dist/providers/hetzner.js +73 -0
  24. package/dist/providers/hetzner.js.map +1 -0
  25. package/dist/types/index.d.ts +33 -0
  26. package/dist/types/index.d.ts.map +1 -0
  27. package/dist/types/index.js +2 -0
  28. package/dist/types/index.js.map +1 -0
  29. package/dist/utils/cloudInit.d.ts +2 -0
  30. package/dist/utils/cloudInit.d.ts.map +1 -0
  31. package/dist/utils/cloudInit.js +30 -0
  32. package/dist/utils/cloudInit.js.map +1 -0
  33. package/dist/utils/logger.d.ts +11 -0
  34. package/dist/utils/logger.d.ts.map +1 -0
  35. package/dist/utils/logger.js +31 -0
  36. package/dist/utils/logger.js.map +1 -0
  37. package/dist/utils/prompts.d.ts +5 -0
  38. package/dist/utils/prompts.d.ts.map +1 -0
  39. package/dist/utils/prompts.js +77 -0
  40. package/dist/utils/prompts.js.map +1 -0
  41. package/jest.config.cjs +30 -0
  42. package/package.json +41 -0
  43. package/src/commands/init.ts +101 -0
  44. package/src/index.ts +18 -0
  45. package/src/providers/base.ts +11 -0
  46. package/src/providers/digitalocean.ts +37 -0
  47. package/src/providers/hetzner.ts +85 -0
  48. package/src/types/index.ts +36 -0
  49. package/src/utils/cloudInit.ts +29 -0
  50. package/src/utils/logger.ts +37 -0
  51. package/src/utils/prompts.ts +84 -0
  52. package/tests/__mocks__/axios.ts +14 -0
  53. package/tests/__mocks__/chalk.ts +15 -0
  54. package/tests/__mocks__/inquirer.ts +5 -0
  55. package/tests/__mocks__/ora.ts +16 -0
  56. package/tests/e2e/init.test.ts +190 -0
  57. package/tests/integration/hetzner.test.ts +274 -0
  58. package/tests/unit/cloudInit.test.ts +53 -0
  59. package/tests/unit/logger.test.ts +76 -0
  60. package/tests/unit/prompts.test.ts +220 -0
  61. package/tests/unit/validators.test.ts +63 -0
  62. package/tsconfig.json +19 -0
  63. 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,14 @@
1
+ const axios = {
2
+ get: jest.fn(),
3
+ post: jest.fn(),
4
+ put: jest.fn(),
5
+ delete: jest.fn(),
6
+ patch: jest.fn(),
7
+ defaults: {
8
+ headers: {
9
+ common: {},
10
+ },
11
+ },
12
+ };
13
+
14
+ export default axios;
@@ -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,5 @@
1
+ const inquirer = {
2
+ prompt: jest.fn(),
3
+ };
4
+
5
+ export default inquirer;
@@ -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
+ });