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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servyx-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Servyx CLI — Upload local projects and deploy via the Servyx platform",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- function createClient(token = getToken()) {
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
- return axios.create({
157
+ const client = axios.create({
7
158
  baseURL,
8
- timeout: 120000,
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, action = 'request') {
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
- `Cannot reach Servyx API at ${getApiBaseUrl()} (${err.code}). Is cli-service running?`,
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
- const client = createClient();
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
- mapNetworkError(err, 'start deployment');
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