@synopackageland/cli 0.9.6 → 0.10.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 (111) hide show
  1. package/CHANGELOG.md +144 -5
  2. package/README.md +5 -0
  3. package/dist/add.js +7 -8
  4. package/dist/build-spk/dsm-arch.js +1 -1
  5. package/dist/build-spk/dsm-icons.d.ts +8 -1
  6. package/dist/build-spk/dsm-icons.js +26 -12
  7. package/dist/build-spk/dsm-version.d.ts +1 -1
  8. package/dist/build-spk/dsm-version.js +4 -4
  9. package/dist/build-spk/gateway.js +2 -2
  10. package/dist/build-spk/generator.js +1 -1
  11. package/dist/build-spk/png.js +16 -16
  12. package/dist/capabilities.js +1 -1
  13. package/dist/capability-overlays.d.ts +16 -5
  14. package/dist/capability-overlays.js +30 -16
  15. package/dist/cli.js +107 -107
  16. package/dist/compose/analyze.js +23 -23
  17. package/dist/compose/privilege.js +12 -12
  18. package/dist/contract/i18n.js +3 -3
  19. package/dist/contract/icon.js +2 -2
  20. package/dist/contract/posix-all-shares.js +2 -2
  21. package/dist/contract/schema-validate.js +3 -3
  22. package/dist/contract/validate.js +16 -16
  23. package/dist/contract/web-launch.d.ts +2 -1
  24. package/dist/contract/web-launch.js +3 -2
  25. package/dist/deploy/deploy.js +8 -8
  26. package/dist/deploy/homes-values.js +1 -1
  27. package/dist/deploy/identity-shared.js +1 -1
  28. package/dist/deploy/native-install-values.js +1 -1
  29. package/dist/deploy/param-values.js +4 -4
  30. package/dist/deploy/plan.d.ts +1 -1
  31. package/dist/deploy/port-preflight.js +3 -3
  32. package/dist/deploy/run-as-values.js +2 -2
  33. package/dist/deploy/share-permission-preflight.js +6 -6
  34. package/dist/deploy/share-values.js +3 -3
  35. package/dist/error-registry.js +369 -346
  36. package/dist/errors.js +1 -1
  37. package/dist/host/dsm-port-profile.d.ts +1 -1
  38. package/dist/host/dsm-port-profile.js +1 -1
  39. package/dist/info.js +11 -11
  40. package/dist/init.d.ts +2 -0
  41. package/dist/init.js +85 -38
  42. package/dist/inspect.js +5 -5
  43. package/dist/portal/alias.d.ts +1 -1
  44. package/dist/portal/alias.js +4 -4
  45. package/dist/runtime/executor-build.js +12 -1
  46. package/dist/runtime/executor-digests.json +8 -85
  47. package/dist/runtime/executor-inspect.js +11 -4
  48. package/dist/runtime/executor-policy.d.ts +9 -2
  49. package/dist/runtime/executor-policy.js +27 -8
  50. package/dist/runtime/root-programs.d.ts +47 -0
  51. package/dist/runtime/root-programs.js +153 -0
  52. package/dist/runtime/sdk-source.d.ts +5 -4
  53. package/dist/runtime/sdk-source.js +8 -4
  54. package/dist/skill.js +14 -14
  55. package/dist/spk/check-info.js +17 -17
  56. package/dist/spk/check-metadata.js +18 -18
  57. package/dist/spk/check-payload.js +10 -10
  58. package/dist/spk/rules.d.ts +57 -57
  59. package/dist/spk/rules.js +50 -50
  60. package/dist/spk/validate-spk.js +5 -5
  61. package/dist/standalone/api-permissions.js +1 -1
  62. package/dist/standalone/background.d.ts +3 -1
  63. package/dist/standalone/background.js +8 -4
  64. package/dist/standalone/binary.js +7 -7
  65. package/dist/standalone/bridge-app.d.ts +23 -0
  66. package/dist/standalone/bridge-app.js +141 -0
  67. package/dist/standalone/bridge-backend.d.ts +40 -0
  68. package/dist/standalone/bridge-backend.js +95 -0
  69. package/dist/standalone/bridge-build.d.ts +21 -0
  70. package/dist/standalone/bridge-build.js +62 -0
  71. package/dist/standalone/bridge-json.d.ts +16 -0
  72. package/dist/standalone/bridge-json.js +59 -0
  73. package/dist/standalone/bridge-runtime.d.ts +16 -0
  74. package/dist/standalone/bridge-runtime.js +46 -0
  75. package/dist/standalone/bridge-rust.d.ts +16 -0
  76. package/dist/standalone/bridge-rust.js +89 -0
  77. package/dist/standalone/build.js +18 -14
  78. package/dist/standalone/daemon.js +6 -6
  79. package/dist/standalone/dsm-window.d.ts +10 -5
  80. package/dist/standalone/dsm-window.js +64 -16
  81. package/dist/standalone/i18n.js +18 -18
  82. package/dist/standalone/inspect-bridge.d.ts +31 -0
  83. package/dist/standalone/inspect-bridge.js +111 -0
  84. package/dist/standalone/inspect-spk.d.ts +1 -1
  85. package/dist/standalone/inspect-spk.js +77 -69
  86. package/dist/standalone/lifecycle.js +22 -3
  87. package/dist/standalone/manifest.d.ts +17 -9
  88. package/dist/standalone/manifest.js +143 -65
  89. package/dist/standalone/portal-window.js +14 -7
  90. package/dist/standalone/request-timeout.d.ts +9 -0
  91. package/dist/standalone/request-timeout.js +13 -0
  92. package/dist/standalone/root-ops.d.ts +23 -2
  93. package/dist/standalone/root-ops.js +118 -13
  94. package/dist/standalone/sdk-check.js +4 -4
  95. package/dist/standalone/shared-folder.js +7 -7
  96. package/dist/standalone/spk.d.ts +2 -0
  97. package/dist/standalone/spk.js +30 -11
  98. package/dist/standalone/uninstall-hook.js +22 -4
  99. package/dist/standalone/uninstall.js +7 -7
  100. package/dist/standalone/window-size.d.ts +17 -0
  101. package/dist/standalone/window-size.js +31 -0
  102. package/dist/types.d.ts +1 -1
  103. package/dist/validate.js +13 -13
  104. package/package.json +2 -2
  105. package/schema/bridge-manifest.v1.schema.json +86 -0
  106. package/skills/reference/create.md +76 -2
  107. package/skills/reference/ui-json.md +2 -1
  108. package/skills/reference/ui-vue.md +9 -3
  109. package/skills/reference/wrap.md +1 -1
  110. package/dist/standalone/rust-build.d.ts +0 -35
  111. package/dist/standalone/rust-build.js +0 -127
@@ -1,8 +1,16 @@
1
1
  import type { StandaloneError } from "./manifest.js";
2
- /** Fixed root scripts are dispatched only through executor root.run. */
2
+ import { type NativeArchFamily } from "../build-spk/dsm-arch.js";
3
+ import { type RootSettings } from "../runtime/root-programs.js";
4
+ /** Fixed root programs, run only by the executor: a script, or a binary per architecture. */
3
5
  export interface RootOp {
4
6
  name: string;
5
- body: string;
7
+ /** root/<name>.sh */
8
+ body?: string;
9
+ /** root/<arch>/<name>, declared `kind: binary` in root.programs. */
10
+ binary?: Partial<Record<NativeArchFamily, {
11
+ path: string;
12
+ digest: string;
13
+ }>>;
6
14
  }
7
15
  export type RootOpsResult = {
8
16
  ok: true;
@@ -15,3 +23,16 @@ export declare const ROOT_OPS_DIR = "root";
15
23
  /** Shell that tries to become root: sudo, chown root or setuid/setgid modes. */
16
24
  export declare const ROOT_SCRIPT_HINTS: RegExp[];
17
25
  export declare function loadRootOps(dir: string): RootOpsResult;
26
+ /** Every binary root program is declared kind: binary and built for each architecture the package ships. */
27
+ export declare function rootBinaryProblem(ops: readonly RootOp[], settings: RootSettings | undefined, arches: readonly NativeArchFamily[]): string | undefined;
28
+ /**
29
+ * dsm-package.yaml `root`: `programs.<name>` gives a root/ program its kind,
30
+ * params schema and run or job limits; `stop` names the program the executor
31
+ * runs at package stop and upgrade; `state: true` gives root programs a
32
+ * directory only root can read. Returns why the settings are refused.
33
+ */
34
+ export declare function readRootSettings(value: unknown, ops: readonly RootOp[] | undefined): {
35
+ settings: RootSettings;
36
+ } | {
37
+ error: string;
38
+ };
@@ -1,46 +1,151 @@
1
1
  import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
5
+ import { ELF_MACHINE } from "./binary.js";
6
+ import { rootLimitsProblem, rootSchemaProblem } from "../runtime/root-programs.js";
3
7
  export const ROOT_OPS_DIR = "root";
4
8
  /** Shell that tries to become root: sudo, chown root or setuid/setgid modes. */
5
9
  export const ROOT_SCRIPT_HINTS = [/\bsudo\b/, /\bchown\s+(-\w+\s+)*root\b/, /chmod\s+[ugo]*\+s\b/, /chmod\s+[2467][0-7]{3}\b/];
6
10
  const MAX_ROOT_OP_BYTES = 16 * 1024;
11
+ const MAX_ROOT_BINARY_BYTES = 64 * 1024 * 1024;
12
+ const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
7
13
  const OP_FILE = /^([a-z][a-z0-9_]{0,31})\.sh$/;
8
14
  export function loadRootOps(dir) {
9
15
  const names = existsSync(dir) ? readdirSync(dir).sort() : [];
10
16
  if (names.length === 0) {
11
17
  return {
12
18
  ok: false,
13
- errors: [{ code: "STANDALONE_ROOT_OPS_MISSING", message: `capabilities.rootOps: true 需要 ${ROOT_OPS_DIR}/<名稱>.sh,至少一個 root 操作。` }],
19
+ errors: [{ code: "STANDALONE_ROOT_OPS_MISSING", message: `capabilities.rootOps: true needs at least one root operation at ${ROOT_OPS_DIR}/<name>.sh.` }],
14
20
  };
15
21
  }
16
22
  const errors = [];
17
- const ops = [];
23
+ const invalid = (file, error) => errors.push({ code: "STANDALONE_ROOT_OP_INVALID", message: `${ROOT_OPS_DIR}/${file}: ${error}` });
24
+ const ops = new Map();
18
25
  for (const file of names) {
19
- const op = readRootOp(join(dir, file), file);
20
- if ("error" in op) {
21
- errors.push({ code: "STANDALONE_ROOT_OP_INVALID", message: `${ROOT_OPS_DIR}/${file}:${op.error}` });
22
- }
23
- else {
24
- ops.push(op);
26
+ if (isNativeArchFamily(file) && lstatSync(join(dir, file)).isDirectory()) {
27
+ for (const name of readdirSync(join(dir, file)).sort()) {
28
+ const binary = readRootBinary(join(dir, file, name), name, file);
29
+ if ("error" in binary)
30
+ invalid(`${file}/${name}`, binary.error);
31
+ else if (ops.get(name)?.body !== undefined)
32
+ invalid(`${file}/${name}`, `has the same name as ${ROOT_OPS_DIR}/${name}.sh.`);
33
+ else
34
+ ops.set(name, { name, binary: { ...ops.get(name)?.binary, [file]: binary } });
35
+ }
36
+ continue;
25
37
  }
38
+ const op = readRootOp(join(dir, file), file);
39
+ if ("error" in op)
40
+ invalid(file, op.error);
41
+ else if (ops.has(op.name))
42
+ invalid(file, `has the same name as ${ROOT_OPS_DIR}/<arch>/${op.name}.`);
43
+ else
44
+ ops.set(op.name, op);
45
+ }
46
+ const sorted = [...ops.values()].sort((a, b) => a.name < b.name ? -1 : 1);
47
+ return errors.length === 0 ? { ok: true, ops: sorted } : { ok: false, errors };
48
+ }
49
+ /** root/<arch>/<name>: an ELF executable for that architecture. */
50
+ function readRootBinary(path, name, arch) {
51
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(name))
52
+ return { error: "A binary file name must consist of lowercase alphanumerics and underscores (at most 32 characters)." };
53
+ const stat = lstatSync(path);
54
+ if (!stat.isFile())
55
+ return { error: "It must be a regular file (not a symlink or directory)." };
56
+ if (stat.size > MAX_ROOT_BINARY_BYTES)
57
+ return { error: `It must not exceed ${MAX_ROOT_BINARY_BYTES} bytes.` };
58
+ const bytes = readFileSync(path);
59
+ if (bytes.length < 20 || !bytes.subarray(0, 4).equals(ELF_MAGIC) || bytes.readUInt16LE(18) !== ELF_MACHINE[arch])
60
+ return { error: `It must be an ${arch} ELF executable.` };
61
+ return { path, digest: createHash("sha256").update(bytes).digest("hex") };
62
+ }
63
+ /** Every binary root program is declared kind: binary and built for each architecture the package ships. */
64
+ export function rootBinaryProblem(ops, settings, arches) {
65
+ for (const op of ops) {
66
+ if (!op.binary)
67
+ continue;
68
+ if (settings?.programs?.[op.name]?.kind !== "binary")
69
+ return `root: ${ROOT_OPS_DIR}/<arch>/${op.name} needs root.programs.${op.name}.kind: binary.`;
70
+ const missing = arches.filter(arch => !op.binary[arch]);
71
+ if (missing.length)
72
+ return `root: binary program ${op.name} is missing ${missing.map(arch => `${ROOT_OPS_DIR}/${arch}/${op.name}`).join(", ")}.`;
26
73
  }
27
- return errors.length === 0 ? { ok: true, ops } : { ok: false, errors };
74
+ return undefined;
28
75
  }
29
76
  function readRootOp(path, file) {
30
77
  const name = OP_FILE.exec(file)?.[1];
31
78
  if (!name) {
32
- return { error: "檔名必須是小寫英數與底線組成的 <名稱>.sh(最多 32 字元),此資料夾不放其他檔案。" };
79
+ return { error: "The file name must be <name>.sh with lowercase alphanumerics and underscores (at most 32 characters); this directory holds nothing else." };
33
80
  }
34
81
  const stat = lstatSync(path);
35
82
  if (!stat.isFile()) {
36
- return { error: "必須是一般檔案(不可是 symlink 或資料夾)。" };
83
+ return { error: "It must be a regular file (not a symlink or directory)." };
37
84
  }
38
85
  if (stat.size > MAX_ROOT_OP_BYTES) {
39
- return { error: `不可超過 ${MAX_ROOT_OP_BYTES} bytes。` };
86
+ return { error: `It must not exceed ${MAX_ROOT_OP_BYTES} bytes.` };
40
87
  }
41
88
  const body = readFileSync(path, "utf8");
42
89
  if (!body.startsWith("#!/bin/sh\n") || body.includes("\0") || body.includes("�")) {
43
- return { error: "必須是以 #!/bin/sh 開頭的 UTF-8 文字 script。" };
90
+ return { error: "It must be a UTF-8 text script starting with #!/bin/sh." };
44
91
  }
45
92
  return { name, body };
46
93
  }
94
+ const ROOT_SETTINGS_FIELDS = ["programs", "stop", "state"];
95
+ const PROGRAM_FIELDS = ["kind", "schema", "run", "job"];
96
+ /**
97
+ * dsm-package.yaml `root`: `programs.<name>` gives a root/ program its kind,
98
+ * params schema and run or job limits; `stop` names the program the executor
99
+ * runs at package stop and upgrade; `state: true` gives root programs a
100
+ * directory only root can read. Returns why the settings are refused.
101
+ */
102
+ export function readRootSettings(value, ops) {
103
+ const fail = (why) => ({ error: `root: ${why}` });
104
+ if (!ops)
105
+ return fail("capabilities.rootOps: true is required.");
106
+ if (typeof value !== "object" || value === null || Array.isArray(value))
107
+ return fail(`Only ${ROOT_SETTINGS_FIELDS.join(", ")} are accepted.`);
108
+ const raw = value;
109
+ const other = Object.keys(raw).find(key => !ROOT_SETTINGS_FIELDS.includes(key));
110
+ if (other)
111
+ return fail(`Unsupported ${other}; only ${ROOT_SETTINGS_FIELDS.join(", ")} are accepted (the uninstall root program is written in uninstall.purge.root).`);
112
+ const names = new Set(ops.map(op => op.name));
113
+ const programs = raw.programs ?? {};
114
+ if (typeof programs !== "object" || programs === null || Array.isArray(programs))
115
+ return fail("programs is <name>: {kind, schema, run, job}.");
116
+ for (const [name, setting] of Object.entries(programs)) {
117
+ const problem = programProblem(name, setting, ops.find(op => op.name === name));
118
+ if (problem)
119
+ return fail(`programs.${name}: ${problem}`);
120
+ }
121
+ if (raw.stop !== undefined && (typeof raw.stop !== "string" || !names.has(raw.stop)))
122
+ return fail(`stop must be a program name in root/, not ${JSON.stringify(raw.stop)}.`);
123
+ if (raw.state !== undefined && raw.state !== true)
124
+ return fail("state can only be true (the state directory of root programs).");
125
+ return { settings: raw };
126
+ }
127
+ function programProblem(name, value, op) {
128
+ if (typeof value !== "object" || value === null || Array.isArray(value))
129
+ return "It must be {kind, schema, run, job}.";
130
+ const setting = value;
131
+ const other = Object.keys(setting).find(key => !PROGRAM_FIELDS.includes(key));
132
+ if (other)
133
+ return `Unsupported ${other}; only ${PROGRAM_FIELDS.join(", ")} are accepted.`;
134
+ const kind = setting.kind ?? "script";
135
+ if (kind !== "script" && kind !== "binary")
136
+ return "kind is script or binary.";
137
+ if (!op || (kind === "script") !== (op.body !== undefined))
138
+ return `There is no matching ${kind === "script" ? `${ROOT_OPS_DIR}/${name}.sh` : `${ROOT_OPS_DIR}/<arch>/${name}`}.`;
139
+ if (setting.schema !== undefined) {
140
+ const problem = typeof setting.schema === "object" && setting.schema !== null && setting.schema.type === "object"
141
+ ? rootSchemaProblem(setting.schema) : "schema must be a JSON Schema with type: object";
142
+ if (problem)
143
+ return `schema: ${problem}.`;
144
+ }
145
+ for (const which of ["run", "job"]) {
146
+ const problem = setting[which] === undefined ? undefined : rootLimitsProblem(setting[which], which);
147
+ if (problem)
148
+ return `${problem}.`;
149
+ }
150
+ return undefined;
151
+ }
@@ -4,7 +4,7 @@ import { CLI_VERSION } from "../cli-version.js";
4
4
  import { appSdkVersion, SDK_DEPENDENCY, SDK_PACKAGE } from "../runtime/sdk-source.js";
5
5
  const minorLine = (version) => version.split(".").slice(0, 2).join(".");
6
6
  /** The file that names each backend kind's SDK version. */
7
- const SDK_VERSION_FILE = { go: "backend/go.mod", rust: "backend/Cargo.lock" };
7
+ const SDK_VERSION_FILE = { go: "backend/go.mod", rust: "Cargo.lock", python: "backend.sdk", node: "backend.sdk" };
8
8
  /**
9
9
  * The backend's dependency files name the App's SDK version; the frontend
10
10
  * package and this CLI must agree with it. `needsSdk`: the App declares
@@ -22,16 +22,16 @@ export function sdkProblems(backend, frontendDir, needsSdk, cliVersion = CLI_VER
22
22
  throw error;
23
23
  }
24
24
  if (!version) {
25
- return needsSdk ? [{ code: "SDK_REQUIRED", message: `capabilities.delegate 與 rootOps 需要 SDK(${minorLine(cliVersion)}.x):${SDK_DEPENDENCY[backend.kind]}` }] : [];
25
+ return needsSdk ? [{ code: "SDK_REQUIRED", message: `Python, Node and Rust Apps, plus capabilities.delegate and rootOps, need the SDK (${minorLine(cliVersion)}.x): ${SDK_DEPENDENCY[backend.kind]}` }] : [];
26
26
  }
27
27
  const file = SDK_VERSION_FILE[backend.kind];
28
28
  const problems = [];
29
29
  if (minorLine(version) !== minorLine(cliVersion)) {
30
- problems.push({ code: "SDK_UNSUPPORTED", message: `${file} 要求 SDK ${version};CLI ${cliVersion} 只支援 SDK ${minorLine(cliVersion)}.x。請改用對應的 CLI,或把 SDK 升到 ${minorLine(cliVersion)}.x。` });
30
+ problems.push({ code: "SDK_UNSUPPORTED", message: `${file} requires SDK ${version}; CLI ${cliVersion} supports only SDK ${minorLine(cliVersion)}.x. Use the matching CLI, or raise the SDK to ${minorLine(cliVersion)}.x.` });
31
31
  }
32
32
  const frontend = frontendDir === undefined ? undefined : frontendSdkVersion(frontendDir);
33
33
  if (frontend && frontend.version !== version) {
34
- problems.push({ code: "SDK_VERSION_MISMATCH", message: `frontend/package.json 的 ${SDK_PACKAGE} 是 ${frontend.spec}(${frontend.version ?? "讀不到版本"}),${file} 要求 ${version};兩者必須相同。` });
34
+ problems.push({ code: "SDK_VERSION_MISMATCH", message: `frontend/package.json has ${SDK_PACKAGE} ${frontend.spec} (${frontend.version ?? "version unreadable"}), while ${file} requires ${version}; the two must match.` });
35
35
  }
36
36
  return problems;
37
37
  }
@@ -14,25 +14,25 @@ export function readSharedFolder(value, fail) {
14
14
  return undefined;
15
15
  };
16
16
  if (!isRecord(value) || Object.keys(value).some((key) => key !== "sharedFolder")) {
17
- return refuse(":install 只接受 sharedFolder。");
17
+ return refuse(": only sharedFolder is allowed.");
18
18
  }
19
19
  const folder = value.sharedFolder;
20
20
  if (!isRecord(folder) || Object.keys(folder).some((key) => !FIELDS.has(key))) {
21
- return refuse("只接受 mode、key、name 與 permission。");
21
+ return refuse(" accepts only mode, key, name and permission.");
22
22
  }
23
23
  const { mode, key, name, permission } = folder;
24
24
  if (mode !== "select" && mode !== "create")
25
- return refuse("的 mode 必須是 select 或 create。");
25
+ return refuse(" mode must be select or create.");
26
26
  if (permission !== "ro" && permission !== "rw")
27
- return refuse("的 permission 必須是 ro 或 rw。");
27
+ return refuse(" permission must be ro or rw.");
28
28
  if (typeof key !== "string" || !KEY.test(key) || RESERVED_KEY.test(key)) {
29
- return refuse("的 key 必須是小寫英文字母開頭、只含小寫英數與底線(最長 32 字),且不能以 pkgwizard_、synopkg、change_ 開頭。");
29
+ return refuse(" key must start with a lowercase letter, contain only lowercase alphanumerics and underscores (at most 32 characters), and must not start with pkgwizard_, synopkg or change_.");
30
30
  }
31
31
  if (mode === "select") {
32
- return name === undefined ? { mode, key, permission } : refuse("的 name 只用於 create;select 由安裝精靈選擇。");
32
+ return name === undefined ? { mode, key, permission } : refuse(" name applies only to create; select is chosen by the install wizard.");
33
33
  }
34
34
  if (typeof name !== "string" || !SHARE_NAME.test(name) || SYSTEM_SHARE.test(name)) {
35
- return refuse("create 需要 name:英數字開頭、只含英數字、底線與連字號(最長 32 字),且不能是 DSM 系統共用資料夾名稱。");
35
+ return refuse("create needs name: start with an alphanumeric, contain only alphanumerics, underscores and hyphens (at most 32 characters), and not be a DSM system shared folder name.");
36
36
  }
37
37
  return { mode, key, name, permission };
38
38
  }
@@ -16,6 +16,8 @@ export interface AssembleOptions {
16
16
  daemonControl?: string;
17
17
  /** The compiled background worker, required exactly when capabilities.jobs is enabled. */
18
18
  backgroundWorker?: string;
19
+ /** A Python, Node or Rust App's finished directory, installed as target/app. */
20
+ app?: string;
19
21
  }
20
22
  /**
21
23
  * Packs an unsigned Standalone Package SPK. DSM runs installation/lifecycle
@@ -17,9 +17,11 @@ import { DSM_LANGS, LOCALES } from "./i18n.js";
17
17
  import { appTitle, CGI_PAYLOAD_PATH, renderUiConfig, renderWindowScript, windowScriptName } from "./dsm-window.js";
18
18
  import { writeUiTexts } from "../build-spk/ui-texts.js";
19
19
  import { copyStandaloneNotices } from "../build-spk/notices.js";
20
+ import { copyAppTree } from "./bridge-app.js";
21
+ import { isBridgeBackend } from "./bridge-backend.js";
20
22
  import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
21
23
  import { copyDaemonPayload } from "./daemon.js";
22
- import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit } from "./background.js";
24
+ import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS } from "./background.js";
23
25
  import { renderStandaloneResource } from "./resource.js";
24
26
  import { nativePortal } from "./portal.js";
25
27
  import { portalByExecutor } from "../build-spk/dsm-version.js";
@@ -63,8 +65,8 @@ export async function assembleStandaloneSpk(options) {
63
65
  const appDir = join(uiRoot, "app");
64
66
  copyUiTree(manifest.ui.dir, appDir);
65
67
  const assetVersion = digestTree(appDir).slice(0, 12);
66
- writeFile(join(uiRoot, "config"), renderUiConfig(manifest));
67
- writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion));
68
+ writeFile(join(uiRoot, "config"), renderUiConfig(manifest, icons));
69
+ writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons));
68
70
  writeFile(join(uiRoot, hostScriptName(manifest.build)), renderHostAdapterScript(manifest.package));
69
71
  writeDsmWindowIcons(uiRoot, icons);
70
72
  writeUiTexts(uiRoot, appTitle(manifest));
@@ -86,9 +88,14 @@ export async function assembleStandaloneSpk(options) {
86
88
  chmodSync(worker, EXEC_MODE);
87
89
  }
88
90
  const backend = manifest.backend;
91
+ if (isBridgeBackend(backend)) {
92
+ if (!options.app)
93
+ throw new StandaloneBuildError("STANDALONE_BRIDGE_APP_INVALID", "App directory required.");
94
+ copyAppTree(options.app, join(staging, "package", "app"));
95
+ }
89
96
  // A daemon is a Go program of the project's backend directory.
90
97
  const sdk = backend.kind !== 'binary' ? backend : manifest.daemon ? { kind: 'go', dir: join(manifest.root, 'backend') } : undefined;
91
- copyStandaloneNotices(manifest.root, manifest.ui.dir, join(staging, 'package'), { go: backend.kind === 'go' || Boolean(manifest.daemon), rust: backend.kind === 'rust', sdk });
98
+ copyStandaloneNotices(manifest.root, manifest.ui.dir, join(staging, 'package'), { go: backend.kind !== 'binary' || Boolean(manifest.daemon), rust: backend.kind === 'rust', sdk });
92
99
  if (manifest.daemon)
93
100
  copyFileSync(new URL('../../assets/licenses/TEMPLATE.txt', import.meta.url), join(staging, 'package', 'LICENSES', 'DAEMON-LICENSE.txt'));
94
101
  const portal = nativePortal(manifest);
@@ -105,8 +112,12 @@ export async function assembleStandaloneSpk(options) {
105
112
  chmodSync(join(executorDir, "lifecycle"), EXEC_MODE);
106
113
  writeFile(join(executorDir, "policy.json"), options.executor.policyText);
107
114
  writeFile(join(executorDir, "setup.json"), JSON.stringify(executorPlan(options.executor)));
108
- for (const op of manifest.rootOps ?? [])
109
- writeFile(join(staging, "package", "root", op.name + ".sh"), op.body);
115
+ for (const op of manifest.rootOps ?? []) {
116
+ if (op.body !== undefined)
117
+ writeFile(join(staging, "package", "root", op.name + ".sh"), op.body);
118
+ else
119
+ copyExecutable(op.binary[options.arch].path, join(staging, "package", "root", op.name));
120
+ }
110
121
  }
111
122
  const hook = manifest.uninstall && uninstallExecProblem(join(staging, "package"), manifest.uninstall.purge);
112
123
  if (hook)
@@ -124,7 +135,7 @@ export async function assembleStandaloneSpk(options) {
124
135
  writeFile(join(staging, "conf", "resource"), resource);
125
136
  if (manifest.capabilities.jobs) {
126
137
  mkdirSync(join(staging, BACKGROUND_UNIT_DIR), { recursive: true });
127
- writeFile(join(staging, BACKGROUND_UNIT_DIR, backgroundUnitName(manifest.package)), renderBackgroundUnit(manifest.package));
138
+ writeFile(join(staging, BACKGROUND_UNIT_DIR, backgroundUnitName(manifest.package)), renderBackgroundUnit(manifest.package, isBridgeBackend(manifest.backend) ? BRIDGE_WORKER_ARGS : []));
128
139
  }
129
140
  mkdirSync(join(staging, "scripts"));
130
141
  for (const name of LIFECYCLE_SCRIPTS) {
@@ -153,6 +164,11 @@ export async function assembleStandaloneSpk(options) {
153
164
  rmSync(staging, { recursive: true, force: true });
154
165
  }
155
166
  }
167
+ function copyExecutable(source, target) {
168
+ mkdirSync(dirname(target), { recursive: true });
169
+ copyFileSync(source, target);
170
+ chmodSync(target, EXEC_MODE);
171
+ }
156
172
  function writeFile(path, content, mode = FILE_MODE) {
157
173
  mkdirSync(dirname(path), { recursive: true });
158
174
  writeFileSync(path, content, "utf8");
@@ -180,6 +196,9 @@ function renderInfo(manifest, arch, checksum, executorDigest) {
180
196
  // Package Center writes INFO as root; root tasks accept only this executor.
181
197
  if (executorDigest)
182
198
  fields.push([EXECUTOR_ANCHOR, executorDigest]);
199
+ const backend = manifest.backend;
200
+ if (isBridgeBackend(backend) && backend.node)
201
+ fields.push(["install_dep_packages", backend.node.package]);
183
202
  fields.push(...metadataInfoFields(manifest.metadata));
184
203
  // Background packages need prestart: it runs before DSM's systemd-user-unit acquire.
185
204
  if (manifest.daemon || manifest.capabilities.jobs)
@@ -196,7 +215,7 @@ function renderInfo(manifest, arch, checksum, executorDigest) {
196
215
  function copyUiTree(source, target) {
197
216
  for (const entry of ["app.js", "app.css"]) {
198
217
  if (!existsSync(join(source, entry))) {
199
- throw new StandaloneBuildError("STANDALONE_UI_ENTRY_MISSING", `UI 目錄缺少 ${entry}:${source}`);
218
+ throw new StandaloneBuildError("STANDALONE_UI_ENTRY_MISSING", `The UI directory is missing ${entry}: ${source}`);
200
219
  }
201
220
  }
202
221
  let total = 0;
@@ -207,7 +226,7 @@ function copyUiTree(source, target) {
207
226
  const src = join(from, name);
208
227
  const stat = lstatSync(src);
209
228
  if (stat.isSymbolicLink()) {
210
- throw new StandaloneBuildError("STANDALONE_UI_SYMLINK", `UI 目錄不可含 symlink:${src}`);
229
+ throw new StandaloneBuildError("STANDALONE_UI_SYMLINK", `The UI directory must not contain a symlink: ${src}`);
211
230
  }
212
231
  if (stat.isDirectory()) {
213
232
  walk(src, join(to, name));
@@ -215,13 +234,13 @@ function copyUiTree(source, target) {
215
234
  else if (stat.isFile()) {
216
235
  total += stat.size;
217
236
  if (total > MAX_UI_BYTES) {
218
- throw new StandaloneBuildError("STANDALONE_UI_TOO_LARGE", "UI 目錄超過 64 MiB。");
237
+ throw new StandaloneBuildError("STANDALONE_UI_TOO_LARGE", "The UI directory exceeds 64 MiB.");
219
238
  }
220
239
  copyFileSync(src, join(to, name));
221
240
  chmodSync(join(to, name), FILE_MODE);
222
241
  }
223
242
  else {
224
- throw new StandaloneBuildError("STANDALONE_UI_SPECIAL_FILE", `UI 目錄只可含一般檔案與目錄:${src}`);
243
+ throw new StandaloneBuildError("STANDALONE_UI_SPECIAL_FILE", `The UI directory may contain only regular files and directories: ${src}`);
225
244
  }
226
245
  }
227
246
  };
@@ -44,16 +44,34 @@ ${manifest.uninstall?.state ? wizardFlag("delete_state", ["pkgwizard_delete_stat
44
44
  (cd "$hook_var" 2>/dev/null || cd /; exec /usr/bin/env -i ${env} ${argv}) </dev/null >"$hook_var/uninstall-hook.log" 2>&1 &
45
45
  hook_pid=$!
46
46
  set +m
47
+ # DSM's bash logs a failed kill to syslog even with 2>/dev/null, so a
48
+ # finished hook is looked up in /proc wherever there is one.
49
+ hook_running() {
50
+ if [ -d /proc/self ]; then [ -d "/proc/$hook_pid" ]; else kill -0 "$hook_pid" 2>/dev/null; fi
51
+ }
52
+ # A process in /proc whose group is the hook's ("pid (comm) state ppid pgrp ...").
53
+ hook_group_running() {
54
+ cat /proc/[0-9]*/stat 2>/dev/null | awk -v group="$hook_pid" '{ sub(/.*\) /, ""); if ($3 == group) found = 1 } END { exit !found }'
55
+ }
56
+ hook_signal() {
57
+ if [ ! -d /proc/self ]; then
58
+ kill -"$1" -"$hook_pid" 2>/dev/null || kill -"$1" "$hook_pid" 2>/dev/null
59
+ elif hook_group_running; then
60
+ kill -"$1" -"$hook_pid"
61
+ elif hook_running; then
62
+ kill -"$1" "$hook_pid"
63
+ fi
64
+ }
47
65
  hook_waited=0
48
- while kill -0 "$hook_pid" 2>/dev/null && [ "$hook_waited" -lt ${purge.timeout} ]; do
66
+ while hook_running && [ "$hook_waited" -lt ${purge.timeout} ]; do
49
67
  sleep 1
50
68
  hook_waited=$((hook_waited + 1))
51
69
  done
52
70
  hook_failure=
53
- if kill -0 "$hook_pid" 2>/dev/null; then
54
- kill -TERM -"$hook_pid" 2>/dev/null || kill -TERM "$hook_pid" 2>/dev/null
71
+ if hook_running; then
72
+ hook_signal TERM
55
73
  sleep 1
56
- kill -KILL -"$hook_pid" 2>/dev/null || kill -KILL "$hook_pid" 2>/dev/null
74
+ hook_signal KILL
57
75
  wait "$hook_pid" 2>/dev/null
58
76
  hook_failure="timeout after ${purge.timeout} s"
59
77
  else
@@ -16,7 +16,7 @@ export function readUninstall(value, context, fail) {
16
16
  if (value === undefined)
17
17
  return undefined;
18
18
  const invalid = () => {
19
- fail({ code: "UNINSTALL_INVALID", message: "uninstall 只接受 purge {exec, args, env, timeout, root} 與 state {items: [{key}]};exec 須是套件內固定執行檔(不可在 executor/ 或 root/),timeout 為 2–120 秒整數。" });
19
+ fail({ code: "UNINSTALL_INVALID", message: "uninstall accepts only purge {exec, args, env, timeout, root} and state {items: [{key}]}; exec must be a fixed executable inside the package (not in executor/ or root/), and timeout is an integer from 2 to 120 seconds." });
20
20
  return undefined;
21
21
  };
22
22
  if (!record(value) || Object.keys(value).some(k => !FIELDS.has(k)))
@@ -24,7 +24,7 @@ export function readUninstall(value, context, fail) {
24
24
  if (value.purge === undefined) {
25
25
  if (value.state === undefined)
26
26
  return invalid();
27
- fail({ code: "UNINSTALL_STATE_REQUIRES_PURGE", message: "uninstall.state 需要 uninstall.purge:只有 App 自己知道狀態存在哪裡。" });
27
+ fail({ code: "UNINSTALL_STATE_REQUIRES_PURGE", message: "uninstall.state needs uninstall.purge: only the App knows where the state lives." });
28
28
  return undefined;
29
29
  }
30
30
  const purge = readPurge(value.purge, context, fail);
@@ -35,7 +35,7 @@ export function readUninstall(value, context, fail) {
35
35
  return invalid();
36
36
  const missing = (state?.items ?? []).map(item => stateTextKey(item.key)).filter(key => context.en?.[key] === undefined);
37
37
  if (missing.length) {
38
- fail({ code: "UNINSTALL_STATE_TEXT_MISSING", message: `uninstall.state 的項目需要 i18n en.json 文字:${missing.join(", ")}。` });
38
+ fail({ code: "UNINSTALL_STATE_TEXT_MISSING", message: `The uninstall.state items need i18n en.json text: ${missing.join(", ")}.` });
39
39
  return undefined;
40
40
  }
41
41
  return purge && { purge, ...(state ? { state } : {}) };
@@ -61,11 +61,11 @@ function readPurge(value, context, fail) {
61
61
  if (typeof root !== "string")
62
62
  return "invalid";
63
63
  if (!context.rootOps) {
64
- fail({ code: "UNINSTALL_ROOT_REQUIRES_ROOT_OPS", message: "uninstall.purge.root 需要 capabilities.rootOps: true。" });
64
+ fail({ code: "UNINSTALL_ROOT_REQUIRES_ROOT_OPS", message: "uninstall.purge.root needs capabilities.rootOps: true." });
65
65
  return undefined;
66
66
  }
67
67
  if (context.ops && !context.ops.some(op => op.name === root)) {
68
- fail({ code: "UNINSTALL_ROOT_OP_MISSING", message: `uninstall.purge.root「${root}」需要 root/${root}.sh。` });
68
+ fail({ code: "UNINSTALL_ROOT_OP_MISSING", message: `uninstall.purge.root "${root}" needs root/${root}.sh.` });
69
69
  return undefined;
70
70
  }
71
71
  }
@@ -99,11 +99,11 @@ export function uninstallExecProblem(packageDir, purge) {
99
99
  stat = undefined;
100
100
  }
101
101
  if (!stat?.isFile() || (stat.mode & 0o111) === 0) {
102
- return { code: "UNINSTALL_EXEC_MISSING", message: `uninstall.purge.exec「${purge.exec}」不是套件內可執行的一般檔案。` };
102
+ return { code: "UNINSTALL_EXEC_MISSING", message: `uninstall.purge.exec "${purge.exec}" is not a regular executable file inside the package.` };
103
103
  }
104
104
  const text = readFileSync(path).toString("latin1");
105
105
  if (text.startsWith("#!") && ROOT_SCRIPT_HINTS.some(hint => hint.test(text))) {
106
- return { code: "UNINSTALL_EXEC_UNSAFE", message: `uninstall.purge.exec「${purge.exec}」嘗試取得 root(sudo、chown root 或 setuid);root 只能經 uninstall.purge.root。` };
106
+ return { code: "UNINSTALL_EXEC_UNSAFE", message: `uninstall.purge.exec "${purge.exec}" tries to gain root (sudo, chown root or setuid); root is only available through uninstall.purge.root.` };
107
107
  }
108
108
  return undefined;
109
109
  }
@@ -0,0 +1,17 @@
1
+ /** The DSM AppWindow's size and the desktop's minimum size, in pixels. */
2
+ export interface WindowSize {
3
+ width: number;
4
+ height: number;
5
+ minWidth: number;
6
+ minHeight: number;
7
+ }
8
+ export declare const DEFAULT_WINDOW: WindowSize;
9
+ /** A Portal/standalone launch is the whole page, a phone's as well. */
10
+ export declare const FULL_PAGE_MIN_WIDTH = 320;
11
+ export declare const WINDOW_USAGE = "ui.window takes width and height (whole numbers, 320\u20133840 by 240\u20132160) and minWidth and minHeight (no larger than width and height; 700 and 420 by default); omitted fields keep 960\u00D7640, at least 700\u00D7420.";
12
+ /** `ui.window` over the defaults, or why it is refused. */
13
+ export declare function readWindowSize(value: unknown): {
14
+ size: WindowSize;
15
+ } | {
16
+ error: string;
17
+ };
@@ -0,0 +1,31 @@
1
+ export const DEFAULT_WINDOW = { width: 960, height: 640, minWidth: 700, minHeight: 420 };
2
+ /** A Portal/standalone launch is the whole page, a phone's as well. */
3
+ export const FULL_PAGE_MIN_WIDTH = 320;
4
+ const LIMITS = {
5
+ width: [320, 3840], height: [240, 2160], minWidth: [320, 3840], minHeight: [240, 2160],
6
+ };
7
+ export const WINDOW_USAGE = "ui.window takes width and height (whole numbers, 320–3840 by 240–2160) and minWidth and minHeight (no larger than width and height; 700 and 420 by default); omitted fields keep 960×640, at least 700×420.";
8
+ /** `ui.window` over the defaults, or why it is refused. */
9
+ export function readWindowSize(value) {
10
+ const keys = Object.keys(LIMITS);
11
+ if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0)
12
+ return { error: WINDOW_USAGE };
13
+ const raw = value;
14
+ const unknown = Object.keys(raw).filter(key => !keys.includes(key));
15
+ if (unknown.length)
16
+ return { error: `${WINDOW_USAGE} Unsupported: ${unknown.map(key => `ui.window.${key}`).join(", ")}.` };
17
+ const size = { ...DEFAULT_WINDOW };
18
+ for (const key of keys) {
19
+ if (raw[key] === undefined)
20
+ continue;
21
+ const [min, max] = LIMITS[key];
22
+ const n = raw[key];
23
+ if (!Number.isInteger(n) || n < min || n > max)
24
+ return { error: `${WINDOW_USAGE} ui.window.${key} must be a whole number from ${min} to ${max}.` };
25
+ size[key] = n;
26
+ }
27
+ if (size.minWidth > size.width || size.minHeight > size.height) {
28
+ return { error: `${WINDOW_USAGE} The minimum ${size.minWidth}×${size.minHeight} is larger than the window ${size.width}×${size.height}.` };
29
+ }
30
+ return { size };
31
+ }
package/dist/types.d.ts CHANGED
@@ -56,4 +56,4 @@ export interface ComposeDocument {
56
56
  secrets?: Record<string, unknown>;
57
57
  [key: string]: unknown;
58
58
  }
59
- export type TemplateName = "compose" | "binary" | "native-go" | "native-rust";
59
+ export type TemplateName = "compose" | "binary" | "native-go" | "native-python" | "native-node" | "native-rust";
package/dist/validate.js CHANGED
@@ -23,37 +23,37 @@ export function validateProject(project, options = {}) {
23
23
  return { ok: false, errors: [{
24
24
  code: "PROJECT_RUNTIME_EXCLUSIVE",
25
25
  span: { file: DSM_PACKAGE_FILENAME, line: 1, column: 1 },
26
- message: "專案同時存在 compose.yaml 與 dsm-package.yaml,兩者互斥。",
27
- suggestion: "請選擇 compose.yaml(容器 App)或 dsm-package.yaml(原生 App),並刪除另一個檔案。",
26
+ message: "The project has both compose.yaml and dsm-package.yaml, which are mutually exclusive.",
27
+ suggestion: "Choose compose.yaml (container App) or dsm-package.yaml (native App) and delete the other file.",
28
28
  }] };
29
29
  }
30
30
  if (hasStandalone) {
31
31
  const loaded = isNativeLayout(project.root) ? loadNativeLayout(project.root) : loadStandaloneManifest(project.root);
32
32
  return loaded.ok ? { ok: true, errors: [] } : { ok: false, errors: loaded.errors.map(error => ({
33
33
  ...error, span: { file: DSM_PACKAGE_FILENAME, line: 1, column: 1 },
34
- suggestion: getErrorCode(error.code)?.suggestion ?? "請修正 dsm-package.yaml 的 schema 2 欄位與能力宣告。",
34
+ suggestion: getErrorCode(error.code)?.suggestion ?? "Fix the schema 2 fields and capability declarations in dsm-package.yaml.",
35
35
  })) };
36
36
  }
37
37
  const parsed = loadContract(project.contractPath);
38
38
  errors.push(...validateContract(parsed, contractFile));
39
39
  const capabilities = parsed.contract.capabilities;
40
40
  if (capabilities !== undefined) {
41
- errors.push(makeError("CAPABILITY_UNSUPPORTED", contractSpan(contractFile, 1, 1, "capabilities"), "compose 專案不支援 capabilities。", "請移除 capabilities,或使用 dsm-package.yaml 的 native-go 專案。"));
41
+ errors.push(makeError("CAPABILITY_UNSUPPORTED", contractSpan(contractFile, 1, 1, "capabilities"), "A compose project does not support capabilities.", "Remove capabilities, or use a dsm-package.yaml native-go project."));
42
42
  }
43
43
  if (profile === "listing") {
44
44
  for (const { name, type, code } of findListingUnsupportedInstallTypes(parsed.contract)) {
45
45
  const pos = getValuePosition(parsed.doc, ["install", name, "type"]) ??
46
46
  getValuePosition(parsed.doc, ["install", name]) ??
47
47
  { line: 1, column: 1 };
48
- errors.push(makeError(code, contractSpan(contractFile, pos.line, pos.column, `install.${name}.type`), `Install Parameter「${name}」的 type: ${type} 只能由 synopkgland deploy 在管理員 session 取得安裝當下的 Share 清單快照;Package Center/Package Source listing 不支援。`, "請使用 synopkgland deploy --host <name>,由管理員 session 在安裝時取得 Share 清單快照;此型別不能透過 Package Center 或 Package Source listing 上架。"));
48
+ errors.push(makeError(code, contractSpan(contractFile, pos.line, pos.column, `install.${name}.type`), `The Install Parameter "${name}" with type: ${type} can only be snapshotted by synopkgland deploy with an administrator session at install time; Package Center and Package Source listing do not support it.`, "Use synopkgland deploy --host <name> so an administrator session takes the Share list snapshot at install time; this type cannot ship through Package Center or Package Source listing."));
49
49
  }
50
50
  }
51
51
  if (!hasCompose) {
52
52
  errors.push({
53
53
  code: "COMPOSE_MISSING",
54
54
  span: { file: composeFile, line: 1, column: 1 },
55
- message: "找不到 compose.yaml 或 dsm-package.yaml。",
56
- suggestion: "請在專案根目錄建立 compose.yaml 或 schema 2 的 dsm-package.yaml。",
55
+ message: "compose.yaml or dsm-package.yaml was not found.",
56
+ suggestion: "Create compose.yaml or a schema 2 dsm-package.yaml in the project root.",
57
57
  });
58
58
  return { ok: false, errors };
59
59
  }
@@ -95,16 +95,16 @@ export function validateProject(project, options = {}) {
95
95
  errors.push({
96
96
  code: "CONTRACT_DSM_USER_DUPLICATE",
97
97
  span: { file: contractFile, line: 1, column: 1, path: "install" },
98
- message: "每顆 App 至多一個 dsm_user。",
99
- suggestion: "請只保留一個 Container Run-as 參數。",
98
+ message: "Each App has at most one dsm_user.",
99
+ suggestion: "Keep only one Container Run-as parameter.",
100
100
  });
101
101
  }
102
102
  if (hasSharedFolder && dsmUserParams.length === 0) {
103
103
  errors.push({
104
104
  code: "DSM_USER_REQUIRED",
105
105
  span: { file: contractFile, line: 1, column: 1, path: "install" },
106
- message: "有 Shared Folder 時必須宣告恰好一個 dsm_user 作為 Container Run-as。",
107
- suggestion: "請新增 install.<NAME>.type: dsm_user,Compose 引用 ${NAME_UID}:${NAME_GID}。",
106
+ message: "With a Shared Folder, declare exactly one dsm_user as the Container Run-as.",
107
+ suggestion: "Add install.<NAME>.type: dsm_user and reference ${NAME_UID}:${NAME_GID} in Compose.",
108
108
  severity: profile === "listing" ? "error" : "warning",
109
109
  });
110
110
  }
@@ -132,8 +132,8 @@ export function validateProject(project, options = {}) {
132
132
  column: 1,
133
133
  path: `install.${name}`,
134
134
  },
135
- message: `Install Parameter「${name}」未在 Compose 中被引用。`,
136
- suggestion: `請在 compose.yaml 以 \${${name}} 引用此參數。`,
135
+ message: `The Install Parameter "${name}" is not referenced in Compose.`,
136
+ suggestion: `Reference this parameter in compose.yaml as \${${name}}.`,
137
137
  });
138
138
  }
139
139
  }