servyx-cli 0.1.11 → 0.1.13
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 +0 -3
- package/package.json +1 -1
- package/src/commands/deploy.js +3 -1
- package/src/index.js +2 -1
- package/src/lib/api.js +210 -12
package/README.md
CHANGED
|
@@ -61,9 +61,6 @@ servyx status
|
|
|
61
61
|
- Project config: `servyx.json` in your project root
|
|
62
62
|
- Optional excludes: `.servyxignore` (rsync patterns). By default, hidden files (`.env`, `.htaccess`, etc.) are not uploaded and existing server copies are preserved. Use `servyx deploy --with-env` to upload local `.env` / `.env.*` files (useful for React/Vite builds).
|
|
63
63
|
|
|
64
|
-
Environment variables:
|
|
65
|
-
|
|
66
|
-
- `SERVYX_API_URL` — override CLI service base URL
|
|
67
64
|
|
|
68
65
|
## How it works
|
|
69
66
|
|
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
|
-
import { getDeployTarget, getDeployment, startDeployment } from '../lib/api.js';
|
|
2
|
+
import { getDeployTarget, getDeployment, startDeployment, setApiVerbose } from '../lib/api.js';
|
|
3
3
|
import { getToken } from '../lib/config.js';
|
|
4
4
|
import { loadProjectConfig, loadServyxIgnore } from '../lib/project.js';
|
|
5
5
|
import { rsyncDeploy, sshPreflight, DEFAULT_RSYNC_EXCLUDES, preserveServerDotfiles, ensureRemoteDir, verifyRemoteStagingDir } from '../lib/rsync.js';
|
|
@@ -18,6 +18,8 @@ function formatDeploymentLine(deployment) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export async function runDeploy(options = {}) {
|
|
21
|
+
setApiVerbose(Boolean(options.verbose));
|
|
22
|
+
|
|
21
23
|
if (!getToken()) {
|
|
22
24
|
console.error(pc.red('Not logged in. Run: servyx login'));
|
|
23
25
|
process.exit(1);
|
package/src/index.js
CHANGED
|
@@ -47,9 +47,10 @@ program
|
|
|
47
47
|
.description('Rsync local files and trigger a deployment')
|
|
48
48
|
.option('-m, --message <message>', 'Deployment message')
|
|
49
49
|
.option('--with-env', 'Upload local .env files (for React/Vite frontends)')
|
|
50
|
+
.option('-v, --verbose', 'Log API requests and responses')
|
|
50
51
|
.action(async (options) => {
|
|
51
52
|
try {
|
|
52
|
-
await runDeploy({ ...options, withEnv: Boolean(options.withEnv) });
|
|
53
|
+
await runDeploy({ ...options, withEnv: Boolean(options.withEnv), verbose: Boolean(options.verbose) });
|
|
53
54
|
} catch (err) {
|
|
54
55
|
console.error(pc.red(err.message || 'Deploy failed'));
|
|
55
56
|
process.exit(1);
|
package/src/lib/api.js
CHANGED
|
@@ -1,24 +1,186 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
+
import pc from 'picocolors';
|
|
2
3
|
import { getApiBaseUrl, getToken } from './config.js';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
let apiVerbose = false;
|
|
6
|
+
|
|
7
|
+
export function setApiVerbose(enabled) {
|
|
8
|
+
apiVerbose = Boolean(enabled);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isApiVerbose() {
|
|
12
|
+
return apiVerbose || process.env.SERVYX_DEBUG === '1' || process.env.SERVYX_DEBUG === 'true';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildFullUrl(baseURL = '', url = '') {
|
|
16
|
+
const base = String(baseURL).replace(/\/+$/, '');
|
|
17
|
+
const path = String(url).replace(/^\//, '');
|
|
18
|
+
return path ? `${base}/${path}` : base;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function summarizeBody(data) {
|
|
22
|
+
if (data == null) return '(empty)';
|
|
23
|
+
if (typeof data === 'string') {
|
|
24
|
+
const trimmed = data.trim();
|
|
25
|
+
if (!trimmed) return '(empty)';
|
|
26
|
+
if (trimmed.length > 800) {
|
|
27
|
+
return `${trimmed.slice(0, 800)}... [truncated, ${trimmed.length} chars]`;
|
|
28
|
+
}
|
|
29
|
+
return trimmed;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const text = JSON.stringify(data);
|
|
33
|
+
if (text.length > 1200) {
|
|
34
|
+
return `${text.slice(0, 1200)}... [truncated]`;
|
|
35
|
+
}
|
|
36
|
+
return text;
|
|
37
|
+
} catch {
|
|
38
|
+
return String(data);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function logApiRequest(config) {
|
|
43
|
+
const method = config.method?.toUpperCase() || 'REQUEST';
|
|
44
|
+
const url = config._servyxFullUrl || buildFullUrl(config.baseURL, config.url);
|
|
45
|
+
console.error(pc.dim(`→ ${method} ${url}`));
|
|
46
|
+
if (config.params && Object.keys(config.params).length) {
|
|
47
|
+
console.error(pc.dim(` Query: ${summarizeBody(config.params)}`));
|
|
48
|
+
}
|
|
49
|
+
if (config.data != null) {
|
|
50
|
+
console.error(pc.dim(` Body: ${summarizeBody(config.data)}`));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function logApiResponse(response) {
|
|
55
|
+
const method = response.config?.method?.toUpperCase() || 'REQUEST';
|
|
56
|
+
const url = response.config?._servyxFullUrl || buildFullUrl(response.config?.baseURL, response.config?.url);
|
|
57
|
+
const elapsed = response.config?._servyxStart
|
|
58
|
+
? `${Date.now() - response.config._servyxStart}ms`
|
|
59
|
+
: '';
|
|
60
|
+
console.error(pc.dim(`← ${response.status} ${method} ${url}${elapsed ? ` (${elapsed})` : ''}`));
|
|
61
|
+
if (isApiVerbose()) {
|
|
62
|
+
console.error(pc.dim(` Response: ${summarizeBody(response.data)}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function logApiError({ method = 'REQUEST', url = 'unknown', err = null, response = null } = {}) {
|
|
67
|
+
// console.error(pc.yellow('--- Servyx API error ---'));
|
|
68
|
+
// console.error(pc.dim(`${method} ${url}`));
|
|
69
|
+
// console.error(pc.dim(`API base: ${getApiBaseUrl()}`));
|
|
70
|
+
|
|
71
|
+
if (response) {
|
|
72
|
+
// console.error(pc.dim(`Status: ${response.status} ${response.statusText || ''}`));
|
|
73
|
+
const headers = response.headers || {};
|
|
74
|
+
const contentType = headers['content-type'] || headers['Content-Type'];
|
|
75
|
+
if (contentType) {
|
|
76
|
+
// console.error(pc.dim(`Content-Type: ${contentType}`));
|
|
77
|
+
}
|
|
78
|
+
// console.error(pc.dim(`Response body: ${summarizeBody(response.data)}`));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (err) {
|
|
82
|
+
if (err.code) console.error(pc.dim(`Network code: ${err.code}`));
|
|
83
|
+
if (err.syscall) console.error(pc.dim(`Syscall: ${err.syscall}`));
|
|
84
|
+
if (err.errno != null) console.error(pc.dim(`Errno: ${err.errno}`));
|
|
85
|
+
if (err.address) console.error(pc.dim(`Address: ${err.address}${err.port ? `:${err.port}` : ''}`));
|
|
86
|
+
if (err.message) console.error(pc.dim(`Message: ${err.message}`));
|
|
87
|
+
if (err.config && !response) {
|
|
88
|
+
const reqUrl = err.config._servyxFullUrl || buildFullUrl(err.config.baseURL, err.config.url);
|
|
89
|
+
// console.error(pc.dim(`Request: ${err.config.method?.toUpperCase() || 'REQUEST'} ${reqUrl}`));
|
|
90
|
+
if (err.config.data != null) {
|
|
91
|
+
// console.error(pc.dim(`Request body: ${summarizeBody(err.config.data)}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.error(pc.yellow('------------------------'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function attachLogging(client) {
|
|
100
|
+
client.interceptors.request.use((config) => {
|
|
101
|
+
config._servyxStart = Date.now();
|
|
102
|
+
config._servyxFullUrl = buildFullUrl(config.baseURL, config.url);
|
|
103
|
+
if (isApiVerbose()) {
|
|
104
|
+
logApiRequest(config);
|
|
105
|
+
}
|
|
106
|
+
return config;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
client.interceptors.response.use(
|
|
110
|
+
(response) => {
|
|
111
|
+
if (isApiVerbose()) {
|
|
112
|
+
logApiResponse(response);
|
|
113
|
+
}
|
|
114
|
+
return response;
|
|
115
|
+
},
|
|
116
|
+
(error) => {
|
|
117
|
+
const config = error.config || {};
|
|
118
|
+
logApiError({
|
|
119
|
+
method: config.method?.toUpperCase() || 'REQUEST',
|
|
120
|
+
url: config._servyxFullUrl || buildFullUrl(config.baseURL, config.url),
|
|
121
|
+
err: error,
|
|
122
|
+
response: error.response || null,
|
|
123
|
+
});
|
|
124
|
+
return Promise.reject(error);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const START_DEPLOY_TIMEOUT_MS = 180000;
|
|
130
|
+
const NETWORK_RETRY_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED']);
|
|
131
|
+
|
|
132
|
+
function sleep(ms) {
|
|
133
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function findRecentActiveDeployment(appId, maxAgeMs = 120000) {
|
|
137
|
+
try {
|
|
138
|
+
const client = createClient();
|
|
139
|
+
const response = await client.get(`/apps/${appId}/deployments`, { params: { limit: 5 } });
|
|
140
|
+
if (response.status < 200 || response.status >= 300) return null;
|
|
141
|
+
|
|
142
|
+
const deployments = response.data?.data?.deployments || [];
|
|
143
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
144
|
+
|
|
145
|
+
return deployments.find((deployment) => {
|
|
146
|
+
if (!['pending', 'in_progress'].includes(deployment.status)) return false;
|
|
147
|
+
const createdAt = deployment.created_at ? new Date(deployment.created_at).getTime() : 0;
|
|
148
|
+
return createdAt >= cutoff;
|
|
149
|
+
}) || null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createClient(token = getToken(), { timeout = 120000 } = {}) {
|
|
5
156
|
const baseURL = `${getApiBaseUrl()}/api/v1/cli`;
|
|
6
|
-
|
|
157
|
+
const client = axios.create({
|
|
7
158
|
baseURL,
|
|
8
|
-
timeout
|
|
159
|
+
timeout,
|
|
9
160
|
headers: {
|
|
10
161
|
'Content-Type': 'application/json',
|
|
11
162
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
12
163
|
},
|
|
13
164
|
validateStatus: () => true,
|
|
14
165
|
});
|
|
166
|
+
attachLogging(client);
|
|
167
|
+
return client;
|
|
15
168
|
}
|
|
16
169
|
|
|
17
|
-
function mapNetworkError(err,
|
|
170
|
+
function mapNetworkError(err, meta = {}) {
|
|
18
171
|
if (err?.response || err?.status) throw err;
|
|
19
|
-
if (['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'].includes(err?.code)) {
|
|
172
|
+
if (['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNABORTED'].includes(err?.code)) {
|
|
173
|
+
if (!err?.config) {
|
|
174
|
+
logApiError({
|
|
175
|
+
method: meta.method || 'REQUEST',
|
|
176
|
+
url: meta.url || `${getApiBaseUrl()}/api/v1/cli${meta.path || ''}`,
|
|
177
|
+
err,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
20
180
|
const hint = new Error(
|
|
21
|
-
|
|
181
|
+
err.code === 'ECONNRESET'
|
|
182
|
+
? `Connection to ${getApiBaseUrl()} was reset (${err.code}). The deploy may still have started — retry status polling or check the dashboard. Common when redeploying cli-service itself.`
|
|
183
|
+
: `Cannot reach Servyx API at ${getApiBaseUrl()} (${err.code}). Is cli-service running?`,
|
|
22
184
|
);
|
|
23
185
|
hint.code = err.code;
|
|
24
186
|
throw hint;
|
|
@@ -31,6 +193,10 @@ function unwrap(response) {
|
|
|
31
193
|
return response.data;
|
|
32
194
|
}
|
|
33
195
|
|
|
196
|
+
const method = response.config?.method?.toUpperCase() || 'REQUEST';
|
|
197
|
+
const url = response.config?._servyxFullUrl || buildFullUrl(response.config?.baseURL, response.config?.url);
|
|
198
|
+
logApiError({ method, url, response });
|
|
199
|
+
|
|
34
200
|
const isHtml = typeof response.data === 'string' && response.data.includes('<html');
|
|
35
201
|
const message = isHtml
|
|
36
202
|
? `API returned ${response.status} (expected JSON). Check SERVYX_API_URL / cli-service routing.`
|
|
@@ -46,6 +212,7 @@ function unwrap(response) {
|
|
|
46
212
|
export async function login({ username, password, otp, apiUrl }) {
|
|
47
213
|
const baseURL = `${(apiUrl || getApiBaseUrl()).replace(/\/+$/, '')}/api/v1/cli`;
|
|
48
214
|
const client = axios.create({ baseURL, timeout: 60000, validateStatus: () => true });
|
|
215
|
+
attachLogging(client);
|
|
49
216
|
const response = await client.post('/auth/login', { username, password, otp });
|
|
50
217
|
return unwrap(response);
|
|
51
218
|
}
|
|
@@ -61,14 +228,45 @@ export async function getDeployTarget(appId) {
|
|
|
61
228
|
}
|
|
62
229
|
|
|
63
230
|
export async function startDeployment(appId, message, { withEnv = false } = {}) {
|
|
231
|
+
const path = `/apps/${appId}/deployments`;
|
|
232
|
+
const body = {
|
|
233
|
+
message,
|
|
234
|
+
with_env: withEnv || undefined,
|
|
235
|
+
};
|
|
236
|
+
const url = buildFullUrl(`${getApiBaseUrl()}/api/v1/cli`, path);
|
|
237
|
+
const client = createClient(undefined, { timeout: START_DEPLOY_TIMEOUT_MS });
|
|
238
|
+
|
|
239
|
+
const postDeploy = () => client.post(path, body);
|
|
240
|
+
|
|
64
241
|
try {
|
|
65
|
-
|
|
66
|
-
return unwrap(await client.post(`/apps/${appId}/deployments`, {
|
|
67
|
-
message,
|
|
68
|
-
with_env: withEnv || undefined,
|
|
69
|
-
}));
|
|
242
|
+
return unwrap(await postDeploy());
|
|
70
243
|
} catch (err) {
|
|
71
|
-
|
|
244
|
+
if (NETWORK_RETRY_CODES.has(err?.code)) {
|
|
245
|
+
await sleep(1500);
|
|
246
|
+
try {
|
|
247
|
+
return unwrap(await postDeploy());
|
|
248
|
+
} catch (retryErr) {
|
|
249
|
+
err = retryErr;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (NETWORK_RETRY_CODES.has(err?.code)) {
|
|
254
|
+
const deployment = await findRecentActiveDeployment(appId);
|
|
255
|
+
if (deployment) {
|
|
256
|
+
console.error(pc.yellow('Connection dropped after deploy was queued; continuing with the active deployment.'));
|
|
257
|
+
return {
|
|
258
|
+
success: true,
|
|
259
|
+
data: { deployment, queue_position: 1 },
|
|
260
|
+
message: 'CLI deployment queued',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
mapNetworkError(err, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
path,
|
|
268
|
+
url,
|
|
269
|
+
});
|
|
72
270
|
}
|
|
73
271
|
}
|
|
74
272
|
|