@synopackageland/cli 0.1.0 → 0.2.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 (43) hide show
  1. package/dist/bin.js +0 -0
  2. package/dist/build-spk/broker-glue.d.ts +16 -0
  3. package/dist/build-spk/broker-glue.js +40 -0
  4. package/dist/build-spk/delete-app-data.d.ts +11 -0
  5. package/dist/build-spk/delete-app-data.js +56 -0
  6. package/dist/build-spk/generator.js +123 -70
  7. package/dist/build-spk/native-lifecycle.d.ts +51 -0
  8. package/dist/build-spk/native-lifecycle.js +205 -0
  9. package/dist/build-spk/native-payload.d.ts +15 -0
  10. package/dist/build-spk/native-payload.js +65 -39
  11. package/dist/cli.js +26 -3
  12. package/dist/deploy/deploy.d.ts +3 -0
  13. package/dist/deploy/deploy.js +13 -0
  14. package/dist/discovery.d.ts +2 -0
  15. package/dist/discovery.js +2 -0
  16. package/dist/host/digests.js +9 -1
  17. package/dist/host/dsm-client.d.ts +7 -0
  18. package/dist/host/dsm-client.js +25 -0
  19. package/dist/host/types.d.ts +6 -0
  20. package/dist/init.js +1 -0
  21. package/dist/native/load.d.ts +8 -0
  22. package/dist/native/load.js +11 -0
  23. package/dist/native/types.d.ts +28 -0
  24. package/dist/native/types.js +3 -0
  25. package/dist/native/validate.d.ts +6 -0
  26. package/dist/native/validate.js +144 -0
  27. package/dist/publish/curated-release.js +277 -91
  28. package/dist/templates/native/native.yaml +16 -0
  29. package/dist/templates/native/synology-app.yaml +7 -0
  30. package/dist/types.d.ts +1 -1
  31. package/dist/validate.js +37 -4
  32. package/package.json +8 -8
  33. package/skills/reference/diagnose.md +15 -1
  34. package/templates/native/native.yaml +16 -0
  35. package/templates/native/synology-app.yaml +7 -0
  36. package/dist/deploy/package-source.d.ts +0 -7
  37. package/dist/deploy/package-source.js +0 -17
  38. package/dist/deploy/spk-info.d.ts +0 -12
  39. package/dist/deploy/spk-info.js +0 -41
  40. package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  41. package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
  42. package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  43. package/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
@@ -1,39 +1,15 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { chmodSync, closeSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
4
- import { dirname, join, posix, resolve } from "node:path";
5
- import { inflateRawSync } from "node:zlib";
6
- import * as tar from "tar";
7
- import { collectArchiveEntries, createReproducibleTar } from "./reproducible-archive.js";
8
- class NativePayloadError extends Error {
9
- code;
10
- constructor(code, message) {
11
- super(`${code}: ${message}`);
12
- this.name = "NativePayloadError";
13
- this.code = code;
14
- }
15
- }
16
- export function archiveKindFor(nameOrUrl) {
17
- const pathname = nameOrUrlForSuffix(nameOrUrl).toLowerCase();
18
- if (pathname.endsWith(".tar.gz")) {
19
- return "tar.gz";
20
- }
21
- if (pathname.endsWith(".tar.xz")) {
22
- return "tar.xz";
23
- }
24
- if (pathname.endsWith(".zip")) {
25
- return "zip";
26
- }
27
- return "raw";
28
- }
29
- export async function buildNativePayload(options) {
1
+ /**
2
+ * Materializes one target's binary tree into `destDir`, which becomes the
3
+ * payload root (SYNOPKG_PKGDEST). Shared by `buildNativePayload` and by the SPK
4
+ * generator, which needs the tree in its own staging directory so the App
5
+ * Bundle files can sit alongside the binaries in a single package.tgz.
6
+ */
7
+ export async function materializeNativePayload(options) {
30
8
  const stripComponents = readStripComponents(options.source.strip_components);
31
- const outputPath = resolve(options.outputTgz);
32
- const outputParent = dirname(outputPath);
33
- mkdirSync(outputParent, { recursive: true });
34
- rmSync(outputPath, { force: true });
35
- const workDir = mkdtempSync(join(outputParent, ".syno-native-payload-"));
36
- let committed = false;
9
+ const targetDir = resolve(options.destDir);
10
+ mkdirSync(targetDir, { recursive: true, mode: 0o755 });
11
+ chmodSync(targetDir, 0o755);
12
+ const workDir = mkdtempSync(join(dirname(targetDir), ".syno-native-extract-"));
37
13
  try {
38
14
  const sourceName = options.source.path ?? options.source.url;
39
15
  if (sourceName === undefined) {
@@ -52,9 +28,6 @@ export async function buildNativePayload(options) {
52
28
  // extracts package.tgz straight into it. Nesting a literal `target/`
53
29
  // directory here would put the binary at $SYNOPKG_PKGDEST/target/<exec>.
54
30
  // Same layout the Broker SPK ships (packages/broker/src/spk/build.ts).
55
- const targetDir = join(workDir, "package");
56
- mkdirSync(targetDir, { recursive: true, mode: 0o755 });
57
- chmodSync(targetDir, 0o755);
58
31
  if (kind === "raw") {
59
32
  materializeRaw(sourceBytes, targetDir, rawSourceName(sourceName));
60
33
  }
@@ -81,7 +54,55 @@ export async function buildNativePayload(options) {
81
54
  validateExtractedTarTree(rawDir, descriptors, stripComponents);
82
55
  copyStrippedTree(rawDir, targetDir, stripComponents);
83
56
  }
84
- const entries = collectArchiveEntries(targetDir);
57
+ return { sourceSha256, entries: collectArchiveEntries(targetDir) };
58
+ }
59
+ finally {
60
+ rmSync(workDir, { recursive: true, force: true });
61
+ }
62
+ }
63
+ import { execFileSync } from "node:child_process";
64
+ import { createHash } from "node:crypto";
65
+ import { chmodSync, closeSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
66
+ import { dirname, join, posix, resolve } from "node:path";
67
+ import { inflateRawSync } from "node:zlib";
68
+ import * as tar from "tar";
69
+ import { collectArchiveEntries, createReproducibleTar } from "./reproducible-archive.js";
70
+ class NativePayloadError extends Error {
71
+ code;
72
+ constructor(code, message) {
73
+ super(`${code}: ${message}`);
74
+ this.name = "NativePayloadError";
75
+ this.code = code;
76
+ }
77
+ }
78
+ export function archiveKindFor(nameOrUrl) {
79
+ const pathname = nameOrUrlForSuffix(nameOrUrl).toLowerCase();
80
+ if (pathname.endsWith(".tar.gz")) {
81
+ return "tar.gz";
82
+ }
83
+ if (pathname.endsWith(".tar.xz")) {
84
+ return "tar.xz";
85
+ }
86
+ if (pathname.endsWith(".zip")) {
87
+ return "zip";
88
+ }
89
+ return "raw";
90
+ }
91
+ export async function buildNativePayload(options) {
92
+ const outputPath = resolve(options.outputTgz);
93
+ const outputParent = dirname(outputPath);
94
+ mkdirSync(outputParent, { recursive: true });
95
+ rmSync(outputPath, { force: true });
96
+ const workDir = mkdtempSync(join(outputParent, ".syno-native-payload-"));
97
+ let committed = false;
98
+ try {
99
+ const { sourceSha256, entries } = await materializeNativePayload({
100
+ source: options.source,
101
+ projectRoot: options.projectRoot,
102
+ destDir: join(workDir, "package"),
103
+ download: options.download,
104
+ });
105
+ const targetDir = join(workDir, "package");
85
106
  const temporaryOutput = join(workDir, "package.tgz");
86
107
  await createReproducibleTar(targetDir, temporaryOutput, { gzip: true });
87
108
  const packageSha256 = digestFile(temporaryOutput);
@@ -119,6 +140,11 @@ async function readSourceBytes(options) {
119
140
  return bytes;
120
141
  }
121
142
  async function defaultDownload(url) {
143
+ if (url.startsWith("file://")) {
144
+ const { fileURLToPath } = await import("node:url");
145
+ const filePath = fileURLToPath(url);
146
+ return readFileSync(filePath);
147
+ }
122
148
  const response = await fetch(url);
123
149
  if (!response.ok) {
124
150
  throw new NativePayloadError("NATIVE_PAYLOAD_DOWNLOAD_FAILED", `HTTP ${response.status} while downloading ${url}`);
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { validateProject } from "./validate.js";
8
8
  import { runDev } from "./dev.js";
9
9
  import { testProject } from "./test-command.js";
10
10
  import { buildSpk } from "./build-spk/generator.js";
11
+ import { isNativeArchFamily } from "./build-spk/dsm-arch.js";
11
12
  import { deployProject } from "./deploy/deploy.js";
12
13
  import { createNasHost } from "./host/dsm-client.js";
13
14
  import { projectInfo, formatProjectInfo } from "./info.js";
@@ -79,19 +80,29 @@ export async function runCli(argv) {
79
80
  program
80
81
  .command("init")
81
82
  .description("從模板建立 App 專案")
82
- .requiredOption("-t, --template <name>", `模板:${listTemplates().join(" | ")}`)
83
+ .option("-t, --template <name>", `模板:${listTemplates().join(" | ")}`)
84
+ .option("--native", "建立 Native Prebuilt Binary App 專案")
83
85
  .argument("[dir]", "目標目錄", ".")
84
- .action((dir, options, command) => {
86
+ .action(async (dir, options, command) => {
85
87
  const global = command.parent?.opts() ?? {};
86
88
  if (global.project) {
87
89
  console.error("--project 不適用於 init。");
88
90
  process.exit(1);
89
91
  }
92
+ const targetDir = resolve(process.cwd(), dir);
93
+ if (options.native) {
94
+ initFromTemplate("native", targetDir);
95
+ console.log(`已建立 native 專案於 ${targetDir}`);
96
+ return;
97
+ }
98
+ if (!options.template) {
99
+ console.error(`請指定 -t, --template <name> 或 --native。可選模板:${listTemplates().join(" | ")}`);
100
+ process.exit(1);
101
+ }
90
102
  if (!isTemplateName(options.template)) {
91
103
  console.error(`未知模板:${options.template}`);
92
104
  process.exit(1);
93
105
  }
94
- const targetDir = resolve(process.cwd(), dir);
95
106
  initFromTemplate(options.template, targetDir);
96
107
  console.log(`已建立 ${options.template} 專案於 ${targetDir}`);
97
108
  });
@@ -173,6 +184,7 @@ export async function runCli(argv) {
173
184
  .command("deploy")
174
185
  .description("Inner Loop Deploy 到 NAS(產生 SPK、簽發 Local Attestation、安裝)")
175
186
  .requiredOption("--host <name>", "synoagentcli profile 或 NAS 名稱")
187
+ .option("--arch <family>", "native App 的目標架構:x86_64 或 aarch64(省略時由 NAS 判定)")
176
188
  .option("--dev", "部署到 Development Package Identity({package}.dev)")
177
189
  .option("--replace-curated", "危險:將既有 Curated App Package Attestation 降級為 Local Attestation")
178
190
  .option("--share <slot=name>", "此 NAS 上的 Shared Folder 邏輯名(契約 shared_folder 槽,可重複)", collectShareOption)
@@ -186,7 +198,12 @@ export async function runCli(argv) {
186
198
  process.exit(1);
187
199
  }
188
200
  try {
201
+ if (options.arch !== undefined && !isNativeArchFamily(String(options.arch))) {
202
+ console.error(`--arch 只接受 x86_64 或 aarch64,收到 ${String(options.arch)}。`);
203
+ process.exit(1);
204
+ }
189
205
  const result = await deployProject({
206
+ archFamily: options.arch ? String(options.arch) : undefined,
190
207
  project,
191
208
  host,
192
209
  dev: options.dev,
@@ -292,12 +309,18 @@ export async function runCli(argv) {
292
309
  .command("build-spk")
293
310
  .description("產生本機 thin SPK(未簽署)")
294
311
  .option("-o, --output <path>", "輸出 .spk 路徑")
312
+ .option("--arch <family>", "native App 的目標架構:x86_64 或 aarch64(Compose App 不需要)")
295
313
  .action(async (options, command) => {
296
314
  const global = command.parent?.opts() ?? {};
297
315
  const project = resolveProject(process.cwd(), global.project);
316
+ if (options.arch !== undefined && !isNativeArchFamily(String(options.arch))) {
317
+ console.error(`--arch 只接受 x86_64 或 aarch64,收到 ${String(options.arch)}。`);
318
+ process.exit(1);
319
+ }
298
320
  const result = await buildSpk({
299
321
  project,
300
322
  outputPath: options.output ? resolve(process.cwd(), options.output) : undefined,
323
+ archFamily: options.arch ? String(options.arch) : undefined,
301
324
  });
302
325
  if (!result.ok) {
303
326
  console.error(`建置失敗:${result.errors.join(", ")}`);
@@ -1,8 +1,11 @@
1
+ import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
1
2
  import type { ProjectRoot } from "../discovery.js";
2
3
  import type { NasHost } from "../host/types.js";
3
4
  import type { PublishSpkResult } from "@synopackageland/package-source/publish-spk";
4
5
  export interface DeployProjectOptions {
5
6
  project: ProjectRoot;
7
+ /** native App only: which target to build. Omitted means "ask the NAS". */
8
+ archFamily?: NativeArchFamily;
6
9
  host: NasHost;
7
10
  dev?: boolean;
8
11
  replaceCurated?: boolean;
@@ -1,4 +1,5 @@
1
1
  import { buildSpk } from "../build-spk/generator.js";
2
+ import { existsSync } from "node:fs";
2
3
  import { loadContract } from "../contract/load.js";
3
4
  import { digestProjectBundle, digestProjectContract } from "../host/digests.js";
4
5
  import { appOriginFromContract } from "../host/open-url.js";
@@ -67,11 +68,23 @@ export async function deployProject(options) {
67
68
  };
68
69
  }
69
70
  const spkVersion = await resolveDeploySpkVersion(options.project, options.host, identity.packageId);
71
+ let archFamily = options.archFamily;
72
+ if (archFamily === undefined && options.project.nativePath && existsSync(options.project.nativePath)) {
73
+ archFamily = await options.host.getArchFamily?.();
74
+ if (archFamily === undefined) {
75
+ return {
76
+ ok: false,
77
+ code: "NATIVE_ARCH_UNDETERMINED",
78
+ message: "無法從這台 NAS 判定 CPU 架構。請用 --arch x86_64 或 --arch aarch64 明確指定。",
79
+ };
80
+ }
81
+ }
70
82
  const build = await buildSpk({
71
83
  project: options.project,
72
84
  packageIdentity: identity.packageId,
73
85
  displayname: identity.displayname,
74
86
  spkVersion,
87
+ archFamily,
75
88
  });
76
89
  if (!build.ok || !build.outputPath) {
77
90
  return { ok: false, code: "BUILD_SPK_FAILED", message: build.errors.join(", ") };
@@ -1,8 +1,10 @@
1
1
  export declare const CONTRACT_FILENAME = "synology-app.yaml";
2
2
  export declare const COMPOSE_FILENAME = "compose.yaml";
3
+ export declare const NATIVE_FILENAME = "native.yaml";
3
4
  export interface ProjectRoot {
4
5
  root: string;
5
6
  contractPath: string;
6
7
  composePath: string;
8
+ nativePath?: string;
7
9
  }
8
10
  export declare function findProjectRoot(startDir: string): ProjectRoot | null;
package/dist/discovery.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  export const CONTRACT_FILENAME = "synology-app.yaml";
4
4
  export const COMPOSE_FILENAME = "compose.yaml";
5
+ export const NATIVE_FILENAME = "native.yaml";
5
6
  export function findProjectRoot(startDir) {
6
7
  let current = resolve(startDir);
7
8
  while (true) {
@@ -11,6 +12,7 @@ export function findProjectRoot(startDir) {
11
12
  root: current,
12
13
  contractPath,
13
14
  composePath: join(current, COMPOSE_FILENAME),
15
+ nativePath: join(current, NATIVE_FILENAME),
14
16
  };
15
17
  }
16
18
  const parent = dirname(current);
@@ -23,7 +23,15 @@ export function digestProjectBundle(project) {
23
23
  try {
24
24
  const packageDir = join(staging, "package");
25
25
  mkdirSync(packageDir, { recursive: true });
26
- cpSync(project.composePath, join(packageDir, "compose.yaml"));
26
+ // Runtime members are whichever of the two exist; validate already refuses
27
+ // a project that has both or neither. The Broker computes the same set
28
+ // (packages/broker/src/digest.ts), so the two digests have to agree.
29
+ if (existsSync(project.composePath)) {
30
+ cpSync(project.composePath, join(packageDir, "compose.yaml"));
31
+ }
32
+ if (project.nativePath && existsSync(project.nativePath)) {
33
+ cpSync(project.nativePath, join(packageDir, "native.yaml"));
34
+ }
27
35
  cpSync(project.contractPath, join(packageDir, "synology-app.yaml"));
28
36
  const htmlDir = join(project.root, "html");
29
37
  if (existsSync(htmlDir)) {
@@ -4,3 +4,10 @@ export interface CreateNasHostOptions {
4
4
  credentials?: HostCredentials;
5
5
  }
6
6
  export declare function createNasHost(options?: CreateNasHostOptions): NasHost | null;
7
+ /**
8
+ * DSM reports the CPU vendor, not the ISA. Every x86_64 platform Synology
9
+ * ships is Intel or AMD; every aarch64 one is Realtek, Marvell, Annapurna or
10
+ * Rockchip. An unrecognised vendor returns undefined so `deploy` asks for
11
+ * `--arch` rather than guessing wrong and building an unusable SPK.
12
+ */
13
+ export declare function archFamilyForCpuVendor(vendor: string | undefined): "x86_64" | "aarch64" | undefined;
@@ -172,6 +172,15 @@ function createDsmNasHost(credentials, profileName) {
172
172
  projectEnvCache.delete(input.packageId);
173
173
  }
174
174
  },
175
+ async getArchFamily() {
176
+ await ensureLogin();
177
+ const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?api=SYNO.Core.System&version=1&method=info`, { headers: { cookie: cookieHeader ?? "" } });
178
+ const body = (await response.json());
179
+ if (body.success !== true) {
180
+ return undefined;
181
+ }
182
+ return archFamilyForCpuVendor(body.data?.cpu_vendor);
183
+ },
175
184
  async resolveSharePath(logicalName) {
176
185
  return lookupSharePathWithSynoagentcli(logicalName, profileName);
177
186
  },
@@ -288,3 +297,19 @@ function createDsmNasHost(credentials, profileName) {
288
297
  },
289
298
  };
290
299
  }
300
+ /**
301
+ * DSM reports the CPU vendor, not the ISA. Every x86_64 platform Synology
302
+ * ships is Intel or AMD; every aarch64 one is Realtek, Marvell, Annapurna or
303
+ * Rockchip. An unrecognised vendor returns undefined so `deploy` asks for
304
+ * `--arch` rather than guessing wrong and building an unusable SPK.
305
+ */
306
+ export function archFamilyForCpuVendor(vendor) {
307
+ const normalized = (vendor ?? "").trim().toLowerCase();
308
+ if (normalized === "intel" || normalized === "amd") {
309
+ return "x86_64";
310
+ }
311
+ if (["realtek", "marvell", "annapurna labs", "annapurna", "rockchip"].includes(normalized)) {
312
+ return "aarch64";
313
+ }
314
+ return undefined;
315
+ }
@@ -43,6 +43,12 @@ export interface NasHost {
43
43
  spkPath: string;
44
44
  extraValues?: Record<string, string>;
45
45
  }): Promise<void>;
46
+ /**
47
+ * CPU family of this NAS, for picking a native App's target. Returns
48
+ * undefined when the DSM answer does not map to a family we ship, so the
49
+ * caller refuses instead of guessing.
50
+ */
51
+ getArchFamily?(): Promise<"x86_64" | "aarch64" | undefined>;
46
52
  resolveSharePath?(logicalName: string): Promise<string | undefined>;
47
53
  getInstalledProjectEnv?(packageIdentity: string): Promise<Record<string, string> | undefined>;
48
54
  ensurePackageSource?(origin: string): Promise<void>;
package/dist/init.js CHANGED
@@ -6,6 +6,7 @@ const TEMPLATE_NAMES = [
6
6
  "hello-web-page",
7
7
  "hello-files",
8
8
  "compose-import",
9
+ "native",
9
10
  ];
10
11
  export function isTemplateName(value) {
11
12
  return TEMPLATE_NAMES.includes(value);
@@ -0,0 +1,8 @@
1
+ import { type Document } from "yaml";
2
+ import type { NativeConfig } from "./types.js";
3
+ export interface ParsedNative {
4
+ native: NativeConfig;
5
+ source: string;
6
+ doc: Document;
7
+ }
8
+ export declare function loadNative(nativePath: string): ParsedNative | null;
@@ -0,0 +1,11 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { parseDocument } from "yaml";
3
+ export function loadNative(nativePath) {
4
+ if (!existsSync(nativePath)) {
5
+ return null;
6
+ }
7
+ const source = readFileSync(nativePath, "utf8");
8
+ const doc = parseDocument(source);
9
+ const native = (doc.toJSON() ?? {});
10
+ return { native, source, doc };
11
+ }
@@ -0,0 +1,28 @@
1
+ import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
2
+ /** One target's binary source. `path` is Personal-only; Curated needs `url` + `sha256`. */
3
+ export interface NativeTarget {
4
+ path?: string;
5
+ url?: string;
6
+ sha256?: string;
7
+ strip_components?: number;
8
+ }
9
+ /**
10
+ * The single service process a native App runs. `exec` is relative to
11
+ * SYNOPKG_PKGDEST, which N0 proved is the payload root itself. There is no
12
+ * `user:` field: DSM creates the package user at install time and already owns
13
+ * SYNOPKG_PKGVAR, so generated scripts neither create a user nor chown.
14
+ */
15
+ export interface NativeService {
16
+ exec: string;
17
+ args?: string[];
18
+ env?: Record<string, string>;
19
+ }
20
+ /** Parsed `native.yaml`. Schema 1 only; unknown or higher versions are refused. */
21
+ export interface NativeConfig {
22
+ schema: number;
23
+ targets: Partial<Record<NativeArchFamily, NativeTarget>>;
24
+ service: NativeService;
25
+ }
26
+ export declare const NATIVE_FILENAME = "native.yaml";
27
+ /** Data written into SYNOPKG_PKGVAR survives upgrades; `target/` is replaced wholesale. */
28
+ export declare const PKGVAR_PLACEHOLDER = "${SYNOPKG_PKGVAR}";
@@ -0,0 +1,3 @@
1
+ export const NATIVE_FILENAME = "native.yaml";
2
+ /** Data written into SYNOPKG_PKGVAR survives upgrades; `target/` is replaced wholesale. */
3
+ export const PKGVAR_PLACEHOLDER = "${SYNOPKG_PKGVAR}";
@@ -0,0 +1,6 @@
1
+ import type { ValidationError } from "../types.js";
2
+ import type { ParsedNative } from "./load.js";
3
+ export interface NativeValidationOptions {
4
+ profile?: "personal" | "listing";
5
+ }
6
+ export declare function validateNative(parsed: ParsedNative, nativeFile: string, options?: NativeValidationOptions): ValidationError[];
@@ -0,0 +1,144 @@
1
+ import { makeError } from "../errors.js";
2
+ import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
3
+ import { PKGVAR_PLACEHOLDER } from "./types.js";
4
+ import { getValuePosition } from "../contract/load.js";
5
+ function nativeSpan(file, doc, pathSegments, pathString) {
6
+ const dotPath = pathString ?? pathSegments.join(".");
7
+ if (!doc) {
8
+ return { file, line: 1, column: 1, path: dotPath };
9
+ }
10
+ const pos = getValuePosition(doc, pathSegments) ?? { line: 1, column: 1 };
11
+ return { file, line: pos.line, column: pos.column, path: dotPath };
12
+ }
13
+ const HEX64_REGEX = /^[0-9a-fA-F]{64}$/;
14
+ const FORBIDDEN_SERVICE_FIELDS = ["user", "command", "pre_start", "post_start", "shell"];
15
+ export function validateNative(parsed, nativeFile, options = {}) {
16
+ const errors = [];
17
+ const profile = options.profile ?? "personal";
18
+ const { native, doc } = parsed;
19
+ const rawObj = (native && typeof native === "object" ? native : {});
20
+ // 1. schema 必須是整數且只接受 1。未知或更高值 → 拒絕,不靜默降級。
21
+ if (typeof native.schema !== "number" || !Number.isInteger(native.schema) || native.schema !== 1) {
22
+ errors.push(makeError("NATIVE_SCHEMA_INVALID", nativeSpan(nativeFile, doc, ["schema"]), `native.yaml schema 必須是整數且只接受 1,收到 ${JSON.stringify(native.schema)}。`, "請將 schema 設定為 1。"));
23
+ }
24
+ // 3. targets 的 key 只接受 x86_64、aarch64,至少要有一個。其他 key → 拒絕。
25
+ const targets = rawObj.targets;
26
+ if (!targets || typeof targets !== "object" || Array.isArray(targets)) {
27
+ errors.push(makeError("NATIVE_TARGETS_REQUIRED", nativeSpan(nativeFile, doc, ["targets"]), "native.yaml 必須包含 targets 物件,且至少宣告一個架構(x86_64 或 aarch64)。", "請在 targets 底下宣告 x86_64 或 aarch64 的 binary 來源。"));
28
+ }
29
+ else {
30
+ const targetEntries = Object.entries(targets);
31
+ if (targetEntries.length === 0) {
32
+ errors.push(makeError("NATIVE_TARGETS_EMPTY", nativeSpan(nativeFile, doc, ["targets"]), "targets 不得為空,至少要宣告一個架構(x86_64 或 aarch64)。", "請在 targets 底下宣告 x86_64 或 aarch64 的 binary 來源。"));
33
+ }
34
+ for (const [archKey, targetVal] of targetEntries) {
35
+ if (!isNativeArchFamily(archKey)) {
36
+ errors.push(makeError("NATIVE_ARCH_UNKNOWN", nativeSpan(nativeFile, doc, ["targets", archKey]), `不支援的架構「${archKey}」,targets 的 key 只接受 x86_64 或 aarch64。`, "請將 targets 的 key 改為 x86_64 或 aarch64。"));
37
+ continue;
38
+ }
39
+ if (!targetVal || typeof targetVal !== "object" || Array.isArray(targetVal)) {
40
+ errors.push(makeError("NATIVE_TARGET_INVALID", nativeSpan(nativeFile, doc, ["targets", archKey]), `targets.${archKey} 必須是物件。`, "請指定 path 或 url 及 sha256。"));
41
+ continue;
42
+ }
43
+ const targetObj = targetVal;
44
+ const hasPath = typeof targetObj.path === "string" && targetObj.path.trim().length > 0;
45
+ const hasUrl = typeof targetObj.url === "string" && targetObj.url.trim().length > 0;
46
+ // 4. 每個 target 必須恰好有 path: 或 url: 之一。
47
+ if ((hasPath && hasUrl) || (!hasPath && !hasUrl)) {
48
+ errors.push(makeError("NATIVE_TARGET_SOURCE_EXCLUSIVE", nativeSpan(nativeFile, doc, ["targets", archKey]), `targets.${archKey} 必須恰好有 path 或 url 之一。`, "請只提供 path(本機開發)或 url(發佈)。"));
49
+ }
50
+ // 5. path: 只在 personal profile 合法。listing profile 只接受 url: + sha256:(url 沒有 sha256 也要拒絕)。
51
+ if (profile === "listing") {
52
+ if (hasPath) {
53
+ errors.push(makeError("NATIVE_PATH_FORBIDDEN_IN_LISTING", nativeSpan(nativeFile, doc, ["targets", archKey, "path"]), `Listing profile 不允許使用 path(targets.${archKey}.path),只接受 url + sha256。`, "請在 targets 中改用 url 與 sha256 指向上游 release asset。"));
54
+ }
55
+ if (hasUrl && (!targetObj.sha256 || typeof targetObj.sha256 !== "string" || targetObj.sha256.trim() === "")) {
56
+ errors.push(makeError("NATIVE_SHA256_REQUIRED_IN_LISTING", nativeSpan(nativeFile, doc, ["targets", archKey]), `Listing profile 的 targets.${archKey} 必須包含 sha256。`, "請提供 64 位元 SHA-256 雜湊值。"));
57
+ }
58
+ }
59
+ // 6. sha256 給了就必須是 64 個 hex 字元(大小寫都收,比對時 normalize 成小寫)。
60
+ if (targetObj.sha256 !== undefined) {
61
+ if (typeof targetObj.sha256 !== "string" || !HEX64_REGEX.test(targetObj.sha256)) {
62
+ errors.push(makeError("NATIVE_SHA256_INVALID", nativeSpan(nativeFile, doc, ["targets", archKey, "sha256"]), `targets.${archKey}.sha256 必須是 64 個十六進位字元 (hex)。`, "請提供有效的 64 位元 SHA-256 hex 字串。"));
63
+ }
64
+ }
65
+ // 7. strip_components 給了必須是 >= 0 的整數。
66
+ if (targetObj.strip_components !== undefined) {
67
+ if (typeof targetObj.strip_components !== "number" ||
68
+ !Number.isInteger(targetObj.strip_components) ||
69
+ targetObj.strip_components < 0) {
70
+ errors.push(makeError("NATIVE_STRIP_COMPONENTS_INVALID", nativeSpan(nativeFile, doc, ["targets", archKey, "strip_components"]), `targets.${archKey}.strip_components 必須是 >= 0 的整數。`, "請提供大於或等於 0 的整數值。"));
71
+ }
72
+ }
73
+ }
74
+ }
75
+ // service 檢查
76
+ const service = rawObj.service;
77
+ if (!service || typeof service !== "object" || Array.isArray(service)) {
78
+ errors.push(makeError("NATIVE_SERVICE_REQUIRED", nativeSpan(nativeFile, doc, ["service"]), "native.yaml 必須包含 service 定義。", "請在 native.yaml 新增 service 區塊,包含 exec、args 與 env。"));
79
+ return errors;
80
+ }
81
+ const serviceObj = service;
82
+ // 10 & 11. service 禁止的欄位:user (規則10) 以及 shell 字串欄位 command, pre_start, post_start, shell 等 (規則11)
83
+ if ("user" in serviceObj) {
84
+ errors.push(makeError("NATIVE_SERVICE_USER_FORBIDDEN", nativeSpan(nativeFile, doc, ["service", "user"]), "service 不允許包含 user 欄位(DSM 在安裝時會自動建立並管理 package user)。", "請移除 service.user 欄位。"));
85
+ }
86
+ for (const forbiddenField of ["command", "pre_start", "post_start", "shell"]) {
87
+ if (forbiddenField in serviceObj) {
88
+ errors.push(makeError("NATIVE_SERVICE_SHELL_FIELD_FORBIDDEN", nativeSpan(nativeFile, doc, ["service", forbiddenField]), `service 不允許包含 shell 欄位「${forbiddenField}」,執行設定請使用 exec 與 args。`, `請移除 service.${forbiddenField},改用 exec 與 args。`));
89
+ }
90
+ }
91
+ // 9. service.exec 必填、必須是相對路徑:不接受絕對路徑、不接受任何 .. path segment、不接受空字串。
92
+ const exec = serviceObj.exec;
93
+ if (typeof exec !== "string" || exec.trim() === "") {
94
+ errors.push(makeError("NATIVE_EXEC_REQUIRED", nativeSpan(nativeFile, doc, ["service", "exec"]), "service.exec 為必填項目,且不可為空字串。", "請提供相對於 package 根目錄的可執行檔路徑(例如 Radarr 或 bin/my-app)。"));
95
+ }
96
+ else {
97
+ const trimmedExec = exec.trim();
98
+ const segments = trimmedExec.split(/[/\\]/);
99
+ if (trimmedExec.startsWith("/") || trimmedExec.startsWith("\\") || segments.some((s) => s === "..")) {
100
+ errors.push(makeError("NATIVE_EXEC_INVALID_PATH", nativeSpan(nativeFile, doc, ["service", "exec"]), `service.exec「${exec}」必須是相對路徑,不可為絕對路徑或包含「..」路徑區段。`, "請使用純相對路徑(例如 Radarr 或 bin/app),不得以 / 開頭或包含 ..。"));
101
+ }
102
+ }
103
+ // 11. args 必須是字串 array
104
+ let argsValid = true;
105
+ if (serviceObj.args !== undefined) {
106
+ if (!Array.isArray(serviceObj.args) || !serviceObj.args.every((item) => typeof item === "string")) {
107
+ argsValid = false;
108
+ errors.push(makeError("NATIVE_ARGS_INVALID", nativeSpan(nativeFile, doc, ["service", "args"]), "service.args 必須是字串陣列(string[])。", "請將 args 定義為字串清單,例如 [\"--config\", \"${SYNOPKG_PKGVAR}/config.yaml\"]。"));
109
+ }
110
+ }
111
+ // 12. env 必須是 string→string 的 map。
112
+ let envValid = true;
113
+ if (serviceObj.env !== undefined) {
114
+ if (!serviceObj.env ||
115
+ typeof serviceObj.env !== "object" ||
116
+ Array.isArray(serviceObj.env) ||
117
+ !Object.values(serviceObj.env).every((val) => typeof val === "string")) {
118
+ envValid = false;
119
+ errors.push(makeError("NATIVE_ENV_INVALID", nativeSpan(nativeFile, doc, ["service", "env"]), "service.env 必須是 string 到 string 的 key-value map。", "請確認 service.env 中每個環境變數的值皆為字串。"));
120
+ }
121
+ }
122
+ // 8. service.args 或 service.env 至少要出現一次 ${SYNOPKG_PKGVAR},否則拒絕。用 PKGVAR_PLACEHOLDER 常數比對。
123
+ let hasPkgvarPlaceholder = false;
124
+ if (argsValid && Array.isArray(serviceObj.args)) {
125
+ for (const arg of serviceObj.args) {
126
+ if (typeof arg === "string" && arg.includes(PKGVAR_PLACEHOLDER)) {
127
+ hasPkgvarPlaceholder = true;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (!hasPkgvarPlaceholder && envValid && serviceObj.env && typeof serviceObj.env === "object" && !Array.isArray(serviceObj.env)) {
133
+ for (const val of Object.values(serviceObj.env)) {
134
+ if (typeof val === "string" && val.includes(PKGVAR_PLACEHOLDER)) {
135
+ hasPkgvarPlaceholder = true;
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ if (!hasPkgvarPlaceholder) {
141
+ errors.push(makeError("NATIVE_PKGVAR_REQUIRED", nativeSpan(nativeFile, doc, ["service"]), `service.args 或 service.env 必須至少包含一次「${PKGVAR_PLACEHOLDER}」,以確保資料寫入持久化目錄。`, `請在 service.args 或 service.env 中傳入 ${PKGVAR_PLACEHOLDER}(例如 -data=\${SYNOPKG_PKGVAR} 或 TMPDIR: \${SYNOPKG_PKGVAR}/tmp)。`));
142
+ }
143
+ return errors;
144
+ }