@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.
Files changed (128) hide show
  1. package/README.md +47 -7
  2. package/changelog.md +14 -0
  3. package/lib/app/apkshell.js +7 -44
  4. package/lib/app/index.js +178 -64
  5. package/lib/app/lpk_build.js +446 -61
  6. package/lib/app/lpk_build_images.js +749 -0
  7. package/lib/app/lpk_create.js +192 -45
  8. package/lib/app/lpk_create_generator.js +141 -13
  9. package/lib/app/lpk_devshell.js +33 -19
  10. package/lib/app/lpk_embed_images.js +257 -0
  11. package/lib/app/lpk_installer.js +17 -9
  12. package/lib/app/manifest_build.js +259 -0
  13. package/lib/app/project_cp.js +59 -0
  14. package/lib/app/project_deploy.js +58 -0
  15. package/lib/app/project_exec.js +82 -0
  16. package/lib/app/project_info.js +106 -0
  17. package/lib/app/project_log.js +62 -0
  18. package/lib/app/project_runtime.js +356 -0
  19. package/lib/app/project_start.js +95 -0
  20. package/lib/app/project_sync.js +499 -0
  21. package/lib/appstore/apkshell.js +50 -0
  22. package/lib/box/index.js +101 -4
  23. package/lib/box/ssh_remote.js +259 -0
  24. package/lib/build_remote.js +21 -0
  25. package/lib/debug_bridge.js +891 -83
  26. package/lib/docker/index.js +30 -10
  27. package/lib/i18n/locales/en/translation.json +262 -255
  28. package/lib/i18n/locales/zh/translation.json +262 -255
  29. package/lib/lpk/core.js +488 -0
  30. package/lib/lpk/index.js +210 -0
  31. package/lib/migrate/index.js +52 -0
  32. package/lib/package_info.js +135 -0
  33. package/lib/shellapi.js +35 -1
  34. package/lib/sig/core.js +254 -0
  35. package/lib/sig/index.js +88 -0
  36. package/lib/utils.js +94 -15
  37. package/package.json +3 -3
  38. package/scripts/cli.js +6 -0
  39. package/scripts/smoke/frontend-dev-entry.mjs +104 -0
  40. package/scripts/smoke/template-project.mjs +311 -0
  41. package/template/_lpk/README.md +15 -4
  42. package/template/_lpk/gui-vnc.manifest.yml.in +18 -0
  43. package/template/_lpk/hello-vue.manifest.yml.in +38 -0
  44. package/template/_lpk/manifest.yml.in +4 -11
  45. package/template/_lpk/package.yml.in +7 -0
  46. package/template/_lpk/todolist-golang.manifest.yml.in +30 -0
  47. package/template/_lpk/todolist-java.manifest.yml.in +29 -0
  48. package/template/_lpk/todolist-python.manifest.yml.in +37 -0
  49. package/template/_lpk/todolist-serverless.manifest.yml.in +38 -0
  50. package/template/_lpk/vue.lzc-build.yml.in +0 -44
  51. package/template/blank/lzc-build.dev.yml +4 -0
  52. package/template/blank/lzc-build.yml +24 -41
  53. package/template/blank/lzc-manifest.yml +7 -9
  54. package/template/blank/package.yml +7 -0
  55. package/template/golang/Dockerfile +19 -0
  56. package/template/golang/Dockerfile.dev +20 -0
  57. package/template/golang/README.md +44 -0
  58. package/template/golang/_gitignore +3 -0
  59. package/template/golang/_lzcdevignore +21 -0
  60. package/template/golang/go.mod +3 -0
  61. package/template/golang/lzc-build.dev.yml +12 -0
  62. package/template/golang/lzc-build.yml +16 -0
  63. package/template/golang/lzc-icon.png +0 -0
  64. package/template/golang/main.go +252 -0
  65. package/template/golang/manifest.dev.page.js +24 -0
  66. package/template/golang/run.sh +10 -0
  67. package/template/golang/web/index.html +238 -0
  68. package/template/gui-vnc/README.md +23 -0
  69. package/template/gui-vnc/_gitignore +2 -0
  70. package/template/gui-vnc/images/Dockerfile +30 -0
  71. package/template/gui-vnc/images/kasmvnc.yaml +33 -0
  72. package/template/gui-vnc/images/startup-script.desktop +9 -0
  73. package/template/gui-vnc/images/startup-script.sh +6 -0
  74. package/template/gui-vnc/lzc-build.dev.yml +4 -0
  75. package/template/gui-vnc/lzc-build.yml +18 -0
  76. package/template/gui-vnc/lzc-icon.png +0 -0
  77. package/template/python/Dockerfile +15 -0
  78. package/template/python/Dockerfile.dev +18 -0
  79. package/template/python/README.md +50 -0
  80. package/template/python/_gitignore +3 -0
  81. package/template/python/_lzcdevignore +21 -0
  82. package/template/python/app.py +110 -0
  83. package/template/python/lzc-build.dev.yml +12 -0
  84. package/template/python/lzc-build.yml +16 -0
  85. package/template/python/lzc-icon.png +0 -0
  86. package/template/python/manifest.dev.page.js +25 -0
  87. package/template/python/requirements.txt +1 -0
  88. package/template/python/run.sh +14 -0
  89. package/template/python/web/index.html +238 -0
  90. package/template/springboot/Dockerfile +20 -0
  91. package/template/springboot/Dockerfile.dev +20 -0
  92. package/template/springboot/README.md +44 -0
  93. package/template/springboot/_gitignore +3 -0
  94. package/template/springboot/_lzcdevignore +21 -0
  95. package/template/springboot/lzc-build.dev.yml +12 -0
  96. package/template/springboot/lzc-build.yml +16 -0
  97. package/template/springboot/lzc-icon.png +0 -0
  98. package/template/springboot/manifest.dev.page.js +24 -0
  99. package/template/springboot/pom.xml +38 -0
  100. package/template/springboot/run.sh +10 -0
  101. package/template/springboot/src/main/java/cloud/lazycat/app/Application.java +132 -0
  102. package/template/springboot/src/main/resources/application.properties +1 -0
  103. package/template/springboot/src/main/resources/static/index.html +238 -0
  104. package/template/vue/README.md +18 -21
  105. package/template/vue/lzc-build.dev.yml +7 -0
  106. package/template/vue/lzc-build.yml +30 -43
  107. package/template/vue/manifest.dev.page.js +50 -0
  108. package/template/vue/src/App.vue +36 -25
  109. package/template/vue/src/style.css +106 -49
  110. package/template/vue-minidb/README.md +26 -0
  111. package/template/vue-minidb/_gitignore +25 -0
  112. package/template/vue-minidb/index.html +13 -0
  113. package/template/vue-minidb/lzc-build.dev.yml +7 -0
  114. package/template/vue-minidb/lzc-build.yml +46 -0
  115. package/template/vue-minidb/lzc-icon.png +0 -0
  116. package/template/vue-minidb/manifest.dev.page.js +50 -0
  117. package/template/vue-minidb/package.json +21 -0
  118. package/template/vue-minidb/public/vite.svg +1 -0
  119. package/template/vue-minidb/src/App.vue +206 -0
  120. package/template/vue-minidb/src/assets/vue.svg +1 -0
  121. package/template/vue-minidb/src/main.ts +5 -0
  122. package/template/vue-minidb/src/style.css +136 -0
  123. package/template/vue-minidb/src/vite-env.d.ts +1 -0
  124. package/template/vue-minidb/tsconfig.app.json +24 -0
  125. package/template/vue-minidb/tsconfig.json +7 -0
  126. package/template/vue-minidb/tsconfig.node.json +22 -0
  127. package/template/vue-minidb/vite.config.ts +10 -0
  128. /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
- export function fakeLoadManifestYml(file) {
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
- res.split('\n').forEach((v) => {
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.trim();
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
- export function unzipSync(zipPath, destPath, entries = []) {
528
- if (!isFileExist(zipPath)) {
529
- throw t('lzc_cli.lib.utils.unzip_sync_not_exist_fail', `{{ zipPath }} 找不到该文件`, { zipPath, interpolation: { escapeValue: false } });
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
- fs.mkdirSync(destPath, { recursive: true });
534
- // 创建 zip 实例
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
- } else {
546
- // 同步解压所有文件
547
- zip.extractAllTo(destPath, true);
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": "1.3.17",
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
+ });
@@ -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 app install you-awesome.lpk
22
+ lzc-cli lpk install you-awesome.lpk
13
23
  ```
14
24
 
15
- ## 开发
25
+ ## 修改源码后再次部署
16
26
  ```
17
- lzc-cli project devshell -b
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