servyx-cli 0.1.6 → 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 CHANGED
@@ -13,23 +13,44 @@ 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
- # or from source:
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 (defaults to https://cli.servyxhq.com)
41
+ # 1. Authenticate
24
42
  servyx login
25
43
 
26
44
  # 2. Link your project directory to an app
27
45
  cd my-app
28
46
  servyx link
29
47
 
30
- # 3. Deploy
48
+ # 3. Deploy (server .env and .htaccess are preserved by default)
31
49
  servyx deploy
32
50
 
51
+ # 3b. React/Vite frontends: upload local .env files too
52
+ servyx deploy --with-env
53
+
33
54
  # 4. Check recent deployments
34
55
  servyx status
35
56
  ```
@@ -38,7 +59,7 @@ servyx status
38
59
 
39
60
  - Global config: `~/.servyx/config.json` (mode 600)
40
61
  - Project config: `servyx.json` in your project root
41
- - Optional excludes: `.servyxignore` (rsync patterns). Hidden files (`.env`, `.htaccess`, etc.) are not uploaded from your machine; existing server copies are preserved during deploy.
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).
42
63
 
43
64
  Environment variables:
44
65
 
@@ -47,8 +68,8 @@ Environment variables:
47
68
  ## How it works
48
69
 
49
70
  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 (local hidden files are skipped)
51
- 3. Live dotfiles from `public` (`.env`, `.htaccess`, etc.) are copied into staging so they are not removed on deploy
52
- 4. cli-service calls server-service to queue a CLI deployment
53
- 5. The agent syncs staging → public, runs the pipeline, and reports progress
54
- 6. The CLI polls until the deployment succeeds or fails
71
+ 2. Files are rsynced to `<app_root>/.cli-staging` on the server (local hidden files are skipped unless `--with-env`)
72
+ 3. Live dotfiles from `public` are copied into staging so they are not removed on deploy (`.env` is skipped when using `--with-env`)
73
+ 4. cli-service calls server-service to queue a CLI deployment (`with_env` tells the agent to sync uploaded env files)
74
+ 5. The agent syncs staging → public, preserving `.htaccess` and (by default) `.env` on the server
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.6",
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",
@@ -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';
@@ -58,12 +70,17 @@ export async function runDeploy(options = {}) {
58
70
  process.exit(1);
59
71
  }
60
72
 
73
+ const withEnv = Boolean(options.withEnv);
61
74
  const excludes = [
62
75
  ...DEFAULT_RSYNC_EXCLUDES,
63
76
  ...(target.rsync_excludes || []),
64
77
  ...loadServyxIgnore(root),
65
78
  ];
66
79
 
80
+ if (withEnv) {
81
+ console.log(pc.yellow('Including local .env files in this deploy.'));
82
+ }
83
+
67
84
  console.log(pc.bold('Syncing files via rsync...'));
68
85
  ensureRemoteDir({
69
86
  host: target.ssh_host,
@@ -84,6 +101,7 @@ export async function runDeploy(options = {}) {
84
101
  user: target.ssh_user,
85
102
  remotePath: target.staging_path,
86
103
  excludes,
104
+ withEnv,
87
105
  });
88
106
 
89
107
  const stagingCheck = verifyRemoteStagingDir({
@@ -103,20 +121,25 @@ export async function runDeploy(options = {}) {
103
121
  console.log(pc.green('Rsync complete.'));
104
122
 
105
123
  const publicPath = target.public_path || `${target.root_path}/public`;
106
- console.log(pc.bold('Preserving server dotfiles (.env, .htaccess, etc.)...'));
124
+ if (!withEnv) {
125
+ console.log(pc.bold('Preserving server dotfiles (.env, .htaccess, etc.)...'));
126
+ } else {
127
+ console.log(pc.bold('Preserving server dotfiles (.htaccess, etc.)...'));
128
+ }
107
129
  preserveServerDotfiles({
108
130
  host: target.ssh_host,
109
131
  port: target.ssh_port || 22,
110
132
  user: target.ssh_user,
111
133
  publicPath,
112
134
  stagingPath: target.staging_path,
135
+ skipEnv: withEnv,
113
136
  });
114
137
  console.log(pc.green('Server dotfiles preserved.'));
115
138
 
116
139
  console.log(pc.bold('Starting deployment...'));
117
140
  let startResult;
118
141
  try {
119
- startResult = await startDeployment(appId, message);
142
+ startResult = await startDeployment(appId, message, { withEnv });
120
143
  } catch (err) {
121
144
  if (err.code === 'APP_NOT_FOUND') {
122
145
  console.error(pc.red('Application not found by server-service.'));
@@ -138,29 +161,57 @@ export async function runDeploy(options = {}) {
138
161
 
139
162
  console.log(pc.dim(`Deployment ID: ${deploymentId}`));
140
163
 
141
- while (true) {
142
- const statusResult = await getDeployment(deploymentId);
143
- const current = statusResult?.data?.deployment;
144
- if (!current) {
145
- console.error(pc.red('Deployment not found while polling.'));
146
- process.exit(1);
147
- }
164
+ let lastSnapshot = null;
165
+ let current = deployment;
148
166
 
149
- console.log(formatDeploymentLine(current));
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
+ };
150
175
 
151
- if (TERMINAL_STATUSES.has(current.status)) {
152
- if (current.status === 'success') {
153
- console.log(pc.green('Deployment succeeded.'));
154
- process.exit(0);
155
- }
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
+ }
156
188
 
157
- console.error(pc.red(`Deployment ${current.status}.`));
158
- if (current.error_message) {
159
- console.error(pc.red(current.error_message));
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);
160
195
  }
161
- process.exit(1);
196
+ handleDeploymentUpdate(current);
197
+ if (TERMINAL_STATUSES.has(current.status)) break;
198
+ await sleep(FALLBACK_POLL_MS);
162
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
+ }
163
211
 
164
- await sleep(2000);
212
+ console.error(pc.red(`Deployment ${current.status}.`));
213
+ if (current.error_message) {
214
+ console.error(pc.red(current.error_message));
165
215
  }
216
+ process.exit(1);
166
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('0.1.0');
14
+ .version(getCliVersion());
14
15
 
15
16
  program
16
17
  .command('login')
@@ -45,9 +46,10 @@ program
45
46
  .command('deploy')
46
47
  .description('Rsync local files and trigger a deployment')
47
48
  .option('-m, --message <message>', 'Deployment message')
49
+ .option('--with-env', 'Upload local .env files (for React/Vite frontends)')
48
50
  .action(async (options) => {
49
51
  try {
50
- await runDeploy(options);
52
+ await runDeploy({ ...options, withEnv: Boolean(options.withEnv) });
51
53
  } catch (err) {
52
54
  console.error(pc.red(err.message || 'Deploy failed'));
53
55
  process.exit(1);
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
 
@@ -54,10 +61,13 @@ export async function getDeployTarget(appId) {
54
61
  return unwrap(await client.get(`/apps/${appId}/deploy-target`));
55
62
  }
56
63
 
57
- export async function startDeployment(appId, message) {
64
+ export async function startDeployment(appId, message, { withEnv = false } = {}) {
58
65
  try {
59
66
  const client = createClient();
60
- return unwrap(await client.post(`/apps/${appId}/deployments`, { message }));
67
+ return unwrap(await client.post(`/apps/${appId}/deployments`, {
68
+ message,
69
+ with_env: withEnv || undefined,
70
+ }));
61
71
  } catch (err) {
62
72
  mapNetworkError(err, 'start deployment');
63
73
  }
@@ -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
+ }
package/src/lib/rsync.js CHANGED
@@ -1,9 +1,34 @@
1
1
  import { spawnSync } from 'child_process';
2
2
  import path from 'path';
3
3
 
4
- /** Local hidden files are not uploaded; server copies are re-seeded into staging before deploy. */
4
+ /** Local hidden files are not uploaded by default; server copies are re-seeded into staging before deploy. */
5
5
  export const DEFAULT_RSYNC_EXCLUDES = ['.*'];
6
6
 
7
+ /** React/Vite-style env files that can be opted in with --with-env. */
8
+ export const ENV_FILE_PATTERNS = ['.env', '.env.*'];
9
+
10
+ export function buildRsyncFilters({ withEnv = false, excludes = [] } = {}) {
11
+ const filters = [];
12
+ if (withEnv) {
13
+ for (const pattern of ENV_FILE_PATTERNS) {
14
+ filters.push('--include', pattern);
15
+ }
16
+ }
17
+ filters.push('--exclude', '.*');
18
+
19
+ const filteredExcludes = excludes.filter((pattern) => {
20
+ if (pattern === '.*') return false;
21
+ if (withEnv && ENV_FILE_PATTERNS.includes(pattern)) return false;
22
+ return true;
23
+ });
24
+
25
+ for (const pattern of filteredExcludes) {
26
+ filters.push('--exclude', pattern);
27
+ }
28
+
29
+ return filters;
30
+ }
31
+
7
32
  function shellQuote(value) {
8
33
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
9
34
  }
@@ -36,7 +61,7 @@ export function sshPreflight({ host, port = 22, user }) {
36
61
  };
37
62
  }
38
63
 
39
- export function rsyncDeploy({ localDir, host, port = 22, user, remotePath, excludes = [] }) {
64
+ export function rsyncDeploy({ localDir, host, port = 22, user, remotePath, excludes = [], withEnv = false }) {
40
65
  requireBinary('rsync');
41
66
  const target = `${user}@${host}:${remotePath.replace(/\/$/, '')}/`;
42
67
  const args = [
@@ -45,7 +70,7 @@ export function rsyncDeploy({ localDir, host, port = 22, user, remotePath, exclu
45
70
  '--human-readable',
46
71
  '--progress',
47
72
  '-e', `ssh -p ${port} -o StrictHostKeyChecking=accept-new`,
48
- ...excludes.flatMap((pattern) => ['--exclude', pattern]),
73
+ ...buildRsyncFilters({ withEnv, excludes }),
49
74
  `${path.resolve(localDir)}/`,
50
75
  target,
51
76
  ];
@@ -107,14 +132,19 @@ export function verifyRemoteStagingDir({ host, port = 22, user, remotePath }) {
107
132
  }
108
133
 
109
134
  /** Copy live dotfiles from public into staging so the agent's staging→public sync does not delete them. */
110
- export function preserveServerDotfiles({ host, port = 22, user, publicPath, stagingPath }) {
135
+ export function preserveServerDotfiles({ host, port = 22, user, publicPath, stagingPath, skipEnv = false }) {
111
136
  requireBinary('ssh');
112
137
  const target = `${user}@${host}`;
138
+ const skipEnvFlag = skipEnv ? '1' : '0';
113
139
  const cmd = [
114
140
  `mkdir -p ${shellQuote(stagingPath)}`,
115
- `&& find ${shellQuote(publicPath)} -maxdepth 1 -name '.*' -type f`,
116
- `-exec cp -a {} ${shellQuote(`${stagingPath.replace(/\/$/, '')}/`)} \\;`,
117
- '2>/dev/null || true',
141
+ `&& SKIP_ENV=${skipEnvFlag}`,
142
+ `find ${shellQuote(publicPath)} -maxdepth 1 -name '.*' -type f`,
143
+ `| while IFS= read -r f; do`,
144
+ `base=$(basename "$f");`,
145
+ `if [ "$SKIP_ENV" = "1" ]; then case "$base" in .env|.env.*) continue;; esac; fi;`,
146
+ `cp -a "$f" ${shellQuote(`${stagingPath.replace(/\/$/, '')}/`)};`,
147
+ `done 2>/dev/null || true`,
118
148
  ].join(' ');
119
149
 
120
150
  const args = [
@@ -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
+ }