@lazycatcloud/lzc-cli 2.0.0-pre.7 → 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 +1 -1
- package/changelog.md +16 -0
- package/lib/app/lpk_installer.js +1 -1
- package/lib/app/project_deploy.js +13 -12
- package/lib/app/project_sync.js +280 -78
- package/lib/appstore/apkshell.js +66 -6
- package/lib/appstore/publish.js +1 -0
- package/lib/debug_bridge.js +73 -36
- package/package.json +1 -1
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,21 @@
|
|
|
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
|
+
|
|
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)
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
- keep waiting for dev id sync until app reaches a terminal state
|
|
14
|
+
- use app name map for apk trigger
|
|
15
|
+
- include pkg hash in appstore publish payload
|
|
16
|
+
- use debug bridge version for ssh probe
|
|
17
|
+
- fix backend/frontend build
|
|
18
|
+
|
|
3
19
|
## [2.0.0-pre.7](https://gitee.com/linakesi/lzc-cli/compare/v2.0.0-pre.6...v2.0.0-pre.7) (2026-03-26)
|
|
4
20
|
|
|
5
21
|
### Features
|
package/lib/app/lpk_installer.js
CHANGED
|
@@ -109,7 +109,7 @@ export class LpkInstaller {
|
|
|
109
109
|
|
|
110
110
|
logger.debug(t('lzc_cli.lib.app.lpk_installer.install_from_file_gen_apk_tips', '是否生成APK:'), installConfig.apk);
|
|
111
111
|
if (installConfig.apk) {
|
|
112
|
-
triggerApk(manifest['package'], manifest
|
|
112
|
+
triggerApk(manifest['package'], manifest, path.resolve(path.join(tempDir, 'icon.png'))).catch((err) => {
|
|
113
113
|
logger.debug(t('lzc_cli.lib.app.lpk_installer.install_from_file_gen_apk_error', '生成 APK 失败: '), err);
|
|
114
114
|
logger.debug(t('lzc_cli.lib.app.lpk_installer.install_from_file_gen_apk_error_tips', '生成 APK 失败,使用 lzc-cli project devshell --apk n 忽略该错误'));
|
|
115
115
|
});
|
|
@@ -33,15 +33,18 @@ export function buildProjectStartupFailureMessage(deploy = {}, errmsg = '') {
|
|
|
33
33
|
return `Project app entered error state before dev.id sync. Instance status: ${instanceStatus}.${suffix}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export function isProjectStartupTerminalState(deploy = {}) {
|
|
37
|
+
const appStatus = String(deploy?.appStatus ?? '').trim().toLowerCase();
|
|
38
|
+
const instanceStatus = String(deploy?.instanceStatus ?? '').trim().toLowerCase();
|
|
39
|
+
if (appStatus === 'failed' || appStatus === 'paused') {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return instanceStatus.includes('error') || instanceStatus.includes('paused');
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
async function detectProjectStartupFailure(runtime) {
|
|
37
46
|
const deploy = await getProjectDeployInfo(runtime);
|
|
38
|
-
|
|
39
|
-
const instanceStatus = String(deploy?.instanceStatus ?? '').trim();
|
|
40
|
-
const instanceStatusLower = instanceStatus.toLowerCase();
|
|
41
|
-
if (appStatus === 'NotInstalled') {
|
|
42
|
-
throw new Error('Project package is not installed after deploy. Please retry and run "lzc-cli project info" for details.');
|
|
43
|
-
}
|
|
44
|
-
if (!instanceStatusLower.includes('error')) {
|
|
47
|
+
if (!isProjectStartupTerminalState(deploy)) {
|
|
45
48
|
return null;
|
|
46
49
|
}
|
|
47
50
|
const errmsg = await getProjectErrmsgByDeployId(runtime, deploy.deployId);
|
|
@@ -51,8 +54,8 @@ async function detectProjectStartupFailure(runtime) {
|
|
|
51
54
|
async function syncProjectDevID(runtime, { waitForContainer = false } = {}) {
|
|
52
55
|
const devId = await runtime.bridge.resolveDevId();
|
|
53
56
|
logger.info(`Sync dev.id: ${devId || '<empty>'}`);
|
|
54
|
-
|
|
55
|
-
for (
|
|
57
|
+
let attempt = 1;
|
|
58
|
+
for (;;) {
|
|
56
59
|
try {
|
|
57
60
|
await runtime.bridge.syncDevID(runtime.pkgId, devId, runtime.userApp);
|
|
58
61
|
logger.info('dev id synced successfully.');
|
|
@@ -62,13 +65,11 @@ async function syncProjectDevID(runtime, { waitForContainer = false } = {}) {
|
|
|
62
65
|
throw error;
|
|
63
66
|
}
|
|
64
67
|
await detectProjectStartupFailure(runtime);
|
|
65
|
-
if (attempt === maxAttempts) {
|
|
66
|
-
throw error;
|
|
67
|
-
}
|
|
68
68
|
if (attempt === 1) {
|
|
69
69
|
logger.info('Waiting for app container before syncing dev.id...');
|
|
70
70
|
}
|
|
71
71
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
72
|
+
attempt += 1;
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
}
|
package/lib/app/project_sync.js
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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)}:${
|
|
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
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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/lib/appstore/apkshell.js
CHANGED
|
@@ -7,16 +7,53 @@ import { t } from '../i18n/index.js';
|
|
|
7
7
|
import { appStoreServerUrl } from './env.js';
|
|
8
8
|
|
|
9
9
|
// axios@1.7.7 依赖的 form-data 使用了 util.isArray 导致 node 会输出弃用警告 (所以将逻辑迁移避免依赖)
|
|
10
|
-
|
|
10
|
+
function buildAppNameMap(appInfo) {
|
|
11
|
+
if (typeof appInfo === 'string') {
|
|
12
|
+
const name = appInfo.trim();
|
|
13
|
+
return name ? { zh: name } : {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!appInfo || typeof appInfo !== 'object' || Array.isArray(appInfo)) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (appInfo.name && typeof appInfo.name === 'object' && !Array.isArray(appInfo.name)) {
|
|
21
|
+
const directNameMap = Object.fromEntries(
|
|
22
|
+
Object.entries(appInfo.name).filter(([, value]) => typeof value === 'string' && value.trim() !== ''),
|
|
23
|
+
);
|
|
24
|
+
if (Object.keys(directNameMap).length > 0) {
|
|
25
|
+
return directNameMap;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (appInfo.locales && typeof appInfo.locales === 'object' && !Array.isArray(appInfo.locales)) {
|
|
30
|
+
const localizedNameMap = Object.fromEntries(
|
|
31
|
+
Object.entries(appInfo.locales)
|
|
32
|
+
.map(([locale, value]) => [locale, typeof value?.name === 'string' ? value.name.trim() : ''])
|
|
33
|
+
.filter(([, value]) => value !== ''),
|
|
34
|
+
);
|
|
35
|
+
if (Object.keys(localizedNameMap).length > 0) {
|
|
36
|
+
return localizedNameMap;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const name = typeof appInfo.name === 'string' ? appInfo.name.trim() : '';
|
|
41
|
+
return name ? { zh: name } : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function triggerApk(id, appInfo, iconPath) {
|
|
11
45
|
if (!id) {
|
|
12
46
|
logger.error(t('lzc_cli.lib.appstore.apkshell.trigger_apk_empty_appid', 'Appid 为必填项!'));
|
|
13
47
|
return;
|
|
14
48
|
}
|
|
15
49
|
|
|
16
|
-
const
|
|
50
|
+
const requestUrl = `${appStoreServerUrl}/api/trigger_latest_for_app`;
|
|
17
51
|
const form = new FormData();
|
|
18
52
|
form.append('app_id', id);
|
|
19
|
-
|
|
53
|
+
const appNameMap = buildAppNameMap(appInfo);
|
|
54
|
+
if (Object.keys(appNameMap).length > 0) {
|
|
55
|
+
form.append('app_name_map', JSON.stringify(appNameMap));
|
|
56
|
+
}
|
|
20
57
|
|
|
21
58
|
if (iconPath && fs.existsSync(iconPath)) {
|
|
22
59
|
const iconBuffer = fs.readFileSync(iconPath);
|
|
@@ -27,13 +64,28 @@ export async function triggerApk(id, name, iconPath) {
|
|
|
27
64
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
28
65
|
|
|
29
66
|
try {
|
|
30
|
-
|
|
67
|
+
logger.debug('triggerApk request:', {
|
|
68
|
+
url: requestUrl,
|
|
69
|
+
appId: id,
|
|
70
|
+
hasAppNameMap: Object.keys(appNameMap).length > 0,
|
|
71
|
+
appNameMapKeys: Object.keys(appNameMap),
|
|
72
|
+
hasAppIcon: Boolean(iconPath && fs.existsSync(iconPath)),
|
|
73
|
+
appIconPath: iconPath || '',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const resp = await fetch(requestUrl, {
|
|
31
77
|
method: 'POST',
|
|
32
78
|
body: form,
|
|
33
79
|
signal: controller.signal,
|
|
34
80
|
});
|
|
35
81
|
|
|
36
|
-
logger.debug('triggerApk
|
|
82
|
+
logger.debug('triggerApk response:', {
|
|
83
|
+
url: requestUrl,
|
|
84
|
+
appId: id,
|
|
85
|
+
status: resp.status,
|
|
86
|
+
statusText: resp.statusText,
|
|
87
|
+
ok: resp.ok,
|
|
88
|
+
});
|
|
37
89
|
if (resp.status == 304) {
|
|
38
90
|
logger.debug(t('lzc_cli.lib.appstore.apkshell.trigger_apk_build_tips', `APK构建任务已创建成功,如需使用安卓端,请耐心等待1分钟左右`));
|
|
39
91
|
} else if (resp.status <= 201) {
|
|
@@ -43,8 +95,16 @@ export async function triggerApk(id, name, iconPath) {
|
|
|
43
95
|
throw t('lzc_cli.lib.appstore.apkshell.trigger_apk_build_failed_tips', `请求生成应用出错! 使用 --apk=n 停止生成APK`);
|
|
44
96
|
}
|
|
45
97
|
} catch (error) {
|
|
46
|
-
logger.debug(error
|
|
98
|
+
logger.debug('triggerApk error:', {
|
|
99
|
+
url: requestUrl,
|
|
100
|
+
appId: id,
|
|
101
|
+
error,
|
|
102
|
+
});
|
|
47
103
|
} finally {
|
|
48
104
|
clearTimeout(timer);
|
|
49
105
|
}
|
|
50
106
|
}
|
|
107
|
+
|
|
108
|
+
export const __test__ = {
|
|
109
|
+
buildAppNameMap,
|
|
110
|
+
};
|
package/lib/appstore/publish.js
CHANGED
|
@@ -360,6 +360,7 @@ export class Publish {
|
|
|
360
360
|
name: lpkInfo.version,
|
|
361
361
|
icon_path: lpkInfo.iconPath,
|
|
362
362
|
pkg_path: lpkInfo.url,
|
|
363
|
+
pkg_hash: lpkInfo.sha256,
|
|
363
364
|
unsupported_platforms: lpkInfo.unsupportedPlatforms,
|
|
364
365
|
min_os_version: lpkInfo.minOsVersion,
|
|
365
366
|
lpk_size: lpkInfo.lpkSize,
|
package/lib/debug_bridge.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import spawn from 'cross-spawn';
|
|
3
3
|
import logger from 'loglevel';
|
|
4
|
-
import fetch from 'node-fetch';
|
|
5
4
|
|
|
6
5
|
import shellApi from './shellapi.js';
|
|
7
|
-
import { _SYSTEM_ENV_PREFIX } from './config/env.js';
|
|
8
6
|
import { isTraceMode, resolveDomain, sleep, findSshPublicKey, selectSshPublicKey, isWindows, compareVersions } from './utils.js';
|
|
9
7
|
import { t } from './i18n/index.js';
|
|
10
8
|
import { resolveBuildRemoteFromFile } from './build_remote.js';
|
|
11
9
|
|
|
12
|
-
const bannerfileContent = `˄=ᆽ=ᐟ \\`;
|
|
13
10
|
const DEBUG_BRIDGE_CONTAINER = 'cloudlazycatdevelopertools-app-1';
|
|
14
11
|
const DEBUG_BRIDGE_BINARY = '/lzcapp/pkg/content/debug.bridge';
|
|
15
12
|
const DEBUG_BRIDGE_APP_ID = 'cloud.lazycat.developer.tools';
|
|
@@ -52,6 +49,15 @@ export function buildLegacySSHArgs(target, commandArgs = [], { tty = false } = {
|
|
|
52
49
|
return args;
|
|
53
50
|
}
|
|
54
51
|
|
|
52
|
+
export function buildLegacySSHProbeArgs(target) {
|
|
53
|
+
const args = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'ControlMaster=no', '-o', 'BatchMode=yes', '-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no', '-o', 'KbdInteractiveAuthentication=no', '-p', '22222'];
|
|
54
|
+
if (isTraceMode()) {
|
|
55
|
+
args.push('-v');
|
|
56
|
+
}
|
|
57
|
+
args.push(target, 'version');
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
55
61
|
function stripAnsi(text = '') {
|
|
56
62
|
return String(text).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
57
63
|
}
|
|
@@ -131,6 +137,38 @@ function normalizeErrorMessage(error) {
|
|
|
131
137
|
return String(error).trim();
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
function extractLegacySSHProbeDetail(result = {}) {
|
|
141
|
+
const parts = [];
|
|
142
|
+
const errorMessage = normalizeErrorMessage(result?.error);
|
|
143
|
+
if (errorMessage) {
|
|
144
|
+
parts.push(errorMessage);
|
|
145
|
+
}
|
|
146
|
+
const stderr = String(result?.stderr ?? '').trim();
|
|
147
|
+
if (stderr) {
|
|
148
|
+
parts.push(stderr);
|
|
149
|
+
}
|
|
150
|
+
const stdout = String(result?.stdout ?? '').trim();
|
|
151
|
+
if (stdout) {
|
|
152
|
+
parts.push(stdout);
|
|
153
|
+
}
|
|
154
|
+
return stripAnsi(parts.join('\n')).trim();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function classifyLegacySSHProbeResult(result = {}) {
|
|
158
|
+
if (result?.status === 0) {
|
|
159
|
+
return 'ok';
|
|
160
|
+
}
|
|
161
|
+
const detail = extractLegacySSHProbeDetail(result).toLowerCase();
|
|
162
|
+
if (
|
|
163
|
+
detail.includes('permission denied') ||
|
|
164
|
+
detail.includes('publickey') ||
|
|
165
|
+
detail.includes('too many authentication failures')
|
|
166
|
+
) {
|
|
167
|
+
return 'unauthorized';
|
|
168
|
+
}
|
|
169
|
+
return 'unavailable';
|
|
170
|
+
}
|
|
171
|
+
|
|
134
172
|
export function shellEscapeArg(value) {
|
|
135
173
|
const text = String(value ?? '');
|
|
136
174
|
if (text === '') {
|
|
@@ -157,7 +195,6 @@ export class DebugBridge {
|
|
|
157
195
|
this.boxname = shellApi.boxname;
|
|
158
196
|
}
|
|
159
197
|
this.domain = `dev.${this.boxname}.heiyu.space`;
|
|
160
|
-
this.checkUseResolve = !!process.env[`${_SYSTEM_ENV_PREFIX}_CHECK_DNS_RESOLVE`];
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
isBuildRemoteMode() {
|
|
@@ -320,6 +357,19 @@ export class DebugBridge {
|
|
|
320
357
|
return shellApi.resolveClientId();
|
|
321
358
|
}
|
|
322
359
|
|
|
360
|
+
async probeLegacySSHAccess() {
|
|
361
|
+
const resolvedIp = await resolveDomain(this.domain);
|
|
362
|
+
const ssh = spawn.sync(sshBinary(), buildLegacySSHProbeArgs(`box@${resolvedIp}`), {
|
|
363
|
+
shell: false,
|
|
364
|
+
encoding: 'utf-8',
|
|
365
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
366
|
+
});
|
|
367
|
+
return {
|
|
368
|
+
kind: classifyLegacySSHProbeResult(ssh),
|
|
369
|
+
detail: extractLegacySSHProbeDetail(ssh),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
323
373
|
async init() {
|
|
324
374
|
await this.checkDevTools();
|
|
325
375
|
if (!(await this.canPublicKey())) {
|
|
@@ -348,39 +398,26 @@ export class DebugBridge {
|
|
|
348
398
|
}
|
|
349
399
|
}
|
|
350
400
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
fetch(url, { redirect: 'error' })
|
|
361
|
-
.then(async (res) => {
|
|
362
|
-
const content = await res.text();
|
|
363
|
-
if (res.status == 200 && content == bannerfileContent) {
|
|
364
|
-
resolve();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
logger.warn(
|
|
368
|
-
t(
|
|
369
|
-
'lzc_cli.lib.debug_bridge.check_dev_tools_not_exist_tips',
|
|
370
|
-
`检测到你还没有安装 '懒猫开发者工具',请先到商店中搜索安装
|
|
401
|
+
try {
|
|
402
|
+
const probe = await this.probeLegacySSHAccess();
|
|
403
|
+
if (probe.kind !== 'unavailable') {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
logger.warn(
|
|
407
|
+
t(
|
|
408
|
+
'lzc_cli.lib.debug_bridge.check_dev_tools_not_exist_tips',
|
|
409
|
+
`检测到你还没有安装 '懒猫开发者工具',请先到商店中搜索安装
|
|
371
410
|
点击直接跳转 https://appstore.{{ boxname }}.heiyu.space/#/shop/detail/cloud.lazycat.developer.tools
|
|
372
411
|
点击打开应用 https://dev.{{ boxname }}.heiyu.space 查看应用状态
|
|
373
412
|
`,
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
});
|
|
413
|
+
{ boxname: this.boxname, interpolation: { escapeValue: false } },
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
throw new Error(probe.detail || t('lzc_cli.lib.debug_bridge.check_dev_tools_fail_tips', `检测懒猫开发者工具失败,请检测您当前的网络或者懒猫微服客户端是否正常启动。`));
|
|
417
|
+
} catch (err) {
|
|
418
|
+
logger.error(t('lzc_cli.lib.debug_bridge.check_dev_tools_fail_tips', `检测懒猫开发者工具失败,请检测您当前的网络或者懒猫微服客户端是否正常启动。`), err);
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
384
421
|
}
|
|
385
422
|
|
|
386
423
|
async common(cmd, args) {
|
|
@@ -471,8 +508,8 @@ export class DebugBridge {
|
|
|
471
508
|
return ssh.status === 0;
|
|
472
509
|
}
|
|
473
510
|
try {
|
|
474
|
-
await this.
|
|
475
|
-
return
|
|
511
|
+
const probe = await this.probeLegacySSHAccess();
|
|
512
|
+
return probe.kind === 'ok';
|
|
476
513
|
} catch (err) {
|
|
477
514
|
logger.debug('canPublicKey error: ', err);
|
|
478
515
|
if (err?.code == 'ETIMEOUT') {
|