@synopackageland/cli 0.7.3 → 0.8.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/CHANGELOG.md +125 -0
  2. package/README.md +1 -1
  3. package/dist/build-spk/gateway.d.ts +2 -2
  4. package/dist/build-spk/gateway.js +2 -2
  5. package/dist/build-spk/generator.d.ts +1 -0
  6. package/dist/build-spk/generator.js +2 -2
  7. package/dist/cli.js +107 -43
  8. package/dist/error-registry.js +6 -1
  9. package/dist/reproducible-config.d.ts +8 -0
  10. package/dist/reproducible-config.js +32 -0
  11. package/dist/reproducible.d.ts +38 -0
  12. package/dist/reproducible.js +121 -0
  13. package/dist/runtime/executor-build.d.ts +1 -1
  14. package/dist/runtime/executor-build.js +3 -3
  15. package/dist/spk/check-info.d.ts +5 -0
  16. package/dist/spk/check-info.js +123 -0
  17. package/dist/spk/check-metadata.d.ts +7 -0
  18. package/dist/spk/check-metadata.js +113 -0
  19. package/dist/spk/check-payload.d.ts +2 -0
  20. package/dist/spk/check-payload.js +156 -0
  21. package/dist/spk/dsm-spec.d.ts +42 -0
  22. package/dist/spk/dsm-spec.js +86 -0
  23. package/dist/spk/findings.d.ts +13 -0
  24. package/dist/spk/findings.js +8 -0
  25. package/dist/spk/inspect.d.ts +18 -0
  26. package/dist/spk/inspect.js +62 -0
  27. package/dist/spk/rules.d.ts +265 -0
  28. package/dist/spk/rules.js +53 -0
  29. package/dist/spk/validate-spk.d.ts +13 -0
  30. package/dist/spk/validate-spk.js +107 -0
  31. package/dist/standalone/arch-jobs.d.ts +11 -0
  32. package/dist/standalone/arch-jobs.js +54 -0
  33. package/dist/standalone/build.d.ts +11 -2
  34. package/dist/standalone/build.js +74 -31
  35. package/dist/standalone/daemon.d.ts +1 -1
  36. package/dist/standalone/daemon.js +2 -2
  37. package/dist/standalone/inspect-spk.d.ts +1 -0
  38. package/dist/standalone/inspect-spk.js +1 -1
  39. package/dist/standalone/manifest.js +10 -1
  40. package/dist/standalone/native-layout.d.ts +3 -0
  41. package/dist/standalone/native-layout.js +94 -33
  42. package/package.json +1 -1
  43. package/schema/synology-app.schema.json +9 -0
@@ -0,0 +1,11 @@
1
+ /** `--jobs`: how many architectures build at once; 1 keeps the sequential build. */
2
+ export declare function checkJobs(jobs?: number): number;
3
+ /**
4
+ * Builds each architecture, at most `jobs` at a time, and returns the results in
5
+ * `arches` order. After a failure no further architecture starts; running ones
6
+ * finish so they clean up. The first failure in `arches` order is rethrown with
7
+ * `[arch]` in front of its message.
8
+ */
9
+ export declare function forEachArch<A extends string, T>(arches: readonly A[], jobs: number, build: (arch: A) => Promise<T>): Promise<T[]>;
10
+ /** Builds every SPK in a private folder inside `outDir` and moves them into `outDir` only when all of them succeed. */
11
+ export declare function commitOutputs(outDir: string, build: (stage: string) => Promise<string[]>): Promise<string[]>;
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ /** `--jobs`: how many architectures build at once; 1 keeps the sequential build. */
4
+ export function checkJobs(jobs = 1) {
5
+ if (!Number.isSafeInteger(jobs) || jobs < 1) {
6
+ throw Object.assign(new Error(`[CLI_JOBS_INVALID] --jobs must be a positive integer, got ${jobs}.`), { code: "CLI_JOBS_INVALID" });
7
+ }
8
+ return jobs;
9
+ }
10
+ /**
11
+ * Builds each architecture, at most `jobs` at a time, and returns the results in
12
+ * `arches` order. After a failure no further architecture starts; running ones
13
+ * finish so they clean up. The first failure in `arches` order is rethrown with
14
+ * `[arch]` in front of its message.
15
+ */
16
+ export async function forEachArch(arches, jobs, build) {
17
+ const results = [];
18
+ const failures = [];
19
+ let next = 0;
20
+ const worker = async () => {
21
+ while (next < arches.length && !failures.length) {
22
+ const index = next++;
23
+ try {
24
+ results[index] = await build(arches[index]);
25
+ }
26
+ catch (error) {
27
+ failures.push({ index, error });
28
+ }
29
+ }
30
+ };
31
+ await Promise.all(Array.from({ length: Math.min(checkJobs(jobs), arches.length) }, worker));
32
+ const [first] = failures.sort((a, b) => a.index - b.index);
33
+ if (first) {
34
+ if (first.error instanceof Error)
35
+ first.error.message = `[${arches[first.index]}] ${first.error.message}`;
36
+ throw first.error;
37
+ }
38
+ return results;
39
+ }
40
+ /** Builds every SPK in a private folder inside `outDir` and moves them into `outDir` only when all of them succeed. */
41
+ export async function commitOutputs(outDir, build) {
42
+ mkdirSync(outDir, { recursive: true });
43
+ const stage = mkdtempSync(join(outDir, ".synopkgland-build-"));
44
+ try {
45
+ return (await build(stage)).map((staged) => {
46
+ const output = join(outDir, basename(staged));
47
+ renameSync(staged, output);
48
+ return output;
49
+ });
50
+ }
51
+ finally {
52
+ rmSync(stage, { recursive: true, force: true });
53
+ }
54
+ }
@@ -4,11 +4,14 @@ export interface CommandResult {
4
4
  stderr: string;
5
5
  /** The program itself was not found. */
6
6
  missing?: boolean;
7
+ /** Standard output, when the caller asked to capture it. */
8
+ stdout?: string;
7
9
  }
8
10
  export type CommandRunner = (command: string, args: string[], options: {
9
11
  cwd: string;
10
12
  env: Record<string, string | undefined>;
11
- }) => CommandResult;
13
+ capture?: boolean;
14
+ }) => CommandResult | Promise<CommandResult>;
12
15
  export interface StandaloneBuildOptions {
13
16
  root: string;
14
17
  /** Defaults to every arch in the manifest. */
@@ -17,10 +20,16 @@ export interface StandaloneBuildOptions {
17
20
  outDir?: string;
18
21
  /** INFO os_min_ver for every SPK (a release such as 7.1, or a full 7.x-build); defaults to the manifest's. */
19
22
  minDsm?: string;
23
+ /** Architectures built at once; defaults to 1. */
24
+ jobs?: number;
20
25
  run?: CommandRunner;
21
26
  }
22
- /** Argv only, never a shell; output streams to the terminal except stderr, which is also kept. */
27
+ /** Argv only, never a shell; output streams to the terminal except stderr, which is also kept, and captured stdout. */
23
28
  export declare const runCommand: CommandRunner;
29
+ /** `runCommand` for concurrent builds: every output line goes to stderr behind `prefix`, so architectures stay apart. */
30
+ export declare function prefixedRunner(prefix: string): CommandRunner;
31
+ /** The runner one architecture uses: the caller's, or a labelled one while several architectures share the terminal. */
32
+ export declare function runnerFor(run: CommandRunner | undefined, jobs: number, arch: string): CommandRunner;
24
33
  /** `build` for a Standalone Package: build the UI, compile the CGI, pack one SPK per arch. */
25
34
  export declare function buildStandalone(options: StandaloneBuildOptions): Promise<{
26
35
  outputs: string[];
@@ -1,5 +1,5 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { goBuildEnv } from "../build-spk/dsm-arch.js";
@@ -11,20 +11,61 @@ import { buildDaemonControl } from "./daemon.js";
11
11
  import { buildExecutor } from "../runtime/executor-build.js";
12
12
  import { BACKGROUND_WORKER_MAIN } from "./background.js";
13
13
  import { readApiPermissions } from "./api-permissions.js";
14
- /** Argv only, never a shell; output streams to the terminal except stderr, which is also kept. */
15
- export const runCommand = (command, args, options) => {
16
- const result = spawnSync(command, args, {
14
+ import { checkJobs, commitOutputs, forEachArch } from "./arch-jobs.js";
15
+ /** Argv only, never a shell; output streams to the terminal except stderr, which is also kept, and captured stdout. */
16
+ export const runCommand = (command, args, options) => new Promise((done) => {
17
+ const child = spawn(command, args, {
17
18
  cwd: options.cwd,
18
19
  env: options.env,
19
- stdio: ["ignore", process.stderr, "pipe"],
20
- encoding: "utf8",
20
+ stdio: ["ignore", options.capture ? "pipe" : process.stderr, "pipe"],
21
21
  });
22
- if (result.stderr) {
23
- process.stderr.write(result.stderr);
24
- }
25
- const missing = result.error?.code === "ENOENT";
26
- return { status: result.status, stderr: result.stderr ?? "", missing };
27
- };
22
+ let stderr = "";
23
+ let stdout = "";
24
+ child.stdout?.setEncoding("utf8");
25
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
26
+ child.stderr.setEncoding("utf8");
27
+ child.stderr.on("data", (chunk) => {
28
+ stderr += chunk;
29
+ process.stderr.write(chunk);
30
+ });
31
+ child.on("error", (error) => done({ status: null, stderr, missing: error.code === "ENOENT" }));
32
+ child.on("close", (status) => done({ status, stderr, missing: false, ...(options.capture ? { stdout } : {}) }));
33
+ });
34
+ /** `runCommand` for concurrent builds: every output line goes to stderr behind `prefix`, so architectures stay apart. */
35
+ export function prefixedRunner(prefix) {
36
+ return (command, args, options) => new Promise((done) => {
37
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
38
+ let stderr = "";
39
+ let stdout = "";
40
+ if (options.capture) {
41
+ child.stdout.setEncoding("utf8");
42
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
43
+ }
44
+ const relay = (stream, keep) => {
45
+ let partial = "";
46
+ stream.setEncoding("utf8");
47
+ stream.on("data", (chunk) => {
48
+ if (keep)
49
+ stderr += chunk;
50
+ const lines = (partial + chunk).split("\n");
51
+ partial = lines.pop();
52
+ for (const line of lines)
53
+ process.stderr.write(`${prefix}${line}\n`);
54
+ });
55
+ stream.on("end", () => { if (partial)
56
+ process.stderr.write(`${prefix}${partial}\n`); });
57
+ };
58
+ if (!options.capture)
59
+ relay(child.stdout, false);
60
+ relay(child.stderr, true);
61
+ child.on("error", (error) => done({ status: null, stderr, missing: error.code === "ENOENT" }));
62
+ child.on("close", (status) => done({ status, stderr, missing: false, ...(options.capture ? { stdout } : {}) }));
63
+ });
64
+ }
65
+ /** The runner one architecture uses: the caller's, or a labelled one while several architectures share the terminal. */
66
+ export function runnerFor(run, jobs, arch) {
67
+ return run ?? (jobs > 1 ? prefixedRunner(`[${arch}] `) : runCommand);
68
+ }
28
69
  /** `build` for a Standalone Package: build the UI, compile the CGI, pack one SPK per arch. */
29
70
  export async function buildStandalone(options) {
30
71
  const loaded = loadStandaloneManifest(options.root);
@@ -41,45 +82,47 @@ export async function buildStandalone(options) {
41
82
  if (manifest.portal && !isDsmAtLeast(manifest.os_min_ver, PORTAL_MIN_DSM)) {
42
83
  throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
43
84
  }
44
- const run = options.run ?? runCommand;
85
+ const jobs = checkJobs(options.jobs);
45
86
  const arches = options.arches ?? manifest.arches;
46
87
  for (const arch of arches) {
47
88
  if (!manifest.arches.includes(arch)) {
48
89
  throw new StandaloneBuildError("STANDALONE_ARCH_NOT_DECLARED", `${arch} 不在 dsm-package.yaml 的 arches 內;要建置它,請先把 ${arch} 加進 arches。`);
49
90
  }
50
91
  }
51
- buildUi(manifest, run);
92
+ await buildUi(manifest, options.run ?? runCommand);
52
93
  const outDir = options.outDir ?? join(options.root, "dist");
53
94
  const work = mkdtempSync(join(tmpdir(), "syno-standalone-build-"));
54
95
  try {
55
- const outputs = [];
56
- for (const arch of arches) {
57
- const executor = buildExecutor(manifest, arch, work, run);
96
+ const outputs = await commitOutputs(outDir, (stage) => forEachArch(arches, jobs, async (arch) => {
97
+ const run = runnerFor(options.run, jobs, arch);
98
+ const archWork = join(work, arch);
99
+ mkdirSync(archWork);
100
+ const executor = await buildExecutor(manifest, arch, archWork, run);
58
101
  const builtManifest = executor ? { ...manifest, executor } : manifest;
59
- const cgiPath = backendFor(builtManifest, arch, work, run);
60
- const problem = checkCgiBinary(manifest.backend.kind === "go" ? work : manifest.root, cgiPath, arch);
102
+ const cgiPath = await backendFor(builtManifest, arch, archWork, run);
103
+ const problem = checkCgiBinary(manifest.backend.kind === "go" ? archWork : manifest.root, cgiPath, arch);
61
104
  if (problem) {
62
105
  throw new StandaloneBuildError(problem.code, problem.message);
63
106
  }
64
- const outputPath = join(outDir, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
65
- const daemonControl = buildDaemonControl(manifest, arch, work, run);
66
- const backgroundWorker = buildBackgroundWorker(builtManifest, arch, work, run);
107
+ const outputPath = join(stage, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
108
+ const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
109
+ const backgroundWorker = await buildBackgroundWorker(builtManifest, arch, archWork, run);
67
110
  await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, backgroundWorker, executor });
68
- outputs.push(outputPath);
69
- }
111
+ return outputPath;
112
+ }));
70
113
  return { outputs };
71
114
  }
72
115
  finally {
73
116
  rmSync(work, { recursive: true, force: true });
74
117
  }
75
118
  }
76
- function buildUi(manifest, run) {
119
+ async function buildUi(manifest, run) {
77
120
  const build = manifest.ui.build;
78
121
  if (!build) {
79
122
  return;
80
123
  }
81
124
  const [command, ...args] = build.command;
82
- const result = run(command, args, { cwd: build.cwd, env: { ...process.env } });
125
+ const result = await run(command, args, { cwd: build.cwd, env: { ...process.env } });
83
126
  if (result.missing || result.status !== 0) {
84
127
  const installHint = command === "npm" && existsSync(join(build.cwd, "package.json")) && !existsSync(join(build.cwd, "node_modules"))
85
128
  ? `\nUI 依賴尚未安裝。請在 ${build.cwd} 執行 npm ${existsSync(join(build.cwd, "package-lock.json")) ? "ci" : "install"},然後重新執行 build。`
@@ -87,7 +130,7 @@ function buildUi(manifest, run) {
87
130
  throw new StandaloneBuildError("STANDALONE_UI_BUILD_FAILED", `UI build(${build.command.join(" ")})失敗。${installHint}\n${result.stderr}`);
88
131
  }
89
132
  }
90
- function backendFor(manifest, arch, work, run) {
133
+ async function backendFor(manifest, arch, work, run) {
91
134
  const backend = manifest.backend;
92
135
  if (backend.kind === "binary") {
93
136
  return backend.files[arch];
@@ -113,7 +156,7 @@ function backendFor(manifest, arch, work, run) {
113
156
  ...(manifest.entry ? [`-X main.entry=${manifest.entry}`] : []),
114
157
  ...(manifest.portal ? ["-X main.portal=on"] : []),
115
158
  ].join(" ");
116
- const result = run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, backend.main], {
159
+ const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, backend.main], {
117
160
  cwd: backend.dir,
118
161
  env: { ...process.env, ...goBuildEnv(arch) },
119
162
  });
@@ -126,14 +169,14 @@ function backendFor(manifest, arch, work, run) {
126
169
  return out;
127
170
  }
128
171
  /** The fixed worker is a second binary of the project's own Go module. */
129
- function buildBackgroundWorker(manifest, arch, work, run) {
172
+ async function buildBackgroundWorker(manifest, arch, work, run) {
130
173
  if (!manifest.capabilities.jobs || manifest.backend.kind !== "go")
131
174
  return undefined;
132
175
  const out = join(work, `background-worker-${arch}`);
133
176
  const ldflags = ["-s", "-w", "-buildid=", `-X main.packageID=${manifest.package}`, ...delegateBuildFlags(manifest),
134
177
  ...(manifest.executor ? [`-X main.executorDigest=${manifest.executor.executorDigest}`, `-X main.policyDigest=${manifest.executor.policyDigest}`] : []),
135
178
  ].join(" ");
136
- const result = run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, BACKGROUND_WORKER_MAIN], {
179
+ const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, BACKGROUND_WORKER_MAIN], {
137
180
  cwd: manifest.backend.dir,
138
181
  env: { ...process.env, ...goBuildEnv(arch) },
139
182
  });
@@ -20,5 +20,5 @@ export interface Daemon {
20
20
  }
21
21
  export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
22
22
  export declare function daemonConfig(manifest: StandaloneManifest): string;
23
- export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): string | undefined;
23
+ export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
24
24
  export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
@@ -55,11 +55,11 @@ export function daemonConfig(manifest) {
55
55
  const { exec, args, uninstall_args, env, timeouts } = manifest.daemon;
56
56
  return JSON.stringify({ package: manifest.package, exec, args, ...(uninstall_args ? { uninstall_args } : {}), env, timeouts }) + "\n";
57
57
  }
58
- export function buildDaemonControl(manifest, arch, work, run) {
58
+ export async function buildDaemonControl(manifest, arch, work, run) {
59
59
  if (!manifest.daemon)
60
60
  return undefined;
61
61
  const out = join(work, `daemon-control-${arch}`);
62
- const result = run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", "-s -w -buildid=", "-o", out, "."], {
62
+ const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", "-s -w -buildid=", "-o", out, "."], {
63
63
  cwd: join(templatesRoot(), "_daemon"), env: { ...process.env, ...pinnedGoBuildEnv(arch) },
64
64
  });
65
65
  if (result.missing || result.status !== 0)
@@ -40,3 +40,4 @@ export interface SpkInspection {
40
40
  export declare function inspectStandaloneSpk(spkPath: string): Promise<SpkInspection>;
41
41
  /** Auto-detect the generated runtime; retain strict native-go inspection when requested. */
42
42
  export declare function inspectSpk(spkPath: string, expectedKind?: SpkKind): Promise<SpkInspection>;
43
+ export declare function listPayload(packageTgz: string, findings: string[]): Promise<SpkInspection["payload"]>;
@@ -509,7 +509,7 @@ function checkUninstall(dir, findings) {
509
509
  findings.push(`UNINSTALL_WIZARD_NOT_HONOURED WIZARD_UIFILES/${box.name} 提供 ${box.key} 勾選框,但 scripts/postuninst 沒有讀它`);
510
510
  }
511
511
  }
512
- async function listPayload(packageTgz, findings) {
512
+ export async function listPayload(packageTgz, findings) {
513
513
  if (!existsSync(packageTgz)) {
514
514
  findings.push("缺少 package.tgz");
515
515
  return [];
@@ -13,6 +13,7 @@ import { readSharedFolder } from "./shared-folder.js";
13
13
  import { floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
14
14
  import { readPortalDeclaration } from "../portal/alias.js";
15
15
  import { renderPackageIcons } from "../build-spk/dsm-icons.js";
16
+ import { validateReproducible } from "../reproducible-config.js";
16
17
  /**
17
18
  * `dsm-package.yaml`: a Standalone Package, i.e. an unsigned native SPK that
18
19
  * runs as its own DSM package account with a CGI backend and a DSM window UI.
@@ -20,7 +21,7 @@ import { renderPackageIcons } from "../build-spk/dsm-icons.js";
20
21
  export const STANDALONE_MANIFEST = "dsm-package.yaml";
21
22
  export const KNOWN_FIELDS = new Set([
22
23
  "schema", "package", "version", "build", "displayname", "description",
23
- "metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "entry", "portal", "icon",
24
+ "metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "entry", "portal", "icon", "reproducible",
24
25
  ]);
25
26
  const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
26
27
  const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
@@ -87,6 +88,14 @@ function readManifest(raw, c) {
87
88
  c.fail("STANDALONE_FIELD_UNKNOWN", `不支援的欄位「${key}」。Standalone Package 不接受自訂 lifecycle scripts 或 DSM conf。`);
88
89
  }
89
90
  }
91
+ if (raw.reproducible !== undefined) {
92
+ try {
93
+ validateReproducible(raw.reproducible);
94
+ }
95
+ catch (error) {
96
+ c.fail("REPRODUCIBLE_INVALID", error.message);
97
+ }
98
+ }
90
99
  if (raw.schema !== 2) {
91
100
  c.fail("STANDALONE_SCHEMA_INVALID", "schema: 2 必填;請改成 schema: 2、backend.kind: go|binary,並將 privilege: root、background、fileStation 分別改為 capabilities.rootOps: true、capabilities.jobs: true、capabilities.files: session。");
92
101
  }
@@ -63,6 +63,8 @@ export declare function loadNativeLayout(root: string): {
63
63
  }[];
64
64
  };
65
65
  export declare function verifyToolkitArchive(path: string, expected: string): void;
66
+ /** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
67
+ export declare function toolkitCacheDir(env: Record<string, string | undefined>, platform: string, home: string): string;
66
68
  export declare function layoutFiles(root: string): Array<{
67
69
  path: string;
68
70
  mode: number;
@@ -76,6 +78,7 @@ export declare function buildNativeLayout(options: {
76
78
  outDir?: string;
77
79
  build?: number;
78
80
  minDsm?: string;
81
+ jobs?: number;
79
82
  run?: CommandRunner;
80
83
  }): Promise<{
81
84
  outputs: string[];
@@ -2,14 +2,20 @@ import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
2
2
  /** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
3
3
  import { createHash } from 'node:crypto';
4
4
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
5
+ import { homedir, tmpdir } from 'node:os';
6
6
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { parse } from 'yaml';
8
8
  import { createReproducibleTar } from '../build-spk/reproducible-archive.js';
9
- import { runCommand } from './build.js';
9
+ import { runCommand, runnerFor } from './build.js';
10
+ import { checkJobs, commitOutputs, forEachArch } from './arch-jobs.js';
11
+ import { validateReproducible } from '../reproducible-config.js';
10
12
  import { resolveMinDsm } from '../build-spk/dsm-version.js';
13
+ import { infoArchValue } from '../build-spk/dsm-arch.js';
14
+ import { LIFECYCLE_SCRIPTS, REPLACE_SCRIPTS } from '../spk/dsm-spec.js';
11
15
  import { renderPackageIcons, writePackageCenterIcons } from '../build-spk/dsm-icons.js';
12
16
  export const LAYOUT_ARCHES = { x86_64: [2, 62], aarch64: [2, 183], armv8: [2, 183], armv7: [1, 40] };
17
+ /** INFO arch: aarch64 is not an Appendix A value, so it lists the platforms as native-go and binary do. */
18
+ const layoutInfoArch = (arch) => arch === 'aarch64' ? infoArchValue('aarch64') : arch;
13
19
  function fail(message) { throw Object.assign(new Error(`NATIVE_LAYOUT_INVALID: ${message}`), { code: 'NATIVE_LAYOUT_INVALID' }); }
14
20
  const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
15
21
  function object(value, fields) {
@@ -84,7 +90,7 @@ export function isNativeLayout(root) {
84
90
  }
85
91
  export function loadNativeLayout(root) {
86
92
  try {
87
- const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon']);
93
+ const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible']);
88
94
  if (m.schema !== 2)
89
95
  fail('schema 2 required; migrate schema: 1 to schema: 2 and add backend: {kind: native}');
90
96
  const backend = object(m.backend, ['kind']);
@@ -105,6 +111,8 @@ export function loadNativeLayout(root) {
105
111
  fail('invalid architecture list');
106
112
  if (m.icon !== undefined)
107
113
  m.icon = icon(root, m.icon);
114
+ if (m.reproducible !== undefined)
115
+ validateReproducible(m.reproducible);
108
116
  const layout = object(m.layout, ['payload', 'metadata', 'binaries', 'anchors']);
109
117
  layout.payload = pathText(layout.payload);
110
118
  layout.metadata = pathText(layout.metadata);
@@ -142,11 +150,37 @@ export function verifyToolkitArchive(path, expected) {
142
150
  if (!lstatSync(path).isFile() || digest(readFileSync(path)) !== expected)
143
151
  fail('toolkit checksum mismatch');
144
152
  }
153
+ /** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
154
+ export function toolkitCacheDir(env, platform, home) {
155
+ if (env.SYNOPKGLAND_CACHE_DIR)
156
+ return resolve(env.SYNOPKGLAND_CACHE_DIR);
157
+ if (platform === 'darwin')
158
+ return join(home, 'Library', 'Caches', 'synopkgland');
159
+ const xdg = env.XDG_CACHE_HOME;
160
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.cache'), 'synopkgland');
161
+ }
162
+ // Concurrent architectures that pin the same archive share one download.
163
+ const downloads = new Map();
164
+ async function download(m, cache, input, archive, run) {
165
+ const tmp = mkdtempSync(join(cache, '.download-'));
166
+ try {
167
+ const out = join(tmp, 'archive');
168
+ const r = await run('curl', ['--fail', '--location', '--proto', '=https', '--proto-redir', '=https', '--retry', '2', '--retry-all-errors', '--connect-timeout', '20', '--max-time', '600', '--output', out, input.url], { cwd: m.root, env: { ...process.env } });
169
+ if (r.status !== 0)
170
+ fail('toolkit download failed');
171
+ // Rename is atomic, so another build process sees either no archive or a verified one.
172
+ verifyToolkitArchive(out, input.sha256);
173
+ renameSync(out, archive);
174
+ }
175
+ finally {
176
+ rmSync(tmp, { recursive: true, force: true });
177
+ }
178
+ }
145
179
  async function toolkitFor(m, arch, run) {
146
180
  const inputs = m.native.toolkits[arch] ?? [];
147
181
  if (!inputs.length)
148
182
  return '';
149
- const cache = resolve(process.env.SYNOPKGLAND_CACHE_DIR ?? join(m.root, '.synopkgland-cache'));
183
+ const cache = toolkitCacheDir(process.env, process.platform, homedir());
150
184
  mkdirSync(cache, { recursive: true, mode: 0o700 });
151
185
  if (lstatSync(cache).isSymbolicLink() || (lstatSync(cache).mode & 0o022))
152
186
  fail('toolkit cache must be owned and private');
@@ -154,18 +188,12 @@ async function toolkitFor(m, arch, run) {
154
188
  for (const input of inputs) {
155
189
  const archive = join(cache, `${input.sha256}.txz`);
156
190
  if (!existsSync(archive)) {
157
- const download = mkdtempSync(join(cache, '.download-'));
158
- try {
159
- const out = join(download, 'archive');
160
- const r = run('curl', ['--fail', '--location', '--proto', '=https', '--proto-redir', '=https', '--retry', '2', '--retry-all-errors', '--connect-timeout', '20', '--max-time', '600', '--output', out, input.url], { cwd: m.root, env: { ...process.env } });
161
- if (r.status !== 0)
162
- fail('toolkit download failed');
163
- verifyToolkitArchive(out, input.sha256);
164
- renameSync(out, archive);
165
- }
166
- finally {
167
- rmSync(download, { recursive: true, force: true });
191
+ let pending = downloads.get(archive);
192
+ if (!pending) {
193
+ pending = download(m, cache, input, archive, run).finally(() => downloads.delete(archive));
194
+ downloads.set(archive, pending);
168
195
  }
196
+ await pending;
169
197
  }
170
198
  verifyToolkitArchive(archive, input.sha256);
171
199
  archives.push(archive);
@@ -173,8 +201,11 @@ async function toolkitFor(m, arch, run) {
173
201
  // Re-extract verified bytes: a package-writable marker never authenticates an extracted tree.
174
202
  const stage = mkdtempSync(join(cache, `toolkit-${arch}-`));
175
203
  try {
204
+ const flavor = await tarFlavor(m, run);
176
205
  for (const archive of archives) {
177
- const r = run('tar', ['-xJf', archive, '-C', stage, '--keep-directory-symlink'], { cwd: m.root, env: { ...process.env } });
206
+ if (flavor === 'bsdtar')
207
+ await refuseEscapingMembers(m, archive, run);
208
+ const r = await run('tar', flavor === 'gnu' ? ['-xJf', archive, '-C', stage, '--keep-directory-symlink'] : ['-xPJf', archive, '-C', stage], { cwd: m.root, env: { ...process.env } });
178
209
  if (r.status !== 0)
179
210
  fail('toolkit extraction failed');
180
211
  }
@@ -185,9 +216,33 @@ async function toolkitFor(m, arch, run) {
185
216
  throw e;
186
217
  }
187
218
  }
188
- function runHook(m, h, env, run) {
219
+ /**
220
+ * A later toolkit archive may write through a directory symlink an earlier one
221
+ * created. GNU tar keeps such a symlink only with --keep-directory-symlink;
222
+ * bsdtar only with -P, which also allows absolute and .. members, so those are
223
+ * refused from the listing first.
224
+ */
225
+ async function tarFlavor(m, run) {
226
+ const r = await run('tar', ['--version'], { cwd: m.root, env: { ...process.env }, capture: true });
227
+ const version = r.status === 0 ? r.stdout ?? '' : '';
228
+ if (/\bGNU tar\b/.test(version))
229
+ return 'gnu';
230
+ if (/\bbsdtar\b/.test(version))
231
+ return 'bsdtar';
232
+ throw Object.assign(new Error(`NATIVE_TOOLKIT_TAR_UNSUPPORTED: extracting native.toolkits needs GNU tar or bsdtar as \`tar\`; found ${r.missing ? 'no tar' : JSON.stringify(version.split('\n')[0] ?? '')}`), { code: 'NATIVE_TOOLKIT_TAR_UNSUPPORTED' });
233
+ }
234
+ async function refuseEscapingMembers(m, archive, run) {
235
+ const r = await run('tar', ['-tJf', archive], { cwd: m.root, env: { ...process.env }, capture: true });
236
+ if (r.status !== 0)
237
+ fail('toolkit extraction failed');
238
+ for (const member of (r.stdout ?? '').split('\n').filter(Boolean)) {
239
+ if (member.startsWith('/') || member.split('/').includes('..'))
240
+ fail(`toolkit member escapes the toolkit folder: ${member}`);
241
+ }
242
+ }
243
+ async function runHook(m, h, env, run) {
189
244
  const [cmd, ...args] = h.command;
190
- const r = run(cmd, args, { cwd: inside(m.root, h.cwd), env: { ...process.env, ...h.env, ...env } });
245
+ const r = await run(cmd, args, { cwd: inside(m.root, h.cwd), env: { ...process.env, ...h.env, ...env } });
191
246
  if (r.status !== 0 || r.missing)
192
247
  fail(`native hook failed: ${r.stderr}`);
193
248
  }
@@ -255,6 +310,11 @@ export function checkLayoutMetadata(root, inspection = false, packageId) {
255
310
  fail('native layout requires package-only lifecycle privilege');
256
311
  if (!existsSync(join(root, 'scripts/start-stop-status')))
257
312
  fail('start-stop-status missing');
313
+ for (const name of [...LIFECYCLE_SCRIPTS, ...REPLACE_SCRIPTS]) {
314
+ const path = join(root, 'scripts', name);
315
+ if (existsSync(path) && !(lstatSync(path).mode & 0o111))
316
+ throw Object.assign(new Error(`NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE: scripts/${name} is not executable (mode ${(lstatSync(path).mode & 0o777).toString(8)}); DSM runs it directly and inspect reports SPK_SCRIPT_NOT_EXECUTABLE. Run chmod +x on it in the layout metadata folder and build again.`), { code: 'NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE' });
317
+ }
258
318
  if (existsSync(join(root, 'conf/resource'))) {
259
319
  const resource = object(JSON.parse(readFileSync(join(root, 'conf/resource'), 'utf8')), ['systemd-unit']);
260
320
  if (resource['systemd-unit'] !== undefined && Object.keys(object(resource['systemd-unit'], [])).length)
@@ -307,18 +367,20 @@ export async function buildNativeLayout(options) {
307
367
  if (!arches.length || new Set(arches).size !== arches.length || arches.some(a => !m.arches.includes(a)))
308
368
  fail('architecture not declared');
309
369
  const icons = m.icon === undefined ? undefined : renderPackageIcons(inside(m.root, m.icon));
310
- const run = options.run ?? runCommand;
370
+ const jobs = checkJobs(options.jobs);
371
+ if (jobs > 1 && !m.layout.payload.includes('{arch}'))
372
+ fail('--jobs above 1 needs a layout.payload path with {arch}, so architectures do not overwrite one payload folder');
311
373
  const version = `${m.version}-${String(m.build).padStart(4, '0')}`;
312
374
  const env = { SYNOPKGLAND_PACKAGE: m.package, SYNOPKGLAND_VERSION: m.version, SYNOPKGLAND_BUILD: String(m.build), SYNOPKGLAND_SPK_VERSION: version, SYNOPKGLAND_PROJECT_DIR: m.root };
313
375
  if (m.native.prepare)
314
- runHook(m, m.native.prepare, env, run);
315
- const outputs = [];
316
- for (const requested of arches) {
317
- const arch = requested;
376
+ await runHook(m, m.native.prepare, env, options.run ?? runCommand);
377
+ const outputs = await commitOutputs(options.outDir ?? join(m.root, 'dist'), (outDir) => forEachArch(arches, jobs, async (arch) => {
378
+ const run = runnerFor(options.run, jobs, arch);
318
379
  const toolkit = await toolkitFor(m, arch, run);
319
380
  const stage = mkdtempSync(join(tmpdir(), 'synopkgland-layout-'));
381
+ const work = mkdtempSync(join(tmpdir(), 'synopkgland-work-'));
320
382
  try {
321
- runHook(m, m.native.hook, { ...env, SYNOPKGLAND_ARCH: arch, SYNOPKGLAND_TOOLKIT_DIR: toolkit }, run);
383
+ await runHook(m, m.native.hook, { ...env, SYNOPKGLAND_ARCH: arch, SYNOPKGLAND_TOOLKIT_DIR: toolkit, SYNOPKGLAND_WORK_DIR: work }, run);
322
384
  const payload = inside(m.root, m.layout.payload.replaceAll('{arch}', arch));
323
385
  const metadata = inside(m.root, m.layout.metadata);
324
386
  checkLayoutMetadata(metadata, false, m.package);
@@ -339,23 +401,22 @@ export async function buildNativeLayout(options) {
339
401
  const files = copyTree(payload, target);
340
402
  await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
341
403
  rmSync(target, { recursive: true });
342
- const fields = { package: m.package, version, arch, os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
404
+ const fields = { package: m.package, version, arch: layoutInfoArch(arch), os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
343
405
  const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files)];
344
406
  if (new Set(info.map(([k]) => k)).size !== info.length)
345
407
  fail('INFO anchor collides with an INFO field');
346
408
  writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
347
409
  writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}) }));
348
- const output = join(options.outDir ?? join(m.root, 'dist'), `${m.package}-${version}-${arch}.spk`);
349
- mkdirSync(dirname(output), { recursive: true });
410
+ const output = join(outDir, `${m.package}-${version}-${arch}.spk`);
350
411
  await createReproducibleTar(stage, output);
351
- outputs.push(output);
412
+ return output;
352
413
  }
353
414
  finally {
354
- rmSync(stage, { recursive: true, force: true });
355
- if (toolkit)
356
- rmSync(toolkit, { recursive: true, force: true });
415
+ for (const dir of [stage, work, toolkit])
416
+ if (dir)
417
+ rmSync(dir, { recursive: true, force: true });
357
418
  }
358
- }
419
+ }));
359
420
  return { outputs };
360
421
  }
361
422
  export function inspectNativeLayout(dir, payload, info) {
@@ -363,7 +424,7 @@ export function inspectNativeLayout(dir, payload, info) {
363
424
  if (!/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package ?? '') || !/^\d+(\.\d+){0,3}-\d+$/.test(info.version ?? '') || !/^7\.\d+(\.\d+)?-\d+$/.test(info.os_min_ver ?? ''))
364
425
  fail('invalid native INFO identity/version/DSM');
365
426
  const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors']);
366
- if (inv.format !== 1 || inv.arch !== info.arch || !Object.hasOwn(LAYOUT_ARCHES, inv.arch))
427
+ if (inv.format !== 1 || !Object.hasOwn(LAYOUT_ARCHES, inv.arch) || layoutInfoArch(inv.arch) !== info.arch)
367
428
  fail('invalid native layout inventory');
368
429
  checkLayoutMetadata(dir, true, info.package);
369
430
  const actual = layoutFiles(payload);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,15 @@
43
43
  "type": "string",
44
44
  "minLength": 1
45
45
  },
46
+ "reproducible": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "description": "build --verify-reproducible settings: dependency files for the fingerprint (inputs) and paths removed before the second build (clean, may use {arch}). Paths are relative to the project.",
50
+ "properties": {
51
+ "inputs": { "type": "array", "maxItems": 64, "items": { "type": "string", "minLength": 1 } },
52
+ "clean": { "type": "array", "maxItems": 64, "items": { "type": "string", "minLength": 1 } }
53
+ }
54
+ },
46
55
  "icon": {
47
56
  "type": "string",
48
57
  "minLength": 1,