@synopackageland/cli 0.19.0 → 0.19.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.1 (2026-10-20)
4
+
5
+ - Record SDK 0.19.1 executor and lifecycle digests for the executor fix that accepts `deadline_ms` on delegated `webapi.call`; SDK 0.19.0 digests remain. Executor Apps rebuilt with SDK 0.19.1 require one administrator reapproval after upgrading.
6
+ - Compare upgrade approval requirements with a baseline SPK: `build --baseline` and `inspect --baseline` report root program digests, policy differences, task template changes and executor-only handoff eligibility.
7
+ - Retain templates 0.19.0 (SDK 0.19.0) and UI 0.12.5.
8
+
3
9
  ## 0.19.0 (2026-10-20)
4
10
 
5
11
  - Align the CLI SDK minor line and templates at 0.19.0; retain UI 0.12.5.
package/README.md CHANGED
@@ -119,6 +119,19 @@ window's approval waits for it, while a status read answers `executor_starting`
119
119
  SDK's panels check again on their own. The program cannot declare `run` or `job`, and
120
120
  declaring it changes the policy digest, so an administrator approves the App again.
121
121
 
122
+ Before releasing an executor App, compare it with the previous released SPK of
123
+ that package and architecture. Include the resulting summary in release notes:
124
+
125
+ ```sh
126
+ synopkgland build --arch x86_64 --baseline previous.spk --json
127
+ synopkgland inspect current.spk --baseline previous.spk
128
+ ```
129
+
130
+ The summary covers root program digests, permission/parameter differences, task
131
+ template changes and executor-only handover eligibility. Without a comparable
132
+ baseline, approval retention is unknown. Eligibility still requires live protected
133
+ runtime and exact DSM task readback. See [the approval guide](skills/reference/approval-upgrades.md).
134
+
122
135
  ## Commands
123
136
 
124
137
  | Command | Purpose |
package/dist/cli.js CHANGED
@@ -33,6 +33,7 @@ import { buildNativeLayout, isNativeLayout, loadNativeLayout, LAYOUT_ARCHES, } f
33
33
  import { buildStandalone, runnerFor } from "./standalone/build.js";
34
34
  import { checkJobs, commitOutputs, forEachArch, } from "./standalone/arch-jobs.js";
35
35
  import { checkReproducible } from "./reproducible.js";
36
+ import { spkApprovalDiff, formatApprovalDiff } from "./runtime/approval-diff.js";
36
37
  import { inspectAnySpk } from "./spk/inspect.js";
37
38
  function failure(code, message, hint = "Fix the configuration and retry; use --help to see command usage.") {
38
39
  return Object.assign(new Error(codedMessage(code, message)), { code, hint });
@@ -389,16 +390,18 @@ export async function runCli(argv) {
389
390
  });
390
391
  program
391
392
  .command("inspect <spk>")
393
+ .option("--baseline <spk>", "Compare approval requirements with the previous SPK")
392
394
  .description("Inspect any SPK against the DSM 7 package format without installing; for a synopkgland-built SPK also check the build contract and root privilege requirements")
393
395
  .option("--json", "Print a single JSON object")
394
- .action(async (input) => {
396
+ .action(async (input, options) => {
395
397
  const spk = resolve(input);
396
398
  if (existsSync(spk) && statSync(spk).isDirectory())
397
399
  throw failure("SPK_INSPECTION_FAILED", `${spk} is a directory; inspect checks .spk files.`, "Use synopkgland check to inspect project settings.");
398
400
  const report = await inspectAnySpk(spk);
399
401
  if (!report.dsm.ok || report.findings.length)
400
402
  throw Object.assign(failure("SPK_INSPECTION_FAILED", formatSpkChecks(report).join("\n"), "Fix the SPK by error code and repackage; see reference/errors for the codes; do not install an SPK that fails inspection."), { report: { spk, ...report } });
401
- emit("inspect", { spk, ...report }, formatSpkReport(spk, report));
403
+ const approval = await spkApprovalDiff(spk, options.baseline && resolve(options.baseline), report);
404
+ emit("inspect", { spk, ...report, approval }, formatSpkReport(spk, report) + "\n" + formatApprovalDiff(approval));
402
405
  });
403
406
  const images = program.command("images").description("Container image tools");
404
407
  images
@@ -624,6 +627,7 @@ export async function runCli(argv) {
624
627
  skill.command("show <id>").action((id) => stdout(readSkill(id)));
625
628
  program
626
629
  .command("build")
630
+ .option("--baseline <spk>", "Compare approval requirements with the previous SPK (one matching architecture)")
627
631
  .description("Build an SPK from synology-app.yaml or dsm-package.yaml")
628
632
  .option("--json", "Print a single JSON object")
629
633
  .option("-o, --output <path>", "Output SPK; use with a single --arch")
@@ -684,13 +688,15 @@ export async function runCli(argv) {
684
688
  outDir: undefined,
685
689
  }),
686
690
  });
691
+ const approval = await Promise.all(outputs.map(async (output) => ({ output, ...await spkApprovalDiff(output, options.baseline && resolve(options.baseline)) })));
687
692
  const lines = [
693
+ ...approval.map(item => item.output + "\n" + formatApprovalDiff(item)),
688
694
  ...outputs.map((output) => `Built ${output}`),
689
695
  ...(reproducible ? reproducibleLines(reproducible) : []),
690
696
  ];
691
697
  if (reproducible && !reproducible.identical)
692
698
  throw Object.assign(failure("BUILD_NOT_REPRODUCIBLE", lines.join("\n"), "Find the payload files that differ between builds (timestamps, random paths, unpinned dependencies) and retry after fixing them."), { report: { outputs, reproducible } });
693
- emit("build", { outputs, ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
699
+ emit("build", { outputs, approval, ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
694
700
  });
695
701
  if (json)
696
702
  console.log = (...args) => console.error(...args);
@@ -11,6 +11,7 @@ export const ERROR_REGISTRY = {
11
11
  CTL_DESCRIBE_FAILED: { "code": "CTL_DESCRIBE_FAILED", "description": "The backend registry export failed.", "suggestion": "Check the backend --describe output and build diagnostics." },
12
12
  CTL_COMMAND_UNREGISTERED: { "code": "CTL_COMMAND_UNREGISTERED", "description": "A command references an unregistered backend method.", "suggestion": "Correct ctl.commands bindings or register the backend method." },
13
13
  CTL_DESCRIPTOR_INVALID: { "code": "CTL_DESCRIPTOR_INVALID", "description": "The generated API description violates the public contract.", "suggestion": "Correct ctl.commands and rebuild the private synopkgland-api.json." },
14
+ APPROVAL_BASELINE_INVALID: { code: "APPROVAL_BASELINE_INVALID", description: "The --baseline SPK failed inspection, so approval retention cannot be compared.", suggestion: "Pass the previous released SPK of the same package and architecture, unmodified; inspect it to see each finding." },
14
15
  CTL_DECLARATION_INVALID: { "code": "CTL_DECLARATION_INVALID", "description": "The ctl declaration is invalid.", "suggestion": "Check ctl.enabled, aliases and explicit commands in dsm-package.yaml." },
15
16
  ...{
16
17
  LAUNCHER_PORTAL_MISMATCH: {
@@ -0,0 +1,37 @@
1
+ import type { ExecutorPolicy } from './executor-policy.js';
2
+ import { type SpkReport } from '../spk/inspect.js';
3
+ export interface ApprovalInventory {
4
+ package: string;
5
+ arch: string;
6
+ executorDigest: string;
7
+ policyDigest: string;
8
+ anchored: boolean;
9
+ policy: ExecutorPolicy;
10
+ tasks: Array<{
11
+ kind: string;
12
+ name: string;
13
+ script: string;
14
+ }>;
15
+ }
16
+ export interface ApprovalDifference {
17
+ retention: 'unknown' | 'eligible' | 'required';
18
+ permissions: 'unknown' | 'unchanged' | 'expanded' | 'review-required';
19
+ executor_only: boolean;
20
+ task_template_changed: boolean | null;
21
+ old_policy_digest?: string;
22
+ new_policy_digest?: string;
23
+ root_programs: Array<{
24
+ name: string;
25
+ old: string | null;
26
+ new: string | null;
27
+ }>;
28
+ policy_changes: Array<{
29
+ path: string;
30
+ old: unknown;
31
+ new: unknown;
32
+ }>;
33
+ reason: string;
34
+ }
35
+ export declare function approvalDiff(old: ApprovalInventory | undefined, next: ApprovalInventory | undefined): ApprovalDifference;
36
+ export declare function spkApprovalDiff(current: string, baseline?: string, report?: SpkReport): Promise<ApprovalDifference>;
37
+ export declare function formatApprovalDiff(d: ApprovalDifference): string;
@@ -0,0 +1,56 @@
1
+ import { inspectAnySpk } from '../spk/inspect.js';
2
+ function canonical(value) {
3
+ if (Array.isArray(value))
4
+ return '[' + value.map(canonical).join(',') + ']';
5
+ if (value && typeof value === 'object')
6
+ return '{' + Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => JSON.stringify(k) + ':' + canonical(v)).join(',') + '}';
7
+ return JSON.stringify(value) ?? 'null';
8
+ }
9
+ function authority(policy) {
10
+ const p = structuredClone(policy);
11
+ p.root = Object.fromEntries(Object.entries(policy.root).map(([name, entry]) => [name, typeof entry === 'string' ? {} : Object.fromEntries(Object.entries(entry).filter(([key]) => key !== 'digest'))]));
12
+ return p;
13
+ }
14
+ function changes(a, b, path = '', out = []) {
15
+ if (canonical(a) === canonical(b))
16
+ return out;
17
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
18
+ for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort())
19
+ changes(a[key], b[key], path + '/' + key.replaceAll('~', '~0').replaceAll('/', '~1'), out);
20
+ }
21
+ else
22
+ out.push({ path, old: a ?? null, new: b ?? null });
23
+ return out;
24
+ }
25
+ const rootDigests = (p) => Object.fromEntries(Object.entries(p.root).map(([name, e]) => [name, typeof e === 'string' ? e : e.digest]));
26
+ export function approvalDiff(old, next) {
27
+ const unknown = { retention: 'unknown', permissions: 'unknown', executor_only: false, task_template_changed: null, root_programs: [], policy_changes: [], reason: 'Cannot determine whether approval can be retained: a valid, matching baseline SPK and executor inventory are required.' };
28
+ if (!old || !next || old.package !== next.package || old.arch !== next.arch)
29
+ return unknown;
30
+ const a = rootDigests(old.policy), b = rootDigests(next.policy);
31
+ const root_programs = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort().filter(name => a[name] !== b[name]).map(name => ({ name, old: a[name] ?? null, new: b[name] ?? null }));
32
+ const policy_changes = changes(authority(old.policy), authority(next.policy));
33
+ const expanded = next.policy.operations.some(op => !old.policy.operations.includes(op)) || Object.keys(next.policy.root).some(name => !(name in old.policy.root));
34
+ const templates = (i) => i.tasks.map(t => ({ kind: t.kind, name: t.name, script: t.script.replaceAll(i.policyDigest, '@POLICY_DIGEST@') })).sort((a, b) => a.kind < b.kind ? -1 : 1);
35
+ const task_template_changed = canonical(templates(old)) !== canonical(templates(next));
36
+ const eligible = old.policyDigest === next.policyDigest && !task_template_changed && (old.executorDigest === next.executorDigest || next.anchored);
37
+ return { retention: eligible ? 'eligible' : 'required', permissions: expanded ? 'expanded' : policy_changes.length ? 'review-required' : 'unchanged', executor_only: eligible && old.executorDigest !== next.executorDigest, task_template_changed, old_policy_digest: old.policyDigest, new_policy_digest: next.policyDigest, root_programs, policy_changes, reason: eligible ? 'Approval can be retained if the installed protected runtime and exact DSM task readback still match this baseline.' : 'Administrator reapproval is required for the changed payload, policy, executor anchor or task definitions.' };
38
+ }
39
+ function inventory(r) {
40
+ if (!r.dsm.ok || r.findings.length || !r.executor || !r.package || !r.arch)
41
+ return;
42
+ return { package: r.package, arch: r.arch, ...r.executor, anchored: true }; // inspect verifies the INFO anchor and every root payload digest.
43
+ }
44
+ export async function spkApprovalDiff(current, baseline, report) {
45
+ if (!baseline)
46
+ return approvalDiff(undefined, undefined);
47
+ const next = inventory(report ?? await inspectAnySpk(current));
48
+ const old = await inspectAnySpk(baseline);
49
+ if (!old.dsm.ok || old.findings.length)
50
+ throw new Error('[APPROVAL_BASELINE_INVALID] Baseline SPK failed inspection: ' + [...old.findings, ...old.dsm.errors.map(e => e.message)].join('; '));
51
+ return approvalDiff(inventory(old), next);
52
+ }
53
+ export function formatApprovalDiff(d) {
54
+ const short = (s) => s?.slice(0, 12) ?? '—';
55
+ return ['Approval: ' + d.reason, 'Permissions: ' + d.permissions, ...d.root_programs.map(p => `Root program ${p.name}: ${short(p.old)} → ${short(p.new)}`), ...d.policy_changes.map(c => `Policy ${c.path}: ${JSON.stringify(c.old)} → ${JSON.stringify(c.new)}`), `Task template: ${d.task_template_changed === null ? 'unknown' : d.task_template_changed ? 'changed' : 'unchanged'}`, `Executor-only handover: ${d.executor_only ? 'eligible under the INFO anchor' : 'not established'}`].join('\n');
56
+ }
@@ -9,5 +9,16 @@
9
9
  "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
10
10
  },
11
11
  "goToolchain": "go1.26.9"
12
+ },
13
+ "0.19.1": {
14
+ "x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa",
15
+ "aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191",
16
+ "armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc",
17
+ "lifecycle": {
18
+ "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972",
19
+ "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb",
20
+ "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
21
+ },
22
+ "goToolchain": "go1.26.9"
12
23
  }
13
24
  }
@@ -359,9 +359,14 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
359
359
  // here, and that first status serves the health check. Approval and
360
360
  // control read back on their own.
361
361
  function checked(status) { return pinned(status) && !!status.tasks && status.tasks.state === "ready"; }
362
+ function approvalExplanation(verified, status) {
363
+ var diff = status && status.approval;
364
+ if (verified.state === "needs-admin-approval" && diff && options.pins && diff.package === options.pins.package && diff.new_policy_digest === options.pins.policyDigest && diff.new_executor_digest === options.pins.executorDigest && (diff.old_policy_digest !== diff.new_policy_digest || diff.task_template_changed)) verified.approval = diff;
365
+ return verified;
366
+ }
362
367
  function adminStatus() {
363
368
  var asked = options.call("executor.status", {});
364
- function verify() { return read().then(function (verified) { return health(verified, 0, asked); }); }
369
+ function verify() { return read().then(function (verified) { return health(verified, 0, asked); }).then(function (verified) { return asked.then(function (status) { return approvalExplanation(verified, status); }, function () { return verified; }); }); }
365
370
  return asked.then(function (status) {
366
371
  return checked(status) ? {state: "ready", tasks: {boot: {state: "ready"}, control: {state: "ready"}}, executor: status} : verify();
367
372
  }, verify);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -5,6 +5,8 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
+ Before releasing an executor App, read [reference/approval-upgrades.md](reference/approval-upgrades.md). Run build/inspect with the previous released SPK as `--baseline` and include the approval difference in release notes. Without a comparable baseline, retention is unknown.
9
+
8
10
  Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
9
11
 
10
12
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
@@ -0,0 +1,36 @@
1
+ # Approval changes before release
2
+
3
+ Run the current CLI against the previous released SPK for the same package and
4
+ architecture before releasing an executor App:
5
+
6
+ ```sh
7
+ synopkgland build --arch x86_64 --baseline previous-x86_64.spk --json
8
+ synopkgland inspect current-x86_64.spk --baseline previous-x86_64.spk
9
+ synopkgland inspect current-x86_64.spk --baseline previous-x86_64.spk --json
10
+ ```
11
+
12
+ `inspect` returns `data.approval`; `build` returns one entry per output in
13
+ `data.approval`. Put the human summary in release notes. Optionally copy that
14
+ same summary into the manifest's `metadata.changelog`, which the existing build writes to INFO for Package Center.
15
+ Do not assume DSM automatically opens an App after upgrading it.
16
+
17
+ The summary names root programs and their old/new SHA-256 prefixes, policy
18
+ permission/parameter paths, and task template changes. Task template comparison
19
+ removes only the policy pin; a policy pin change still requires approval.
20
+ `unchanged` means the policy excluding root digests is identical. Added root
21
+ programs or operations are `expanded`; other permission/parameter changes are
22
+ `review-required`, because arbitrary schema and scope changes cannot generally
23
+ be proven to narrow permissions. Program bytes can change behavior even when
24
+ the declared policy is unchanged.
25
+
26
+ `eligible` is conditional: the live protected runtime must still match the
27
+ baseline and DSM must read back the exact approved tasks. An executor-only
28
+ handover additionally needs the new executor's valid INFO anchor. This offline
29
+ summary does not grant approval. With no baseline, an incompatible package or
30
+ architecture, or no executor inventory, retention is `unknown`; never promise
31
+ that approval will be retained. Invalid baselines fail inspection.
32
+
33
+ The SDK approval screen reads root-produced diagnostics through executor.status,
34
+ then uses the existing PasswordConfirm and exact task readback. Older protected
35
+ runtimes without diagnostic support show an explicit unknown explanation. No App
36
+ option supplies an approved baseline or permission claim.