@lazycatcloud/lzc-cli 1.3.14 → 2.0.0-pre.0

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 (101) hide show
  1. package/README.md +30 -5
  2. package/changelog.md +4 -0
  3. package/lib/app/index.js +174 -58
  4. package/lib/app/lpk_build.js +192 -17
  5. package/lib/app/lpk_build_images.js +728 -0
  6. package/lib/app/lpk_create.js +93 -21
  7. package/lib/app/lpk_create_generator.js +144 -9
  8. package/lib/app/lpk_devshell.js +33 -19
  9. package/lib/app/lpk_embed_images.js +257 -0
  10. package/lib/app/lpk_installer.js +14 -7
  11. package/lib/app/project_cp.js +64 -0
  12. package/lib/app/project_deploy.js +33 -0
  13. package/lib/app/project_exec.js +45 -0
  14. package/lib/app/project_info.js +106 -0
  15. package/lib/app/project_log.js +67 -0
  16. package/lib/app/project_runtime.js +261 -0
  17. package/lib/app/project_start.js +100 -0
  18. package/lib/box/index.js +101 -4
  19. package/lib/box/ssh_remote.js +259 -0
  20. package/lib/build_remote.js +22 -0
  21. package/lib/config/index.js +1 -1
  22. package/lib/debug_bridge.js +837 -46
  23. package/lib/docker/index.js +30 -10
  24. package/lib/i18n/index.js +1 -0
  25. package/lib/i18n/locales/en/translation.json +17 -5
  26. package/lib/i18n/locales/zh/translation.json +16 -4
  27. package/lib/lpk/core.js +487 -0
  28. package/lib/lpk/index.js +210 -0
  29. package/lib/sig/core.js +254 -0
  30. package/lib/sig/index.js +88 -0
  31. package/lib/utils.js +3 -1
  32. package/package.json +2 -1
  33. package/scripts/cli.js +4 -0
  34. package/template/_lpk/README.md +11 -3
  35. package/template/_lpk/gui-vnc.manifest.yml.in +27 -0
  36. package/template/_lpk/manifest.yml.in +4 -2
  37. package/template/_lpk/todolist-golang.manifest.yml.in +16 -0
  38. package/template/_lpk/todolist-java.manifest.yml.in +15 -0
  39. package/template/_lpk/todolist-python.manifest.yml.in +15 -0
  40. package/template/_lpk/vue.lzc-build.yml.in +0 -44
  41. package/template/blank/_gitignore +1 -0
  42. package/template/blank/lzc-build.yml +25 -40
  43. package/template/blank/lzc-manifest.yml +14 -7
  44. package/template/golang/Dockerfile +19 -0
  45. package/template/golang/README.md +33 -0
  46. package/template/golang/_gitignore +3 -0
  47. package/template/golang/go.mod +3 -0
  48. package/template/golang/lzc-build.yml +21 -0
  49. package/template/golang/lzc-icon.png +0 -0
  50. package/template/golang/main.go +252 -0
  51. package/template/golang/run.sh +3 -0
  52. package/template/golang/web/index.html +238 -0
  53. package/template/gui-vnc/README.md +19 -0
  54. package/template/gui-vnc/_gitignore +2 -0
  55. package/template/gui-vnc/images/Dockerfile +30 -0
  56. package/template/gui-vnc/images/kasmvnc.yaml +33 -0
  57. package/template/gui-vnc/images/startup-script.desktop +9 -0
  58. package/template/gui-vnc/images/startup-script.sh +6 -0
  59. package/template/gui-vnc/lzc-build.yml +23 -0
  60. package/template/gui-vnc/lzc-icon.png +0 -0
  61. package/template/python/Dockerfile +15 -0
  62. package/template/python/README.md +33 -0
  63. package/template/python/_gitignore +3 -0
  64. package/template/python/app.py +110 -0
  65. package/template/python/lzc-build.yml +21 -0
  66. package/template/python/lzc-icon.png +0 -0
  67. package/template/python/requirements.txt +1 -0
  68. package/template/python/run.sh +3 -0
  69. package/template/python/web/index.html +238 -0
  70. package/template/springboot/Dockerfile +20 -0
  71. package/template/springboot/README.md +33 -0
  72. package/template/springboot/_gitignore +3 -0
  73. package/template/springboot/lzc-build.yml +21 -0
  74. package/template/springboot/lzc-icon.png +0 -0
  75. package/template/springboot/pom.xml +38 -0
  76. package/template/springboot/run.sh +3 -0
  77. package/template/springboot/src/main/java/cloud/lazycat/app/Application.java +132 -0
  78. package/template/springboot/src/main/resources/application.properties +1 -0
  79. package/template/springboot/src/main/resources/static/index.html +238 -0
  80. package/template/vue/README.md +17 -7
  81. package/template/vue/_gitignore +1 -0
  82. package/template/vue/lzc-build.yml +31 -42
  83. package/template/vue/src/App.vue +36 -25
  84. package/template/vue/src/style.css +106 -49
  85. package/template/vue-minidb/README.md +34 -0
  86. package/template/vue-minidb/_gitignore +26 -0
  87. package/template/vue-minidb/index.html +13 -0
  88. package/template/vue-minidb/lzc-build.yml +48 -0
  89. package/template/vue-minidb/lzc-icon.png +0 -0
  90. package/template/vue-minidb/package.json +21 -0
  91. package/template/vue-minidb/public/vite.svg +1 -0
  92. package/template/vue-minidb/src/App.vue +206 -0
  93. package/template/vue-minidb/src/assets/vue.svg +1 -0
  94. package/template/vue-minidb/src/main.ts +5 -0
  95. package/template/vue-minidb/src/style.css +136 -0
  96. package/template/vue-minidb/src/vite-env.d.ts +1 -0
  97. package/template/vue-minidb/tsconfig.app.json +24 -0
  98. package/template/vue-minidb/tsconfig.json +7 -0
  99. package/template/vue-minidb/tsconfig.node.json +22 -0
  100. package/template/vue-minidb/vite.config.ts +10 -0
  101. /package/template/{vue → vue-minidb}/src/components/HelloWorld.vue +0 -0
@@ -2,8 +2,9 @@ import path from 'node:path';
2
2
  import logger from 'loglevel';
3
3
  import { DebugBridge } from '../debug_bridge.js';
4
4
  import shellApi from '../shellapi.js';
5
- import { isFileExist, isUserApp, loadFromYaml } from '../utils.js';
5
+ import { isFileExist, loadFromYaml } from '../utils.js';
6
6
  import { t } from '../i18n/index.js';
7
+ import { resolveBuildRemoteFromFile } from '../build_remote.js';
7
8
 
8
9
  function dockerMiddleware(argv, yargs) {
9
10
  if (argv.$0 == 'lzc-docker' || argv.$0 == 'lzc-docker-compose') {
@@ -53,8 +54,11 @@ export function singleLzcDockerCommand(commandName = 'docker') {
53
54
  builder: builder,
54
55
  handler: async (args) => {
55
56
  logger.debug('args: ', args);
56
- await shellApi.init();
57
- const bridge = new DebugBridge();
57
+ const buildRemote = resolveBuildRemoteFromFile(process.cwd());
58
+ if (!buildRemote) {
59
+ await shellApi.init();
60
+ }
61
+ const bridge = new DebugBridge(process.cwd(), buildRemote);
58
62
  await bridge.init();
59
63
  logger.debug('docker', args.dockerArgs);
60
64
  await bridge.lzcDocker(args.dockerArgs);
@@ -69,7 +73,10 @@ export function singleLzcDockerComposeCommand(commandName = 'docker-compose') {
69
73
  builder: builder,
70
74
  handler: async (args) => {
71
75
  logger.debug('args: ', args);
72
- await shellApi.init();
76
+ const buildRemote = resolveBuildRemoteFromFile(process.cwd());
77
+ if (!buildRemote) {
78
+ await shellApi.init();
79
+ }
73
80
  let manifest = null;
74
81
  try {
75
82
  for (let name of ['lzc-manifest.yml', 'manifest.yml']) {
@@ -83,13 +90,20 @@ export function singleLzcDockerComposeCommand(commandName = 'docker-compose') {
83
90
  logger.debug('load manifest: ', e);
84
91
  }
85
92
  const pkgId = manifest ? manifest['package'] : '';
86
- const userApp = manifest ? isUserApp(manifest) : false;
87
93
 
88
- const bridge = new DebugBridge();
94
+ const bridge = new DebugBridge(process.cwd(), buildRemote);
89
95
  await bridge.init();
90
96
  const options = args.dockerArgs;
91
- if (pkgId != '' && !options.some((o) => o == '--project-directory')) {
92
- options.unshift('--project-directory', `/lzcapp/run/lzc-docker/compose/${pkgId}${userApp ? '/' + shellapi.uid : ''}`);
97
+ if (
98
+ pkgId != '' &&
99
+ !options.some((o) => o == '--project-directory') &&
100
+ !options.some((o) => o == '-p') &&
101
+ !options.some((o) => o == '--project-name') &&
102
+ !options.some((o) => o == '-f') &&
103
+ !options.some((o) => o == '--file')
104
+ ) {
105
+ const projectName = pkgId.replaceAll('.', '');
106
+ options.unshift('-p', projectName);
93
107
  }
94
108
  logger.debug('docker-compose', options.join(' '));
95
109
  await bridge.lzcDockerCompose(options);
@@ -98,6 +112,12 @@ export function singleLzcDockerComposeCommand(commandName = 'docker-compose') {
98
112
  }
99
113
 
100
114
  export function lzcDockerCommand(program) {
101
- program.command(singleLzcDockerCommand());
102
- program.command(singleLzcDockerComposeCommand());
115
+ program.command({
116
+ ...singleLzcDockerCommand(),
117
+ desc: false,
118
+ });
119
+ program.command({
120
+ ...singleLzcDockerComposeCommand(),
121
+ desc: false,
122
+ });
103
123
  }
package/lib/i18n/index.js CHANGED
@@ -15,6 +15,7 @@ const __dirname = dirname(__filename)
15
15
 
16
16
  i18next.use(backend).init({
17
17
  debug: false,
18
+ showSupportNotice: false,
18
19
  initAsync: false,
19
20
  fallbackLng: "zh",
20
21
  ns: ["translation"],
@@ -55,14 +55,26 @@
55
55
  "lpk_create": {
56
56
  "ask_lpk_info_message": "Please enter the app ID, for example app-demo1",
57
57
  "ask_lpk_info_validate_fail": "A combination of lowercase letters or numbers or - is allowed, but does not start with a number, and cannot start or end with -, nor can there be consecutive dashes --",
58
- "choose_template_message": "Select project build template",
58
+ "choose_template_message": "Select project build template",
59
+ "template_option_hello_vue": "hello-vue (Vue starter)",
60
+ "template_option_todolist_java": "todolist-java (Java Todo demo)",
61
+ "template_option_todolist_python": "todolist-python (Python Todo demo)",
62
+ "template_option_todolist_golang": "todolist-golang (Golang Todo demo)",
63
+ "template_option_gui_vnc": "gui-vnc (GUI VNC embed demo)",
64
+ "template_option_todolist_serverless": "todolist-serverless (Serverless Todo demo)",
59
65
  "exec_init_project_name_exist_tips": "! The same directory has been detected and the old directory has been automatically renamed to {{renamedFileName}}",
60
66
  "exec_init_project_success_tips": "✨ Initialize Lazycat Cloud application",
61
67
  "exec_init_project_tips": "✨ Initialize project {{name}}"
62
68
  },
63
69
  "lpk_create_generator": {
64
- "app_create_success_tip": "✨ Lazycat Microservice application has been created successfully!\n✨ After completing the following steps, you can enter container development\n cd {{name}}\n lzc-cli project devshell",
65
- "template_config_vue3_green": "⚙️ After entering the application container, execute the following command:\n npm install\n npm rundev\n🚀 Launch the application:\n Enter the LCMD client launcher page and click the application icon to test the application.",
70
+ "app_create_success_tip": "✨ Lazycat Microservice application has been created successfully!\n✨ Finish the first deployment first and verify access from mobile/PC\n cd {{name}}\n npm install\n lzc-cli project deploy\n lzc-cli project info",
71
+ "app_create_success_tip_common": " Lazycat Microservice application has been created successfully!\n Finish the first deployment first and verify access from mobile/PC\n cd {{name}}{{installBlock}}\n lzc-cli project deploy\n lzc-cli project info",
72
+ "template_config_python_green": "🛠 Flask app starts in container runtime:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh",
73
+ "template_config_springboot_green": "🛠 Spring Boot app starts inside image build/runtime:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh",
74
+ "template_config_golang_green": "🛠 Golang app starts in container runtime:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh",
75
+ "template_config_gui_vnc_green": "🛠 GUI VNC app (embedded image template):\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec -s desktop /bin/sh",
76
+ "template_config_vue_minidb_green": "🛠 This template includes @lazycatcloud/minidb:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh",
77
+ "template_config_vue3_green": "🛠 After editing source code, redeploy to verify changes:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh",
66
78
  "write_file_tree_create_file": "Create file {{filePath}}"
67
79
  },
68
80
  "lpk_devshell": {
@@ -87,7 +99,7 @@
87
99
  "install_from_file_done_tips": "👉 Please visit https://{{subdomain}}.{{boxname}}.heiyu.space in your browser",
88
100
  "install_from_file_fail_tips": "Installation failed: {{error}}",
89
101
  "install_from_file_gen_apk_error": "Failed to generate APK:",
90
- "install_from_file_gen_apk_error_tips": "Failed to generate APK, use lzc-cli project devshell --apk n to ignore the error",
102
+ "install_from_file_gen_apk_error_tips": "Failed to generate APK, you can run lzc-cli app install --apk n to ignore this error",
91
103
  "install_from_file_gen_apk_tips": "Whether to generate APK:",
92
104
  "install_from_file_login_tips": "👉 And log in using your Microservice username and password",
93
105
  "install_from_file_lpk_path_fail": "install must specify a pkg path",
@@ -249,4 +261,4 @@
249
261
  }
250
262
  }
251
263
  }
252
- }
264
+ }
@@ -55,14 +55,26 @@
55
55
  "lpk_create": {
56
56
  "ask_lpk_info_message": "请输入应用ID,例如 app-demo1",
57
57
  "ask_lpk_info_validate_fail": "允许小写字母或数字或-的组合,但不以数字开头,且不能以 - 开头或结束,也不能有连续的短横线 --",
58
- "choose_template_message": "选择项目构建模板",
58
+ "choose_template_message": "选择项目构建模板",
59
+ "template_option_hello_vue": "hello-vue (Vue基础模板)",
60
+ "template_option_todolist_java": "todolist-java (Java Todo示例)",
61
+ "template_option_todolist_python": "todolist-python (Python Todo示例)",
62
+ "template_option_todolist_golang": "todolist-golang (Golang Todo示例)",
63
+ "template_option_gui_vnc": "gui-vnc (GUI VNC Embed示例)",
64
+ "template_option_todolist_serverless": "todolist-serverless (Serverless Todo示例)",
59
65
  "exec_init_project_name_exist_tips": "! 检测到相同目录,已自动将旧目录重命名为 {{ renamedFileName }}",
60
66
  "exec_init_project_success_tips": "✨ 初始化懒猫云应用",
61
67
  "exec_init_project_tips": "✨ 初始化项目 {{ name }}"
62
68
  },
63
69
  "lpk_create_generator": {
64
- "app_create_success_tip": "\n✨ 懒猫微服应用已创建成功 !\n✨ 进行下面步骤后即可进入容器开发\n cd {{ name }}\n lzc-cli project devshell\n ",
65
- "template_config_vue3_green": "\n⚙️ 进入应用容器后执行下面命令:\n npm install\n npm run dev\n🚀 启动应用:\n 进入微服客户端启动器页面点击应用图标来测试应用\n ",
70
+ "app_create_success_tip": "\n✨ 懒猫微服应用已创建成功 !\n✨ 先完成第一次部署,确认手机/PC都可以访问\n cd {{ name }}\n npm install\n lzc-cli project deploy\n lzc-cli project info\n ",
71
+ "app_create_success_tip_common": "\n✨ 懒猫微服应用已创建成功 !\n✨ 先完成第一次部署,确认手机/PC都可以访问\n cd {{ name }}{{ installBlock }}\n lzc-cli project deploy\n lzc-cli project info\n ",
72
+ "template_config_python_green": "\n🛠 Flask app starts in container runtime:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh\n ",
73
+ "template_config_springboot_green": "\n🛠 Spring Boot app starts inside image build/runtime:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh\n ",
74
+ "template_config_golang_green": "\n🛠 Golang app starts in container runtime:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh\n ",
75
+ "template_config_gui_vnc_green": "\n🛠 GUI VNC app (embedded image template):\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec -s desktop /bin/sh\n ",
76
+ "template_config_vue_minidb_green": "\n🛠 This template includes @lazycatcloud/minidb:\n lzc-cli project deploy\n🔎 For troubleshooting:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh\n ",
77
+ "template_config_vue3_green": "\n🛠 修改源码后,重新执行部署即可查看效果:\n lzc-cli project deploy\n🔎 如果要排查问题:\n lzc-cli project log -f\n lzc-cli project exec /bin/sh\n ",
66
78
  "write_file_tree_create_file": "创建文件 {{ filePath }}"
67
79
  },
68
80
  "lpk_devshell": {
@@ -87,7 +99,7 @@
87
99
  "install_from_file_done_tips": "👉 请在浏览器中访问 https://{{ subdomain }}.{{ boxname }}.heiyu.space",
88
100
  "install_from_file_fail_tips": "安装失败: {{ error }}",
89
101
  "install_from_file_gen_apk_error": "生成 APK 失败: ",
90
- "install_from_file_gen_apk_error_tips": "生成 APK 失败,使用 lzc-cli project devshell --apk n 忽略该错误",
102
+ "install_from_file_gen_apk_error_tips": "生成 APK 失败,可使用 lzc-cli app install --apk n 忽略该错误",
91
103
  "install_from_file_gen_apk_tips": "是否生成APK:",
92
104
  "install_from_file_login_tips": "👉 并使用微服的用户名和密码登录",
93
105
  "install_from_file_lpk_path_fail": "install 必须指定一个 pkg 路径",
@@ -0,0 +1,487 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import archiver from 'archiver';
6
+ import AdmZip from 'adm-zip';
7
+ import * as tar from 'tar';
8
+ import yaml from 'js-yaml';
9
+ import logger from 'loglevel';
10
+ import shellApi from '../shellapi.js';
11
+ import { DebugBridge } from '../debug_bridge.js';
12
+ import { resolveBuildRemoteFromFile } from '../build_remote.js';
13
+
14
+ function toPosixPath(filePath) {
15
+ return filePath.split(path.sep).join('/');
16
+ }
17
+
18
+ export function formatBytes(bytes) {
19
+ const value = Number(bytes ?? 0);
20
+ if (!Number.isFinite(value) || value <= 0) {
21
+ return '0 B';
22
+ }
23
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
24
+ let size = value;
25
+ let unitIndex = 0;
26
+ while (size >= 1024 && unitIndex < units.length - 1) {
27
+ size /= 1024;
28
+ unitIndex += 1;
29
+ }
30
+ const digits = unitIndex === 0 ? 0 : 2;
31
+ return `${size.toFixed(digits)} ${units[unitIndex]}`;
32
+ }
33
+
34
+ function normalizeDigest(raw) {
35
+ const value = String(raw ?? '').trim().toLowerCase();
36
+ if (!value.startsWith('sha256:')) {
37
+ return '';
38
+ }
39
+ const hexPart = value.slice('sha256:'.length);
40
+ if (!/^[0-9a-f]{64}$/.test(hexPart)) {
41
+ return '';
42
+ }
43
+ return `sha256:${hexPart}`;
44
+ }
45
+
46
+ function digestToBlobPath(blobsDir, digest) {
47
+ const normalized = normalizeDigest(digest);
48
+ if (!normalized) {
49
+ throw new Error(`Invalid digest: ${digest}`);
50
+ }
51
+ return path.join(blobsDir, normalized.slice('sha256:'.length));
52
+ }
53
+
54
+ function detectPackageFormat(pkgPath) {
55
+ const ext = path.basename(pkgPath).toLowerCase();
56
+ if (ext.endsWith('.lpk.tar') || ext.endsWith('.tar')) {
57
+ return 'tar';
58
+ }
59
+ const fd = fs.openSync(pkgPath, 'r');
60
+ try {
61
+ const header = Buffer.alloc(4);
62
+ fs.readSync(fd, header, 0, 4, 0);
63
+ if (header[0] === 0x50 && header[1] === 0x4b) {
64
+ return 'zip';
65
+ }
66
+ } finally {
67
+ fs.closeSync(fd);
68
+ }
69
+ return 'tar';
70
+ }
71
+
72
+ async function extractPackage(pkgPath, format, destDir) {
73
+ if (format === 'zip') {
74
+ const zip = new AdmZip(pkgPath);
75
+ zip.extractAllTo(destDir, true);
76
+ return;
77
+ }
78
+ await tar.x({
79
+ file: pkgPath,
80
+ cwd: destDir,
81
+ });
82
+ }
83
+
84
+ async function packAsZip(srcDir, outPath) {
85
+ return new Promise((resolve, reject) => {
86
+ const output = fs.createWriteStream(outPath);
87
+ const archive = archiver('zip');
88
+ archive.on('error', reject);
89
+ output.on('error', reject);
90
+ output.on('close', resolve);
91
+ archive.pipe(output);
92
+ archive.directory(srcDir, false);
93
+ archive.finalize();
94
+ });
95
+ }
96
+
97
+ async function packAsTar(srcDir, outPath) {
98
+ const entries = fs.readdirSync(srcDir).sort();
99
+ await tar.c(
100
+ {
101
+ cwd: srcDir,
102
+ file: outPath,
103
+ portable: true,
104
+ },
105
+ entries,
106
+ );
107
+ }
108
+
109
+ async function packPackage(srcDir, format, outPath) {
110
+ if (format === 'zip') {
111
+ return packAsZip(srcDir, outPath);
112
+ }
113
+ return packAsTar(srcDir, outPath);
114
+ }
115
+
116
+ function walkFilesRecursive(dir, baseDir = dir) {
117
+ const result = [];
118
+ if (!fs.existsSync(dir)) {
119
+ return result;
120
+ }
121
+ for (const entry of fs.readdirSync(dir)) {
122
+ const absPath = path.join(dir, entry);
123
+ const stat = fs.statSync(absPath);
124
+ if (stat.isDirectory()) {
125
+ result.push(...walkFilesRecursive(absPath, baseDir));
126
+ continue;
127
+ }
128
+ if (stat.isFile()) {
129
+ result.push(toPosixPath(path.relative(baseDir, absPath)));
130
+ }
131
+ }
132
+ return result;
133
+ }
134
+
135
+ async function sha256File(filePath) {
136
+ return new Promise((resolve, reject) => {
137
+ const hash = crypto.createHash('sha256');
138
+ let size = 0;
139
+ const stream = fs.createReadStream(filePath);
140
+ stream.on('data', (chunk) => {
141
+ size += chunk.length;
142
+ hash.update(chunk);
143
+ });
144
+ stream.on('error', reject);
145
+ stream.on('end', () => {
146
+ resolve({
147
+ digest: hash.digest('hex'),
148
+ size,
149
+ });
150
+ });
151
+ });
152
+ }
153
+
154
+ function readYamlFile(filePath) {
155
+ if (!fs.existsSync(filePath)) {
156
+ return {};
157
+ }
158
+ return yaml.load(fs.readFileSync(filePath, 'utf-8')) ?? {};
159
+ }
160
+
161
+ function isSignedPackage(workDir) {
162
+ const releaseLock = path.join(workDir, 'META', 'release.lock');
163
+ const sigDir = path.join(workDir, 'META', 'signatures');
164
+ if (fs.existsSync(releaseLock)) {
165
+ return true;
166
+ }
167
+ if (!fs.existsSync(sigDir) || !fs.statSync(sigDir).isDirectory()) {
168
+ return false;
169
+ }
170
+ return fs.readdirSync(sigDir).some((name) => name.endsWith('.sig'));
171
+ }
172
+
173
+ function buildAliasInfo(lock, blobsDir) {
174
+ const lockImages = lock?.images ?? {};
175
+ const aliases = Object.keys(lockImages).sort();
176
+
177
+ const allEmbeddedDigests = new Set();
178
+ const aliasDetails = [];
179
+ for (const alias of aliases) {
180
+ const imageInfo = lockImages[alias] ?? {};
181
+ const layers = Array.isArray(imageInfo.layers) ? imageInfo.layers : [];
182
+
183
+ let embedLayerCount = 0;
184
+ let upstreamLayerCount = 0;
185
+ let embedSize = 0;
186
+ let missingEmbedLayerCount = 0;
187
+ const uniqueEmbedDigests = new Set();
188
+
189
+ for (const layer of layers) {
190
+ const digest = normalizeDigest(layer?.digest ?? '');
191
+ if (!digest) {
192
+ continue;
193
+ }
194
+ const source = String(layer?.source ?? '').trim().toLowerCase();
195
+ if (source === 'embed') {
196
+ embedLayerCount += 1;
197
+ uniqueEmbedDigests.add(digest);
198
+ allEmbeddedDigests.add(digest);
199
+ const blobPath = digestToBlobPath(blobsDir, digest);
200
+ if (fs.existsSync(blobPath)) {
201
+ embedSize += fs.statSync(blobPath).size;
202
+ } else {
203
+ missingEmbedLayerCount += 1;
204
+ }
205
+ } else {
206
+ upstreamLayerCount += 1;
207
+ }
208
+ }
209
+
210
+ aliasDetails.push({
211
+ alias,
212
+ imageID: String(imageInfo.image_id ?? ''),
213
+ upstream: String(imageInfo.upstream ?? ''),
214
+ embedLayerCount,
215
+ upstreamLayerCount,
216
+ embedSize,
217
+ uniqueEmbedLayerCount: uniqueEmbedDigests.size,
218
+ missingEmbedLayerCount,
219
+ });
220
+ }
221
+
222
+ let totalEmbeddedSize = 0;
223
+ let totalMissingEmbeddedLayerCount = 0;
224
+ for (const digest of allEmbeddedDigests) {
225
+ const blobPath = digestToBlobPath(blobsDir, digest);
226
+ if (fs.existsSync(blobPath)) {
227
+ totalEmbeddedSize += fs.statSync(blobPath).size;
228
+ } else {
229
+ totalMissingEmbeddedLayerCount += 1;
230
+ }
231
+ }
232
+
233
+ return {
234
+ aliases,
235
+ aliasDetails,
236
+ totalEmbeddedLayerCount: allEmbeddedDigests.size,
237
+ totalEmbeddedSize,
238
+ totalMissingEmbeddedLayerCount,
239
+ };
240
+ }
241
+
242
+ export async function inspectLpkPackage(pkgPath) {
243
+ const resolvedPkgPath = path.resolve(pkgPath);
244
+ if (!fs.existsSync(resolvedPkgPath)) {
245
+ throw new Error(`Package not found: ${resolvedPkgPath}`);
246
+ }
247
+
248
+ const format = detectPackageFormat(resolvedPkgPath);
249
+ const pkgStat = fs.statSync(resolvedPkgPath);
250
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lzc-cli-lpk-info-'));
251
+ const workDir = path.join(tempDir, 'work');
252
+ fs.mkdirSync(workDir, { recursive: true });
253
+ try {
254
+ await extractPackage(resolvedPkgPath, format, workDir);
255
+
256
+ const manifest = readYamlFile(path.join(workDir, 'manifest.yml'));
257
+ const imagesDir = path.join(workDir, 'images');
258
+ const imagesLockPath = path.join(workDir, 'images.lock');
259
+ const hasImagesDir = fs.existsSync(imagesDir) && fs.statSync(imagesDir).isDirectory();
260
+ const hasImagesLock = fs.existsSync(imagesLockPath);
261
+ const lpkVersion = hasImagesDir ? 'v2' : 'v1';
262
+
263
+ let imageInfo = {
264
+ aliases: [],
265
+ aliasDetails: [],
266
+ totalEmbeddedLayerCount: 0,
267
+ totalEmbeddedSize: 0,
268
+ totalMissingEmbeddedLayerCount: 0,
269
+ };
270
+ if (hasImagesLock) {
271
+ const lock = readYamlFile(imagesLockPath);
272
+ const blobsDir = path.join(imagesDir, 'blobs', 'sha256');
273
+ imageInfo = buildAliasInfo(lock, blobsDir);
274
+ }
275
+
276
+ return {
277
+ path: resolvedPkgPath,
278
+ size: pkgStat.size,
279
+ format,
280
+ lpkVersion,
281
+ signed: isSignedPackage(workDir),
282
+ packageID: String(manifest?.package ?? ''),
283
+ appVersion: String(manifest?.version ?? ''),
284
+ hasImagesDir,
285
+ hasImagesLock,
286
+ imageInfo,
287
+ };
288
+ } finally {
289
+ fs.rmSync(tempDir, { recursive: true, force: true });
290
+ }
291
+ }
292
+
293
+ async function ensureBridge(baseDir) {
294
+ const buildRemote = resolveBuildRemoteFromFile(baseDir);
295
+ if (!buildRemote) {
296
+ await shellApi.init();
297
+ }
298
+ const bridge = new DebugBridge(baseDir, buildRemote);
299
+ await bridge.init();
300
+ return bridge;
301
+ }
302
+
303
+ async function fillMissingDigestsFromUpstream(bridge, upstreamImage, digestSet, blobsDir, tempRoot) {
304
+ if (!upstreamImage) {
305
+ throw new Error('Upstream image is required for filling missing blobs');
306
+ }
307
+ const pending = new Set([...digestSet].filter((digest) => !fs.existsSync(digestToBlobPath(blobsDir, digest))));
308
+ if (pending.size === 0) {
309
+ return { addedCount: 0, addedBytes: 0 };
310
+ }
311
+
312
+ await bridge.lzcDockerPull(upstreamImage);
313
+ const archivePath = path.join(tempRoot, `upstream-${Date.now()}-${Math.random().toString(16).slice(2)}.tar`);
314
+ await bridge.lzcDockerSave([upstreamImage], archivePath);
315
+
316
+ let addedCount = 0;
317
+ let addedBytes = 0;
318
+ const extractDir = fs.mkdtempSync(path.join(tempRoot, 'upstream-extract-'));
319
+ try {
320
+ await tar.x({
321
+ file: archivePath,
322
+ cwd: extractDir,
323
+ });
324
+
325
+ for (const digest of [...pending]) {
326
+ const hex = digest.slice('sha256:'.length);
327
+ const blobSourcePath = path.join(extractDir, 'blobs', 'sha256', hex);
328
+ if (!fs.existsSync(blobSourcePath)) {
329
+ continue;
330
+ }
331
+ const blobTargetPath = path.join(blobsDir, hex);
332
+ fs.mkdirSync(path.dirname(blobTargetPath), { recursive: true });
333
+ if (!fs.existsSync(blobTargetPath)) {
334
+ fs.copyFileSync(blobSourcePath, blobTargetPath);
335
+ addedCount += 1;
336
+ addedBytes += fs.statSync(blobTargetPath).size;
337
+ }
338
+ pending.delete(digest);
339
+ }
340
+
341
+ if (pending.size > 0) {
342
+ const files = walkFilesRecursive(extractDir).filter((relPath) => relPath.endsWith('.tar')).sort();
343
+ for (const relPath of files) {
344
+ if (pending.size === 0) {
345
+ break;
346
+ }
347
+ const absPath = path.join(extractDir, relPath);
348
+ const { digest } = await sha256File(absPath);
349
+ const fullDigest = `sha256:${digest}`;
350
+ if (!pending.has(fullDigest)) {
351
+ continue;
352
+ }
353
+ const blobTargetPath = path.join(blobsDir, digest);
354
+ fs.mkdirSync(path.dirname(blobTargetPath), { recursive: true });
355
+ if (!fs.existsSync(blobTargetPath)) {
356
+ fs.copyFileSync(absPath, blobTargetPath);
357
+ addedCount += 1;
358
+ addedBytes += fs.statSync(blobTargetPath).size;
359
+ }
360
+ pending.delete(fullDigest);
361
+ }
362
+ }
363
+
364
+ if (pending.size > 0) {
365
+ throw new Error(`Missing upstream layer blobs after sync: ${[...pending].sort().join(', ')}`);
366
+ }
367
+ } finally {
368
+ fs.rmSync(archivePath, { force: true });
369
+ fs.rmSync(extractDir, { recursive: true, force: true });
370
+ }
371
+
372
+ return { addedCount, addedBytes };
373
+ }
374
+
375
+ export async function embedLpkPackage(pkgPath, options = {}) {
376
+ const resolvedPkgPath = path.resolve(pkgPath);
377
+ if (!fs.existsSync(resolvedPkgPath)) {
378
+ throw new Error(`Package not found: ${resolvedPkgPath}`);
379
+ }
380
+
381
+ const format = detectPackageFormat(resolvedPkgPath);
382
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lzc-cli-lpk-embed-'));
383
+ const workDir = path.join(tempDir, 'work');
384
+ const outTemp = path.join(tempDir, format === 'zip' ? 'embedded.lpk' : 'embedded.lpk.tar');
385
+ fs.mkdirSync(workDir, { recursive: true });
386
+
387
+ try {
388
+ await extractPackage(resolvedPkgPath, format, workDir);
389
+ const imagesDir = path.join(workDir, 'images');
390
+ const imagesLockPath = path.join(workDir, 'images.lock');
391
+ if (!fs.existsSync(imagesDir) || !fs.statSync(imagesDir).isDirectory() || !fs.existsSync(imagesLockPath)) {
392
+ throw new Error('This package does not contain images.lock/images directory, only lpk v2 is supported');
393
+ }
394
+
395
+ const lock = readYamlFile(imagesLockPath);
396
+ const lockImages = lock?.images;
397
+ if (!lockImages || typeof lockImages !== 'object') {
398
+ throw new Error('Invalid images.lock: images field is missing');
399
+ }
400
+ const allAliases = Object.keys(lockImages).sort();
401
+ if (allAliases.length === 0) {
402
+ throw new Error('Invalid images.lock: images is empty');
403
+ }
404
+
405
+ const inputAliases = Array.isArray(options.images)
406
+ ? options.images
407
+ .map((item) => String(item ?? '').trim())
408
+ .filter((item) => item !== '')
409
+ : [];
410
+ const targetAliases = inputAliases.length > 0 ? [...new Set(inputAliases)] : allAliases;
411
+ for (const alias of targetAliases) {
412
+ if (!Object.prototype.hasOwnProperty.call(lockImages, alias)) {
413
+ throw new Error(`Image alias not found in images.lock: ${alias}`);
414
+ }
415
+ }
416
+
417
+ const blobsDir = path.join(imagesDir, 'blobs', 'sha256');
418
+ fs.mkdirSync(blobsDir, { recursive: true });
419
+
420
+ const missingByUpstream = new Map();
421
+ let changedLayerCount = 0;
422
+ for (const alias of targetAliases) {
423
+ const image = lockImages[alias];
424
+ const upstream = String(image?.upstream ?? '').trim();
425
+ const layers = Array.isArray(image?.layers) ? image.layers : [];
426
+
427
+ let hasUpstreamLayer = false;
428
+ for (const layer of layers) {
429
+ const digest = normalizeDigest(layer?.digest ?? '');
430
+ if (!digest) {
431
+ continue;
432
+ }
433
+ const source = String(layer?.source ?? '').trim().toLowerCase();
434
+ if (source === 'embed') {
435
+ continue;
436
+ }
437
+ hasUpstreamLayer = true;
438
+ changedLayerCount += 1;
439
+ layer.source = 'embed';
440
+
441
+ const blobPath = digestToBlobPath(blobsDir, digest);
442
+ if (!fs.existsSync(blobPath)) {
443
+ if (!upstream) {
444
+ throw new Error(`Alias "${alias}" layer ${digest} requires upstream blob, but upstream is empty`);
445
+ }
446
+ if (!missingByUpstream.has(upstream)) {
447
+ missingByUpstream.set(upstream, new Set());
448
+ }
449
+ missingByUpstream.get(upstream).add(digest);
450
+ }
451
+ }
452
+
453
+ if (hasUpstreamLayer) {
454
+ image.upstream = '';
455
+ }
456
+ }
457
+
458
+ let addedBlobCount = 0;
459
+ let addedBlobBytes = 0;
460
+ if (missingByUpstream.size > 0) {
461
+ const bridge = await ensureBridge(process.cwd());
462
+ for (const [upstream, digestSet] of missingByUpstream.entries()) {
463
+ logger.info(`Sync upstream blobs from: ${upstream}`);
464
+ const result = await fillMissingDigestsFromUpstream(bridge, upstream, digestSet, blobsDir, tempDir);
465
+ addedBlobCount += result.addedCount;
466
+ addedBlobBytes += result.addedBytes;
467
+ }
468
+ }
469
+
470
+ fs.writeFileSync(imagesLockPath, yaml.dump(lock, { lineWidth: -1 }));
471
+ await packPackage(workDir, format, outTemp);
472
+
473
+ const finalPath = options.output ? path.resolve(options.output) : resolvedPkgPath;
474
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
475
+ fs.copyFileSync(outTemp, finalPath);
476
+
477
+ return {
478
+ outputPath: finalPath,
479
+ targetAliases,
480
+ changedLayerCount,
481
+ addedBlobCount,
482
+ addedBlobBytes,
483
+ };
484
+ } finally {
485
+ fs.rmSync(tempDir, { recursive: true, force: true });
486
+ }
487
+ }