servyx-cli 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/README.md +53 -0
- package/bin/servyx.js +2 -0
- package/package.json +34 -0
- package/src/commands/deploy.js +118 -0
- package/src/commands/link.js +54 -0
- package/src/commands/login.js +67 -0
- package/src/commands/status.js +34 -0
- package/src/index.js +70 -0
- package/src/lib/api.js +58 -0
- package/src/lib/config.js +49 -0
- package/src/lib/project.js +36 -0
- package/src/lib/rsync.js +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# servyx-cli
|
|
2
|
+
|
|
3
|
+
Deploy local projects to Servyx by rsyncing files over SSH, then triggering the existing Servyx deployment queue and pipeline.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js 18+
|
|
8
|
+
- `rsync` and `ssh` available on your PATH
|
|
9
|
+
- Your SSH public key added to the app's system user in the Servyx dashboard
|
|
10
|
+
- Network access to the target server on port 22
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g servyx-cli
|
|
16
|
+
# or from source:
|
|
17
|
+
cd servyx-cli && npm install && npm link
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 1. Authenticate (defaults to http://127.0.0.1:3871)
|
|
24
|
+
servyx login
|
|
25
|
+
|
|
26
|
+
# 2. Link your project directory to an app
|
|
27
|
+
cd my-app
|
|
28
|
+
servyx link
|
|
29
|
+
|
|
30
|
+
# 3. Deploy
|
|
31
|
+
servyx deploy
|
|
32
|
+
|
|
33
|
+
# 4. Check recent deployments
|
|
34
|
+
servyx status
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
- Global config: `~/.servyx/config.json` (mode 600)
|
|
40
|
+
- Project config: `servyx.json` in your project root
|
|
41
|
+
- Optional excludes: `.servyxignore` (rsync patterns)
|
|
42
|
+
|
|
43
|
+
Environment variables:
|
|
44
|
+
|
|
45
|
+
- `SERVYX_API_URL` — override CLI service base URL
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
1. `servyx deploy` fetches the deploy target (SSH user, host, staging path) from cli-service
|
|
50
|
+
2. Files are rsynced to `<app_root>/.cli-staging` on the server
|
|
51
|
+
3. cli-service calls server-service to queue a CLI deployment
|
|
52
|
+
4. The agent syncs staging → public, runs the pipeline, and reports progress
|
|
53
|
+
5. The CLI polls until the deployment succeeds or fails
|
package/bin/servyx.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "servyx-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Servyx CLI — rsync local projects and deploy via the Servyx platform",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"servyx": "./bin/servyx.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/servyx.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"servyx",
|
|
22
|
+
"deploy",
|
|
23
|
+
"rsync",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"author": "Servyx Team",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"axios": "^1.7.9",
|
|
30
|
+
"commander": "^12.1.0",
|
|
31
|
+
"picocolors": "^1.1.1",
|
|
32
|
+
"prompts": "^2.4.2"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { getDeployTarget, getDeployment, startDeployment } from '../lib/api.js';
|
|
3
|
+
import { getToken } from '../lib/config.js';
|
|
4
|
+
import { loadProjectConfig, loadServyxIgnore } from '../lib/project.js';
|
|
5
|
+
import { rsyncDeploy, sshPreflight } from '../lib/rsync.js';
|
|
6
|
+
|
|
7
|
+
const TERMINAL_STATUSES = new Set(['success', 'failure', 'cancelled']);
|
|
8
|
+
|
|
9
|
+
function sleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatDeploymentLine(deployment) {
|
|
14
|
+
const pct = deployment.percent != null ? `${deployment.percent}%` : '';
|
|
15
|
+
const phase = deployment.phase || deployment.status || 'unknown';
|
|
16
|
+
const msg = deployment.message || '';
|
|
17
|
+
return `${pc.cyan(phase)} ${pct} ${msg}`.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runDeploy(options = {}) {
|
|
21
|
+
if (!getToken()) {
|
|
22
|
+
console.error(pc.red('Not logged in. Run: servyx login'));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const project = loadProjectConfig();
|
|
27
|
+
if (!project?.config?.appId) {
|
|
28
|
+
console.error(pc.red('No linked app. Run: servyx link'));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { root, config } = project;
|
|
33
|
+
const appId = config.appId;
|
|
34
|
+
const message = options.message || `CLI deploy from ${config.appSlug || config.appName || 'local'}`;
|
|
35
|
+
|
|
36
|
+
console.log(pc.bold(`Deploying ${config.appName || config.appSlug}...`));
|
|
37
|
+
|
|
38
|
+
const targetResult = await getDeployTarget(appId);
|
|
39
|
+
const target = targetResult?.data;
|
|
40
|
+
if (!target?.ssh_host || !target?.ssh_user || !target?.staging_path) {
|
|
41
|
+
console.error(pc.red('Deploy target is incomplete. Check app configuration in the dashboard.'));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(pc.dim(`SSH: ${target.ssh_user}@${target.ssh_host}:${target.ssh_port || 22}`));
|
|
46
|
+
console.log(pc.dim(`Staging: ${target.staging_path}`));
|
|
47
|
+
|
|
48
|
+
const sshCheck = sshPreflight({
|
|
49
|
+
host: target.ssh_host,
|
|
50
|
+
port: target.ssh_port || 22,
|
|
51
|
+
user: target.ssh_user,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!sshCheck.ok) {
|
|
55
|
+
console.error(pc.red('SSH connection failed.'));
|
|
56
|
+
console.error(pc.dim(sshCheck.stderr || 'Unable to connect with your SSH key.'));
|
|
57
|
+
console.error(pc.yellow('Add your SSH public key to the app system user in the Servyx dashboard, then retry.'));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const excludes = [
|
|
62
|
+
...(target.rsync_excludes || []),
|
|
63
|
+
...loadServyxIgnore(root),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
console.log(pc.bold('Syncing files via rsync...'));
|
|
67
|
+
rsyncDeploy({
|
|
68
|
+
localDir: root,
|
|
69
|
+
host: target.ssh_host,
|
|
70
|
+
port: target.ssh_port || 22,
|
|
71
|
+
user: target.ssh_user,
|
|
72
|
+
remotePath: target.staging_path,
|
|
73
|
+
excludes,
|
|
74
|
+
});
|
|
75
|
+
console.log(pc.green('Rsync complete.'));
|
|
76
|
+
|
|
77
|
+
console.log(pc.bold('Starting deployment...'));
|
|
78
|
+
const startResult = await startDeployment(appId, message);
|
|
79
|
+
const deployment = startResult?.data?.deployment;
|
|
80
|
+
const deploymentId = deployment?.id;
|
|
81
|
+
|
|
82
|
+
if (!deploymentId) {
|
|
83
|
+
console.error(pc.red('Failed to start deployment.'));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (startResult?.data?.queue_position > 1) {
|
|
88
|
+
console.log(pc.yellow(`Queued at position ${startResult.data.queue_position}`));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(pc.dim(`Deployment ID: ${deploymentId}`));
|
|
92
|
+
|
|
93
|
+
while (true) {
|
|
94
|
+
const statusResult = await getDeployment(deploymentId);
|
|
95
|
+
const current = statusResult?.data?.deployment;
|
|
96
|
+
if (!current) {
|
|
97
|
+
console.error(pc.red('Deployment not found while polling.'));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(formatDeploymentLine(current));
|
|
102
|
+
|
|
103
|
+
if (TERMINAL_STATUSES.has(current.status)) {
|
|
104
|
+
if (current.status === 'success') {
|
|
105
|
+
console.log(pc.green('Deployment succeeded.'));
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.error(pc.red(`Deployment ${current.status}.`));
|
|
110
|
+
if (current.error_message) {
|
|
111
|
+
console.error(pc.red(current.error_message));
|
|
112
|
+
}
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await sleep(2000);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { listApps } from '../lib/api.js';
|
|
4
|
+
import { getToken } from '../lib/config.js';
|
|
5
|
+
import { loadProjectConfig, saveProjectConfig } from '../lib/project.js';
|
|
6
|
+
|
|
7
|
+
export async function runLink(options = {}) {
|
|
8
|
+
if (!getToken()) {
|
|
9
|
+
console.error(pc.red('Not logged in. Run: servyx login'));
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const result = await listApps();
|
|
14
|
+
const apps = result?.data?.apps || [];
|
|
15
|
+
if (!apps.length) {
|
|
16
|
+
console.error(pc.red('No apps found for your account.'));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let appId = options.app;
|
|
21
|
+
if (!appId) {
|
|
22
|
+
const answer = await prompts({
|
|
23
|
+
type: 'select',
|
|
24
|
+
name: 'appId',
|
|
25
|
+
message: 'Select an app to link',
|
|
26
|
+
choices: apps.map((app) => ({
|
|
27
|
+
title: `${app.name} (${app.slug}) — ${app.server_name || app.hostname}`,
|
|
28
|
+
value: app.id,
|
|
29
|
+
})),
|
|
30
|
+
});
|
|
31
|
+
if (!answer.appId) {
|
|
32
|
+
console.log(pc.yellow('Link cancelled.'));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
appId = answer.appId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const app = apps.find((a) => String(a.id) === String(appId));
|
|
39
|
+
if (!app) {
|
|
40
|
+
console.error(pc.red('App not found.'));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
saveProjectConfig(cwd, {
|
|
46
|
+
appId: app.id,
|
|
47
|
+
appName: app.name,
|
|
48
|
+
appSlug: app.slug,
|
|
49
|
+
linkedAt: new Date().toISOString(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
console.log(pc.green(`Linked ${app.name} (${app.slug})`));
|
|
53
|
+
console.log(pc.dim(`Wrote servyx.json in ${cwd}`));
|
|
54
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { login } from '../lib/api.js';
|
|
4
|
+
import { getApiBaseUrl, setAuth } from '../lib/config.js';
|
|
5
|
+
|
|
6
|
+
export async function runLogin(options = {}) {
|
|
7
|
+
const apiUrl = options.api || getApiBaseUrl();
|
|
8
|
+
|
|
9
|
+
let username = options.username;
|
|
10
|
+
let password = options.password;
|
|
11
|
+
let otp = options.otp;
|
|
12
|
+
|
|
13
|
+
if (!username) {
|
|
14
|
+
const answer = await prompts({
|
|
15
|
+
type: 'text',
|
|
16
|
+
name: 'username',
|
|
17
|
+
message: 'Email or username',
|
|
18
|
+
validate: (v) => v.trim() ? true : 'Required',
|
|
19
|
+
});
|
|
20
|
+
if (!answer.username) {
|
|
21
|
+
console.log(pc.yellow('Login cancelled.'));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
username = answer.username;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!password) {
|
|
28
|
+
const answer = await prompts({
|
|
29
|
+
type: 'password',
|
|
30
|
+
name: 'password',
|
|
31
|
+
message: 'Password',
|
|
32
|
+
validate: (v) => v ? true : 'Required',
|
|
33
|
+
});
|
|
34
|
+
if (!answer.password) {
|
|
35
|
+
console.log(pc.yellow('Login cancelled.'));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
password = answer.password;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let result = await login({ username, password, otp, apiUrl });
|
|
42
|
+
|
|
43
|
+
if (!otp && result?.data?.requires_2fa) {
|
|
44
|
+
const answer = await prompts({
|
|
45
|
+
type: 'text',
|
|
46
|
+
name: 'otp',
|
|
47
|
+
message: 'Two-factor code',
|
|
48
|
+
validate: (v) => v.trim() ? true : 'Required',
|
|
49
|
+
});
|
|
50
|
+
if (!answer.otp) {
|
|
51
|
+
console.log(pc.yellow('Login cancelled.'));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
result = await login({ username, password, otp: answer.otp, apiUrl });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const token = result?.data?.token;
|
|
58
|
+
const user = result?.data?.user;
|
|
59
|
+
if (!token) {
|
|
60
|
+
console.error(pc.red('Login failed: no token received'));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setAuth({ token, apiUrl, user });
|
|
65
|
+
console.log(pc.green(`Logged in as ${user?.username || user?.email || username}`));
|
|
66
|
+
console.log(pc.dim(`API: ${apiUrl}`));
|
|
67
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { listAppDeployments } from '../lib/api.js';
|
|
3
|
+
import { getToken } from '../lib/config.js';
|
|
4
|
+
import { loadProjectConfig } from '../lib/project.js';
|
|
5
|
+
|
|
6
|
+
export async function runStatus(options = {}) {
|
|
7
|
+
if (!getToken()) {
|
|
8
|
+
console.error(pc.red('Not logged in. Run: servyx login'));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const project = loadProjectConfig();
|
|
13
|
+
if (!project?.config?.appId) {
|
|
14
|
+
console.error(pc.red('No linked app. Run: servyx link'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const limit = options.limit || 10;
|
|
19
|
+
const result = await listAppDeployments(project.config.appId, limit);
|
|
20
|
+
const deployments = result?.data?.deployments || [];
|
|
21
|
+
|
|
22
|
+
if (!deployments.length) {
|
|
23
|
+
console.log(pc.yellow('No deployments found.'));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log(pc.bold(`Recent deployments for ${project.config.appName || project.config.appSlug}`));
|
|
28
|
+
for (const d of deployments) {
|
|
29
|
+
const when = d.finished_at || d.started_at || d.created_at;
|
|
30
|
+
const pct = d.percent != null ? `${d.percent}%` : '';
|
|
31
|
+
console.log(`${pc.cyan(d.status.padEnd(12))} ${pc.dim(d.id)} ${d.phase || ''} ${pct} ${d.message || ''}`.trim());
|
|
32
|
+
if (when) console.log(pc.dim(` ${when}`));
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { runLogin } from './commands/login.js';
|
|
4
|
+
import { runLink } from './commands/link.js';
|
|
5
|
+
import { runDeploy } from './commands/deploy.js';
|
|
6
|
+
import { runStatus } from './commands/status.js';
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('servyx')
|
|
12
|
+
.description('Servyx CLI — rsync and deploy applications')
|
|
13
|
+
.version('0.1.0');
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command('login')
|
|
17
|
+
.description('Authenticate with Servyx')
|
|
18
|
+
.option('--api <url>', 'CLI service API URL')
|
|
19
|
+
.option('-u, --username <username>', 'Email or username')
|
|
20
|
+
.option('-p, --password <password>', 'Password')
|
|
21
|
+
.option('--otp <code>', 'Two-factor code')
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
try {
|
|
24
|
+
await runLogin(options);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(pc.red(err.message || 'Login failed'));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command('link')
|
|
33
|
+
.description('Link the current directory to a Servyx app')
|
|
34
|
+
.option('--app <id>', 'App ID to link')
|
|
35
|
+
.action(async (options) => {
|
|
36
|
+
try {
|
|
37
|
+
await runLink(options);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(pc.red(err.message || 'Link failed'));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('deploy')
|
|
46
|
+
.description('Rsync local files and trigger a deployment')
|
|
47
|
+
.option('-m, --message <message>', 'Deployment message')
|
|
48
|
+
.action(async (options) => {
|
|
49
|
+
try {
|
|
50
|
+
await runDeploy(options);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(pc.red(err.message || 'Deploy failed'));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
program
|
|
58
|
+
.command('status')
|
|
59
|
+
.description('Show recent deployments for the linked app')
|
|
60
|
+
.option('-n, --limit <count>', 'Number of deployments to show', '10')
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
try {
|
|
63
|
+
await runStatus({ ...options, limit: Number(options.limit) || 10 });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(pc.red(err.message || 'Status failed'));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
program.parseAsync(process.argv);
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getApiBaseUrl, getToken } from './config.js';
|
|
3
|
+
|
|
4
|
+
function createClient(token = getToken()) {
|
|
5
|
+
const baseURL = `${getApiBaseUrl()}/api/v1/cli`;
|
|
6
|
+
return axios.create({
|
|
7
|
+
baseURL,
|
|
8
|
+
timeout: 60000,
|
|
9
|
+
headers: {
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
12
|
+
},
|
|
13
|
+
validateStatus: () => true,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function unwrap(response) {
|
|
18
|
+
if (response.status >= 200 && response.status < 300) {
|
|
19
|
+
return response.data;
|
|
20
|
+
}
|
|
21
|
+
const err = new Error(response.data?.error || response.statusText || 'Request failed');
|
|
22
|
+
err.status = response.status;
|
|
23
|
+
err.code = response.data?.code;
|
|
24
|
+
err.data = response.data;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function login({ username, password, otp, apiUrl }) {
|
|
29
|
+
const baseURL = `${(apiUrl || getApiBaseUrl()).replace(/\/+$/, '')}/api/v1/cli`;
|
|
30
|
+
const client = axios.create({ baseURL, timeout: 60000, validateStatus: () => true });
|
|
31
|
+
const response = await client.post('/auth/login', { username, password, otp });
|
|
32
|
+
return unwrap(response);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function listApps() {
|
|
36
|
+
const client = createClient();
|
|
37
|
+
return unwrap(await client.get('/apps'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getDeployTarget(appId) {
|
|
41
|
+
const client = createClient();
|
|
42
|
+
return unwrap(await client.get(`/apps/${appId}/deploy-target`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function startDeployment(appId, message) {
|
|
46
|
+
const client = createClient();
|
|
47
|
+
return unwrap(await client.post(`/apps/${appId}/deployments`, { message }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function getDeployment(deploymentId) {
|
|
51
|
+
const client = createClient();
|
|
52
|
+
return unwrap(await client.get(`/deployments/${deploymentId}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listAppDeployments(appId, limit = 10) {
|
|
56
|
+
const client = createClient();
|
|
57
|
+
return unwrap(await client.get(`/apps/${appId}/deployments`, { params: { limit } }));
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.servyx');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
export function getConfigDir() {
|
|
9
|
+
return CONFIG_DIR;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadGlobalConfig() {
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
15
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveGlobalConfig(config) {
|
|
22
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
23
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
}
|
|
25
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getApiBaseUrl() {
|
|
29
|
+
const env = process.env.SERVYX_API_URL;
|
|
30
|
+
if (env) return env.replace(/\/+$/, '');
|
|
31
|
+
const cfg = loadGlobalConfig();
|
|
32
|
+
return (cfg?.apiUrl || 'http://127.0.0.1:3871').replace(/\/+$/, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getToken() {
|
|
36
|
+
const cfg = loadGlobalConfig();
|
|
37
|
+
return cfg?.token || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setAuth({ token, apiUrl, user }) {
|
|
41
|
+
const existing = loadGlobalConfig() || {};
|
|
42
|
+
saveGlobalConfig({
|
|
43
|
+
...existing,
|
|
44
|
+
token,
|
|
45
|
+
apiUrl: apiUrl || existing.apiUrl || getApiBaseUrl(),
|
|
46
|
+
user: user || existing.user || null,
|
|
47
|
+
updatedAt: new Date().toISOString(),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export const PROJECT_FILE = 'servyx.json';
|
|
5
|
+
|
|
6
|
+
export function findProjectRoot(startDir = process.cwd()) {
|
|
7
|
+
let dir = path.resolve(startDir);
|
|
8
|
+
while (true) {
|
|
9
|
+
const candidate = path.join(dir, PROJECT_FILE);
|
|
10
|
+
if (fs.existsSync(candidate)) return dir;
|
|
11
|
+
const parent = path.dirname(dir);
|
|
12
|
+
if (parent === dir) return null;
|
|
13
|
+
dir = parent;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadProjectConfig(cwd = process.cwd()) {
|
|
18
|
+
const root = findProjectRoot(cwd);
|
|
19
|
+
if (!root) return null;
|
|
20
|
+
const file = path.join(root, PROJECT_FILE);
|
|
21
|
+
return { root, config: JSON.parse(fs.readFileSync(file, 'utf8')) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function saveProjectConfig(root, config) {
|
|
25
|
+
const file = path.join(root, PROJECT_FILE);
|
|
26
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadServyxIgnore(root) {
|
|
30
|
+
const file = path.join(root, '.servyxignore');
|
|
31
|
+
if (!fs.existsSync(file)) return [];
|
|
32
|
+
return fs.readFileSync(file, 'utf8')
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((line) => line.trim())
|
|
35
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
36
|
+
}
|
package/src/lib/rsync.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export function requireBinary(name) {
|
|
5
|
+
const result = spawnSync('which', [name], { encoding: 'utf8' });
|
|
6
|
+
if (result.status !== 0) {
|
|
7
|
+
throw new Error(`${name} is required but was not found in PATH`);
|
|
8
|
+
}
|
|
9
|
+
return result.stdout.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sshPreflight({ host, port = 22, user }) {
|
|
13
|
+
requireBinary('ssh');
|
|
14
|
+
const target = `${user}@${host}`;
|
|
15
|
+
const args = [
|
|
16
|
+
'-o', 'BatchMode=yes',
|
|
17
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
18
|
+
'-o', `ConnectTimeout=10`,
|
|
19
|
+
'-p', String(port),
|
|
20
|
+
target,
|
|
21
|
+
'true',
|
|
22
|
+
];
|
|
23
|
+
const result = spawnSync('ssh', args, { encoding: 'utf8' });
|
|
24
|
+
return {
|
|
25
|
+
ok: result.status === 0,
|
|
26
|
+
target,
|
|
27
|
+
stderr: (result.stderr || '').trim(),
|
|
28
|
+
stdout: (result.stdout || '').trim(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rsyncDeploy({ localDir, host, port = 22, user, remotePath, excludes = [] }) {
|
|
33
|
+
requireBinary('rsync');
|
|
34
|
+
const target = `${user}@${host}:${remotePath.replace(/\/$/, '')}/`;
|
|
35
|
+
const args = [
|
|
36
|
+
'-az',
|
|
37
|
+
'--delete',
|
|
38
|
+
'--human-readable',
|
|
39
|
+
'--progress',
|
|
40
|
+
'-e', `ssh -p ${port} -o StrictHostKeyChecking=accept-new`,
|
|
41
|
+
...excludes.flatMap((pattern) => ['--exclude', pattern]),
|
|
42
|
+
`${path.resolve(localDir)}/`,
|
|
43
|
+
target,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const child = spawnSync('rsync', args, {
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
stdio: 'inherit',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (child.status !== 0) {
|
|
52
|
+
const err = new Error(`rsync failed with exit code ${child.status ?? 'unknown'}`);
|
|
53
|
+
err.exitCode = child.status;
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { target };
|
|
58
|
+
}
|