@lazycatcloud/lzc-cli 1.3.17 → 2.0.0-pre.1
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 +47 -7
- package/changelog.md +14 -0
- package/lib/app/apkshell.js +7 -44
- package/lib/app/index.js +178 -64
- package/lib/app/lpk_build.js +446 -61
- package/lib/app/lpk_build_images.js +749 -0
- package/lib/app/lpk_create.js +192 -45
- package/lib/app/lpk_create_generator.js +141 -13
- package/lib/app/lpk_devshell.js +33 -19
- package/lib/app/lpk_embed_images.js +257 -0
- package/lib/app/lpk_installer.js +17 -9
- package/lib/app/manifest_build.js +259 -0
- package/lib/app/project_cp.js +59 -0
- package/lib/app/project_deploy.js +58 -0
- package/lib/app/project_exec.js +82 -0
- package/lib/app/project_info.js +106 -0
- package/lib/app/project_log.js +62 -0
- package/lib/app/project_runtime.js +356 -0
- package/lib/app/project_start.js +95 -0
- package/lib/app/project_sync.js +499 -0
- package/lib/appstore/apkshell.js +50 -0
- package/lib/box/index.js +101 -4
- package/lib/box/ssh_remote.js +259 -0
- package/lib/build_remote.js +21 -0
- package/lib/debug_bridge.js +891 -83
- package/lib/docker/index.js +30 -10
- package/lib/i18n/locales/en/translation.json +262 -255
- package/lib/i18n/locales/zh/translation.json +262 -255
- package/lib/lpk/core.js +488 -0
- package/lib/lpk/index.js +210 -0
- package/lib/migrate/index.js +52 -0
- package/lib/package_info.js +135 -0
- package/lib/shellapi.js +35 -1
- package/lib/sig/core.js +254 -0
- package/lib/sig/index.js +88 -0
- package/lib/utils.js +94 -15
- package/package.json +3 -3
- package/scripts/cli.js +6 -0
- package/scripts/smoke/frontend-dev-entry.mjs +104 -0
- package/scripts/smoke/template-project.mjs +311 -0
- package/template/_lpk/README.md +15 -4
- package/template/_lpk/gui-vnc.manifest.yml.in +18 -0
- package/template/_lpk/hello-vue.manifest.yml.in +38 -0
- package/template/_lpk/manifest.yml.in +4 -11
- package/template/_lpk/package.yml.in +7 -0
- package/template/_lpk/todolist-golang.manifest.yml.in +30 -0
- package/template/_lpk/todolist-java.manifest.yml.in +29 -0
- package/template/_lpk/todolist-python.manifest.yml.in +37 -0
- package/template/_lpk/todolist-serverless.manifest.yml.in +38 -0
- package/template/_lpk/vue.lzc-build.yml.in +0 -44
- package/template/blank/lzc-build.dev.yml +4 -0
- package/template/blank/lzc-build.yml +24 -41
- package/template/blank/lzc-manifest.yml +7 -9
- package/template/blank/package.yml +7 -0
- package/template/golang/Dockerfile +19 -0
- package/template/golang/Dockerfile.dev +20 -0
- package/template/golang/README.md +44 -0
- package/template/golang/_gitignore +3 -0
- package/template/golang/_lzcdevignore +21 -0
- package/template/golang/go.mod +3 -0
- package/template/golang/lzc-build.dev.yml +12 -0
- package/template/golang/lzc-build.yml +16 -0
- package/template/golang/lzc-icon.png +0 -0
- package/template/golang/main.go +252 -0
- package/template/golang/manifest.dev.page.js +24 -0
- package/template/golang/run.sh +10 -0
- package/template/golang/web/index.html +238 -0
- package/template/gui-vnc/README.md +23 -0
- package/template/gui-vnc/_gitignore +2 -0
- package/template/gui-vnc/images/Dockerfile +30 -0
- package/template/gui-vnc/images/kasmvnc.yaml +33 -0
- package/template/gui-vnc/images/startup-script.desktop +9 -0
- package/template/gui-vnc/images/startup-script.sh +6 -0
- package/template/gui-vnc/lzc-build.dev.yml +4 -0
- package/template/gui-vnc/lzc-build.yml +18 -0
- package/template/gui-vnc/lzc-icon.png +0 -0
- package/template/python/Dockerfile +15 -0
- package/template/python/Dockerfile.dev +18 -0
- package/template/python/README.md +50 -0
- package/template/python/_gitignore +3 -0
- package/template/python/_lzcdevignore +21 -0
- package/template/python/app.py +110 -0
- package/template/python/lzc-build.dev.yml +12 -0
- package/template/python/lzc-build.yml +16 -0
- package/template/python/lzc-icon.png +0 -0
- package/template/python/manifest.dev.page.js +25 -0
- package/template/python/requirements.txt +1 -0
- package/template/python/run.sh +14 -0
- package/template/python/web/index.html +238 -0
- package/template/springboot/Dockerfile +20 -0
- package/template/springboot/Dockerfile.dev +20 -0
- package/template/springboot/README.md +44 -0
- package/template/springboot/_gitignore +3 -0
- package/template/springboot/_lzcdevignore +21 -0
- package/template/springboot/lzc-build.dev.yml +12 -0
- package/template/springboot/lzc-build.yml +16 -0
- package/template/springboot/lzc-icon.png +0 -0
- package/template/springboot/manifest.dev.page.js +24 -0
- package/template/springboot/pom.xml +38 -0
- package/template/springboot/run.sh +10 -0
- package/template/springboot/src/main/java/cloud/lazycat/app/Application.java +132 -0
- package/template/springboot/src/main/resources/application.properties +1 -0
- package/template/springboot/src/main/resources/static/index.html +238 -0
- package/template/vue/README.md +18 -21
- package/template/vue/lzc-build.dev.yml +7 -0
- package/template/vue/lzc-build.yml +30 -43
- package/template/vue/manifest.dev.page.js +50 -0
- package/template/vue/src/App.vue +36 -25
- package/template/vue/src/style.css +106 -49
- package/template/vue-minidb/README.md +26 -0
- package/template/vue-minidb/_gitignore +25 -0
- package/template/vue-minidb/index.html +13 -0
- package/template/vue-minidb/lzc-build.dev.yml +7 -0
- package/template/vue-minidb/lzc-build.yml +46 -0
- package/template/vue-minidb/lzc-icon.png +0 -0
- package/template/vue-minidb/manifest.dev.page.js +50 -0
- package/template/vue-minidb/package.json +21 -0
- package/template/vue-minidb/public/vite.svg +1 -0
- package/template/vue-minidb/src/App.vue +206 -0
- package/template/vue-minidb/src/assets/vue.svg +1 -0
- package/template/vue-minidb/src/main.ts +5 -0
- package/template/vue-minidb/src/style.css +136 -0
- package/template/vue-minidb/src/vite-env.d.ts +1 -0
- package/template/vue-minidb/tsconfig.app.json +24 -0
- package/template/vue-minidb/tsconfig.json +7 -0
- package/template/vue-minidb/tsconfig.node.json +22 -0
- package/template/vue-minidb/vite.config.ts +10 -0
- /package/template/{vue → vue-minidb}/src/components/HelloWorld.vue +0 -0
package/lib/utils.js
CHANGED
|
@@ -182,28 +182,38 @@ export function loadFromYaml(file) {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
// lzc-manifest.yml 要支持模板,目前无法直接用yaml库解释,手动读出必要字段
|
|
185
|
-
|
|
186
|
-
const res = fs.readFileSync(file, 'utf8');
|
|
185
|
+
function fakeLoadManifestCore(text) {
|
|
187
186
|
let obj = {
|
|
188
187
|
application: {
|
|
189
188
|
subdomain: undefined,
|
|
190
189
|
},
|
|
191
190
|
};
|
|
192
191
|
|
|
193
|
-
|
|
192
|
+
const normalizeValue = (value) => {
|
|
193
|
+
const commentIndex = value.indexOf(' #');
|
|
194
|
+
if (commentIndex >= 0) {
|
|
195
|
+
value = value.slice(0, commentIndex);
|
|
196
|
+
}
|
|
197
|
+
return value.trim();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
String(text ?? '').split('\n').forEach((v) => {
|
|
194
201
|
let line = v.trim();
|
|
195
202
|
const arr = line.split(':');
|
|
196
203
|
if (arr.length != 2) {
|
|
197
204
|
return;
|
|
198
205
|
}
|
|
199
206
|
let [key, value] = arr;
|
|
200
|
-
value = value
|
|
207
|
+
value = normalizeValue(value);
|
|
201
208
|
if (!obj.package && key == 'package') {
|
|
202
209
|
obj.package = value;
|
|
203
210
|
}
|
|
204
211
|
if (!obj.application.subdomain && key == 'subdomain') {
|
|
205
212
|
obj.application.subdomain = value;
|
|
206
213
|
}
|
|
214
|
+
if (!obj.name && key == 'name') {
|
|
215
|
+
obj.name = value;
|
|
216
|
+
}
|
|
207
217
|
if (!obj.version && key == 'version') {
|
|
208
218
|
obj.version = value;
|
|
209
219
|
}
|
|
@@ -211,6 +221,14 @@ export function fakeLoadManifestYml(file) {
|
|
|
211
221
|
return obj;
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
export function fakeLoadManifestText(text) {
|
|
225
|
+
return fakeLoadManifestCore(text);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function fakeLoadManifestYml(file) {
|
|
229
|
+
return fakeLoadManifestCore(fs.readFileSync(file, 'utf8'));
|
|
230
|
+
}
|
|
231
|
+
|
|
214
232
|
export function dumpToYaml(template, target) {
|
|
215
233
|
fs.writeFileSync(
|
|
216
234
|
target,
|
|
@@ -426,12 +444,14 @@ export function isUserApp(manifest) {
|
|
|
426
444
|
return !!manifest['application']['user_app'];
|
|
427
445
|
}
|
|
428
446
|
|
|
429
|
-
export async function tarContentDir(from, to, cwd = './') {
|
|
447
|
+
export async function tarContentDir(from, to, cwd = './', options = {}) {
|
|
448
|
+
const gzipEnabled = !!options.gzip;
|
|
430
449
|
return new Promise((resolve, reject) => {
|
|
431
450
|
const dest = fs.createWriteStream(to);
|
|
432
451
|
tar.c(
|
|
433
452
|
{
|
|
434
453
|
cwd: cwd,
|
|
454
|
+
gzip: gzipEnabled,
|
|
435
455
|
filter: (filePath) => {
|
|
436
456
|
logger.debug(`tar gz ${filePath}`);
|
|
437
457
|
return true;
|
|
@@ -524,16 +544,29 @@ export async function resolveDomain(domain, quiet = false) {
|
|
|
524
544
|
}
|
|
525
545
|
}
|
|
526
546
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
547
|
+
function detectLpkArchiveFormatSync(filePath) {
|
|
548
|
+
const fd = fs.openSync(filePath, 'r');
|
|
549
|
+
try {
|
|
550
|
+
const header = Buffer.alloc(512);
|
|
551
|
+
const bytesRead = fs.readSync(fd, header, 0, header.length, 0);
|
|
552
|
+
if (bytesRead >= 4 && header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04) {
|
|
553
|
+
return 'zip';
|
|
554
|
+
}
|
|
555
|
+
if (bytesRead >= 265 && header.subarray(257, 262).toString() === 'ustar') {
|
|
556
|
+
return 'tar';
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`Unsupported LPK archive format: ${filePath}`);
|
|
559
|
+
} finally {
|
|
560
|
+
fs.closeSync(fd);
|
|
530
561
|
}
|
|
562
|
+
}
|
|
531
563
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const zip = new AdmZip(zipPath);
|
|
564
|
+
function normalizeArchiveEntryPath(entryPath) {
|
|
565
|
+
return String(entryPath ?? '').replace(/^\.\//, '').replace(/\\/g, '/');
|
|
566
|
+
}
|
|
536
567
|
|
|
568
|
+
function extractZipEntriesSync(zipPath, destPath, entries = []) {
|
|
569
|
+
const zip = new AdmZip(zipPath);
|
|
537
570
|
if (entries.length > 0) {
|
|
538
571
|
entries.forEach((entry) => {
|
|
539
572
|
try {
|
|
@@ -542,10 +575,56 @@ export function unzipSync(zipPath, destPath, entries = []) {
|
|
|
542
575
|
logger.debug(t('lzc_cli.lib.utils.unzip_sync_not_exist_file_log', `压缩包中没有找到 {{ entry }} 文件`, { entry, interpolation: { escapeValue: false } }));
|
|
543
576
|
}
|
|
544
577
|
});
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
zip.extractAllTo(destPath, true);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function extractTarEntriesSync(tarPath, destPath, entries = []) {
|
|
584
|
+
const wanted = new Set((entries ?? []).map((entry) => normalizeArchiveEntryPath(entry)).filter(Boolean));
|
|
585
|
+
tar.x({
|
|
586
|
+
file: tarPath,
|
|
587
|
+
cwd: destPath,
|
|
588
|
+
sync: true,
|
|
589
|
+
filter: (entryPath) => {
|
|
590
|
+
if (wanted.size === 0) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
const normalized = normalizeArchiveEntryPath(entryPath);
|
|
594
|
+
return wanted.has(normalized);
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
if (wanted.size === 0) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
for (const entry of wanted) {
|
|
602
|
+
if (!isFileExist(path.join(destPath, entry))) {
|
|
603
|
+
logger.debug(t('lzc_cli.lib.utils.unzip_sync_not_exist_file_log', `压缩包中没有找到 {{ entry }} 文件`, { entry, interpolation: { escapeValue: false } }));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function extractLpkSync(lpkPath, destPath, entries = []) {
|
|
609
|
+
if (!isFileExist(lpkPath)) {
|
|
610
|
+
throw t('lzc_cli.lib.utils.unzip_sync_not_exist_fail', `{{ zipPath }} 找不到该文件`, { zipPath: lpkPath, interpolation: { escapeValue: false } });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
614
|
+
const format = detectLpkArchiveFormatSync(lpkPath);
|
|
615
|
+
if (format === 'zip') {
|
|
616
|
+
extractZipEntriesSync(lpkPath, destPath, entries);
|
|
617
|
+
return;
|
|
548
618
|
}
|
|
619
|
+
if (format === 'tar') {
|
|
620
|
+
extractTarEntriesSync(lpkPath, destPath, entries);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
throw new Error(`Unsupported LPK archive format: ${lpkPath}`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function unzipSync(zipPath, destPath, entries = []) {
|
|
627
|
+
extractLpkSync(zipPath, destPath, entries);
|
|
549
628
|
}
|
|
550
629
|
|
|
551
630
|
export async function selectSshPublicKey(avaiableKeys) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazycatcloud/lzc-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-pre.1",
|
|
4
4
|
"description": "lazycat cloud developer kit",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"release": "release-it patch",
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
"@lazycatcloud/sdk": "^0.1.423",
|
|
35
35
|
"adm-zip": "^0.5.16",
|
|
36
36
|
"archiver": "^7.0.1",
|
|
37
|
-
"axios": "^1.7.7",
|
|
38
37
|
"chalk": "^5.3.0",
|
|
39
38
|
"chokidar": "^3.6.0",
|
|
40
39
|
"command-exists": "^1.2.9",
|
|
@@ -86,6 +85,7 @@
|
|
|
86
85
|
},
|
|
87
86
|
"publishConfig": {
|
|
88
87
|
"registry": "https://registry.npmjs.org",
|
|
89
|
-
"access": "public"
|
|
88
|
+
"access": "public",
|
|
89
|
+
"tag": "lpkv2"
|
|
90
90
|
}
|
|
91
91
|
}
|
package/scripts/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import { appstoreCommand } from '../lib/appstore/index.js';
|
|
|
14
14
|
import { lpkAppCommand, lpkProjectCommand } from '../lib/app/index.js';
|
|
15
15
|
import { configCommand } from '../lib/config/index.js';
|
|
16
16
|
import { lzcDockerCommand } from '../lib/docker/index.js';
|
|
17
|
+
import { lpkCommand } from '../lib/lpk/index.js';
|
|
18
|
+
import { migrateCommand } from '../lib/migrate/index.js';
|
|
17
19
|
|
|
18
20
|
function setLoggerLevel({ log }) {
|
|
19
21
|
logger.setLevel(log, false);
|
|
@@ -26,6 +28,7 @@ function checkLatestVersion(controller) {
|
|
|
26
28
|
case 'project':
|
|
27
29
|
case 'app':
|
|
28
30
|
case 'appstore':
|
|
31
|
+
case 'lpk':
|
|
29
32
|
getLatestVersion(controller);
|
|
30
33
|
}
|
|
31
34
|
}
|
|
@@ -63,6 +66,8 @@ lpkAppCommand(program);
|
|
|
63
66
|
lpkProjectCommand(program);
|
|
64
67
|
appstoreCommand(program);
|
|
65
68
|
lzcDockerCommand(program);
|
|
69
|
+
lpkCommand(program);
|
|
70
|
+
migrateCommand(program);
|
|
66
71
|
|
|
67
72
|
// 当没有参数的时候,默认显示帮助。
|
|
68
73
|
(async () => {
|
|
@@ -84,6 +89,7 @@ lzcDockerCommand(program);
|
|
|
84
89
|
case 'project':
|
|
85
90
|
case 'appstore':
|
|
86
91
|
case 'config':
|
|
92
|
+
case 'lpk':
|
|
87
93
|
program.showHelp();
|
|
88
94
|
return;
|
|
89
95
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
|
|
8
|
+
async function loadPlaywright() {
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const hints = [
|
|
11
|
+
process.env.LZC_CLI_PLAYWRIGHT_MODULE,
|
|
12
|
+
path.join(process.cwd(), 'node_modules', 'playwright'),
|
|
13
|
+
'/tmp/pw-lzc-test/node_modules/playwright',
|
|
14
|
+
'playwright',
|
|
15
|
+
].filter(Boolean);
|
|
16
|
+
for (const hint of hints) {
|
|
17
|
+
try {
|
|
18
|
+
const resolved = require.resolve(hint);
|
|
19
|
+
return await import(pathToFileURL(resolved).href);
|
|
20
|
+
} catch {}
|
|
21
|
+
try {
|
|
22
|
+
return await import(hint);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
throw new Error('playwright module not found; set LZC_CLI_PLAYWRIGHT_MODULE or install playwright');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getArg(name, fallback = '') {
|
|
29
|
+
const prefix = `--${name}=`;
|
|
30
|
+
const hit = process.argv.find((arg) => arg.startsWith(prefix));
|
|
31
|
+
return hit ? hit.slice(prefix.length) : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requireArg(name) {
|
|
35
|
+
const value = getArg(name);
|
|
36
|
+
if (!value) {
|
|
37
|
+
throw new Error(`missing --${name}`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const mode = requireArg('mode');
|
|
43
|
+
const target = requireArg('url');
|
|
44
|
+
const screenshot = getArg('screenshot');
|
|
45
|
+
const username = getArg('username');
|
|
46
|
+
const password = getArg('password');
|
|
47
|
+
const expectedTitle = getArg('title');
|
|
48
|
+
const requireTexts = getArg('require-text').split('||').map((item) => item.trim()).filter(Boolean);
|
|
49
|
+
const forbidTexts = getArg('forbid-text').split('||').map((item) => item.trim()).filter(Boolean);
|
|
50
|
+
|
|
51
|
+
const playwright = await loadPlaywright();
|
|
52
|
+
const chromium = playwright.chromium || playwright.default?.chromium || playwright["module.exports"]?.chromium;
|
|
53
|
+
if (!chromium) {
|
|
54
|
+
throw new Error("playwright chromium export not found");
|
|
55
|
+
}
|
|
56
|
+
const browser = await chromium.launch({ headless: true });
|
|
57
|
+
const page = await browser.newPage();
|
|
58
|
+
try {
|
|
59
|
+
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
|
60
|
+
if (page.url().includes('/sys/login')) {
|
|
61
|
+
if (!username || !password) {
|
|
62
|
+
throw new Error('login required but --username/--password not provided');
|
|
63
|
+
}
|
|
64
|
+
await page.fill('#username', username);
|
|
65
|
+
await page.fill('#password', password);
|
|
66
|
+
await Promise.all([
|
|
67
|
+
page.waitForURL((url) => !url.toString().includes('/sys/login'), { timeout: 120000 }),
|
|
68
|
+
page.getByRole('button', { name: 'Login' }).click(),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
await page.waitForLoadState('networkidle');
|
|
72
|
+
if (mode === 'ready') {
|
|
73
|
+
await page.reload({ waitUntil: 'networkidle', timeout: 120000 });
|
|
74
|
+
}
|
|
75
|
+
const body = await page.locator('body').innerText();
|
|
76
|
+
const title = await page.title();
|
|
77
|
+
if (expectedTitle && title !== expectedTitle) {
|
|
78
|
+
throw new Error(`unexpected title: ${title}`);
|
|
79
|
+
}
|
|
80
|
+
for (const text of requireTexts) {
|
|
81
|
+
if (!body.includes(text)) {
|
|
82
|
+
throw new Error(`required text missing: ${text}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const text of forbidTexts) {
|
|
86
|
+
if (body.includes(text)) {
|
|
87
|
+
throw new Error(`forbidden text found: ${text}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (screenshot) {
|
|
91
|
+
fs.mkdirSync(path.dirname(screenshot), { recursive: true });
|
|
92
|
+
await page.screenshot({ path: screenshot, fullPage: true });
|
|
93
|
+
}
|
|
94
|
+
console.log(JSON.stringify({
|
|
95
|
+
ok: true,
|
|
96
|
+
mode,
|
|
97
|
+
url: page.url(),
|
|
98
|
+
title,
|
|
99
|
+
body_head: body.slice(0, 400),
|
|
100
|
+
screenshot,
|
|
101
|
+
}, null, 2));
|
|
102
|
+
} finally {
|
|
103
|
+
await browser.close();
|
|
104
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const cliRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..');
|
|
8
|
+
const cliEntrypoint = path.join(cliRoot, 'scripts', 'cli.js');
|
|
9
|
+
const frontendSmokeEntrypoint = path.join(cliRoot, 'scripts', 'smoke', 'frontend-dev-entry.mjs');
|
|
10
|
+
|
|
11
|
+
function getArg(name, fallback = '') {
|
|
12
|
+
const prefix = `--${name}=`;
|
|
13
|
+
const hit = process.argv.find((arg) => arg.startsWith(prefix));
|
|
14
|
+
return hit ? hit.slice(prefix.length) : fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasFlag(name) {
|
|
18
|
+
return process.argv.includes(`--${name}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeBoxName(input) {
|
|
22
|
+
return String(input || '').trim().replace(/\.heiyu\.space$/i, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function capture(command, args, options = {}) {
|
|
26
|
+
return await new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(command, args, {
|
|
28
|
+
cwd: options.cwd || process.cwd(),
|
|
29
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
shell: false,
|
|
32
|
+
});
|
|
33
|
+
let stdout = '';
|
|
34
|
+
let stderr = '';
|
|
35
|
+
child.stdout.on('data', (chunk) => {
|
|
36
|
+
stdout += chunk.toString();
|
|
37
|
+
});
|
|
38
|
+
child.stderr.on('data', (chunk) => {
|
|
39
|
+
stderr += chunk.toString();
|
|
40
|
+
});
|
|
41
|
+
child.on('error', reject);
|
|
42
|
+
child.on('close', (code) => {
|
|
43
|
+
if (code === 0) {
|
|
44
|
+
resolve({ stdout, stderr });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
reject(new Error(`${command} ${args.join(' ')} failed with code ${code}\n${stdout}\n${stderr}`));
|
|
48
|
+
});
|
|
49
|
+
child.stdin.end(options.stdinText || '');
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function spawnStreaming(command, args, options = {}) {
|
|
54
|
+
const child = spawn(command, args, {
|
|
55
|
+
cwd: options.cwd || process.cwd(),
|
|
56
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
57
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
58
|
+
shell: false,
|
|
59
|
+
});
|
|
60
|
+
let stdout = '';
|
|
61
|
+
let stderr = '';
|
|
62
|
+
child.stdout.on('data', (chunk) => {
|
|
63
|
+
const text = chunk.toString();
|
|
64
|
+
stdout += text;
|
|
65
|
+
process.stdout.write(text);
|
|
66
|
+
});
|
|
67
|
+
child.stderr.on('data', (chunk) => {
|
|
68
|
+
const text = chunk.toString();
|
|
69
|
+
stderr += text;
|
|
70
|
+
process.stderr.write(text);
|
|
71
|
+
});
|
|
72
|
+
if (options.stdinText) {
|
|
73
|
+
child.stdin.write(options.stdinText);
|
|
74
|
+
}
|
|
75
|
+
child.stdin.end();
|
|
76
|
+
return { child, getStdout: () => stdout, getStderr: () => stderr };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function runCli(projectDir, argv, options = {}) {
|
|
80
|
+
const { stdout, stderr } = await capture(process.execPath, [cliEntrypoint, ...argv], {
|
|
81
|
+
cwd: projectDir,
|
|
82
|
+
stdinText: options.stdinText || '',
|
|
83
|
+
});
|
|
84
|
+
if (stdout.trim()) {
|
|
85
|
+
process.stdout.write(stdout);
|
|
86
|
+
}
|
|
87
|
+
if (stderr.trim()) {
|
|
88
|
+
process.stderr.write(stderr);
|
|
89
|
+
}
|
|
90
|
+
return { stdout, stderr };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runSmoke(argv, options = {}) {
|
|
94
|
+
const { stdout, stderr } = await capture(process.execPath, [frontendSmokeEntrypoint, ...argv], options);
|
|
95
|
+
if (stdout.trim()) {
|
|
96
|
+
process.stdout.write(stdout);
|
|
97
|
+
}
|
|
98
|
+
if (stderr.trim()) {
|
|
99
|
+
process.stderr.write(stderr);
|
|
100
|
+
}
|
|
101
|
+
return JSON.parse(stdout);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readDefaultBoxName() {
|
|
105
|
+
const { stdout } = await capture(process.execPath, [cliEntrypoint, 'box', 'default']);
|
|
106
|
+
const box = normalizeBoxName(stdout.trim());
|
|
107
|
+
if (!box) {
|
|
108
|
+
throw new Error('default box is empty');
|
|
109
|
+
}
|
|
110
|
+
return box;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractTarEntry(lpkPath, entryName) {
|
|
114
|
+
const result = spawnSync('tar', ['-xOf', lpkPath, entryName], { encoding: 'utf8' });
|
|
115
|
+
if (result.status !== 0) {
|
|
116
|
+
throw new Error(`extract ${entryName} failed\n${result.stdout}\n${result.stderr}`);
|
|
117
|
+
}
|
|
118
|
+
return result.stdout;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assert(condition, message) {
|
|
122
|
+
if (!condition) {
|
|
123
|
+
throw new Error(message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function waitForOutput(getText, pattern, timeoutMs = 30000) {
|
|
128
|
+
const startedAt = Date.now();
|
|
129
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
130
|
+
if (pattern.test(getText())) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`timeout waiting for output: ${String(pattern)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function retry(action, timeoutMs = 30000, intervalMs = 1000) {
|
|
139
|
+
const startedAt = Date.now();
|
|
140
|
+
let lastError = null;
|
|
141
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
142
|
+
try {
|
|
143
|
+
return await action();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
lastError = error;
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw lastError || new Error('retry timeout');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function verifyReleaseLayout(projectDir, expectedPackage, expectedSubdomain) {
|
|
153
|
+
const lpkPath = path.join(projectDir, 'release.lpk');
|
|
154
|
+
await runCli(projectDir, ['project', 'release', '-o', lpkPath]);
|
|
155
|
+
const manifestText = extractTarEntry(lpkPath, 'manifest.yml');
|
|
156
|
+
const packageText = extractTarEntry(lpkPath, 'package.yml');
|
|
157
|
+
assert(!manifestText.includes('injects:'), 'release manifest must not contain injects');
|
|
158
|
+
assert(!manifestText.includes('name:'), 'release manifest must not contain static name');
|
|
159
|
+
assert(!manifestText.includes('description:'), 'release manifest must not contain static description');
|
|
160
|
+
assert(packageText.includes(`package: ${expectedPackage}`), 'package.yml missing package id');
|
|
161
|
+
assert(manifestText.includes(`subdomain: ${expectedSubdomain}`), 'release manifest missing subdomain');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function smokeNotReadyPage({ url, title, requireText = [], username, password, screenshot }) {
|
|
165
|
+
await runSmoke([
|
|
166
|
+
'--mode=not-ready',
|
|
167
|
+
`--url=${url}`,
|
|
168
|
+
`--username=${username}`,
|
|
169
|
+
`--password=${password}`,
|
|
170
|
+
`--title=${title}`,
|
|
171
|
+
`--require-text=${requireText.join('||')}`,
|
|
172
|
+
`--screenshot=${screenshot}`,
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function smokeReadyPage({ url, title, requireText, forbidText, username, password, screenshot }) {
|
|
177
|
+
await runSmoke([
|
|
178
|
+
'--mode=ready',
|
|
179
|
+
`--url=${url}`,
|
|
180
|
+
`--username=${username}`,
|
|
181
|
+
`--password=${password}`,
|
|
182
|
+
`--title=${title}`,
|
|
183
|
+
`--require-text=${requireText.join('||')}`,
|
|
184
|
+
`--forbid-text=${forbidText.join('||')}`,
|
|
185
|
+
`--screenshot=${screenshot}`,
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function uninstallPackage(pkgId) {
|
|
190
|
+
await capture(process.execPath, [cliEntrypoint, 'lpk', 'uninstall', pkgId]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function runHelloVue(projectDir, appName, boxName, username, password) {
|
|
194
|
+
const pkgId = `cloud.lazycat.app.${appName}.dev`;
|
|
195
|
+
const url = `https://${appName}.${boxName}.heiyu.space`;
|
|
196
|
+
try {
|
|
197
|
+
await verifyReleaseLayout(projectDir, `cloud.lazycat.app.${appName}`, appName);
|
|
198
|
+
await runCli(projectDir, ['project', 'deploy']);
|
|
199
|
+
await runCli(projectDir, ['project', 'start', '--restart']);
|
|
200
|
+
await runCli(projectDir, ['project', 'exec', '--tty=false', 'pwd']);
|
|
201
|
+
await smokeNotReadyPage({
|
|
202
|
+
url,
|
|
203
|
+
title: 'Frontend Dev',
|
|
204
|
+
username,
|
|
205
|
+
password,
|
|
206
|
+
screenshot: path.join(projectDir, 'hello-vue-not-ready.png'),
|
|
207
|
+
});
|
|
208
|
+
const vite = spawnStreaming('npm', ['run', 'dev'], { cwd: projectDir });
|
|
209
|
+
try {
|
|
210
|
+
await waitForOutput(vite.getStdout, /ready in|Local:\s+http:\/\/localhost:3000\//, 30000);
|
|
211
|
+
await retry(async () => {
|
|
212
|
+
await smokeReadyPage({
|
|
213
|
+
url,
|
|
214
|
+
title: 'Vite + Vue + TS',
|
|
215
|
+
requireText: ['Welcome to Lazycat Microservice', 'Open Developer Docs', 'Fast local iteration'],
|
|
216
|
+
forbidText: ['Frontend dev server is not ready', 'Dev machine is offline', 'Dev machine is not linked yet'],
|
|
217
|
+
username,
|
|
218
|
+
password,
|
|
219
|
+
screenshot: path.join(projectDir, 'hello-vue-ready.png'),
|
|
220
|
+
});
|
|
221
|
+
}, 30000, 1000);
|
|
222
|
+
} finally {
|
|
223
|
+
vite.child.kill('SIGINT');
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
await uninstallPackage(pkgId).catch(() => {});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runTodolistGolang(projectDir, appName, boxName, username, password) {
|
|
231
|
+
const pkgId = `cloud.lazycat.app.${appName}.dev`;
|
|
232
|
+
const url = `https://${appName}.${boxName}.heiyu.space`;
|
|
233
|
+
try {
|
|
234
|
+
await verifyReleaseLayout(projectDir, `cloud.lazycat.app.${appName}`, appName);
|
|
235
|
+
await runCli(projectDir, ['project', 'deploy']);
|
|
236
|
+
await runCli(projectDir, ['project', 'start', '--restart']);
|
|
237
|
+
const execResult = await runCli(projectDir, ['project', 'exec', '--tty=false', 'pwd']);
|
|
238
|
+
assert(execResult.stdout.includes('/lzcapp/cache/project-mirror'), 'project exec should enter project mirror workdir');
|
|
239
|
+
await smokeNotReadyPage({
|
|
240
|
+
url,
|
|
241
|
+
title: 'Backend Dev',
|
|
242
|
+
requireText: ['Backend dev service is not ready', 'project sync --watch', 'project exec /bin/sh', 'Expected local port: 3000'],
|
|
243
|
+
username,
|
|
244
|
+
password,
|
|
245
|
+
screenshot: path.join(projectDir, 'todolist-golang-not-ready.png'),
|
|
246
|
+
});
|
|
247
|
+
} finally {
|
|
248
|
+
await uninstallPackage(pkgId).catch(() => {});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function runTodolistPython(projectDir, appName, boxName, username, password) {
|
|
253
|
+
const pkgId = `cloud.lazycat.app.${appName}.dev`;
|
|
254
|
+
const url = `https://${appName}.${boxName}.heiyu.space`;
|
|
255
|
+
try {
|
|
256
|
+
await verifyReleaseLayout(projectDir, `cloud.lazycat.app.${appName}`, appName);
|
|
257
|
+
await runCli(projectDir, ['project', 'deploy']);
|
|
258
|
+
await runCli(projectDir, ['project', 'start', '--restart']);
|
|
259
|
+
const execResult = await runCli(projectDir, ['project', 'exec', '--tty=false', 'pwd']);
|
|
260
|
+
assert(execResult.stdout.includes('/lzcapp/cache/project-mirror'), 'project exec should enter project mirror workdir');
|
|
261
|
+
await retry(async () => {
|
|
262
|
+
await smokeReadyPage({
|
|
263
|
+
url,
|
|
264
|
+
title: 'Lazycat Python Todo Template',
|
|
265
|
+
requireText: ['Todo demo powered by Flask API', 'Open Developer Docs', 'Todo quick demo'],
|
|
266
|
+
forbidText: ['Python dev service is not ready'],
|
|
267
|
+
username,
|
|
268
|
+
password,
|
|
269
|
+
screenshot: path.join(projectDir, 'todolist-python-ready.png'),
|
|
270
|
+
});
|
|
271
|
+
}, 45000, 1000);
|
|
272
|
+
} finally {
|
|
273
|
+
await uninstallPackage(pkgId).catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function main() {
|
|
278
|
+
const template = getArg('template', 'hello-vue');
|
|
279
|
+
const username = getArg('username', 'c');
|
|
280
|
+
const password = getArg('password', 'ccc123');
|
|
281
|
+
const keep = hasFlag('keep');
|
|
282
|
+
const boxName = normalizeBoxName(getArg('box')) || await readDefaultBoxName();
|
|
283
|
+
const workspace = mkdtempSync(path.join(os.tmpdir(), `lzc-cli-smoke-${template}-`));
|
|
284
|
+
const appName = `${template}-smoke-${Date.now().toString(36)}`.replace(/[^a-z0-9-]/g, '-');
|
|
285
|
+
console.log(`workspace=${workspace}`);
|
|
286
|
+
console.log(`box=${boxName}`);
|
|
287
|
+
console.log(`app=${appName}`);
|
|
288
|
+
try {
|
|
289
|
+
await runCli(workspace, ['project', 'create', appName, '--template', template], { stdinText: '\n' });
|
|
290
|
+
const projectDir = path.join(workspace, appName);
|
|
291
|
+
if (template === 'hello-vue') {
|
|
292
|
+
await runHelloVue(projectDir, appName, boxName, username, password);
|
|
293
|
+
} else if (template === 'todolist-golang') {
|
|
294
|
+
await runTodolistGolang(projectDir, appName, boxName, username, password);
|
|
295
|
+
} else if (template === 'todolist-python') {
|
|
296
|
+
await runTodolistPython(projectDir, appName, boxName, username, password);
|
|
297
|
+
} else {
|
|
298
|
+
throw new Error(`unsupported template: ${template}`);
|
|
299
|
+
}
|
|
300
|
+
console.log(JSON.stringify({ ok: true, template, workspace, boxName, appName }, null, 2));
|
|
301
|
+
} finally {
|
|
302
|
+
if (!keep) {
|
|
303
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
main().catch((error) => {
|
|
309
|
+
console.error(error?.stack || String(error));
|
|
310
|
+
process.exit(1);
|
|
311
|
+
});
|
package/template/_lpk/README.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
|
|
2
1
|
# 懒猫云应用
|
|
3
2
|
|
|
3
|
+
## 第一次部署(先看到结果)
|
|
4
|
+
```
|
|
5
|
+
lzc-cli project deploy
|
|
6
|
+
lzc-cli project info
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
默认情况下,project 命令在存在 `lzc-build.dev.yml` 时会优先使用它。
|
|
10
|
+
每个命令都会打印实际使用的 `Build config`。
|
|
11
|
+
如果要操作 `lzc-build.yml`,请显式加上 `--release`。
|
|
12
|
+
如果是前端模板,`project deploy` 会按 `buildscript` 自动安装依赖并完成构建,不需要额外先执行 `npm install`。
|
|
13
|
+
|
|
4
14
|
## 构建
|
|
5
15
|
```
|
|
6
16
|
lzc-cli project build -o you-awesome.lpk
|
|
@@ -9,12 +19,13 @@ lzc-cli project build -o you-awesome.lpk
|
|
|
9
19
|
|
|
10
20
|
## 安装
|
|
11
21
|
```
|
|
12
|
-
lzc-cli
|
|
22
|
+
lzc-cli lpk install you-awesome.lpk
|
|
13
23
|
```
|
|
14
24
|
|
|
15
|
-
##
|
|
25
|
+
## 修改源码后再次部署
|
|
16
26
|
```
|
|
17
|
-
lzc-cli project
|
|
27
|
+
lzc-cli project deploy
|
|
28
|
+
lzc-cli project log -f
|
|
18
29
|
```
|
|
19
30
|
|
|
20
31
|
## 成为懒猫云应用开发者
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# application 是默认前台容器,对应固定 service 名 app
|
|
2
|
+
application:
|
|
3
|
+
subdomain: ${subdomain} # 默认访问子域名前缀
|
|
4
|
+
routes:
|
|
5
|
+
# 将入口流量转发到 desktop service 的 6901 端口(VNC Web)
|
|
6
|
+
- /=http://desktop:6901/
|
|
7
|
+
# app 在启动时依赖 desktop 就绪
|
|
8
|
+
depends_on:
|
|
9
|
+
- desktop
|
|
10
|
+
# 多实例:每个用户部署自己的实例
|
|
11
|
+
multi_instance: true
|
|
12
|
+
|
|
13
|
+
services:
|
|
14
|
+
desktop:
|
|
15
|
+
# 使用容器内桌面用户启动
|
|
16
|
+
user: lazycat:kasm-user
|
|
17
|
+
# 引用 lzc-build.yml 里的 images.app-runtime
|
|
18
|
+
image: embed:app-runtime
|