servyx-cli 0.1.7 → 0.1.8
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 +21 -3
- package/package.json +7 -2
- package/src/commands/deploy.js +58 -18
- package/src/index.js +2 -1
- package/src/lib/api.js +7 -0
- package/src/lib/deploy-stream.js +99 -0
- package/src/lib/version.js +9 -0
package/README.md
CHANGED
|
@@ -13,14 +13,32 @@ Deploy local projects to Servyx by rsyncing files over SSH, then triggering the
|
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
npm install -g servyx-cli
|
|
16
|
-
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This installs the `latest` published version (no version pin needed). To upgrade an existing install:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm update -g servyx-cli
|
|
22
|
+
# or explicitly:
|
|
23
|
+
npm install -g servyx-cli@latest
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Check your installed version:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
servyx --version
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
From source:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
17
35
|
cd servyx-cli && npm install && npm link
|
|
18
36
|
```
|
|
19
37
|
|
|
20
38
|
## Quick start
|
|
21
39
|
|
|
22
40
|
```bash
|
|
23
|
-
# 1. Authenticate
|
|
41
|
+
# 1. Authenticate
|
|
24
42
|
servyx login
|
|
25
43
|
|
|
26
44
|
# 2. Link your project directory to an app
|
|
@@ -54,4 +72,4 @@ Environment variables:
|
|
|
54
72
|
3. Live dotfiles from `public` are copied into staging so they are not removed on deploy (`.env` is skipped when using `--with-env`)
|
|
55
73
|
4. cli-service calls server-service to queue a CLI deployment (`with_env` tells the agent to sync uploaded env files)
|
|
56
74
|
5. The agent syncs staging → public, preserving `.htaccess` and (by default) `.env` on the server
|
|
57
|
-
6. The CLI
|
|
75
|
+
6. The CLI listens on a live deploy stream (MQTT → cli-service SSE) until the deployment succeeds or fails, with slow HTTP fallback if streaming is unavailable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "servyx-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Servyx CLI — Upload local projects and deploy via the Servyx platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
"node": ">=18.0.0"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"start": "node bin/servyx.js"
|
|
18
|
+
"start": "node bin/servyx.js",
|
|
19
|
+
"prepublishOnly": "node -e \"const p=require('./package.json');const l=require('./package-lock.json');if(l.version!==p.version||l.packages[''].version!==p.version){console.error('package-lock.json version mismatch — run npm install');process.exit(1)}\""
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"tag": "latest",
|
|
23
|
+
"access": "public"
|
|
19
24
|
},
|
|
20
25
|
"keywords": [
|
|
21
26
|
"servyx",
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { getDeployTarget, getDeployment, startDeployment } from '../lib/api.js';
|
|
3
3
|
import { getToken } from '../lib/config.js';
|
|
4
|
+
import { watchDeploymentStream } from '../lib/deploy-stream.js';
|
|
4
5
|
import { loadProjectConfig, loadServyxIgnore } from '../lib/project.js';
|
|
5
6
|
import { rsyncDeploy, sshPreflight, DEFAULT_RSYNC_EXCLUDES, preserveServerDotfiles, ensureRemoteDir, verifyRemoteStagingDir } from '../lib/rsync.js';
|
|
6
7
|
|
|
7
8
|
const TERMINAL_STATUSES = new Set(['success', 'failure', 'cancelled']);
|
|
9
|
+
const FALLBACK_POLL_MS = 15000;
|
|
8
10
|
|
|
9
11
|
function sleep(ms) {
|
|
10
12
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
function deploymentSnapshot(deployment) {
|
|
16
|
+
return [
|
|
17
|
+
deployment.status,
|
|
18
|
+
deployment.phase,
|
|
19
|
+
deployment.percent,
|
|
20
|
+
deployment.message,
|
|
21
|
+
deployment.error_message,
|
|
22
|
+
].join('|');
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
function formatDeploymentLine(deployment) {
|
|
14
26
|
const pct = deployment.percent != null ? `${deployment.percent}%` : '';
|
|
15
27
|
const phase = deployment.phase || deployment.status || 'unknown';
|
|
@@ -149,29 +161,57 @@ export async function runDeploy(options = {}) {
|
|
|
149
161
|
|
|
150
162
|
console.log(pc.dim(`Deployment ID: ${deploymentId}`));
|
|
151
163
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const current = statusResult?.data?.deployment;
|
|
155
|
-
if (!current) {
|
|
156
|
-
console.error(pc.red('Deployment not found while polling.'));
|
|
157
|
-
process.exit(1);
|
|
158
|
-
}
|
|
164
|
+
let lastSnapshot = null;
|
|
165
|
+
let current = deployment;
|
|
159
166
|
|
|
160
|
-
|
|
167
|
+
const handleDeploymentUpdate = (next) => {
|
|
168
|
+
current = next;
|
|
169
|
+
const snapshot = deploymentSnapshot(current);
|
|
170
|
+
if (snapshot !== lastSnapshot) {
|
|
171
|
+
console.log(formatDeploymentLine(current));
|
|
172
|
+
lastSnapshot = snapshot;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
161
175
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
176
|
+
try {
|
|
177
|
+
await watchDeploymentStream(deploymentId, {
|
|
178
|
+
onUpdate: (next) => handleDeploymentUpdate(next),
|
|
179
|
+
});
|
|
180
|
+
} catch (streamError) {
|
|
181
|
+
if (streamError.code !== 'MQTT_UNAVAILABLE' && streamError.status !== 404) {
|
|
182
|
+
console.log(pc.yellow(`Live deploy stream unavailable (${streamError.message}). Falling back to slow status checks...`));
|
|
183
|
+
} else if (streamError.code === 'MQTT_UNAVAILABLE') {
|
|
184
|
+
console.log(pc.yellow('MQTT deploy stream unavailable. Falling back to slow status checks...'));
|
|
185
|
+
} else {
|
|
186
|
+
throw streamError;
|
|
187
|
+
}
|
|
167
188
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
189
|
+
while (!TERMINAL_STATUSES.has(current?.status)) {
|
|
190
|
+
const statusResult = await getDeployment(deploymentId);
|
|
191
|
+
current = statusResult?.data?.deployment;
|
|
192
|
+
if (!current) {
|
|
193
|
+
console.error(pc.red('Deployment not found while checking status.'));
|
|
194
|
+
process.exit(1);
|
|
171
195
|
}
|
|
172
|
-
|
|
196
|
+
handleDeploymentUpdate(current);
|
|
197
|
+
if (TERMINAL_STATUSES.has(current.status)) break;
|
|
198
|
+
await sleep(FALLBACK_POLL_MS);
|
|
173
199
|
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!current) {
|
|
203
|
+
console.error(pc.red('Deployment status unavailable.'));
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (current.status === 'success') {
|
|
208
|
+
console.log(pc.green('Deployment succeeded.'));
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
174
211
|
|
|
175
|
-
|
|
212
|
+
console.error(pc.red(`Deployment ${current.status}.`));
|
|
213
|
+
if (current.error_message) {
|
|
214
|
+
console.error(pc.red(current.error_message));
|
|
176
215
|
}
|
|
216
|
+
process.exit(1);
|
|
177
217
|
}
|
package/src/index.js
CHANGED
|
@@ -4,13 +4,14 @@ import { runLogin } from './commands/login.js';
|
|
|
4
4
|
import { runLink } from './commands/link.js';
|
|
5
5
|
import { runDeploy } from './commands/deploy.js';
|
|
6
6
|
import { runStatus } from './commands/status.js';
|
|
7
|
+
import { getCliVersion } from './lib/version.js';
|
|
7
8
|
|
|
8
9
|
const program = new Command();
|
|
9
10
|
|
|
10
11
|
program
|
|
11
12
|
.name('servyx')
|
|
12
13
|
.description('Servyx CLI — rsync and deploy applications')
|
|
13
|
-
.version(
|
|
14
|
+
.version(getCliVersion());
|
|
14
15
|
|
|
15
16
|
program
|
|
16
17
|
.command('login')
|
package/src/lib/api.js
CHANGED
|
@@ -34,6 +34,13 @@ function unwrap(response) {
|
|
|
34
34
|
err.status = response.status;
|
|
35
35
|
err.code = response.data?.code;
|
|
36
36
|
err.data = response.data;
|
|
37
|
+
const retryAfter = response.headers?.['retry-after'];
|
|
38
|
+
if (retryAfter) {
|
|
39
|
+
const seconds = Number.parseInt(retryAfter, 10);
|
|
40
|
+
if (!Number.isNaN(seconds)) {
|
|
41
|
+
err.retryAfterSeconds = seconds;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
37
44
|
throw err;
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { getApiBaseUrl, getToken } from './config.js';
|
|
2
|
+
|
|
3
|
+
const TERMINAL_STATUSES = new Set(['success', 'failure', 'cancelled']);
|
|
4
|
+
|
|
5
|
+
function parseSseChunk(buffer, onEvent) {
|
|
6
|
+
const blocks = buffer.split('\n\n');
|
|
7
|
+
const remainder = blocks.pop() || '';
|
|
8
|
+
|
|
9
|
+
for (const block of blocks) {
|
|
10
|
+
if (!block.trim()) continue;
|
|
11
|
+
|
|
12
|
+
let event = 'message';
|
|
13
|
+
let data = '';
|
|
14
|
+
|
|
15
|
+
for (const line of block.split('\n')) {
|
|
16
|
+
if (line.startsWith('event:')) {
|
|
17
|
+
event = line.slice(6).trim();
|
|
18
|
+
} else if (line.startsWith('data:')) {
|
|
19
|
+
data += line.slice(5).trim();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!data) continue;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
onEvent(event, JSON.parse(data));
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore malformed SSE frames
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return remainder;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Watch deployment progress over a single long-lived SSE stream backed by MQTT.
|
|
37
|
+
*/
|
|
38
|
+
export async function watchDeploymentStream(deploymentId, { onUpdate, signal } = {}) {
|
|
39
|
+
const token = getToken();
|
|
40
|
+
if (!token) {
|
|
41
|
+
throw new Error('Not logged in');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const url = `${getApiBaseUrl()}/api/v1/cli/deployments/${deploymentId}/stream`;
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${token}`,
|
|
48
|
+
Accept: 'text/event-stream',
|
|
49
|
+
},
|
|
50
|
+
signal,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const err = new Error(`Deploy stream failed (${response.status})`);
|
|
55
|
+
err.status = response.status;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!response.body) {
|
|
60
|
+
throw new Error('Deploy stream response has no body');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const reader = response.body.getReader();
|
|
64
|
+
const decoder = new TextDecoder();
|
|
65
|
+
let buffer = '';
|
|
66
|
+
let latestDeployment = null;
|
|
67
|
+
let streamError = null;
|
|
68
|
+
|
|
69
|
+
while (true) {
|
|
70
|
+
const { done, value } = await reader.read();
|
|
71
|
+
if (done) break;
|
|
72
|
+
|
|
73
|
+
buffer += decoder.decode(value, { stream: true });
|
|
74
|
+
buffer = parseSseChunk(buffer, (event, payload) => {
|
|
75
|
+
if (event === 'snapshot' || event === 'progress' || event === 'done') {
|
|
76
|
+
latestDeployment = payload?.deployment || latestDeployment;
|
|
77
|
+
if (latestDeployment) {
|
|
78
|
+
onUpdate?.(latestDeployment, { event, payload });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (event === 'error') {
|
|
83
|
+
const err = new Error(payload?.message || 'Deploy stream error');
|
|
84
|
+
err.code = payload?.code;
|
|
85
|
+
streamError = err;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (streamError) {
|
|
90
|
+
throw streamError;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return latestDeployment;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isTerminalDeployment(deployment) {
|
|
98
|
+
return TERMINAL_STATUSES.has(deployment?.status);
|
|
99
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
6
|
+
|
|
7
|
+
export function getCliVersion() {
|
|
8
|
+
return JSON.parse(readFileSync(packagePath, 'utf8')).version;
|
|
9
|
+
}
|