@synopackageland/cli 0.10.2 → 0.10.3

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 (66) hide show
  1. package/CHANGELOG.md +215 -1696
  2. package/README.md +21 -2
  3. package/assets/login-style/mask.png +0 -0
  4. package/assets/login-style/wallpaper-thumbnail.jpg +0 -0
  5. package/assets/login-style/wallpaper.jpg +0 -0
  6. package/dist/build-spk/generator.js +10 -1
  7. package/dist/build-spk/login-style.d.ts +55 -0
  8. package/dist/build-spk/login-style.js +115 -0
  9. package/dist/cli.js +22 -1
  10. package/dist/error-registry.js +37 -1
  11. package/dist/host/dsm-client.js +6 -9
  12. package/dist/init.js +9 -5
  13. package/dist/runtime/delegate-contract.d.ts +2 -92
  14. package/dist/runtime/delegate-contract.js +2 -24
  15. package/dist/runtime/executor-digests.json +11 -0
  16. package/dist/runtime/executor-policy-check.d.ts +1 -1
  17. package/dist/runtime/executor-policy-check.js +5 -7
  18. package/dist/runtime/executor-policy.d.ts +2 -0
  19. package/dist/runtime/executor-policy.js +5 -1
  20. package/dist/runtime/root-programs.d.ts +2 -0
  21. package/dist/runtime/sdk-source.d.ts +4 -0
  22. package/dist/runtime/sdk-source.js +7 -0
  23. package/dist/runtime/sdk-timeouts.d.ts +23 -0
  24. package/dist/runtime/sdk-timeouts.js +44 -0
  25. package/dist/skill.js +1 -0
  26. package/dist/standalone/app-scripts.d.ts +26 -0
  27. package/dist/standalone/app-scripts.js +141 -0
  28. package/dist/standalone/bridge-app.d.ts +2 -0
  29. package/dist/standalone/bridge-app.js +9 -1
  30. package/dist/standalone/bridge-backend.d.ts +1 -1
  31. package/dist/standalone/build.js +5 -2
  32. package/dist/standalone/dsm-window.d.ts +14 -3
  33. package/dist/standalone/dsm-window.js +24 -11
  34. package/dist/standalone/host-adapter.d.ts +1 -1
  35. package/dist/standalone/host-adapter.js +1 -1
  36. package/dist/standalone/host-file-navigation.d.ts +6 -2
  37. package/dist/standalone/host-file-navigation.js +8 -2
  38. package/dist/standalone/inspect-spk.d.ts +9 -0
  39. package/dist/standalone/inspect-spk.js +60 -6
  40. package/dist/standalone/lifecycle.js +3 -1
  41. package/dist/standalone/manifest.d.ts +6 -0
  42. package/dist/standalone/manifest.js +51 -3
  43. package/dist/standalone/portal-window.d.ts +6 -6
  44. package/dist/standalone/portal-window.js +12 -32
  45. package/dist/standalone/request-timeout.d.ts +6 -3
  46. package/dist/standalone/request-timeout.js +10 -4
  47. package/dist/standalone/root-ops.d.ts +3 -2
  48. package/dist/standalone/root-ops.js +9 -3
  49. package/dist/standalone/root-window.d.ts +2 -2
  50. package/dist/standalone/root-window.js +173 -49
  51. package/dist/standalone/sdk-check.js +4 -1
  52. package/dist/standalone/spk.d.ts +3 -0
  53. package/dist/standalone/spk.js +10 -3
  54. package/dist/validate.js +2 -0
  55. package/package.json +2 -3
  56. package/skills/SKILL.md +3 -1
  57. package/skills/reference/create.md +5 -1
  58. package/skills/reference/identity.md +1 -1
  59. package/skills/reference/lifecycle.md +110 -0
  60. package/skills/reference/portal.md +12 -0
  61. package/dist/authorization-sdk.d.ts +0 -6
  62. package/dist/authorization-sdk.js +0 -6
  63. package/dist/standalone/native-authorization-window.d.ts +0 -2
  64. package/dist/standalone/native-authorization-window.js +0 -36
  65. package/dist/standalone/native-root-window.d.ts +0 -29
  66. package/dist/standalone/native-root-window.js +0 -307
package/README.md CHANGED
@@ -52,7 +52,8 @@ The CLI packages, deploys and checks. The app-side code is the SDK
52
52
  `github.com/synopackageland/syno-pkg-land-sdk` and the npm package `@synopackageland/sdk` (Vue components at
53
53
  `@synopackageland/sdk/vue`). The `require github.com/synopackageland/syno-pkg-land-sdk` line in
54
54
  `backend/go.mod` is the app's only SDK version; `build` compiles the executor of that version
55
- and checks it against the digests shipped in this CLI. CLI 0.9.x builds SDK 0.9.x.
55
+ and checks it against the digests shipped in this CLI. A CLI builds the SDK releases of its own minor version
56
+ (CLI X.Y.z builds SDK X.Y.*) that its digest table records; `EXECUTOR_DIGEST_MISMATCH` names the ones it knows.
56
57
 
57
58
  The SDK repositories are private for now, so Go cannot fetch them from proxy.golang.org. Set
58
59
  `go env -w GOPRIVATE=github.com/synopackageland/*` and give git a credential that can read
@@ -77,11 +78,29 @@ Add them with `init --with …` or later with `synopkgland add <capability…>`.
77
78
  | `login` | Sign users in with their DSM account, without the DSM desktop | Nobody; requires HTTPS for password sign-in |
78
79
  | `delegate` | Move files into a user's chosen folder while the user is offline | An administrator, once |
79
80
  | `root-ops` | Run fixed `root/*.sh` scripts for administrators | An administrator, once |
80
- | `portal` | Open the app from its own Application Portal alias | An administrator sets the alias; needs `root-ops` or `delegate` and DSM 7.1-42661+ (below 7.2-64570 the executor registers the entry) |
81
+ | `portal` | Open the app from its own Application Portal alias, with a login page style (`login_style` changes the colour, wallpaper and title) | An administrator sets the alias; needs `root-ops` or `delegate` and DSM 7.1-42661+ (below 7.2-64570 the executor registers the entry) |
81
82
 
82
83
  Delegate is implemented; its default operation is `files.moveFromStaging`.
83
84
  Device lifecycle acceptance on real hardware is still pending for delegate and root operations.
84
85
 
86
+ ## Lifecycle scripts
87
+
88
+ Every template has a `dsm-scripts/` folder with DSM's own script names: `preinst`, `postinst`,
89
+ `preupgrade`, `postupgrade`, `preuninst`, `postuninst` and `start-stop-status`. Each does nothing
90
+ until you edit it. `build` packs them and calls each one from the generated script of the same
91
+ name, after the SDK's steps (before them for `preupgrade`, `preuninst` and `stop`); they run as
92
+ the package account, with DSM's arguments, environment and exit codes. Projects without
93
+ `dsm-scripts/` build as before. See `synopkgland skill show lifecycle`.
94
+
95
+ Root work after an install or upgrade, such as removing what an older version left, is not a
96
+ lifecycle script: name a `root/` program as `root.start` in dsm-package.yaml. The executor runs
97
+ it as root once at the first start of each new version, while the administrator's approval is
98
+ live, for at most 60 seconds; a failure is logged and does not block the start, and the executor
99
+ `status` reports the result as `start_step`. While it runs, the executor answers only `status`
100
+ (`state: "starting"`) and refuses other requests with the retryable `EXECUTOR_STARTING`; the DSM
101
+ window's approval waits for it. The program cannot declare `run` or `job`, and
102
+ declaring it changes the policy digest, so an administrator approves the App again.
103
+
85
104
  ## Commands
86
105
 
87
106
  | Command | Purpose |
Binary file
Binary file
@@ -19,6 +19,7 @@ import { createReproducibleTar } from "./reproducible-archive.js";
19
19
  import { copyProjectNotices } from "./notices.js";
20
20
  import { renderDeleteAppDataBlock } from "./delete-app-data.js";
21
21
  import { renderUninstallWizardFiles } from "./uninstall-wizard.js";
22
+ import { APP_SCRIPTS_DIR, APP_SCRIPTS_SPK_DIR, composeAppScript, readAppScripts } from "../standalone/app-scripts.js";
22
23
  import { renderInstallWizardFiles } from "./install-wizard.js";
23
24
  import { renderWizardFiles, writeWizardUiFiles } from "./wizard-i18n.js";
24
25
  import { sharedFolderCombobox } from "./share-combobox.js";
@@ -121,11 +122,19 @@ export async function buildSpk(options) {
121
122
  }
122
123
  writeWizards(staging, contract, parsedCompose?.compose, "container");
123
124
  mkdirSync(join(staging, "scripts"), { recursive: true });
125
+ // validateProject already refused a bad scripts/ entry.
126
+ const appScripts = readAppScripts(options.project.root, () => { }) ?? [];
124
127
  for (const name of SCRIPT_NAMES) {
125
128
  const scriptPath = join(staging, "scripts", name);
126
- writeFileSync(scriptPath, renderManagedScript(name, contract, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute, rwEligibility?.headless === true), "utf8");
129
+ const script = renderManagedScript(name, contract, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute, rwEligibility?.headless === true);
130
+ writeFileSync(scriptPath, appScripts.includes(name) ? composeAppScript(name, script) : script, "utf8");
127
131
  chmodSync(scriptPath, SCRIPT_MODE);
128
132
  }
133
+ for (const name of appScripts) {
134
+ mkdirSync(join(staging, APP_SCRIPTS_SPK_DIR), { recursive: true });
135
+ copyFileSync(join(options.project.root, APP_SCRIPTS_DIR, name), join(staging, APP_SCRIPTS_SPK_DIR, name));
136
+ chmodSync(join(staging, APP_SCRIPTS_SPK_DIR, name), SCRIPT_MODE);
137
+ }
129
138
  mkdirSync(join(staging, "package"), { recursive: true });
130
139
  copyFileSync(options.project.composePath, join(staging, "package", "compose.yaml"));
131
140
  copyFileSync(options.project.contractPath, join(staging, "package", "synology-app.yaml"));
@@ -0,0 +1,55 @@
1
+ /**
2
+ * DSM's Login Portal draws an Application Portal entry from the launcher's
3
+ * `loginStyle` in ui/config: the thumbnail in Control Panel > Login Portal
4
+ * and the alias's own login page. Without it the thumbnail stays blank.
5
+ * Paths are relative to ui/; DSM replaces `{0}` with 1x or 2x, and a path
6
+ * without it serves both.
7
+ */
8
+ /** Ships next to dist/ and src/ alike: <package>/assets/login-style, drawn by scripts/render-login-style.py. */
9
+ export declare const DEFAULT_LOGIN_STYLE_DIR: string;
10
+ /** The login button colour; the default mask's button has the same colour. */
11
+ export declare const DEFAULT_LOGIN_COLOR = "#2F6BD8";
12
+ export declare const LOGIN_COLOR: RegExp;
13
+ /** `login_style` in dsm-package.yaml, its paths resolved inside the project. */
14
+ export interface LoginStyle {
15
+ color?: string;
16
+ wallpaper?: {
17
+ image: string;
18
+ thumbnail: string;
19
+ };
20
+ title?: string;
21
+ }
22
+ /** The project files login_style names, by their dsm-package.yaml field. */
23
+ export type LoginImageField = "wallpaper" | "wallpaper_thumbnail" | "title";
24
+ /** The frame size of a JPEG, from its first SOF segment. */
25
+ export declare function jpegSize(bytes: Buffer): {
26
+ width: number;
27
+ height: number;
28
+ };
29
+ /** Why a login_style image cannot be used, or undefined. */
30
+ export declare function loginImageProblem(field: LoginImageField, path: string): string | undefined;
31
+ /** ui/config `loginStyle` fields and the ui/ files they name. */
32
+ export interface LoginStyleAssets {
33
+ fields: Record<string, string>;
34
+ files: Map<string, Buffer>;
35
+ }
36
+ /**
37
+ * The App's login style over the CLI defaults. The file name carries the
38
+ * content, as the window icons do, so DSM and browsers load a changed image.
39
+ */
40
+ export declare function loginStyleAssets(style?: LoginStyle): LoginStyleAssets;
41
+ /** Whether a payload file is the CLI's default of that name. */
42
+ export declare function isDefaultLoginAsset(name: "wallpaper.jpg" | "wallpaper-thumbnail.jpg" | "mask.png", bytes: Buffer): boolean;
43
+ /** check's view of login_style: each override by its project path, or the default. */
44
+ export interface LoginStyleReport {
45
+ color: string;
46
+ wallpaper: "default" | string;
47
+ title: "app-icon" | string;
48
+ }
49
+ export declare function loginStyleReport(root: string, style?: LoginStyle): LoginStyleReport;
50
+ /** One line for check and inspect; inspect knows only "custom", check the project path. */
51
+ export declare function formatLoginStyle(style: {
52
+ color: string;
53
+ wallpaper: string;
54
+ title: string;
55
+ }): string;
@@ -0,0 +1,115 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { decodePng } from "./png.js";
6
+ /**
7
+ * DSM's Login Portal draws an Application Portal entry from the launcher's
8
+ * `loginStyle` in ui/config: the thumbnail in Control Panel > Login Portal
9
+ * and the alias's own login page. Without it the thumbnail stays blank.
10
+ * Paths are relative to ui/; DSM replaces `{0}` with 1x or 2x, and a path
11
+ * without it serves both.
12
+ */
13
+ /** Ships next to dist/ and src/ alike: <package>/assets/login-style, drawn by scripts/render-login-style.py. */
14
+ export const DEFAULT_LOGIN_STYLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "login-style");
15
+ /** The login button colour; the default mask's button has the same colour. */
16
+ export const DEFAULT_LOGIN_COLOR = "#2F6BD8";
17
+ export const LOGIN_COLOR = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
18
+ const MAX_WALLPAPER_BYTES = 2 * 1024 * 1024;
19
+ /** The frame size of a JPEG, from its first SOF segment. */
20
+ export function jpegSize(bytes) {
21
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
22
+ throw new Error("Not a JPEG file (no start-of-image marker).");
23
+ let at = 2;
24
+ while (at + 4 <= bytes.length) {
25
+ if (bytes[at] !== 0xff)
26
+ break;
27
+ const marker = bytes[at + 1];
28
+ if (marker === 0xff) {
29
+ at += 1;
30
+ continue;
31
+ }
32
+ if (marker === 0xd9 || marker === 0xda)
33
+ break;
34
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
35
+ at += 2;
36
+ continue;
37
+ }
38
+ const length = bytes.readUInt16BE(at + 2);
39
+ const frame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
40
+ if (frame && at + 9 <= bytes.length)
41
+ return { height: bytes.readUInt16BE(at + 5), width: bytes.readUInt16BE(at + 7) };
42
+ at += 2 + length;
43
+ }
44
+ throw new Error("The JPEG has no frame header.");
45
+ }
46
+ function exactly(name, size, width, height) {
47
+ if (size.width !== width || size.height !== height) {
48
+ throw new Error(`${name} is ${size.width}x${size.height}; it should be ${width}x${height}.`);
49
+ }
50
+ }
51
+ /** Format and size per field: the sizes DSM's own packages ship and its pages draw. */
52
+ const IMAGE_RULES = {
53
+ wallpaper: (name, bytes) => {
54
+ const size = jpegSize(bytes);
55
+ if (size.width < 1920 || size.height < 1080)
56
+ throw new Error(`${name} is ${size.width}x${size.height}; the login page needs at least 1920x1080.`);
57
+ if (bytes.length > MAX_WALLPAPER_BYTES)
58
+ throw new Error(`${name} is ${bytes.length} bytes, over 2 MiB; every login page loads it.`);
59
+ },
60
+ wallpaper_thumbnail: (name, bytes) => exactly(name, jpegSize(bytes), 320, 200),
61
+ title: (name, bytes) => {
62
+ const image = decodePng(bytes);
63
+ exactly(name, image, 680, 124);
64
+ },
65
+ };
66
+ /** Why a login_style image cannot be used, or undefined. */
67
+ export function loginImageProblem(field, path) {
68
+ const name = basename(path);
69
+ if (!existsSync(path))
70
+ return `${name} was not found.`;
71
+ try {
72
+ IMAGE_RULES[field](name, readFileSync(path));
73
+ return undefined;
74
+ }
75
+ catch (error) {
76
+ return error.message;
77
+ }
78
+ }
79
+ /**
80
+ * The App's login style over the CLI defaults. The file name carries the
81
+ * content, as the window icons do, so DSM and browsers load a changed image.
82
+ */
83
+ export function loginStyleAssets(style = {}) {
84
+ const fields = { mainColor: style.color ?? DEFAULT_LOGIN_COLOR };
85
+ const files = new Map();
86
+ const add = (field, name, bytes) => {
87
+ const path = `images/login_${createHash("sha256").update(bytes).digest("hex").slice(0, 12)}_${name}`;
88
+ fields[field] = path;
89
+ files.set(path, bytes);
90
+ };
91
+ const asset = (name) => readFileSync(join(DEFAULT_LOGIN_STYLE_DIR, name));
92
+ add("defaultLoginWallpaper", "wallpaper.jpg", style.wallpaper ? readFileSync(style.wallpaper.image) : asset("wallpaper.jpg"));
93
+ add("defaultLoginWallpaperThumbnail", "wallpaper-thumbnail.jpg", style.wallpaper ? readFileSync(style.wallpaper.thumbnail) : asset("wallpaper-thumbnail.jpg"));
94
+ add("loginPreviewThumbnailMaskPath", "mask.png", asset("mask.png"));
95
+ if (style.title)
96
+ add("titleImgPath", "title.png", readFileSync(style.title));
97
+ return { fields, files };
98
+ }
99
+ /** Whether a payload file is the CLI's default of that name. */
100
+ export function isDefaultLoginAsset(name, bytes) {
101
+ return bytes.equals(readFileSync(join(DEFAULT_LOGIN_STYLE_DIR, name)));
102
+ }
103
+ export function loginStyleReport(root, style = {}) {
104
+ return {
105
+ color: style.color ?? DEFAULT_LOGIN_COLOR,
106
+ wallpaper: style.wallpaper ? relative(root, style.wallpaper.image) : "default",
107
+ title: style.title ? relative(root, style.title) : "app-icon",
108
+ };
109
+ }
110
+ /** One line for check and inspect; inspect knows only "custom", check the project path. */
111
+ export function formatLoginStyle(style) {
112
+ const wallpaper = style.wallpaper === "default" ? "CLI default wallpaper" : style.wallpaper === "custom" ? "custom wallpaper" : `wallpaper ${style.wallpaper}`;
113
+ const title = style.title === "app-icon" ? "App icon as title" : style.title === "none" ? "no title" : style.title === "custom" ? "custom title" : `title ${style.title}`;
114
+ return `Login style: colour ${style.color}, ${wallpaper}, ${title}`;
115
+ }
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { buildSpk } from "./build-spk/generator.js";
20
20
  import { CONTAINER_ARCH_FAMILIES, isContainerArchFamily, isNativeArchFamily, NATIVE_ARCH_FAMILIES, parseArchList, } from "./build-spk/dsm-arch.js";
21
21
  import { DEFAULT_MIN_DSM, CONTAINER_MIN_DSM } from "./build-spk/dsm-version.js";
22
22
  import { iconReport } from "./build-spk/dsm-icons.js";
23
+ import { formatLoginStyle, loginStyleReport } from "./build-spk/login-style.js";
23
24
  import { deployProject } from "./deploy/deploy.js";
24
25
  import { planDeployment, deployStandalone } from "./deploy/plan.js";
25
26
  import { createNasHost } from "./host/dsm-client.js";
@@ -90,6 +91,16 @@ function formatSpkChecks(report) {
90
91
  : []),
91
92
  ];
92
93
  }
94
+ /** The root programs the executor runs at lifecycle events, without a session. */
95
+ function formatRootSteps(report) {
96
+ const policy = report.executor?.policy;
97
+ const steps = [
98
+ ...(policy?.start ? [`start ${policy.start} (once after each install or upgrade)`] : []),
99
+ ...(policy?.stop ? [`stop ${policy.stop} (at package stop and upgrade)`] : []),
100
+ ...(policy?.purge ? [`purge ${policy.purge} (at uninstall)`] : []),
101
+ ];
102
+ return steps.length ? [`Root steps: ${steps.join(", ")}`] : [];
103
+ }
93
104
  function formatSpkReport(spk, report) {
94
105
  return [
95
106
  `SPK: ${spk}`,
@@ -100,6 +111,9 @@ function formatSpkReport(spk, report) {
100
111
  ...(report.icon ? [`Icon: ${report.icon.source === "default" ? "SDK default icon" : report.icon.source === "custom" ? `custom (PACKAGE_ICON_256 sha256 ${report.icon.sha256.slice(0, 12)}…)` : "no PACKAGE_ICON"}`] : []),
101
112
  ...(report.entry === "admin-only" ? ["Entry: entry: admin-only; only a DSM administrator can open it and call any route (including SDK routes)"] : []),
102
113
  ...(report.entry === "app-privilege" ? ["Entry: entry: app-privilege; DSM Control Panel > Application Privileges decides who can use it"] : []),
114
+ ...formatRootSteps(report),
115
+ ...(report.appScripts ? [`App scripts: ${report.appScripts.join(", ")} (scripts/app, run by the generated scripts of the same name as the package account)`] : []),
116
+ ...(report.loginStyle ? [formatLoginStyle(report.loginStyle)] : []),
103
117
  ...(report.portal ? [`Application Portal: default alias /${report.portal.alias}/ (${report.portal.appId}), active after an administrator applies it`] : []),
104
118
  ...report.capabilities.map((cap) => `Capability ${cap.name}: ${cap.requiresAdminApproval ? "needs DSM administrator approval" : "ready after install"}`),
105
119
  "payload:",
@@ -217,11 +231,18 @@ export async function runCli(argv) {
217
231
  const iconText = formatIconReport(icon, "dsm-package.yaml");
218
232
  if (iconText.warning)
219
233
  console.error(iconText.warning);
234
+ // A portal build carries the login style; a native layout keeps its own ui/config.
235
+ const loginStyle = !isNativeLayout(standalone) && "portal" in loaded.manifest && loaded.manifest.portal
236
+ ? loginStyleReport(standalone, "loginStyle" in loaded.manifest ? loaded.manifest.loginStyle : undefined) : undefined;
220
237
  emit("check", {
221
238
  package: loaded.manifest.package,
222
239
  capabilities: "capabilities" in loaded.manifest ? loaded.manifest.capabilities : {},
223
240
  icon,
224
- }, ["Validation passed.", ...iconText.lines].join("\n"));
241
+ ...(loginStyle ? { loginStyle } : {}),
242
+ }, ["Validation passed.", ...iconText.lines, ...(loginStyle ? [
243
+ formatLoginStyle(loginStyle),
244
+ "info: set login_style: in dsm-package.yaml (color, wallpaper with wallpaper_thumbnail, title) to brand the Login Portal page.",
245
+ ] : [])].join("\n"));
225
246
  return;
226
247
  }
227
248
  const project = requireProject(rootOf(command));
@@ -389,6 +389,26 @@ export const ERROR_REGISTRY = {
389
389
  "description": "dsm-package.yaml is not a valid YAML object.",
390
390
  "suggestion": "Fix the YAML syntax in dsm-package.yaml."
391
391
  },
392
+ "APP_SCRIPT_UNKNOWN": {
393
+ "code": "APP_SCRIPT_UNKNOWN",
394
+ "description": "A file in the project's dsm-scripts/ folder does not have a DSM package script name the SDK runs.",
395
+ "suggestion": "Name it preinst, postinst, preuninst, postuninst, preupgrade, postupgrade or start-stop-status, or move it out of dsm-scripts/. prereplace and postreplace are not supported."
396
+ },
397
+ "APP_SCRIPT_INVALID": {
398
+ "code": "APP_SCRIPT_INVALID",
399
+ "description": "A dsm-scripts/<DSM script name> entry is a symlink, directory or special file.",
400
+ "suggestion": "Make it a regular file in the project's dsm-scripts/ folder."
401
+ },
402
+ "APP_SCRIPT_NOT_EXECUTABLE": {
403
+ "code": "APP_SCRIPT_NOT_EXECUTABLE",
404
+ "description": "A dsm-scripts/<DSM script name> file is not executable; DSM runs it directly.",
405
+ "suggestion": "Run chmod +x on the file."
406
+ },
407
+ "APP_SCRIPT_NO_SHEBANG": {
408
+ "code": "APP_SCRIPT_NO_SHEBANG",
409
+ "description": "A dsm-scripts/<DSM script name> file does not start with #!.",
410
+ "suggestion": "Start the file with an interpreter line such as #!/bin/sh."
411
+ },
392
412
  "STANDALONE_FIELD_UNKNOWN": {
393
413
  "code": "STANDALONE_FIELD_UNKNOWN",
394
414
  "description": "dsm-package.yaml contains an unsupported field.",
@@ -594,6 +614,11 @@ export const ERROR_REGISTRY = {
594
614
  "description": "backend.timeout_seconds needs a Go main that reads the requestTimeout the build passes.",
595
615
  "suggestion": "Declare var requestTimeout = \"\" in main.go and pass its seconds to api.WithRequestDeadline(handler, api.Timeout(...)), or remove timeout_seconds."
596
616
  },
617
+ "STANDALONE_REQUEST_TIMEOUT_SHORT": {
618
+ "code": "STANDALONE_REQUEST_TIMEOUT_SHORT",
619
+ "description": "The request deadline ends before a root program that root.run may run would answer: the executor answers 10 seconds after run.timeout_s and its client waits 10 more.",
620
+ "suggestion": "Set backend.timeout_seconds to at least the program's run.timeout_s (20 by default) + 20, or lower run.timeout_s."
621
+ },
597
622
  "STANDALONE_UI_WINDOW_INVALID": {
598
623
  "code": "STANDALONE_UI_WINDOW_INVALID",
599
624
  "description": "ui.window does not give a valid DSM window size.",
@@ -644,6 +669,16 @@ export const ERROR_REGISTRY = {
644
669
  "description": "An App without an executor (capabilities.rootOps or delegate) declares portal.",
645
670
  "suggestion": "Removing the sign-in alias after uninstall needs root so nginx stops serving it, but an App without escalation uses no root at any stage. Add capabilities.rootOps or delegate (executor level), or remove portal and let an administrator configure a reverse proxy in DSM."
646
671
  },
672
+ "STANDALONE_LOGIN_STYLE_INVALID": {
673
+ "code": "STANDALONE_LOGIN_STYLE_INVALID",
674
+ "description": "login_style in dsm-package.yaml is malformed, or set without portal.",
675
+ "suggestion": "Declare portal, and give login_style only color (#RRGGBB), wallpaper together with wallpaper_thumbnail, and title; omit login_style to use the CLI default style."
676
+ },
677
+ "STANDALONE_LOGIN_STYLE_IMAGE_INVALID": {
678
+ "code": "STANDALONE_LOGIN_STYLE_IMAGE_INVALID",
679
+ "description": "An image login_style names is missing, in the wrong format or the wrong size.",
680
+ "suggestion": "Use a JPEG wallpaper of at least 1920x1080 and at most 2 MiB, a 320x200 JPEG wallpaper_thumbnail and a 680x124 PNG title, inside the project."
681
+ },
647
682
  "STANDALONE_I18N_INVALID": {
648
683
  "code": "STANDALONE_I18N_INVALID",
649
684
  "description": "The i18n configuration in dsm-package.yaml is invalid.",
@@ -757,7 +792,7 @@ for (const [code, description, suggestion] of [
757
792
  ["TEMPLATES_MISSING", "@synopackageland/templates was not found.", "Reinstall @synopackageland/cli, or point SYNOPKGLAND_TEMPLATES_DIR at a templates checkout."],
758
793
  ["EXECUTOR_PIN_REQUIRED", "A package that declares rootOps or delegate was built without the executor artifact.", "Use synopkgland build so the CLI compiles and pins the executor; do not call the low-level SPK API directly."],
759
794
  ["EXECUTOR_POLICY", "capabilities.delegate has invalid operations or scope, or a binary root program lacks the file for this architecture, so the executor policy cannot be generated.", "Use the delegate declaration init/add produces and fix scope and operations against the manifest reference; for binary root programs provide root/<arch>/<name> for every arch in arches."],
760
- ["EXECUTOR_POLICY_REJECTED", "The executor of the App's SDK version refuses the policy the CLI generated, so the NAS would refuse it at approval.", "The reason names the refused part: policy root/stop/purge → root.programs (schema, run, job), root.stop, uninstall.purge; policy scope/operation/destination/move/lifetime → capabilities.delegate; policy portal → portal; policy entry → entry. Fix that part of dsm-package.yaml; if it already matches the manifest reference, use the SDK version this CLI release names."],
795
+ ["EXECUTOR_POLICY_REJECTED", "The executor of the App's SDK version refuses the policy the CLI generated, so the NAS would refuse it at approval.", "The reason names the refused part: policy root/start/stop/purge → root.programs (schema, run, job), root.start, root.stop, uninstall.purge; policy scope/operation/destination/move/lifetime → capabilities.delegate; policy portal → portal; policy entry → entry. Fix that part of dsm-package.yaml; if it already matches the manifest reference, use the SDK version this CLI release names."],
761
796
  ["EXECUTOR_ROOT_PROGRAMS_UNSUPPORTED", "The root settings in dsm-package.yaml need an executor that supports root program settings, which the App's SDK version lacks.", "Use an SDK with root programs, stop and root_state (executor/stop_step.go), or remove the root settings."],
762
797
  ["EXECUTOR_PACKAGE", "The package name pinned into the root task is invalid.", "package must start with a letter and contain only alphanumerics (2-64 characters)."],
763
798
  ["EXECUTOR_DIGEST", "The sha256 pin for the executor or policy is malformed.", "Build again so the CLI recomputes the pin; do not hand-edit pins inside the SPK."],
@@ -781,6 +816,7 @@ for (const [code, description, suggestion] of [
781
816
  ["STANDALONE_BRIDGE_APP_INVALID", "The App directory contains a symlink or special file, or exceeds 256 MiB.", "Keep only regular files and directories in the App directory; move large data out of it."],
782
817
  ["STANDALONE_RUST_BUILD_FAILED", "The Rust App's cargo zigbuild cross-compile failed, or it produced no binary for backend.entry.", "Install Rust with rustup and the three targets (x86_64/aarch64-unknown-linux-musl, armv7-unknown-linux-gnueabihf), install cargo-zigbuild and zig; set backend.entry to the Cargo binary name; check the compiler output in the message."],
783
818
  ["STANDALONE_BRIDGE_MANIFEST_INVALID", "The bridge.json the CLI produced does not match spec/bridge/v1 manifest.schema.json.", "Check backend.entry, methods and node.package; if it still fails, report the CLI bug."],
819
+ ["STANDALONE_PYTHON_SDK_MISSING", "The App's SDK version has no Python SDK (python/synopkgland_app).", "Point SYNOPKGLAND_SDK_DIR at a syno-pkg-land-sdk checkout that has python/synopkgland_app, or require an SDK release that ships it."],
784
820
  ["STANDALONE_NODE_SDK_NOT_BUILT", "The App's SDK version has no built Node SDK (node/dist).", "Run npm ci && npm run build in node/ of the SYNOPKGLAND_SDK_DIR checkout first; a release SDK must contain node/dist."],
785
821
  ])
786
822
  ERROR_REGISTRY[code] = { code, description, suggestion };
@@ -186,17 +186,14 @@ function createDsmNasHost(credentials, profileName) {
186
186
  throw e;
187
187
  return call("SYNO.Core.TaskScheduler", method, legacyVersion, legacyParams);
188
188
  });
189
- const records = await call("SYNO.Core.EventScheduler", "list", 1, {});
190
- if (!Array.isArray(records))
191
- throw new Error("Invalid EventScheduler readback.");
189
+ // EventScheduler keys a task by its unique name; get answers no data for a missing one.
190
+ const bootName = `${packageIdentity} executor boot`;
192
191
  const tasks = [];
193
- for (const summary of records) {
194
- if (summary?.task_name !== `${packageIdentity} executor boot`)
195
- continue;
196
- const detail = await call("SYNO.Core.EventScheduler", "get", 1, { task_name: summary.task_name });
197
- if (!detail || detail.task_name !== summary.task_name)
192
+ const boot = await call("SYNO.Core.EventScheduler", "get", 1, { task_name: bootName });
193
+ if (boot !== undefined && boot !== null && !(typeof boot === "object" && Object.keys(boot).length === 0)) {
194
+ if (boot.task_name !== bootName)
198
195
  throw new Error("Invalid boot task readback.");
199
- tasks.push({ ...detail, name: detail.task_name, kind: "boot" });
196
+ tasks.push({ ...boot, name: boot.task_name, kind: "boot" });
200
197
  }
201
198
  const listed = await taskRead("list", 3, { start: 0, limit: 500 }, 2, { offset: 0, limit: 500 });
202
199
  if (!Array.isArray(listed?.tasks) || !Number.isInteger(listed.total) || listed.total !== listed.tasks.length || listed.total > 500)
package/dist/init.js CHANGED
@@ -5,10 +5,11 @@ import { capabilityNames, capabilitySettings } from "./capabilities.js";
5
5
  import { MOVE_FROM_STAGING } from './runtime/delegate-contract.js';
6
6
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
7
  import { basename, dirname, join, relative, resolve } from "node:path";
8
+ import { APP_SCRIPTS_DIR } from "./standalone/app-scripts.js";
8
9
  import { SDK_MODULE, SDK_PACKAGE, sdkSource } from "./runtime/sdk-source.js";
9
10
  import { templatesRoot } from "./template-paths.js";
10
11
  import { BASE_BORROWED, BRIDGE_TEMPLATES, borrowedFiles, borrowedTarget, capabilityOverlays, VUE_OVERLAYS } from "./capability-overlays.js";
11
- import { copyAppTree, copyNodeSdk, NODE_SDK_PACKAGE } from "./standalone/bridge-app.js";
12
+ import { copyNodeSdk, copyPythonSdk, NODE_SDK_PACKAGE } from "./standalone/bridge-app.js";
12
13
  import { API_PERMISSIONS_FILE } from "./standalone/api-permissions.js";
13
14
  import { resolveMinDsm, assertContainerMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast } from "./build-spk/dsm-version.js";
14
15
  import { readSharedFolder } from "./standalone/shared-folder.js";
@@ -135,7 +136,9 @@ export function initFromTemplate(template, targetDir, options = {}) {
135
136
  // Shared legal resources also live in the CLI package; only the template
136
137
  // terms are an unconditional part of a generated project.
137
138
  const shared = join(templatesRoot(), "_shared");
138
- const sharedFiles = ["LICENSES/TEMPLATE.txt", "AGENTS.md", "CLAUDE.md"];
139
+ // dsm-scripts/: the DSM lifecycle scripts every project starts with (doing nothing); older templates have none.
140
+ const sharedScripts = existsSync(join(shared, APP_SCRIPTS_DIR)) ? readdirSync(join(shared, APP_SCRIPTS_DIR)).sort().map(name => `${APP_SCRIPTS_DIR}/${name}`) : [];
141
+ const sharedFiles = ["LICENSES/TEMPLATE.txt", "AGENTS.md", "CLAUDE.md", ...sharedScripts];
139
142
  const files = [...templateFiles(source), ...sharedFiles, ...overlays.flatMap(root => templateFiles(root)),
140
143
  ...borrowed.map(borrowedTarget), ...(ui === "vue" ? ["frontend/third-party-notices.mjs"] : [])];
141
144
  const collisions = files.map(targetName).filter((file) => existsSync(join(targetDir, file)));
@@ -144,9 +147,10 @@ export function initFromTemplate(template, targetDir, options = {}) {
144
147
  }
145
148
  mkdirSync(targetDir, { recursive: true });
146
149
  copyTemplateTree(source, targetDir);
147
- mkdirSync(join(targetDir, "LICENSES"), { recursive: true });
148
- for (const file of sharedFiles)
150
+ for (const file of sharedFiles) {
151
+ mkdirSync(dirname(join(targetDir, file)), { recursive: true });
149
152
  cpSync(join(shared, file), join(targetDir, file));
153
+ }
150
154
  for (const overlay of overlays)
151
155
  copyTemplateTree(overlay, targetDir);
152
156
  for (const dir of (ui === "vue" ? ["frontend"] : [])) {
@@ -275,7 +279,7 @@ function vendorAppSdk(template, targetDir) {
275
279
  const sdk = sdkSource(version).goDir;
276
280
  const vendor = join(targetDir, "backend/vendor");
277
281
  if (template === "native-python")
278
- return copyAppTree(join(sdk, "python/synopkgland_app"), join(vendor, "synopkgland_app"));
282
+ return copyPythonSdk(sdk, join(vendor, "synopkgland_app"));
279
283
  const target = join(vendor, NODE_SDK_PACKAGE);
280
284
  copyNodeSdk(join(sdk, "node"), target);
281
285
  const { devDependencies: _dev, scripts: _scripts, ...pkg } = JSON.parse(readFileSync(join(target, "package.json"), "utf8"));
@@ -1,94 +1,4 @@
1
- /** Frozen schema-2 / v1 extension. Definitions alone do not enable capabilities. */
1
+ /** Frozen schema-2 / v1 extension. Definitions alone do not enable capabilities.
2
+ * The DTOs and the golden vector live in the SDK (ts/src/delegate/long-running.ts). */
2
3
  export declare const MOVE_FROM_STAGING: "files.moveFromStaging";
3
4
  export declare const DELEGATE_EXTENSION_OPERATIONS: readonly ["files.moveFromStaging", "files.operationStatus", "files.cancelOperation", "delegate.disable", "delegate.enable", "delegate.audit"];
4
- export declare const DELEGATE_OPERATION_STATES: readonly ["queued", "copying", "committed", "canceled", "interrupted", "uncertain"];
5
- export type DelegateOperationState = typeof DELEGATE_OPERATION_STATES[number];
6
- export interface LongRunningDelegateCapability {
7
- scope: string;
8
- operations: [typeof MOVE_FROM_STAGING];
9
- destinations: string[];
10
- lifetime: 'until-revoked';
11
- }
12
- export interface SelectDestination {
13
- key: string;
14
- path: string;
15
- }
16
- /** Protected executor selection, not the package-owned installer draft. */
17
- export interface ScopeSelection {
18
- scope: string;
19
- root: string;
20
- generation: number;
21
- }
22
- export interface DestinationState {
23
- key: string;
24
- selection: ScopeSelection | null;
25
- destination: DestinationGrant | null;
26
- status: 'active' | 'missing' | 'reselection-required' | 'needs-scope-confirmation' | 'disabled';
27
- }
28
- export interface DestinationGrant {
29
- id: string;
30
- key: string;
31
- path: string;
32
- username: string;
33
- lifetime: 'until-revoked';
34
- }
35
- /** Owner/UID/destination are resolved from the protected grant, never supplied here. */
36
- export interface MoveFromStaging {
37
- delegation_id: string;
38
- job_id: string;
39
- staging: string;
40
- /**
41
- * File name, or folders below the destination and a file name, for example
42
- * `Album/CD1/01.flac`. Missing folders are created as the user, at most
43
- * `DELEGATE_LIMITS.maxDestinationFolders` deep and `maxDestinationBytes` long.
44
- */
45
- name: string;
46
- size: number;
47
- /**
48
- * When the name is taken: `fail` (default) refuses with `destination-exists`,
49
- * `overwrite` atomically replaces a regular file the user can write, `rename`
50
- * publishes `name (n).ext` (n up to 99) and the receipt reports that path.
51
- */
52
- conflict?: DelegateConflictPolicy;
53
- }
54
- export declare const DELEGATE_CONFLICT_POLICIES: readonly ["fail", "overwrite", "rename"];
55
- export type DelegateConflictPolicy = typeof DELEGATE_CONFLICT_POLICIES[number];
56
- export interface OperationRef {
57
- delegation_id: string;
58
- job_id: string;
59
- }
60
- export interface OperationResult {
61
- job_id: string;
62
- state: DelegateOperationState;
63
- /** Only a committed executor receipt may supply a final destination path. */
64
- receipt?: {
65
- path: string;
66
- size: number;
67
- };
68
- code?: string;
69
- reason?: string;
70
- }
71
- export declare const DELEGATE_ROUTES: {
72
- readonly 'delegate.example.export': "authenticated";
73
- readonly 'delegate.destination': "authenticated";
74
- readonly 'delegate.select_destination': "authenticated";
75
- readonly 'delegate.move_from_staging': "authenticated";
76
- readonly 'delegate.operations.get': "authenticated";
77
- readonly 'delegate.operations.list': "authenticated";
78
- readonly 'delegate.operations.cancel': "authenticated";
79
- readonly 'delegate.disable': "administrators";
80
- readonly 'delegate.enable': "administrators";
81
- readonly 'delegate.audit': "authenticated";
82
- };
83
- /** Shared, explicit limits; legacy TTL/job limits remain separate. */
84
- export declare const DELEGATE_LIMITS: {
85
- readonly maxFileBytes: number;
86
- readonly maxQueued: 16;
87
- readonly maxActive: 1;
88
- readonly maxCopySeconds: 86400;
89
- readonly maxOperationBytes: number;
90
- readonly maxAuditBytes: number;
91
- readonly revalidateSeconds: 5;
92
- readonly maxDestinationFolders: 16;
93
- readonly maxDestinationBytes: 1024;
94
- };
@@ -1,29 +1,7 @@
1
- /** Frozen schema-2 / v1 extension. Definitions alone do not enable capabilities. */
1
+ /** Frozen schema-2 / v1 extension. Definitions alone do not enable capabilities.
2
+ * The DTOs and the golden vector live in the SDK (ts/src/delegate/long-running.ts). */
2
3
  export const MOVE_FROM_STAGING = 'files.moveFromStaging';
3
4
  export const DELEGATE_EXTENSION_OPERATIONS = [
4
5
  MOVE_FROM_STAGING, 'files.operationStatus', 'files.cancelOperation',
5
6
  'delegate.disable', 'delegate.enable', 'delegate.audit',
6
7
  ];
7
- export const DELEGATE_OPERATION_STATES = [
8
- 'queued', 'copying', 'committed', 'canceled', 'interrupted', 'uncertain',
9
- ];
10
- export const DELEGATE_CONFLICT_POLICIES = ['fail', 'overwrite', 'rename'];
11
- export const DELEGATE_ROUTES = {
12
- 'delegate.example.export': 'authenticated',
13
- 'delegate.destination': 'authenticated',
14
- 'delegate.select_destination': 'authenticated',
15
- 'delegate.move_from_staging': 'authenticated',
16
- 'delegate.operations.get': 'authenticated',
17
- 'delegate.operations.list': 'authenticated',
18
- 'delegate.operations.cancel': 'authenticated',
19
- 'delegate.disable': 'administrators',
20
- 'delegate.enable': 'administrators',
21
- 'delegate.audit': 'authenticated',
22
- };
23
- /** Shared, explicit limits; legacy TTL/job limits remain separate. */
24
- export const DELEGATE_LIMITS = {
25
- maxFileBytes: 16 * 1024 ** 3, maxQueued: 16, maxActive: 1,
26
- maxCopySeconds: 86400, maxOperationBytes: 10 * 1024 ** 2,
27
- maxAuditBytes: 50 * 1024 ** 2, revalidateSeconds: 5,
28
- maxDestinationFolders: 16, maxDestinationBytes: 1024,
29
- };
@@ -31,5 +31,16 @@
31
31
  "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
32
32
  },
33
33
  "goToolchain": "go1.26.9"
34
+ },
35
+ "0.10.3": {
36
+ "x86_64": "4ba7040761a719e74d6b047ef68b9415e658ebb8b373b69555b8c307524e26ba",
37
+ "aarch64": "ba80b389e3291419389b66be7b0a0e1085d3dfec55c9c651fecae427279bb3d5",
38
+ "armv7": "49474467b9c9a7f31b30b5053b70eb4f71a95f2c06fbf3566e8de8f16972a299",
39
+ "lifecycle": {
40
+ "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
41
+ "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
42
+ "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
43
+ },
44
+ "goToolchain": "go1.26.9"
34
45
  }
35
46
  }
@@ -1,7 +1,7 @@
1
1
  import { type StandaloneManifest } from '../standalone/manifest.js';
2
2
  /**
3
3
  * Why the SDK executor would refuse the policy, such as `invalid policy: policy root`;
4
- * undefined when it accepts it or its SDK predates check-policy. The executor is
4
+ * undefined when it accepts it. The executor is
5
5
  * built for this host from executorDir, so its own rules decide, not a copy in the CLI.
6
6
  */
7
7
  export declare function executorPolicyRefusal(executorDir: string, pkg: string, policyText: string, env?: NodeJS.ProcessEnv): string | undefined;