@lazycatcloud/lzc-cli 2.0.0-pre.8 → 2.0.0-pre.9

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
@@ -94,6 +94,6 @@ lzc-cli box add-by-ssh root 192.168.31.13
94
94
 
95
95
  1. 参数格式为 `loginUser address`,地址支持 `host` 或 `host:port`
96
96
  2. 配置后会自动设为默认盒子,可通过 `box list/switch/default` 管理
97
- 3. `project release/deploy/start/exec/cp/log/info`、`lpk install/uninstall`、`docker/docker-compose` 都会优先使用该远端
97
+ 3. `project release/deploy/start/exec/cp/log/info/sync`、`lpk install/uninstall`、`docker/docker-compose` 都会优先使用该远端
98
98
  4. `lzc-build.yml` 不再支持 `remote` 字段
99
99
  5. 可选基础配置文件为 `lzc-build.base.yml`(与构建配置同目录)
package/changelog.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.0-pre.9](https://gitee.com/linakesi/lzc-cli/compare/v2.0.0-pre.8...v2.0.0-pre.9) (2026-03-30)
4
+
5
+ ### Features
6
+
7
+ - project sync support ssh box
8
+
3
9
  ## [2.0.0-pre.8](https://gitee.com/linakesi/lzc-cli/compare/v2.0.0-pre.7...v2.0.0-pre.8) (2026-03-27)
4
10
 
5
11
  ### Bug Fixes
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import net from 'node:net';
2
3
  import path from 'node:path';
3
4
  import chokidar from 'chokidar';
4
5
  import debounce from 'lodash.debounce';
@@ -6,12 +7,15 @@ import ignore from 'ignore';
6
7
  import spawn from 'cross-spawn';
7
8
  import logger from 'loglevel';
8
9
  import { checkRsync, contextDirname, isDebugMode, isWindows, resolveDomain } from '../utils.js';
10
+ import { sshBinary } from '../debug_bridge.js';
9
11
  import { addProjectTargetOptions, resolveProjectRuntime, getProjectDeployInfo } from './project_runtime.js';
10
12
 
11
13
  export const DEFAULT_PROJECT_SYNC_TARGET = '/lzcapp/cache/project-mirror';
14
+ const DEBUG_BRIDGE_CONTAINER = 'cloudlazycatdevelopertools-app-1';
12
15
  const PROJECT_SYNC_CACHE_ROOT = '/lzcapp/cache';
13
16
  const RSYNC_PASSWORD = 'fakefakefake';
14
17
  const RSYNC_PORT = '874';
18
+ const LOCALHOST = '127.0.0.1';
15
19
  const LZCDEVIGNORE_FILE = '.lzcdevignore';
16
20
  const GITIGNORE_FILE = '.gitignore';
17
21
  const DEFAULT_LZCDEVIGNORE_RULES = [
@@ -125,11 +129,8 @@ function formatRsyncHost(host) {
125
129
  return String(host).includes(':') ? `[${host}]` : String(host);
126
130
  }
127
131
 
128
- async function resolveRsyncHost(runtime) {
129
- if (runtime.bridge.isBuildRemoteMode()) {
130
- return runtime.bridge.buildRemote.sshHost;
131
- }
132
- return resolveDomain(runtime.bridge.domain);
132
+ function formatSshForwardHost(host) {
133
+ return String(host).includes(':') ? `[${host}]` : String(host);
133
134
  }
134
135
 
135
136
  async function resolveRsyncUID(runtime) {
@@ -148,7 +149,26 @@ function buildRsyncModulePath(runtime, uid, targetDir) {
148
149
  return modulePath;
149
150
  }
150
151
 
151
- export function buildRsyncArgs(runtime, uid, host, rootDir, targetDir, sourceDir, deleteMode, dryRun) {
152
+ async function ensureBuildRemoteSyncTarget(runtime, uid, targetDir) {
153
+ if (!runtime.bridge.isBuildRemoteMode()) {
154
+ return;
155
+ }
156
+ const modulePath = buildRsyncModulePath(runtime, uid, targetDir);
157
+ const result = runtime.bridge.remoteHostExec([
158
+ 'lzc-docker',
159
+ 'exec',
160
+ '-i',
161
+ DEBUG_BRIDGE_CONTAINER,
162
+ 'mkdir',
163
+ '-p',
164
+ `/lzcapp/run/data/app/cache/${modulePath}`,
165
+ ]);
166
+ if (result.status !== 0) {
167
+ throw new Error(String(result.stderr ?? result.stdout ?? '').trim() || 'prepare remote sync target failed');
168
+ }
169
+ }
170
+
171
+ export function buildRsyncArgs(runtime, uid, host, rootDir, targetDir, sourceDir, deleteMode, dryRun, port = RSYNC_PORT) {
152
172
  const args = ['--recursive', '--links', '--times', '--perms', '--omit-dir-times', '--human-readable', '--itemize-changes', '--compress'];
153
173
  if (isDebugMode()) {
154
174
  args.push('-P');
@@ -167,12 +187,33 @@ export function buildRsyncArgs(runtime, uid, host, rootDir, targetDir, sourceDir
167
187
  args.push('--relative');
168
188
  }
169
189
  const modulePath = buildRsyncModulePath(runtime, uid, targetDir);
170
- const dest = `rsync://${uid}@${formatRsyncHost(host)}:${RSYNC_PORT}/lzcapp_cache/${modulePath}/`;
190
+ const dest = `rsync://${uid}@${formatRsyncHost(host)}:${port}/lzcapp_cache/${modulePath}/`;
171
191
  const sourceArg = sourceDir ? `./${sourceDir}/` : './';
172
192
  args.push(sourceArg, dest);
173
193
  return args;
174
194
  }
175
195
 
196
+ export function buildBuildRemoteRsyncTunnelArgs(runtime, localPort, rsyncTargetHost) {
197
+ const remoteArgs = runtime.bridge.remoteSshArgsRaw();
198
+ const sshTarget = remoteArgs.at(-1);
199
+ if (!sshTarget) {
200
+ throw new Error('build remote ssh target is empty');
201
+ }
202
+ const formattedTargetHost = formatSshForwardHost(rsyncTargetHost);
203
+ if (!formattedTargetHost) {
204
+ throw new Error('build remote rsync target host is empty');
205
+ }
206
+ return [
207
+ ...remoteArgs.slice(0, -1),
208
+ '-o',
209
+ 'ExitOnForwardFailure=yes',
210
+ '-L',
211
+ `${LOCALHOST}:${localPort}:${formattedTargetHost}:${RSYNC_PORT}`,
212
+ sshTarget,
213
+ '-N',
214
+ ];
215
+ }
216
+
176
217
  function formatRsyncLine(line, dryRun) {
177
218
  const text = String(line ?? '').trim();
178
219
  if (!text) {
@@ -250,6 +291,161 @@ function runRsyncProcess(command, args, cwd, dryRun) {
250
291
  });
251
292
  }
252
293
 
294
+ function reserveLocalPort() {
295
+ return new Promise((resolve, reject) => {
296
+ const server = net.createServer();
297
+ server.once('error', reject);
298
+ server.listen(0, LOCALHOST, () => {
299
+ const address = server.address();
300
+ if (!address || typeof address === 'string') {
301
+ server.close(() => reject(new Error('reserve local port failed')));
302
+ return;
303
+ }
304
+ server.close((error) => {
305
+ if (error) {
306
+ reject(error);
307
+ return;
308
+ }
309
+ resolve(address.port);
310
+ });
311
+ });
312
+ });
313
+ }
314
+
315
+ function waitForTunnelReady(child, host, port, stderrLines) {
316
+ return new Promise((resolve, reject) => {
317
+ let settled = false;
318
+ let timer = null;
319
+ let closeHandler = null;
320
+ let errorHandler = null;
321
+ const deadline = Date.now() + 5000;
322
+
323
+ const finish = (callback) => {
324
+ if (settled) {
325
+ return;
326
+ }
327
+ settled = true;
328
+ if (timer) {
329
+ clearTimeout(timer);
330
+ }
331
+ if (closeHandler) {
332
+ child.off('close', closeHandler);
333
+ }
334
+ if (errorHandler) {
335
+ child.off('error', errorHandler);
336
+ }
337
+ callback();
338
+ };
339
+
340
+ const retry = () => {
341
+ if (settled) {
342
+ return;
343
+ }
344
+ if (child.exitCode !== null) {
345
+ const detail = stderrLines.join('\n').trim();
346
+ finish(() => reject(new Error(detail || 'ssh tunnel exited before ready')));
347
+ return;
348
+ }
349
+ const socket = net.createConnection({ host, port });
350
+ socket.once('connect', () => {
351
+ socket.destroy();
352
+ finish(() => resolve());
353
+ });
354
+ socket.once('error', () => {
355
+ socket.destroy();
356
+ if (Date.now() >= deadline) {
357
+ const detail = stderrLines.join('\n').trim();
358
+ finish(() => reject(new Error(detail || 'ssh tunnel ready timeout')));
359
+ return;
360
+ }
361
+ timer = setTimeout(retry, 50);
362
+ });
363
+ };
364
+
365
+ closeHandler = () => {
366
+ const detail = stderrLines.join('\n').trim();
367
+ finish(() => reject(new Error(detail || 'ssh tunnel exited before ready')));
368
+ };
369
+ errorHandler = (error) => {
370
+ const detail = stderrLines.join('\n').trim();
371
+ finish(() => reject(new Error(detail || error.message || 'ssh tunnel start failed')));
372
+ };
373
+ child.once('close', closeHandler);
374
+ child.once('error', errorHandler);
375
+ retry();
376
+ });
377
+ }
378
+
379
+ function closeTunnel(child) {
380
+ return new Promise((resolve) => {
381
+ if (!child || child.exitCode !== null) {
382
+ resolve();
383
+ return;
384
+ }
385
+ const forceTimer = setTimeout(() => {
386
+ if (child.exitCode === null) {
387
+ child.kill('SIGKILL');
388
+ }
389
+ }, 1000);
390
+ child.once('close', () => {
391
+ clearTimeout(forceTimer);
392
+ resolve();
393
+ });
394
+ child.kill('SIGTERM');
395
+ });
396
+ }
397
+
398
+ async function createBuildRemoteRsyncTransport(runtime) {
399
+ const inspect = runtime.bridge.remoteHostExec([
400
+ 'lzc-docker',
401
+ 'inspect',
402
+ '-f',
403
+ '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}',
404
+ DEBUG_BRIDGE_CONTAINER,
405
+ ]);
406
+ if (inspect.status !== 0) {
407
+ throw new Error(String(inspect.stderr ?? inspect.stdout ?? '').trim() || 'resolve debug bridge container ip failed');
408
+ }
409
+ const rsyncTargetHost = String(inspect.stdout ?? '').trim();
410
+ if (!rsyncTargetHost) {
411
+ throw new Error('debug bridge container ip is empty');
412
+ }
413
+ const localPort = await reserveLocalPort();
414
+ const stderrLines = [];
415
+ const child = spawn(sshBinary(), buildBuildRemoteRsyncTunnelArgs(runtime, localPort, rsyncTargetHost), {
416
+ shell: false,
417
+ stdio: ['ignore', 'ignore', 'pipe'],
418
+ });
419
+ child.stderr?.on('data', (chunk) => {
420
+ const text = String(chunk ?? '').trim();
421
+ if (text) {
422
+ stderrLines.push(text);
423
+ }
424
+ });
425
+ try {
426
+ await waitForTunnelReady(child, LOCALHOST, localPort, stderrLines);
427
+ return {
428
+ host: LOCALHOST,
429
+ port: String(localPort),
430
+ close: async () => closeTunnel(child),
431
+ };
432
+ } catch (error) {
433
+ await closeTunnel(child);
434
+ throw error;
435
+ }
436
+ }
437
+
438
+ async function createRsyncTransport(runtime) {
439
+ if (runtime.bridge.isBuildRemoteMode()) {
440
+ return createBuildRemoteRsyncTransport(runtime);
441
+ }
442
+ return {
443
+ host: await resolveDomain(runtime.bridge.domain),
444
+ port: RSYNC_PORT,
445
+ close: async () => {},
446
+ };
447
+ }
448
+
253
449
  async function ensureProjectDeployed(runtime) {
254
450
  const deploy = await getProjectDeployInfo(runtime);
255
451
  if (!deploy.deployed) {
@@ -305,27 +501,28 @@ function collectWatchChange(state, eventName, relPath, deleteEnabled) {
305
501
  }
306
502
  }
307
503
 
308
- async function runSyncPath(runtime, rootDir, options, sourceDir, deleteMode) {
309
- const [uid, host] = await Promise.all([resolveRsyncUID(runtime), resolveRsyncHost(runtime)]);
504
+ async function runSyncPath(runtime, transport, rootDir, options, sourceDir, deleteMode) {
505
+ const uid = await resolveRsyncUID(runtime);
506
+ await ensureBuildRemoteSyncTarget(runtime, uid, options.target);
310
507
  const rsyncCmd = resolveRsyncCommand();
311
- const rsyncArgs = buildRsyncArgs(runtime, uid, host, rootDir, options.target, sourceDir, deleteMode, options.dryRun);
508
+ const rsyncArgs = buildRsyncArgs(runtime, uid, transport.host, rootDir, options.target, sourceDir, deleteMode, options.dryRun, transport.port);
312
509
  logger.debug('project sync rsync:', rsyncCmd, rsyncArgs.join(' '));
313
510
  return runRsyncProcess(rsyncCmd, rsyncArgs, rootDir, options.dryRun);
314
511
  }
315
512
 
316
- async function runInitialSync(runtime, rootDir, options) {
513
+ async function runInitialSync(runtime, transport, rootDir, options) {
317
514
  await ensureProjectDeployed(runtime);
318
- const hasChanges = await runSyncPath(runtime, rootDir, options, '', options.delete);
515
+ const hasChanges = await runSyncPath(runtime, transport, rootDir, options, '', options.delete);
319
516
  if (!hasChanges) {
320
517
  logger.info('project sync: no changes');
321
518
  }
322
519
  }
323
520
 
324
- async function runDirtySync(runtime, rootDir, options, state) {
521
+ async function runDirtySync(runtime, transport, rootDir, options, state) {
325
522
  await ensureProjectDeployed(runtime);
326
523
  if (state.fullSyncRequested) {
327
524
  state.fullSyncRequested = false;
328
- const hasChanges = await runSyncPath(runtime, rootDir, options, '', options.delete);
525
+ const hasChanges = await runSyncPath(runtime, transport, rootDir, options, '', options.delete);
329
526
  if (!hasChanges) {
330
527
  logger.info('project sync: no changes');
331
528
  }
@@ -337,10 +534,10 @@ async function runDirtySync(runtime, rootDir, options, state) {
337
534
  state.syncDirs.clear();
338
535
  let hasChanges = false;
339
536
  for (const dir of pendingDeleteDirs) {
340
- hasChanges = (await runSyncPath(runtime, rootDir, options, dir, true)) || hasChanges;
537
+ hasChanges = (await runSyncPath(runtime, transport, rootDir, options, dir, true)) || hasChanges;
341
538
  }
342
539
  for (const dir of pendingSyncDirs) {
343
- hasChanges = (await runSyncPath(runtime, rootDir, options, dir, false)) || hasChanges;
540
+ hasChanges = (await runSyncPath(runtime, transport, rootDir, options, dir, false)) || hasChanges;
344
541
  }
345
542
  if (!hasChanges && pendingDeleteDirs.length === 0 && pendingSyncDirs.length === 0) {
346
543
  logger.info('project sync: no changes');
@@ -421,76 +618,81 @@ export function projectSyncCommand() {
421
618
  resolveSyncCacheSubpath(options.target);
422
619
  logger.info(`Sync root: ${rootDir}`);
423
620
  logger.info(`Sync target: ${options.target}`);
621
+ const transport = await createRsyncTransport(runtime);
424
622
 
425
- if (!watch) {
426
- await runInitialSync(runtime, rootDir, options);
427
- return;
428
- }
429
-
430
- let watcher = null;
431
- let running = false;
432
- let pending = false;
433
- const triggerSync = async () => {
434
- if (running) {
435
- pending = true;
623
+ try {
624
+ if (!watch) {
625
+ await runInitialSync(runtime, transport, rootDir, options);
436
626
  return;
437
627
  }
438
- running = true;
439
- try {
440
- await runDirtySync(runtime, rootDir, options, state);
441
- if (state.reloadWatcherRequested) {
442
- state.reloadWatcherRequested = false;
443
- await watcher.close();
444
- watcher = createWatcher();
445
- await waitWatcherReady(watcher);
446
- logger.info('project sync watch reloaded after .lzcdevignore change.');
628
+
629
+ let watcher = null;
630
+ let running = false;
631
+ let pending = false;
632
+ const triggerSync = async () => {
633
+ if (running) {
634
+ pending = true;
635
+ return;
447
636
  }
448
- } catch (error) {
449
- logger.error(`project sync failed: ${error.message}`);
450
- } finally {
451
- running = false;
452
- if (pending) {
453
- pending = false;
454
- await triggerSync();
637
+ running = true;
638
+ try {
639
+ await runDirtySync(runtime, transport, rootDir, options, state);
640
+ if (state.reloadWatcherRequested) {
641
+ state.reloadWatcherRequested = false;
642
+ await watcher.close();
643
+ watcher = createWatcher();
644
+ await waitWatcherReady(watcher);
645
+ logger.info('project sync watch reloaded after .lzcdevignore change.');
646
+ }
647
+ } catch (error) {
648
+ logger.error(`project sync failed: ${error.message}`);
649
+ } finally {
650
+ running = false;
651
+ if (pending) {
652
+ pending = false;
653
+ await triggerSync();
654
+ }
455
655
  }
456
- }
457
- };
656
+ };
458
657
 
459
- const debouncedTrigger = debounce(() => {
460
- void triggerSync();
461
- }, 300);
462
-
463
- const createWatcher = () => {
464
- const instance = chokidar.watch(rootDir, {
465
- ignoreInitial: true,
466
- ignored: createWatchIgnored(rootDir),
467
- awaitWriteFinish: {
468
- stabilityThreshold: 150,
469
- pollInterval: 50,
470
- },
471
- });
472
- attachWatchHandlers(instance, rootDir, options, state, debouncedTrigger);
473
- return instance;
474
- };
658
+ const debouncedTrigger = debounce(() => {
659
+ void triggerSync();
660
+ }, 300);
661
+
662
+ const createWatcher = () => {
663
+ const instance = chokidar.watch(rootDir, {
664
+ ignoreInitial: true,
665
+ ignored: createWatchIgnored(rootDir),
666
+ awaitWriteFinish: {
667
+ stabilityThreshold: 150,
668
+ pollInterval: 50,
669
+ },
670
+ });
671
+ attachWatchHandlers(instance, rootDir, options, state, debouncedTrigger);
672
+ return instance;
673
+ };
475
674
 
476
- watcher = createWatcher();
477
- await waitWatcherReady(watcher);
478
- await runInitialSync(runtime, rootDir, options);
479
- if (hasPendingChanges(state)) {
480
- await runDirtySync(runtime, rootDir, options, state);
675
+ watcher = createWatcher();
676
+ await waitWatcherReady(watcher);
677
+ await runInitialSync(runtime, transport, rootDir, options);
678
+ if (hasPendingChanges(state)) {
679
+ await runDirtySync(runtime, transport, rootDir, options, state);
680
+ }
681
+ logger.info('project sync watch started. Press Ctrl+C to stop.');
682
+ await new Promise((resolve) => {
683
+ const shutdown = async () => {
684
+ debouncedTrigger.cancel();
685
+ if (watcher) {
686
+ await watcher.close();
687
+ }
688
+ resolve();
689
+ };
690
+ process.once('SIGINT', shutdown);
691
+ process.once('SIGTERM', shutdown);
692
+ });
693
+ } finally {
694
+ await transport.close();
481
695
  }
482
- logger.info('project sync watch started. Press Ctrl+C to stop.');
483
- await new Promise((resolve) => {
484
- const shutdown = async () => {
485
- debouncedTrigger.cancel();
486
- if (watcher) {
487
- await watcher.close();
488
- }
489
- resolve();
490
- };
491
- process.once('SIGINT', shutdown);
492
- process.once('SIGTERM', shutdown);
493
- });
494
696
  },
495
697
  };
496
698
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazycatcloud/lzc-cli",
3
- "version": "2.0.0-pre.8",
3
+ "version": "2.0.0-pre.9",
4
4
  "description": "lazycat cloud developer kit",
5
5
  "scripts": {
6
6
  "release": "release-it patch",