@synopackageland/cli 0.9.5 → 0.9.7

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 (58) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/assets/licenses/MUSL-COPYRIGHT +193 -0
  3. package/assets/licenses/RUST-COPYRIGHT-library.html +5556 -0
  4. package/dist/add.js +27 -27
  5. package/dist/build-spk/notices.d.ts +8 -1
  6. package/dist/build-spk/notices.js +13 -4
  7. package/dist/build-spk/uninstall-wizard.d.ts +9 -0
  8. package/dist/build-spk/uninstall-wizard.js +10 -3
  9. package/dist/build-spk/wizard-i18n.d.ts +1 -1
  10. package/dist/build-spk/wizard-i18n.js +1 -1
  11. package/dist/build-spk/wizard-messages.d.ts +33 -0
  12. package/dist/build-spk/wizard-messages.js +33 -0
  13. package/dist/capability-overlays.d.ts +10 -5
  14. package/dist/capability-overlays.js +32 -10
  15. package/dist/cli.js +5 -5
  16. package/dist/deploy/plan.d.ts +1 -1
  17. package/dist/deploy/plan.js +1 -1
  18. package/dist/error-registry.js +44 -3
  19. package/dist/info.js +2 -2
  20. package/dist/init.js +53 -28
  21. package/dist/runtime/executor-build.d.ts +1 -1
  22. package/dist/runtime/executor-build.js +2 -1
  23. package/dist/runtime/executor-digests.json +22 -0
  24. package/dist/runtime/executor-policy.d.ts +7 -0
  25. package/dist/runtime/executor-policy.js +5 -3
  26. package/dist/runtime/sdk-source.d.ts +14 -2
  27. package/dist/runtime/sdk-source.js +39 -4
  28. package/dist/standalone/api-permissions.d.ts +4 -2
  29. package/dist/standalone/api-permissions.js +6 -5
  30. package/dist/standalone/binary.d.ts +3 -2
  31. package/dist/standalone/binary.js +17 -5
  32. package/dist/standalone/build.js +13 -9
  33. package/dist/standalone/daemon.d.ts +6 -0
  34. package/dist/standalone/daemon.js +8 -4
  35. package/dist/standalone/inspect-spk.d.ts +1 -1
  36. package/dist/standalone/inspect-spk.js +39 -10
  37. package/dist/standalone/lifecycle.js +38 -4
  38. package/dist/standalone/manifest.d.ts +12 -0
  39. package/dist/standalone/manifest.js +55 -17
  40. package/dist/standalone/native-layout.d.ts +1 -7
  41. package/dist/standalone/native-layout.js +4 -97
  42. package/dist/standalone/root-ops.d.ts +2 -0
  43. package/dist/standalone/root-ops.js +2 -0
  44. package/dist/standalone/rust-build.d.ts +35 -0
  45. package/dist/standalone/rust-build.js +127 -0
  46. package/dist/standalone/sdk-check.d.ts +5 -3
  47. package/dist/standalone/sdk-check.js +21 -8
  48. package/dist/standalone/spk.js +14 -3
  49. package/dist/standalone/toolkit.d.ts +10 -0
  50. package/dist/standalone/toolkit.js +101 -0
  51. package/dist/standalone/uninstall-hook.d.ts +10 -0
  52. package/dist/standalone/uninstall-hook.js +71 -0
  53. package/dist/standalone/uninstall.d.ts +53 -0
  54. package/dist/standalone/uninstall.js +136 -0
  55. package/dist/types.d.ts +1 -1
  56. package/dist/yaml-edit.d.ts +6 -0
  57. package/dist/yaml-edit.js +31 -0
  58. package/package.json +2 -2
package/dist/init.js CHANGED
@@ -1,19 +1,26 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { isMap, isScalar, parse, parseDocument, stringify } from "yaml";
2
+ import { isMap, isScalar, parse, parseDocument } from "yaml";
3
+ import { setTopLevel } from "./yaml-edit.js";
3
4
  import { capabilityNames, capabilitySettings } from "./capabilities.js";
4
5
  import { MOVE_FROM_STAGING } from './runtime/delegate-contract.js';
5
6
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
- import { basename, join, relative, resolve } from "node:path";
7
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
8
  import { SDK_MODULE, SDK_PACKAGE } from "./runtime/sdk-source.js";
8
9
  import { templatesRoot } from "./template-paths.js";
9
- import { borrowedTarget, capabilityOverlays } from "./capability-overlays.js";
10
+ import { borrowedFiles, borrowedTarget, capabilityOverlays, VUE_OVERLAYS } from "./capability-overlays.js";
11
+ import { API_PERMISSIONS_FILE } from "./standalone/api-permissions.js";
10
12
  import { resolveMinDsm, assertContainerMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast } from "./build-spk/dsm-version.js";
11
13
  import { readSharedFolder } from "./standalone/shared-folder.js";
12
14
  const TEMPLATE_NAMES = [
13
15
  "compose",
14
16
  "binary",
15
17
  "native-go",
18
+ "native-rust",
16
19
  ];
20
+ /** Templates built from source with the SDK: capabilities, a Vue or plain UI, a shared folder. */
21
+ const NATIVE_TEMPLATES = ["native-go", "native-rust"];
22
+ const isNativeTemplate = (template) => NATIVE_TEMPLATES.includes(template);
23
+ const BACKEND_KIND = { "native-go": "go", "native-rust": "rust" };
17
24
  /** Templates whose App runs through the DSM docker-project resource worker. */
18
25
  const CONTAINER_TEMPLATES = [
19
26
  "compose",
@@ -38,7 +45,7 @@ export function nativeCapabilitySettings(names) {
38
45
  return settings;
39
46
  }
40
47
  /** Templates whose identity is filled in at init: every generated file may carry a placeholder. */
41
- const IDENTIFIED = ["native-go", "binary"];
48
+ const IDENTIFIED = [...NATIVE_TEMPLATES, "binary"];
42
49
  const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
43
50
  /** npm never publishes files named .gitignore, so templates ship them as `gitignore`. */
44
51
  const RENAMED = { gitignore: ".gitignore" };
@@ -67,9 +74,9 @@ function templateFiles(root, dir = root) {
67
74
  function targetName(file) {
68
75
  return RENAMED[file] ?? file;
69
76
  }
70
- /** native-go defaults to the Vue shell; the CLI and the library entry point agree. */
77
+ /** Native templates default to the Vue shell; the CLI and the library entry point agree. */
71
78
  function resolvedUi(template, options) {
72
- return template === "native-go" ? options.ui ?? "vue" : options.ui;
79
+ return isNativeTemplate(template) ? options.ui ?? "vue" : options.ui;
73
80
  }
74
81
  /**
75
82
  * Copy one template tree into a project. A template directory may carry a local
@@ -93,9 +100,9 @@ export function initFromTemplate(template, targetDir, options = {}) {
93
100
  throw new Error("[CAPABILITY_COMBINATION_INVALID] delegate 的 install-selected scope 只能在 Vue 面板確認;plain UI 不支援,請改用 --ui vue。");
94
101
  if (options.from && template !== "compose")
95
102
  throw new Error("[INIT_OPTIONS_UNSUPPORTED] --from 只適用於 compose。");
96
- if (caps.length && template !== "native-go")
103
+ if (caps.length && !isNativeTemplate(template))
97
104
  throw new Error("[CAPABILITY_UNSUPPORTED] binary/compose 不支援 --with 能力。");
98
- const localSdk = template === "native-go" && process.env.SYNOPKGLAND_SDK_DIR ? resolve(process.env.SYNOPKGLAND_SDK_DIR) : undefined;
105
+ const localSdk = isNativeTemplate(template) && process.env.SYNOPKGLAND_SDK_DIR ? resolve(process.env.SYNOPKGLAND_SDK_DIR) : undefined;
99
106
  if (localSdk)
100
107
  assertLocalSdkReady(localSdk, resolvedUi(template, options) === "vue");
101
108
  const source = join(templatesRoot(), template);
@@ -112,14 +119,15 @@ export function initFromTemplate(template, targetDir, options = {}) {
112
119
  if (rootExample && minDsm !== undefined && !isDsmAtLeast(minDsm, DEFAULT_MIN_DSM)) {
113
120
  throw new Error(`[MIN_DSM_INVALID] root templates require DSM ${DEFAULT_MIN_DSM} or later.`);
114
121
  }
115
- const capability = capabilityOverlays(caps);
116
- const overlays = [...(ui === "vue" ? ["native-go-vue"] : []), ...capability.overlays].map(name => join(templatesRoot(), name));
122
+ const capability = isNativeTemplate(template) ? capabilityOverlays(template, caps) : { overlays: [], borrowed: [], removed: [] };
123
+ const borrowed = borrowedFiles(templatesRoot(), capability.borrowed);
124
+ const overlays = [...(ui === "vue" && isNativeTemplate(template) ? VUE_OVERLAYS[template] : []), ...capability.overlays].map(name => join(templatesRoot(), name));
117
125
  // Shared legal resources also live in the CLI package; only the template
118
126
  // terms are an unconditional part of a generated project.
119
127
  const shared = join(templatesRoot(), "_shared");
120
128
  const sharedFiles = ["LICENSES/TEMPLATE.txt", "AGENTS.md", "CLAUDE.md"];
121
129
  const files = [...templateFiles(source), ...sharedFiles, ...overlays.flatMap(root => templateFiles(root)),
122
- ...capability.borrowed.map(borrowedTarget), ...(ui === "vue" ? ["frontend/third-party-notices.mjs"] : [])];
130
+ ...borrowed.map(borrowedTarget), ...(ui === "vue" ? ["frontend/third-party-notices.mjs"] : [])];
123
131
  const collisions = files.map(targetName).filter((file) => existsSync(join(targetDir, file)));
124
132
  if (collisions.length > 0) {
125
133
  throw new Error(`[INIT_TARGET_NOT_EMPTY] ${targetDir} 已有 ${collisions.join(", ")};init 不會覆蓋既有檔案。請改用空目錄。`);
@@ -134,12 +142,14 @@ export function initFromTemplate(template, targetDir, options = {}) {
134
142
  for (const dir of (ui === "vue" ? ["frontend"] : [])) {
135
143
  cpSync(join(shared, "third-party-notices.mjs"), join(targetDir, dir, "third-party-notices.mjs"));
136
144
  }
137
- for (const path of capability.borrowed)
145
+ for (const path of borrowed) {
146
+ mkdirSync(dirname(join(targetDir, borrowedTarget(path))), { recursive: true });
138
147
  cpSync(join(templatesRoot(), path), join(targetDir, borrowedTarget(path)));
148
+ }
139
149
  for (const file of capability.removed)
140
150
  rmSync(join(targetDir, file));
141
- if (caps.includes("delegate")) {
142
- const path = join(targetDir, "backend/internal/api/permissions.json");
151
+ if (caps.includes("delegate") && isNativeTemplate(template)) {
152
+ const path = join(targetDir, "backend", API_PERMISSIONS_FILE[BACKEND_KIND[template]]);
143
153
  const policy = JSON.parse(readFileSync(path, "utf8"));
144
154
  policy.methods["app.status"] = "authenticated";
145
155
  writeFileSync(path, JSON.stringify(policy, null, 2) + "\n");
@@ -151,8 +161,8 @@ export function initFromTemplate(template, targetDir, options = {}) {
151
161
  }
152
162
  for (const file of files.map(targetName))
153
163
  substitute(join(targetDir, file), substitutions);
154
- if (localSdk)
155
- useLocalSdk(targetDir, localSdk);
164
+ if (localSdk && isNativeTemplate(template))
165
+ useLocalSdk(targetDir, localSdk, BACKEND_KIND[template]);
156
166
  if (minDsm) {
157
167
  const manifest = join(targetDir, template !== "compose" ? "dsm-package.yaml" : "synology-app.yaml");
158
168
  writeFileSync(manifest, readFileSync(manifest, "utf8").replace(/^os_min_ver:.*$/m, `os_min_ver: "${minDsm}"`));
@@ -165,10 +175,9 @@ export function initFromTemplate(template, targetDir, options = {}) {
165
175
  if (ui !== "vue")
166
176
  rmSync(join(targetDir, "frontend"), { recursive: true, force: true });
167
177
  if (template !== "compose") {
178
+ // Only the capabilities change: the template's comments document the optional fields.
168
179
  const path = join(targetDir, "dsm-package.yaml");
169
- const raw = parse(readFileSync(path, "utf8"));
170
- raw.capabilities = nativeCapabilitySettings(caps);
171
- writeFileSync(path, stringify(raw));
180
+ writeFileSync(path, setTopLevel(readFileSync(path, "utf8"), { capabilities: nativeCapabilitySettings(caps) }));
172
181
  }
173
182
  if (template === "compose" && options.from)
174
183
  importCompose(options.from, targetDir, options);
@@ -236,16 +245,32 @@ function assertLocalSdkReady(sdkDir, vue) {
236
245
  throw new Error(`[SDK_DIR_NOT_READY] ${ts} 尚未建置;前端安裝本機 SDK 時 npm 不會替它 build。請先執行 (cd ${ts} && npm ci && npm run build),SDK 原始碼改動後也要重新 build。`);
237
246
  }
238
247
  }
239
- /** SYNOPKGLAND_SDK_DIR: the project builds against a local sdk checkout instead of the released version. */
240
- function useLocalSdk(targetDir, sdkDir) {
241
- const goMod = join(targetDir, "backend/go.mod");
242
- writeFileSync(goMod, `${readFileSync(goMod, "utf8")}\nreplace ${SDK_MODULE} => ${sdkDir}\n`);
248
+ /**
249
+ * SYNOPKGLAND_SDK_DIR: the project builds against a local sdk checkout instead
250
+ * of the released version. A Rust backend keeps its Cargo files; at build time
251
+ * git reads the SDK repository from the checkout.
252
+ */
253
+ function useLocalSdk(targetDir, sdkDir, backend) {
254
+ if (backend === "go") {
255
+ const goMod = join(targetDir, "backend/go.mod");
256
+ writeFileSync(goMod, `${readFileSync(goMod, "utf8")}\nreplace ${SDK_MODULE} => ${sdkDir}\n`);
257
+ }
243
258
  const frontend = join(targetDir, "frontend/package.json");
244
259
  if (!existsSync(frontend))
245
260
  return;
261
+ const local = `file:${join(sdkDir, "ts")}`;
246
262
  const pkg = JSON.parse(readFileSync(frontend, "utf8"));
247
- pkg.dependencies[SDK_PACKAGE] = `file:${join(sdkDir, "ts")}`;
263
+ pkg.dependencies[SDK_PACKAGE] = local;
248
264
  writeFileSync(frontend, JSON.stringify(pkg, null, 2) + "\n");
265
+ // npm installs what the lockfile records; without the released entry it installs the local package.
266
+ const lockPath = join(targetDir, "frontend/package-lock.json");
267
+ if (!existsSync(lockPath))
268
+ return;
269
+ const lock = JSON.parse(readFileSync(lockPath, "utf8"));
270
+ if (lock.packages?.[""]?.dependencies)
271
+ lock.packages[""].dependencies[SDK_PACKAGE] = local;
272
+ delete lock.packages?.[`node_modules/${SDK_PACKAGE}`];
273
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n");
249
274
  }
250
275
  /** Fill the placeholders of one generated file; files without one, or removed by an overlay, stay untouched. */
251
276
  function substitute(path, substitutions) {
@@ -258,11 +283,11 @@ function substitute(path, substitutions) {
258
283
  }
259
284
  function substitutionsFor(template, targetDir, options) {
260
285
  const caps = capabilityNames(options.with ?? []);
261
- if (options.ui !== undefined && template !== "native-go")
262
- throw new Error("[INIT_OPTIONS_UNSUPPORTED] --ui 只適用於 native-go。");
286
+ if (options.ui !== undefined && !isNativeTemplate(template))
287
+ throw new Error("[INIT_OPTIONS_UNSUPPORTED] --ui 只適用於 native-go 與 native-rust。");
263
288
  if (options.sharedFolder) {
264
- if (template !== "native-go")
265
- throw new Error("[INIT_SHARED_FOLDER_INVALID] --shared-folder 只適用於 -t native-go。");
289
+ if (!isNativeTemplate(template))
290
+ throw new Error("[INIT_SHARED_FOLDER_INVALID] --shared-folder 只適用於 -t native-go 與 native-rust。");
266
291
  sharedFolderYaml(options.sharedFolder);
267
292
  }
268
293
  if (options.ui !== undefined && !["plain", "vue"].includes(options.ui))
@@ -1,6 +1,6 @@
1
1
  import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
2
2
  import type { CommandRunner } from '../standalone/build.js';
3
- import type { StandaloneManifest } from '../standalone/manifest.js';
3
+ import { type StandaloneManifest } from '../standalone/manifest.js';
4
4
  import { type ExecutorPolicy } from './executor-policy.js';
5
5
  import { type ExecutorPins, type ExecutorTask } from './executor-tasks.js';
6
6
  import { type ExecutorDigestTable } from './executor-source.js';
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem } from '../build-spk/go-toolchain.js';
4
+ import { sdkBackend } from '../standalone/manifest.js';
4
5
  import { StandaloneBuildError } from '../standalone/spk.js';
5
6
  import { checkCgiBinary } from '../standalone/binary.js';
6
7
  import { executorPolicy, executorPolicyText, sha256 } from './executor-policy.js';
@@ -23,7 +24,7 @@ export async function buildExecutor(m, arch, work, run, options = {}) {
23
24
  return undefined;
24
25
  let sdk;
25
26
  try {
26
- sdk = requiredSdk(m.backend.kind === 'go' ? m.backend.dir : m.root, options.env, options.download);
27
+ sdk = requiredSdk(sdkBackend(m), options.env, options.download);
27
28
  }
28
29
  catch (error) {
29
30
  throw failure(error);
@@ -75,5 +75,27 @@
75
75
  "armv7": "b65a14f0fc77a1d3221c7dc6781eb66059aa200956b094d58a62bd1cad10f116"
76
76
  },
77
77
  "goToolchain": "go1.26.9"
78
+ },
79
+ "0.9.7": {
80
+ "x86_64": "d029ca6129e76cdc5b7016378722af166b511fb346fd1002082e611bec0aef4d",
81
+ "aarch64": "ac8018a9846a8aaca6f06323f033a27f7abef6705601efde57ea02565105a48d",
82
+ "armv7": "f7b4ee5fb90c8f2dd2c56ec1eeb7749300ad117a23d9df6da6840d8b2eff83b8",
83
+ "lifecycle": {
84
+ "x86_64": "f2f0a6aa83399688441b111adee3958d7df50e9d8e33939e3951da393f013819",
85
+ "aarch64": "d0f0d42f367093d5c9318afb4719b9ac76b1019574f1db34dd0fcb3aba44945b",
86
+ "armv7": "02da8b7016abfe00b8f18e27ef2d69bcff1ca376b43e17b8c04983ca6a6ef37d"
87
+ },
88
+ "goToolchain": "go1.26.9"
89
+ },
90
+ "0.9.8": {
91
+ "x86_64": "d029ca6129e76cdc5b7016378722af166b511fb346fd1002082e611bec0aef4d",
92
+ "aarch64": "ac8018a9846a8aaca6f06323f033a27f7abef6705601efde57ea02565105a48d",
93
+ "armv7": "f7b4ee5fb90c8f2dd2c56ec1eeb7749300ad117a23d9df6da6840d8b2eff83b8",
94
+ "lifecycle": {
95
+ "x86_64": "f2f0a6aa83399688441b111adee3958d7df50e9d8e33939e3951da393f013819",
96
+ "aarch64": "d0f0d42f367093d5c9318afb4719b9ac76b1019574f1db34dd0fcb3aba44945b",
97
+ "armv7": "02da8b7016abfe00b8f18e27ef2d69bcff1ca376b43e17b8c04983ca6a6ef37d"
98
+ },
99
+ "goToolchain": "go1.26.9"
78
100
  }
79
101
  }
@@ -26,6 +26,8 @@ export interface ExecutorPolicy {
26
26
  operations: string[];
27
27
  /** Below DSM 7.2 the executor registers this Application Portal entry; DSM 7.1 refuses web-config for unsigned packages. */
28
28
  portal?: PortalEntry;
29
+ /** The root op the executor runs at uninstall while the approval is live; a key of root. */
30
+ purge?: string;
29
31
  root: Record<string, string>;
30
32
  }
31
33
  /** The exact policy file bytes whose sha256 is the policy pin. */
@@ -42,6 +44,11 @@ export declare function executorPolicy(m: {
42
44
  portal?: PortalDeclaration;
43
45
  os_min_ver?: string;
44
46
  displayname?: string;
47
+ uninstall?: {
48
+ purge: {
49
+ root?: string;
50
+ };
51
+ };
45
52
  }): ExecutorPolicy;
46
53
  /** Inventory rejects malformed extensions even if a caller recomputes every pin. */
47
54
  export declare function validExecutorPolicy(value: unknown, pkg: string): value is ExecutorPolicy;
@@ -44,9 +44,10 @@ export function executorPolicy(m) {
44
44
  root[op.name] = sha256(op.body);
45
45
  }
46
46
  const d = m.capabilities.delegate;
47
+ const purge = m.capabilities.rootOps ? m.uninstall?.purge.root : undefined;
47
48
  return { v: 1, package: m.package, app: `SYNO.SDS.${m.package}.Instance`, ...(m.entry ? { entry: m.entry } : {}), ...(d ? { scope: d.scope } : {}),
48
49
  ...(d?.lifetime ? { destinations: [...d.destinations].sort(), lifetime: d.lifetime } : {}),
49
- operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), root };
50
+ operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), ...(purge ? { purge } : {}), root };
50
51
  }
51
52
  /** The entry the executor registers: a portal App whose os_min_ver is below DSM 7.2. */
52
53
  function executorPortal(m) {
@@ -63,11 +64,12 @@ export function validExecutorPolicy(value, pkg) {
63
64
  if (!value || typeof value !== 'object' || Array.isArray(value))
64
65
  return false;
65
66
  const p = value;
66
- if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'root'].includes(key))
67
+ if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'root'].includes(key))
67
68
  || p.v !== 1 || p.package !== pkg || p.app !== `SYNO.SDS.${pkg}.Instance`
68
69
  || 'entry' in p && p.entry !== 'admin-only' && p.entry !== 'app-privilege'
69
70
  || !Array.isArray(p.operations) || !p.operations.includes('status') || new Set(p.operations).size !== p.operations.length
70
- || !p.root || typeof p.root !== 'object' || Array.isArray(p.root))
71
+ || !p.root || typeof p.root !== 'object' || Array.isArray(p.root)
72
+ || 'purge' in p && (typeof p.purge !== 'string' || !Object.hasOwn(p.root, p.purge)))
71
73
  return false;
72
74
  const base = ['status', 'root.run', 'delegate.create', 'delegate.list', 'delegate.revoke'];
73
75
  if (p.operations.some(op => ![...base, ...FILE_OPERATIONS, ...FILE_TASK_OPERATIONS, ...DELEGATE_EXTENSION_OPERATIONS, ...PORTAL_OPERATIONS].includes(op)))
@@ -1,6 +1,9 @@
1
1
  export declare const SDK_PACKAGE = "@synopackageland/sdk";
2
2
  export declare const SDK_MODULE = "github.com/synopackageland/syno-pkg-land-sdk";
3
3
  export declare const EXECUTOR_MODULE = "github.com/synopackageland/syno-pkg-land-sdk/executor";
4
+ /** The Rust SDK crate, a git dependency on a release tag of the SDK repository. */
5
+ export declare const SDK_CRATE = "syno-pkg-land-sdk";
6
+ export declare const SDK_GIT_URL = "https://github.com/synopackageland/syno-pkg-land-sdk";
4
7
  /** Where one SDK version's Go client and executor sources are read from. */
5
8
  export interface SdkSource {
6
9
  version: string;
@@ -8,7 +11,16 @@ export interface SdkSource {
8
11
  executorDir: string;
9
12
  local: boolean;
10
13
  }
11
- /** The SDK version an App requires: `require github.com/synopackageland/syno-pkg-land-sdk vX.Y.Z` in backend/go.mod, the only version source. */
14
+ /** How each backend declares the SDK. */
15
+ export declare const SDK_DEPENDENCY: Record<SdkBackend['kind'], string>;
16
+ /** The App backend whose dependency files name its SDK version. */
17
+ export interface SdkBackend {
18
+ kind: 'go' | 'rust';
19
+ dir: string;
20
+ }
21
+ /** The SDK version an App requires, read from its backend's dependency files. */
22
+ export declare function appSdkVersion(backend: SdkBackend): string | undefined;
23
+ /** The SDK version a Go backend requires: `require github.com/synopackageland/syno-pkg-land-sdk vX.Y.Z` in backend/go.mod, its only version source. */
12
24
  export declare function sdkRequirement(backendDir: string): string | undefined;
13
25
  /** The App's own Go module path, the prefix of every `-X` that targets its packages. */
14
26
  export declare function goModulePath(backendDir: string): string | undefined;
@@ -21,7 +33,7 @@ export declare function sdkSource(version: string, env?: NodeJS.ProcessEnv, down
21
33
  /** The module cache directory of module@version; the cache is read-only, so builds copy it first. */
22
34
  export declare function goModDownload(spec: string, env?: NodeJS.ProcessEnv): string;
23
35
  /** The SDK an App builds its executor with; delegate and rootOps cannot build without one. */
24
- export declare function requiredSdk(backendDir: string, env?: NodeJS.ProcessEnv, download?: (spec: string) => string): SdkSource;
36
+ export declare function requiredSdk(backend: SdkBackend, env?: NodeJS.ProcessEnv, download?: (spec: string) => string): SdkSource;
25
37
  /**
26
38
  * The SDK repositories are private: Go must fetch them with git, not from
27
39
  * proxy.golang.org and sum.golang.org. Says which of the two is missing.
@@ -5,7 +5,42 @@ import { join } from 'node:path';
5
5
  export const SDK_PACKAGE = '@synopackageland/sdk';
6
6
  export const SDK_MODULE = 'github.com/synopackageland/syno-pkg-land-sdk';
7
7
  export const EXECUTOR_MODULE = `${SDK_MODULE}/executor`;
8
- /** The SDK version an App requires: `require github.com/synopackageland/syno-pkg-land-sdk vX.Y.Z` in backend/go.mod, the only version source. */
8
+ /** The Rust SDK crate, a git dependency on a release tag of the SDK repository. */
9
+ export const SDK_CRATE = 'syno-pkg-land-sdk';
10
+ export const SDK_GIT_URL = `https://${SDK_MODULE}`;
11
+ /** How each backend declares the SDK. */
12
+ export const SDK_DEPENDENCY = {
13
+ go: `add \`require ${SDK_MODULE} v<version>\` to backend/go.mod.`,
14
+ rust: `add \`${SDK_CRATE} = { git = "${SDK_GIT_URL}", tag = "v<version>" }\` to backend/Cargo.toml and commit Cargo.lock.`,
15
+ };
16
+ /** The SDK version an App requires, read from its backend's dependency files. */
17
+ export function appSdkVersion(backend) {
18
+ return backend.kind === 'go' ? sdkRequirement(backend.dir) : cargoSdkRequirement(backend.dir);
19
+ }
20
+ /**
21
+ * The SDK release a Rust backend's Cargo.lock pins: the source
22
+ * `git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=vX.Y.Z#<commit>`.
23
+ * A branch, revision, path or registry source is refused, so the executor and
24
+ * the SDK route policy always come from the same release as the crate.
25
+ */
26
+ function cargoSdkRequirement(backendDir) {
27
+ let lock;
28
+ try {
29
+ lock = readFileSync(join(backendDir, 'Cargo.lock'), 'utf8');
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ const sdk = lock.split(/^\[\[package\]\]$/m).filter(entry => /^name = "(.*)"$/m.exec(entry)?.[1] === SDK_CRATE);
35
+ if (!sdk.length)
36
+ return undefined;
37
+ const tag = new RegExp(`^source = "git\\+${SDK_GIT_URL.replaceAll('.', '\\.')}\\?tag=v(\\d+\\.\\d+\\.\\d+)#[0-9a-f]{40}"$`, 'm');
38
+ const version = sdk.length === 1 ? tag.exec(sdk[0])?.[1] : undefined;
39
+ if (!version)
40
+ throw new Error(`[SDK_TAG_REQUIRED] backend/Cargo.lock must pin one ${SDK_CRATE} from a release tag: { git = "${SDK_GIT_URL}", tag = "vX.Y.Z" } in Cargo.toml, then cargo update -p ${SDK_CRATE}.`);
41
+ return version;
42
+ }
43
+ /** The SDK version a Go backend requires: `require github.com/synopackageland/syno-pkg-land-sdk vX.Y.Z` in backend/go.mod, its only version source. */
9
44
  export function sdkRequirement(backendDir) {
10
45
  const module = SDK_MODULE.replaceAll('.', '\\.');
11
46
  return new RegExp(`^\\s*(?:require\\s+)?${module}\\s+v(\\S+)`, 'm').exec(goMod(backendDir))?.[1];
@@ -54,10 +89,10 @@ export function goModDownload(spec, env = process.env) {
54
89
  return info.Dir;
55
90
  }
56
91
  /** The SDK an App builds its executor with; delegate and rootOps cannot build without one. */
57
- export function requiredSdk(backendDir, env = process.env, download) {
58
- const version = sdkRequirement(backendDir);
92
+ export function requiredSdk(backend, env = process.env, download) {
93
+ const version = appSdkVersion(backend);
59
94
  if (!version)
60
- throw new Error(`[SDK_REQUIRED] capabilities.delegate and rootOps need the SDK: add \`require ${SDK_MODULE} v<version>\` to backend/go.mod.`);
95
+ throw new Error(`[SDK_REQUIRED] capabilities.delegate and rootOps need the SDK: ${SDK_DEPENDENCY[backend.kind]}`);
61
96
  return download ? sdkSource(version, env, download) : sdkSource(version, env);
62
97
  }
63
98
  /**
@@ -1,5 +1,7 @@
1
1
  import type { Capabilities } from "./manifest.js";
2
- export declare const API_PERMISSIONS_FILE = "internal/api/permissions.json";
2
+ import { type SdkBackend } from "../runtime/sdk-source.js";
3
+ /** The App route policy inside each backend kind's directory. */
4
+ export declare const API_PERMISSIONS_FILE: Record<SdkBackend["kind"], string>;
3
5
  type SdkPermissions = Record<string, Record<string, ApiPermission>>;
4
6
  export declare const MAX_PERMISSIONS_BYTES = 65536;
5
7
  export declare const MAX_PERMISSION_METHODS = 1024;
@@ -10,7 +12,7 @@ export interface ApiPermissions {
10
12
  methods: Record<string, ApiPermission>;
11
13
  }
12
14
  export declare function parseApiPermissions(raw: Uint8Array): ApiPermissions;
13
- export declare function readApiPermissions(backendDir: string, capabilities?: Capabilities): {
15
+ export declare function readApiPermissions(backend: SdkBackend, capabilities?: Capabilities): {
14
16
  policy: ApiPermissions;
15
17
  digest: string;
16
18
  sdkDigest: string;
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstatSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { sdkRequirement, sdkSource } from "../runtime/sdk-source.js";
4
+ import { appSdkVersion, sdkSource } from "../runtime/sdk-source.js";
5
5
  import { parseDocument } from "yaml";
6
- export const API_PERMISSIONS_FILE = "internal/api/permissions.json";
6
+ /** The App route policy inside each backend kind's directory. */
7
+ export const API_PERMISSIONS_FILE = { go: "internal/api/permissions.json", rust: "permissions.json" };
7
8
  /** SDK-owned routes per capability, in the Go module of the SDK version the App requires. */
8
9
  const SDK_PERMISSIONS_FILE = "api/sdk-permissions.json";
9
10
  export const MAX_PERMISSIONS_BYTES = 65536;
@@ -32,14 +33,14 @@ export function parseApiPermissions(raw) {
32
33
  }
33
34
  return value;
34
35
  }
35
- export function readApiPermissions(backendDir, capabilities = {}) {
36
- const file = join(backendDir, API_PERMISSIONS_FILE);
36
+ export function readApiPermissions(backend, capabilities = {}) {
37
+ const file = join(backend.dir, API_PERMISSIONS_FILE[backend.kind]);
37
38
  const stat = lstatSync(file);
38
39
  if (!stat.isFile() || stat.size > MAX_PERMISSIONS_BYTES)
39
40
  throw new Error("permissions must be a regular bounded JSON file");
40
41
  const raw = readFileSync(file);
41
42
  const policy = parseApiPermissions(raw);
42
- const version = sdkRequirement(backendDir);
43
+ const version = appSdkVersion(backend);
43
44
  const sdkRaw = version ? readFileSync(join(sdkSource(version).goDir, SDK_PERMISSIONS_FILE)) : Buffer.from("{}");
44
45
  const sdk = JSON.parse(sdkRaw.toString("utf8"));
45
46
  for (const methods of Object.values(sdk))
@@ -4,7 +4,8 @@ export declare const ELF_MACHINE: Record<NativeArchFamily, number>;
4
4
  /**
5
5
  * Checks a prebuilt CGI before it is packaged: a regular file inside the
6
6
  * project, a little-endian ELF of `arch`'s class and machine, and statically linked
7
- * (no PT_INTERP), because DSM models ship different libc versions.
7
+ * (no PT_INTERP), because DSM models ship different libc versions; only a
8
+ * build that links against a known DSM glibc may name its `interpreter`.
8
9
  * Returns undefined when the file is acceptable.
9
10
  */
10
- export declare function checkCgiBinary(projectRoot: string, file: string, arch: NativeArchFamily): StandaloneError | undefined;
11
+ export declare function checkCgiBinary(projectRoot: string, file: string, arch: NativeArchFamily, interpreter?: string): StandaloneError | undefined;
@@ -11,10 +11,11 @@ function fail(code, message) {
11
11
  /**
12
12
  * Checks a prebuilt CGI before it is packaged: a regular file inside the
13
13
  * project, a little-endian ELF of `arch`'s class and machine, and statically linked
14
- * (no PT_INTERP), because DSM models ship different libc versions.
14
+ * (no PT_INTERP), because DSM models ship different libc versions; only a
15
+ * build that links against a known DSM glibc may name its `interpreter`.
15
16
  * Returns undefined when the file is acceptable.
16
17
  */
17
- export function checkCgiBinary(projectRoot, file, arch) {
18
+ export function checkCgiBinary(projectRoot, file, arch, interpreter) {
18
19
  let stat;
19
20
  try {
20
21
  stat = lstatSync(file);
@@ -32,7 +33,7 @@ export function checkCgiBinary(projectRoot, file, arch) {
32
33
  if (stat.size > MAX_BINARY_BYTES) {
33
34
  return fail("STANDALONE_BINARY_TOO_LARGE", `${file} 超過 256 MiB。`);
34
35
  }
35
- return checkElf(readHeader(file), file, arch);
36
+ return checkElf(readHeader(file), file, arch, interpreter);
36
37
  }
37
38
  function readHeader(file) {
38
39
  const fd = openSync(file, "r");
@@ -58,7 +59,7 @@ function readElfHeader(buf) {
58
59
  }
59
60
  return undefined;
60
61
  }
61
- function checkElf(buf, file, arch) {
62
+ function checkElf(buf, file, arch, interpreter) {
62
63
  const bits = ELF_CLASS64[arch] ? 64 : 32;
63
64
  const notElf = fail("STANDALONE_BINARY_NOT_ELF", `${file} 不是 ${arch} 需要的 ${bits}-bit little-endian Linux ELF 執行檔。`);
64
65
  const header = readElfHeader(buf);
@@ -73,9 +74,20 @@ function checkElf(buf, file, arch) {
73
74
  return notElf;
74
75
  }
75
76
  for (let i = 0; i < phnum; i++) {
76
- if (buf.readUInt32LE(phoff + i * phentsize) === PT_INTERP) {
77
+ const entry = phoff + i * phentsize;
78
+ if (buf.readUInt32LE(entry) === PT_INTERP && (interpreter === undefined || interpreterOf(buf, entry, header.class64) !== interpreter)) {
77
79
  return fail("STANDALONE_BINARY_DYNAMIC", `${file} 是動態連結執行檔。請改用靜態連結(Go:CGO_ENABLED=0;Rust:*-unknown-linux-musl)。`);
78
80
  }
79
81
  }
80
82
  return undefined;
81
83
  }
84
+ /** The path a PT_INTERP program header names, when it lies within the read header bytes. */
85
+ function interpreterOf(buf, entry, class64) {
86
+ if (entry + (class64 ? 40 : 20) > buf.length)
87
+ return undefined;
88
+ const offset = class64 ? Number(buf.readBigUInt64LE(entry + 8)) : buf.readUInt32LE(entry + 4);
89
+ const size = class64 ? Number(buf.readBigUInt64LE(entry + 32)) : buf.readUInt32LE(entry + 16);
90
+ if (size < 1 || offset + size > buf.length || buf[offset + size - 1] !== 0)
91
+ return undefined;
92
+ return buf.toString("latin1", offset, offset + size - 1);
93
+ }
@@ -7,6 +7,7 @@ import { goBuildEnv } from "../build-spk/dsm-arch.js";
7
7
  import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage } from "../build-spk/dsm-version.js";
8
8
  import { checkCgiBinary } from "./binary.js";
9
9
  import { loadStandaloneManifest } from "./manifest.js";
10
+ import { buildRustBackend } from "./rust-build.js";
10
11
  import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
11
12
  import { buildDaemonControl } from "./daemon.js";
12
13
  import { buildExecutor } from "../runtime/executor-build.js";
@@ -102,14 +103,9 @@ export async function buildStandalone(options) {
102
103
  const executor = await buildExecutor(manifest, arch, archWork, run);
103
104
  executor?.warnings.forEach(warning => warnings.add(warning));
104
105
  const builtManifest = executor ? { ...manifest, executor } : manifest;
105
- const cgiPath = await backendFor(builtManifest, arch, archWork, run);
106
- const problem = checkCgiBinary(manifest.backend.kind === "go" ? archWork : manifest.root, cgiPath, arch);
107
- if (problem) {
108
- throw new StandaloneBuildError(problem.code, problem.message);
109
- }
106
+ const { cgi: cgiPath, worker: backgroundWorker } = await backendFor(builtManifest, arch, archWork, run);
110
107
  const outputPath = join(stage, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
111
108
  const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
112
- const backgroundWorker = await buildBackgroundWorker(builtManifest, arch, archWork, run);
113
109
  await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, backgroundWorker, executor });
114
110
  return outputPath;
115
111
  }));
@@ -133,14 +129,22 @@ async function buildUi(manifest, run) {
133
129
  throw new StandaloneBuildError("STANDALONE_UI_BUILD_FAILED", `UI build(${build.command.join(" ")})失敗。${installHint}\n${result.stderr}`);
134
130
  }
135
131
  }
132
+ /** The checked CGI of one architecture and, with capabilities.jobs, its fixed worker. */
136
133
  async function backendFor(manifest, arch, work, run) {
137
134
  const backend = manifest.backend;
138
- if (backend.kind === "binary") {
139
- return backend.files[arch];
135
+ if (backend.kind === "rust")
136
+ return buildRustBackend(manifest, arch, work, run);
137
+ const cgi = backend.kind === "binary" ? backend.files[arch] : await buildGoCgi(manifest, backend, arch, work, run);
138
+ const problem = checkCgiBinary(backend.kind === "go" ? work : manifest.root, cgi, arch);
139
+ if (problem) {
140
+ throw new StandaloneBuildError(problem.code, problem.message);
140
141
  }
142
+ return { cgi, worker: await buildBackgroundWorker(manifest, arch, work, run) };
143
+ }
144
+ async function buildGoCgi(manifest, backend, arch, work, run) {
141
145
  // UI build hooks run after validation. Refuse policy changes rather than
142
146
  // compiling CGI permissions different from the launcher metadata snapshot.
143
- if (readApiPermissions(backend.dir, manifest.capabilities).digest !== manifest.apiPermissionsDigest || readApiPermissions(backend.dir, manifest.capabilities).sdkDigest !== manifest.apiSDKPermissionsDigest) {
147
+ if (readApiPermissions(backend, manifest.capabilities).digest !== manifest.apiPermissionsDigest || readApiPermissions(backend, manifest.capabilities).sdkDigest !== manifest.apiSDKPermissionsDigest) {
144
148
  throw new StandaloneBuildError("STANDALONE_API_PERMISSIONS_CHANGED", "API permissions changed during build; check and rebuild.");
145
149
  }
146
150
  const module = goModulePath(backend.dir);
@@ -18,6 +18,12 @@ export interface Daemon {
18
18
  uninstall: number;
19
19
  };
20
20
  }
21
+ /** A fixed argv or env value: only the package's own directories expand. */
22
+ export declare const token: (v: unknown) => v is string;
23
+ /** An application variable that cannot steer the loader, the shell or DSM. */
24
+ export declare const envKey: (k: string) => boolean;
25
+ /** A relative path to a fixed executable, without `.` or `..` parts. */
26
+ export declare const relativeExec: (v: unknown) => v is string;
21
27
  export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
22
28
  export declare function daemonConfig(manifest: StandaloneManifest): string;
23
29
  export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
@@ -6,18 +6,22 @@ import { checkCgiBinary } from "./binary.js";
6
6
  import { StandaloneBuildError } from "./spk.js";
7
7
  const fields = new Set(["exec", "targets", "args", "env", "timeouts", "uninstall_args"]);
8
8
  const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
9
- const token = (v) => typeof v === "string" && Buffer.byteLength(v) <= 4096 && !v.includes("\0")
9
+ /** A fixed argv or env value: only the package's own directories expand. */
10
+ export const token = (v) => typeof v === "string" && Buffer.byteLength(v) <= 4096 && !v.includes("\0")
10
11
  && !v.replaceAll("${SYNOPKG_PKGDEST}", "").replaceAll("${SYNOPKG_PKGVAR}", "").includes("${");
11
- const envKey = (k) => /^[A-Z][A-Z0-9_]{0,63}$/.test(k)
12
+ /** An application variable that cannot steer the loader, the shell or DSM. */
13
+ export const envKey = (k) => /^[A-Z][A-Z0-9_]{0,63}$/.test(k)
12
14
  && !/^(PATH|HOME|TMPDIR|ENV|BASH_ENV|SHELLOPTS|BASHOPTS|GODEBUG|GOTRACEBACK|LD_.*|DYLD_.*|SYNOPKG_.*)$/.test(k);
15
+ /** A relative path to a fixed executable, without `.` or `..` parts. */
16
+ export const relativeExec = (v) => typeof v === "string" && v.length <= 200
17
+ && /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(v) && !v.split("/").some(p => p === "." || p === "..");
13
18
  export function readDaemon(value, root, arches, fail) {
14
19
  if (value === undefined)
15
20
  return undefined;
16
21
  const invalid = () => { fail({ code: "STANDALONE_DAEMON_INVALID", message: "daemon 只接受固定 exec、targets、args、env 與 timeouts;路徑須在專案內,環境不接受 loader/shell/system 變數。" }); return undefined; };
17
22
  if (!record(value) || Object.keys(value).some(k => !fields.has(k)))
18
23
  return invalid();
19
- if (typeof value.exec !== "string" || !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(value.exec)
20
- || value.exec.split("/").some(p => p === "." || p === "..") || value.exec.length > 200)
24
+ if (!relativeExec(value.exec))
21
25
  return invalid();
22
26
  if (!record(value.targets) || Object.keys(value.targets).length !== arches.length)
23
27
  return invalid();
@@ -1,7 +1,7 @@
1
1
  import { type ExecutorInspection } from "../runtime/executor-inspect.js";
2
2
  import type { EntryMode } from "./manifest.js";
3
3
  import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
4
- export type SpkKind = "native-go" | "binary" | "container" | "native-layout";
4
+ export type SpkKind = "native-go" | "native-rust" | "binary" | "container" | "native-layout";
5
5
  export interface SpkInspection {
6
6
  kind?: SpkKind;
7
7
  package?: string;