@synopackageland/cli 0.10.2 → 0.10.4

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 +259 -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 +22 -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 +58 -12
  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 +191 -53
  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 +5 -5
  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
@@ -1,22 +1,19 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { PINNED_GO_HINT, PINNED_GO_TOOLCHAIN } from '../build-spk/go-toolchain.js';
6
6
  import { sdkBackend } from '../standalone/manifest.js';
7
7
  import { executorPolicy, executorPolicyText } from './executor-policy.js';
8
8
  import { requiredSdk } from './sdk-source.js';
9
- /** The executor subcommand that validates a policy on stdin as load() does (SDK 0.10.2 and later). */
9
+ /** The executor subcommand that validates a policy on stdin as load() does. */
10
10
  const CHECK_POLICY = 'check-policy';
11
11
  /**
12
12
  * Why the SDK executor would refuse the policy, such as `invalid policy: policy root`;
13
- * undefined when it accepts it or its SDK predates check-policy. The executor is
13
+ * undefined when it accepts it. The executor is
14
14
  * built for this host from executorDir, so its own rules decide, not a copy in the CLI.
15
15
  */
16
16
  export function executorPolicyRefusal(executorDir, pkg, policyText, env = process.env) {
17
- const main = join(executorDir, 'main.go');
18
- if (!existsSync(main) || !readFileSync(main, 'utf8').includes(`"${CHECK_POLICY}"`))
19
- return undefined;
20
17
  const dir = mkdtempSync(join(tmpdir(), 'synopkgland-check-policy-'));
21
18
  try {
22
19
  const binary = join(dir, 'executor');
@@ -38,7 +35,8 @@ export function executorPolicyRefusal(executorDir, pkg, policyText, env = proces
38
35
  }
39
36
  /** Where in dsm-package.yaml each executor refusal comes from. */
40
37
  const POLICY_FIX = [
41
- [/policy (root|stop|purge)/, 'fix root.programs (schema, run, job), root.stop or uninstall.purge in dsm-package.yaml'],
38
+ [/policy (start|stop|purge): .*cannot also declare run or job/, 'root.start, root.stop and uninstall.purge name a program of their own: give them another program name in root.programs, one without run or job, and keep run and job on the session program'],
39
+ [/policy (root|start|stop|purge)/, 'fix root.programs (schema, run, job), root.start, root.stop or uninstall.purge in dsm-package.yaml'],
42
40
  [/policy (scope|operation|destination|move|lifetime)/, 'fix capabilities.delegate in dsm-package.yaml'],
43
41
  [/policy portal/, 'fix portal in dsm-package.yaml'],
44
42
  [/policy entry/, 'fix entry in dsm-package.yaml'],
@@ -32,6 +32,8 @@ export interface ExecutorPolicy {
32
32
  purge?: string;
33
33
  /** The root entry the executor runs when the package stops or upgrades. */
34
34
  stop?: string;
35
+ /** The root entry the executor runs once after each install or upgrade; a lifecycle step only. */
36
+ start?: string;
35
37
  root: Record<string, RootPolicyEntry>;
36
38
  /** Root programs get a directory only root can read, kept across upgrades. */
37
39
  root_state?: true;
@@ -50,6 +50,7 @@ export function executorPolicy(m, arch) {
50
50
  return { v: 1, package: m.package, app: `SYNO.SDS.${m.package}.Instance`, ...(m.entry ? { entry: m.entry } : {}), ...(d ? { scope: d.scope } : {}),
51
51
  ...(d?.lifetime ? { destinations: [...d.destinations].sort(), lifetime: d.lifetime } : {}),
52
52
  operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), ...(purge ? { purge } : {}), ...(settings?.stop ? { stop: settings.stop } : {}),
53
+ ...(settings?.start ? { start: settings.start } : {}),
53
54
  root, ...(settings?.state ? { root_state: true } : {}) };
54
55
  }
55
56
  /** A script without settings keeps the bare digest; a binary pins the bytes built for this architecture. */
@@ -72,18 +73,21 @@ function executorPortal(m) {
72
73
  throw new Error(`[EXECUTOR_POLICY] ${problem}`);
73
74
  return entry;
74
75
  }
76
+ /** A root entry a session may run or start; the executor refuses it as a lifecycle step. */
77
+ const sessionEntry = (entry) => typeof entry !== 'string' && (entry.run !== undefined || entry.job !== undefined);
75
78
  /** Inventory rejects malformed extensions even if a caller recomputes every pin. */
76
79
  export function validExecutorPolicy(value, pkg) {
77
80
  if (!value || typeof value !== 'object' || Array.isArray(value))
78
81
  return false;
79
82
  const p = value;
80
- if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'stop', 'root', 'root_state'].includes(key))
83
+ if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'stop', 'start', 'root', 'root_state'].includes(key))
81
84
  || p.v !== 1 || p.package !== pkg || p.app !== `SYNO.SDS.${pkg}.Instance`
82
85
  || 'entry' in p && p.entry !== 'admin-only' && p.entry !== 'app-privilege'
83
86
  || !Array.isArray(p.operations) || !p.operations.includes('status') || new Set(p.operations).size !== p.operations.length
84
87
  || !p.root || typeof p.root !== 'object' || Array.isArray(p.root)
85
88
  || 'purge' in p && (typeof p.purge !== 'string' || !Object.hasOwn(p.root, p.purge))
86
89
  || 'stop' in p && (typeof p.stop !== 'string' || !Object.hasOwn(p.root, p.stop))
90
+ || 'start' in p && (typeof p.start !== 'string' || !Object.hasOwn(p.root, p.start) || sessionEntry(p.root[p.start]))
87
91
  || 'root_state' in p && p.root_state !== true
88
92
  || Object.entries(p.root).some(([name, entry]) => !ROOT_PROGRAM_NAME.test(name) || rootEntryProblem(entry) !== undefined))
89
93
  return false;
@@ -19,6 +19,8 @@ export interface RootProgramSettings {
19
19
  /** dsm-package.yaml `root`: settings of root/ programs, the stop step and the root state directory. */
20
20
  export interface RootSettings {
21
21
  programs?: Record<string, RootProgramSettings>;
22
+ /** The program the executor runs once after each install or upgrade. */
23
+ start?: string;
22
24
  stop?: string;
23
25
  state?: true;
24
26
  }
@@ -4,6 +4,10 @@ export declare const EXECUTOR_MODULE = "github.com/synopackageland/syno-pkg-land
4
4
  /** The thin Rust App crate (spec/bridge/v1), a git dependency on a release tag of the SDK repository. */
5
5
  export declare const SDK_CRATE = "syno-pkg-land-app";
6
6
  export declare const SDK_GIT_URL = "https://github.com/synopackageland/syno-pkg-land-sdk";
7
+ /** The oldest SDK this CLI supports in its minor line; older patches lack spec/protocol/v1/timeouts.json. */
8
+ export declare const SDK_FLOOR = "0.10.3";
9
+ /** Whether version is an SDK patch older than SDK_FLOOR in the floor's minor line. */
10
+ export declare function sdkBelowFloor(version: string): boolean;
7
11
  /** Where one SDK version's Go client and executor sources are read from. */
8
12
  export interface SdkSource {
9
13
  version: string;
@@ -8,6 +8,13 @@ export const EXECUTOR_MODULE = `${SDK_MODULE}/executor`;
8
8
  /** The thin Rust App crate (spec/bridge/v1), a git dependency on a release tag of the SDK repository. */
9
9
  export const SDK_CRATE = 'syno-pkg-land-app';
10
10
  export const SDK_GIT_URL = `https://${SDK_MODULE}`;
11
+ /** The oldest SDK this CLI supports in its minor line; older patches lack spec/protocol/v1/timeouts.json. */
12
+ export const SDK_FLOOR = '0.10.3';
13
+ /** Whether version is an SDK patch older than SDK_FLOOR in the floor's minor line. */
14
+ export function sdkBelowFloor(version) {
15
+ const [major, minor, patch] = version.split('.').map(Number), [floorMajor, floorMinor, floorPatch] = SDK_FLOOR.split('.').map(Number);
16
+ return major === floorMajor && minor === floorMinor && patch < floorPatch;
17
+ }
11
18
  /** How each backend declares the SDK. */
12
19
  export const SDK_DEPENDENCY = {
13
20
  go: `add \`require ${SDK_MODULE} v<version>\` to backend/go.mod.`,
@@ -0,0 +1,23 @@
1
+ /** The SDK's timeouts on the call chain to the executor (spec/protocol/v1, Timeouts). */
2
+ export declare const SDK_TIMEOUTS_FILE: string;
3
+ /** The waits the CLI generates into an App: its request default, the window's margin, and root.run's needs. */
4
+ export interface SdkTimeouts {
5
+ /** The request deadline unless the App sets backend.timeout_seconds. */
6
+ requestDefaultSeconds: number;
7
+ /** How long the window waits past the request deadline. */
8
+ browserMarginMs: number;
9
+ /** A root.run program's timeout_s unless root.programs sets it. */
10
+ rootRunDefaultSeconds: number;
11
+ /** How much longer than a program's timeout_s a request awaiting root.run must last. */
12
+ rootRunRequestMarginSeconds: number;
13
+ }
14
+ /** What an App without the SDK waits. */
15
+ export declare const NO_SDK_TIMEOUTS: SdkTimeouts;
16
+ /** The timeouts of the SDK checkout or module at goDir. */
17
+ export declare function sdkTimeouts(goDir: string): SdkTimeouts;
18
+ /**
19
+ * The timeouts of the SDK an App's backend requires. A backend without the
20
+ * SDK, or one whose SDK cannot be resolved or is older than the CLI supports
21
+ * (sdkProblems and the API permissions report that), gets NO_SDK_TIMEOUTS.
22
+ */
23
+ export declare function appSdkTimeouts(backend: object, env?: NodeJS.ProcessEnv): SdkTimeouts;
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { appSdkVersion, sdkBelowFloor, sdkSource } from './sdk-source.js';
4
+ /** The SDK's timeouts on the call chain to the executor (spec/protocol/v1, Timeouts). */
5
+ export const SDK_TIMEOUTS_FILE = join('spec', 'protocol', 'v1', 'timeouts.json');
6
+ /** What an App without the SDK waits. */
7
+ export const NO_SDK_TIMEOUTS = { requestDefaultSeconds: 60, browserMarginMs: 2000, rootRunDefaultSeconds: 20, rootRunRequestMarginSeconds: 20 };
8
+ const read = new Map();
9
+ /** The timeouts of the SDK checkout or module at goDir. */
10
+ export function sdkTimeouts(goDir) {
11
+ const cached = read.get(goDir);
12
+ if (cached)
13
+ return cached;
14
+ const spec = JSON.parse(readFileSync(join(goDir, SDK_TIMEOUTS_FILE), 'utf8'));
15
+ const timeouts = {
16
+ requestDefaultSeconds: spec.request.default_s,
17
+ browserMarginMs: spec.browser.margin_ms,
18
+ rootRunDefaultSeconds: spec.executor.root_run_program_default_s,
19
+ // The executor answers root.run this long after the program's timeout, and its client waits margin_s more.
20
+ rootRunRequestMarginSeconds: spec.executor.root_run_answer_s - spec.executor.root_run_program_max_s + spec.margin_s,
21
+ };
22
+ read.set(goDir, timeouts);
23
+ return timeouts;
24
+ }
25
+ /**
26
+ * The timeouts of the SDK an App's backend requires. A backend without the
27
+ * SDK, or one whose SDK cannot be resolved or is older than the CLI supports
28
+ * (sdkProblems and the API permissions report that), gets NO_SDK_TIMEOUTS.
29
+ */
30
+ export function appSdkTimeouts(backend, env = process.env) {
31
+ if (backend.kind === 'binary')
32
+ return NO_SDK_TIMEOUTS;
33
+ let goDir;
34
+ try {
35
+ const version = appSdkVersion(backend);
36
+ if (!version || sdkBelowFloor(version))
37
+ return NO_SDK_TIMEOUTS;
38
+ goDir = sdkSource(version, env).goDir;
39
+ }
40
+ catch {
41
+ return NO_SDK_TIMEOUTS;
42
+ }
43
+ return sdkTimeouts(goDir);
44
+ }
package/dist/skill.js CHANGED
@@ -10,6 +10,7 @@ const SKILL_CATALOG = [
10
10
  { id: "portal", file: "reference/portal.md", when: "Needs a DSM Application Portal alias link" },
11
11
  { id: "ui-vue", file: "reference/ui-vue.md", when: "Write or check a Vue UI" },
12
12
  { id: "ui-json", file: "reference/ui-json.md", when: "Generate or check a JSON UI" },
13
+ { id: "lifecycle", file: "reference/lifecycle.md", when: "Add work to install, upgrade, uninstall, start or stop (dsm-scripts/)" },
13
14
  { id: "deploy", file: "reference/deploy.md", when: "Deploy to a NAS" },
14
15
  { id: "diagnose", file: "reference/diagnose.md", when: "check/deploy/Open failed" },
15
16
  ];
@@ -0,0 +1,26 @@
1
+ import { LIFECYCLE_SCRIPTS } from "../spk/dsm-spec.js";
2
+ import type { StandaloneError } from "./manifest.js";
3
+ /** The project folder whose files carry DSM's own script names; `scripts/` stays free for the App's tools. */
4
+ export declare const APP_SCRIPTS_DIR = "dsm-scripts";
5
+ /** Where the SPK carries them: next to the generated scripts, so postuninst still finds them after the payload is gone. */
6
+ export declare const APP_SCRIPTS_SPK_DIR = "scripts/app";
7
+ export type AppScriptName = (typeof LIFECYCLE_SCRIPTS)[number];
8
+ /**
9
+ * The project's `dsm-scripts/<name>` files, one per DSM lifecycle script name.
10
+ * prereplace/postreplace are refused: DSM runs them only for
11
+ * install_replace_packages, which the SDK never declares.
12
+ */
13
+ export declare function readAppScripts(root: string, fail: (error: StandaloneError) => void): AppScriptName[] | undefined;
14
+ /** Wraps the SDK's script so it runs in a subshell, then calls the App's script of the same name. */
15
+ export declare function composeAppScript(name: string, sdkScript: string): string;
16
+ /** The SDK script inside `script`, or undefined when the App wrapper was edited. */
17
+ export declare function sdkScriptOf(name: string, script: string): {
18
+ sdk: string;
19
+ app: boolean;
20
+ } | undefined;
21
+ /**
22
+ * inspect: checks every App wrapper and App script, then writes the bare SDK
23
+ * script back into the extracted `scripts/` so the fixed-lifecycle checks
24
+ * compare the SDK steps alone. Returns the App scripts DSM will reach.
25
+ */
26
+ export declare function unwrapAppScripts(scriptsDir: string, findings: string[]): AppScriptName[];
@@ -0,0 +1,141 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { LIFECYCLE_SCRIPTS } from "../spk/dsm-spec.js";
4
+ /** The project folder whose files carry DSM's own script names; `scripts/` stays free for the App's tools. */
5
+ export const APP_SCRIPTS_DIR = "dsm-scripts";
6
+ /** Where the SPK carries them: next to the generated scripts, so postuninst still finds them after the payload is gone. */
7
+ export const APP_SCRIPTS_SPK_DIR = "scripts/app";
8
+ /**
9
+ * The project's `dsm-scripts/<name>` files, one per DSM lifecycle script name.
10
+ * prereplace/postreplace are refused: DSM runs them only for
11
+ * install_replace_packages, which the SDK never declares.
12
+ */
13
+ export function readAppScripts(root, fail) {
14
+ const dir = join(root, APP_SCRIPTS_DIR);
15
+ let entries;
16
+ try {
17
+ entries = readdirSync(dir);
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ const names = [];
23
+ for (const name of entries.filter(entry => !entry.startsWith(".")).sort()) {
24
+ const path = `${APP_SCRIPTS_DIR}/${name}`;
25
+ if (!LIFECYCLE_SCRIPTS.includes(name)) {
26
+ fail({ code: "APP_SCRIPT_UNKNOWN", message: `${path} is not a DSM package script name; use one of ${LIFECYCLE_SCRIPTS.join(", ")}.` });
27
+ continue;
28
+ }
29
+ const stat = lstatSync(join(dir, name));
30
+ if (!stat.isFile()) {
31
+ fail({ code: "APP_SCRIPT_INVALID", message: `${path} must be a regular file, not a symlink or directory.` });
32
+ continue;
33
+ }
34
+ if (!(stat.mode & 0o100)) {
35
+ fail({ code: "APP_SCRIPT_NOT_EXECUTABLE", message: `${path} is not executable; run chmod +x ${path}.` });
36
+ continue;
37
+ }
38
+ if (!readFileSync(join(dir, name)).subarray(0, 2).equals(Buffer.from("#!"))) {
39
+ fail({ code: "APP_SCRIPT_NO_SHEBANG", message: `${path} must start with #! (for example #!/bin/sh); DSM runs it directly.` });
40
+ continue;
41
+ }
42
+ names.push(name);
43
+ }
44
+ return names.length ? names : undefined;
45
+ }
46
+ const SHEBANG = "#!/bin/sh\n";
47
+ const SDK_OPEN = "synopkgland_sdk() (\n";
48
+ const SDK_CLOSE = ")\n";
49
+ /** Hooks that tear down: the App script runs first, while the SDK's services still exist. */
50
+ const APP_FIRST = new Set(["preupgrade", "preuninst"]);
51
+ function head(name) {
52
+ return `${SHEBANG}# synopkgland: the SDK steps run in synopkgland_sdk; the App's dsm-scripts/${name} runs from scripts/app/${name}.\n${SDK_OPEN}`;
53
+ }
54
+ /**
55
+ * Exit codes keep DSM's meaning. The SDK step is never skipped once it is
56
+ * due: start-stop-status stop always stops the SDK even when the App's stop
57
+ * fails, and a failed App start stops the SDK again, because DSM then treats
58
+ * the package as stopped and must not leave its ingress or workers running.
59
+ */
60
+ function tail(name) {
61
+ const app = `synopkgland_app="$(dirname "$0")/app/${name}"\n`;
62
+ if (APP_FIRST.has(name))
63
+ return `${SDK_CLOSE}${app}"$synopkgland_app" "$@" || exit $?\nsynopkgland_sdk "$@"\n`;
64
+ if (name !== "start-stop-status")
65
+ return `${SDK_CLOSE}${app}synopkgland_sdk "$@" || exit $?\nexec "$synopkgland_app" "$@"\n`;
66
+ return `${SDK_CLOSE}${app}case "$1" in
67
+ start)
68
+ synopkgland_sdk "$@" || exit $?
69
+ "$synopkgland_app" "$@" && exit 0
70
+ synopkgland_result=$?
71
+ synopkgland_sdk stop
72
+ exit "$synopkgland_result" ;;
73
+ stop|prestop)
74
+ synopkgland_result=0
75
+ "$synopkgland_app" "$@" || synopkgland_result=$?
76
+ synopkgland_sdk "$@" || exit $?
77
+ exit "$synopkgland_result" ;;
78
+ restart)
79
+ "$0" stop || exit $?
80
+ exec "$0" start ;;
81
+ *)
82
+ synopkgland_sdk "$@" || exit $?
83
+ exec "$synopkgland_app" "$@" ;;
84
+ esac
85
+ `;
86
+ }
87
+ /** Wraps the SDK's script so it runs in a subshell, then calls the App's script of the same name. */
88
+ export function composeAppScript(name, sdkScript) {
89
+ if (!sdkScript.startsWith(SHEBANG))
90
+ throw new Error(`SDK script ${name} must start with ${SHEBANG.trim()}`);
91
+ return head(name) + sdkScript.slice(SHEBANG.length) + tail(name);
92
+ }
93
+ /** The SDK script inside `script`, or undefined when the App wrapper was edited. */
94
+ export function sdkScriptOf(name, script) {
95
+ if (!script.startsWith(`${SHEBANG}# synopkgland: the SDK steps`))
96
+ return { sdk: script, app: false };
97
+ const start = head(name), end = tail(name);
98
+ if (!script.startsWith(start) || !script.endsWith(end) || script.length < start.length + end.length)
99
+ return undefined;
100
+ return { sdk: SHEBANG + script.slice(start.length, script.length - end.length), app: true };
101
+ }
102
+ /**
103
+ * inspect: checks every App wrapper and App script, then writes the bare SDK
104
+ * script back into the extracted `scripts/` so the fixed-lifecycle checks
105
+ * compare the SDK steps alone. Returns the App scripts DSM will reach.
106
+ */
107
+ export function unwrapAppScripts(scriptsDir, findings) {
108
+ const appDir = join(scriptsDir, "app");
109
+ const appFiles = new Set(existsSync(appDir) ? readdirSync(appDir) : []);
110
+ const called = [];
111
+ for (const name of LIFECYCLE_SCRIPTS) {
112
+ const path = join(scriptsDir, name);
113
+ if (!existsSync(path))
114
+ continue;
115
+ const found = sdkScriptOf(name, readFileSync(path, "utf8"));
116
+ if (!found) {
117
+ findings.push(`scripts/${name} wraps an App script but differs from the wrapper synopkgland build writes.`);
118
+ continue;
119
+ }
120
+ if (!found.app)
121
+ continue;
122
+ if (!appFiles.has(name))
123
+ findings.push(`scripts/${name} calls scripts/app/${name}, which is missing.`);
124
+ else
125
+ called.push(name);
126
+ writeFileSync(path, found.sdk);
127
+ }
128
+ for (const name of [...appFiles].sort()) {
129
+ const path = join(appDir, name);
130
+ if (!called.includes(name)) {
131
+ findings.push(`scripts/app/${name} is not called by a generated lifecycle script.`);
132
+ continue;
133
+ }
134
+ const stat = lstatSync(path);
135
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o755)
136
+ findings.push(`scripts/app/${name} must be a regular file with mode 0755.`);
137
+ else if (!readFileSync(path).subarray(0, 2).equals(Buffer.from("#!")))
138
+ findings.push(`scripts/app/${name} does not start with #!.`);
139
+ }
140
+ return called;
141
+ }
@@ -17,6 +17,8 @@ export declare function stageScriptApp(m: StandaloneManifest, work: string): Pro
17
17
  * (spec/bridge/v1 SDKs). Returns the methods, sorted.
18
18
  */
19
19
  export declare function describeMethods(command: string, args: string[], cwd: string, env: Record<string, string>, timeoutMs?: number): Promise<string[]>;
20
+ /** The Python SDK package of an SDK release or checkout (its root directory). */
21
+ export declare function copyPythonSdk(sdkRoot: string, target: string): void;
20
22
  /** The Node SDK as npm would install it: its package.json and its compiled dist/. */
21
23
  export declare function copyNodeSdk(source: string, target: string): void;
22
24
  /** Copies an App tree: regular files and directories only, without dot entries and Python bytecode. */
@@ -24,7 +24,7 @@ export async function stageScriptApp(m, work) {
24
24
  if (backend.kind === "python") {
25
25
  const vendor = join(dir, PYTHON_VENDOR_DIR, PYTHON_SDK);
26
26
  rmSync(vendor, { recursive: true, force: true });
27
- copyAppTree(join(sdk.goDir, "python", PYTHON_SDK), vendor);
27
+ copyPythonSdk(sdk.goDir, vendor);
28
28
  }
29
29
  else {
30
30
  const modules = join(dir, "node_modules", NODE_SDK_PACKAGE);
@@ -91,6 +91,14 @@ export function describeMethods(command, args, cwd, env, timeoutMs = DESCRIBE_TI
91
91
  });
92
92
  });
93
93
  }
94
+ /** The Python SDK package of an SDK release or checkout (its root directory). */
95
+ export function copyPythonSdk(sdkRoot, target) {
96
+ const source = join(sdkRoot, "python", PYTHON_SDK);
97
+ if (!existsSync(join(source, "__init__.py"))) {
98
+ throw new StandaloneBuildError("STANDALONE_PYTHON_SDK_MISSING", `The SDK has no Python SDK: ${source}. Point SYNOPKGLAND_SDK_DIR at a syno-pkg-land-sdk checkout that has python/${PYTHON_SDK}, or use an SDK release that ships it.`);
99
+ }
100
+ copyAppTree(source, target);
101
+ }
94
102
  /** The Node SDK as npm would install it: its package.json and its compiled dist/. */
95
103
  export function copyNodeSdk(source, target) {
96
104
  if (!existsSync(join(source, "dist/index.js"))) {
@@ -23,7 +23,7 @@ export interface BridgeBackend {
23
23
  transfers?: Record<string, "upload" | "download">;
24
24
  /** The upload limit; absent, the runtime's 16 GiB. */
25
25
  maxUploadBytes?: number;
26
- /** The request deadline in seconds; absent, DEFAULT_REQUEST_TIMEOUT_SECONDS. */
26
+ /** The request deadline in seconds; absent, the SDK's request default. */
27
27
  timeoutSeconds?: number;
28
28
  }
29
29
  export declare function isBridgeBackend(backend: {
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { goModulePath, privateSdkHint, SDK_MODULE } from "../runtime/sdk-source.js";
2
+ import { goModulePath, privateSdkHint, SDK_MODULE, sdkSource } from "../runtime/sdk-source.js";
3
3
  import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
@@ -16,6 +16,7 @@ import { checkJobs, commitOutputs, forEachArch } from "./arch-jobs.js";
16
16
  import { isBridgeBackend } from "./bridge-backend.js";
17
17
  import { requestTimeoutSeconds } from "./request-timeout.js";
18
18
  import { buildBridge, prepareBridge } from "./bridge-build.js";
19
+ import { portalCatalogs } from "./portal-window.js";
19
20
  /** Argv only, never a shell; output streams to the terminal except stderr, which is also kept, and captured stdout. */
20
21
  export const runCommand = (command, args, options) => new Promise((done) => {
21
22
  const child = spawn(command, args, {
@@ -109,7 +110,9 @@ export async function buildStandalone(options) {
109
110
  const { cgi: cgiPath, worker: backgroundWorker, app } = await backendFor(builtManifest, arch, archWork, run, bridge);
110
111
  const outputPath = join(stage, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
111
112
  const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
112
- await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, backgroundWorker, executor, app });
113
+ // A portal needs the executor, so the executor's SDK carries the panel text.
114
+ const portalText = manifest.portal && executor ? portalCatalogs(sdkSource(executor.sdk).goDir) : undefined;
115
+ await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, backgroundWorker, executor, app, portalText });
113
116
  return outputPath;
114
117
  }));
115
118
  return { outputs, warnings: [...warnings] };
@@ -1,8 +1,14 @@
1
1
  import type { EntryMode, StandaloneManifest } from "./manifest.js";
2
+ import { type PortalCatalogs } from "./portal-window.js";
2
3
  import { type AppTitle } from "../build-spk/ui-texts.js";
3
4
  import { type PackageIcons } from "../build-spk/dsm-icons.js";
4
5
  /** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
5
6
  export declare const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
7
+ /**
8
+ * Relative to the page, as loadApp's assets and DSM's own WebAPI calls are: an
9
+ * Application Portal alias (https://nas/<alias>/) routes only its own paths,
10
+ * and /webman/... there answers 403.
11
+ */
6
12
  export declare function cgiUrl(packageId: string): string;
7
13
  /**
8
14
  * `createI18n(dsmLang, catalogs)` → `{ locale, t }`, plain ES5 so it runs in
@@ -19,8 +25,12 @@ export declare function windowScriptName(manifest: Pick<StandaloneManifest, "bui
19
25
  /** The launcher fields an entry mode fixes; inspect checks a built SPK against them. */
20
26
  export declare function entryLauncherAccess(entry: EntryMode): Record<string, unknown>;
21
27
  export declare function appTitle(manifest: StandaloneManifest): AppTitle;
22
- /** `ui/config`: registers the DSM main-menu app and its window class. */
23
- export declare function renderUiConfig(manifest: StandaloneManifest, icons?: PackageIcons): string;
28
+ /**
29
+ * `ui/config`: registers the DSM main-menu app and its window class. A portal
30
+ * App's launcher also carries the Login Portal style and its icon as the
31
+ * login page icon (DSM shows `loginIcon`, never `icon`, there).
32
+ */
33
+ export declare function renderUiConfig(manifest: StandaloneManifest, icons?: PackageIcons, loginStyle?: Record<string, string>): string;
24
34
  /**
25
35
  * The DSM AppWindow that hosts the project's UI. It loads app/app.js and
26
36
  * app/app.css once, then calls the app's mount(root, ctx). `ctx.call(method,
@@ -31,5 +41,6 @@ export declare function renderUiConfig(manifest: StandaloneManifest, icons?: Pac
31
41
  * launch owns the whole page: `ctx.fullPage` tells the app to lay out full-page
32
42
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
33
43
  * full-page shell shows beside the name, where a DSM window has a title bar.
44
+ * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
34
45
  */
35
- export declare function renderWindowScript(manifest: StandaloneManifest, assetVersion: string, icons?: PackageIcons): string;
46
+ export declare function renderWindowScript(manifest: StandaloneManifest, assetVersion: string, icons?: PackageIcons, portalText?: PortalCatalogs): string;
@@ -3,18 +3,22 @@ import { DSM_LANGS } from "./i18n.js";
3
3
  import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootPanelCatalogs } from "./root-window.js";
4
4
  import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
5
5
  import { permitsOrdinaryUsers } from "./api-permissions.js";
6
- import { PORTAL_RUNTIME } from "./portal-window.js";
6
+ import { portalRuntime } from "./portal-window.js";
7
7
  import { portalByExecutor } from "../build-spk/dsm-version.js";
8
8
  import { uiTitleFields } from "../build-spk/ui-texts.js";
9
9
  import { packageIcons, windowIconFile, windowIconTemplate } from "../build-spk/dsm-icons.js";
10
10
  import { DEFAULT_WINDOW, FULL_PAGE_MIN_WIDTH } from "./window-size.js";
11
11
  import { requestTimeoutSeconds } from "./request-timeout.js";
12
+ import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
12
13
  /** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
13
14
  export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
14
- /** The SDK's clientTimeout(requestTimeout) waits this much past the backend deadline. */
15
- const CLIENT_TIMEOUT_MARGIN = 2000;
15
+ /**
16
+ * Relative to the page, as loadApp's assets and DSM's own WebAPI calls are: an
17
+ * Application Portal alias (https://nas/<alias>/) routes only its own paths,
18
+ * and /webman/... there answers 403.
19
+ */
16
20
  export function cgiUrl(packageId) {
17
- return `/webman/3rdparty/${packageId}/cgi/api.cgi`;
21
+ return `webman/3rdparty/${packageId}/cgi/api.cgi`;
18
22
  }
19
23
  const LOCALE_BY_DSM_LANG = Object.fromEntries(Object.entries(DSM_LANGS).map(([locale, lang]) => [lang, locale]));
20
24
  /**
@@ -81,8 +85,12 @@ function displayNameCatalogs(manifest) {
81
85
  }
82
86
  return catalogs;
83
87
  }
84
- /** `ui/config`: registers the DSM main-menu app and its window class. */
85
- export function renderUiConfig(manifest, icons = packageIcons(manifest.icon)) {
88
+ /**
89
+ * `ui/config`: registers the DSM main-menu app and its window class. A portal
90
+ * App's launcher also carries the Login Portal style and its icon as the
91
+ * login page icon (DSM shows `loginIcon`, never `icon`, there).
92
+ */
93
+ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), loginStyle) {
86
94
  const icon = windowIconTemplate(icons);
87
95
  const ns = `SYNO.SDS.${manifest.package}`;
88
96
  const titleFields = uiTitleFields(appTitle(manifest));
@@ -95,6 +103,7 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon)) {
95
103
  appWindow: `${ns}.MainWindow`,
96
104
  allowMultiInstance: false,
97
105
  ...launcherAccess(manifest),
106
+ ...(loginStyle ? { loginIcon: icon, loginStyle } : {}),
98
107
  depend: [`${ns}.MainWindow`],
99
108
  },
100
109
  [`${ns}.MainWindow`]: {
@@ -117,21 +126,25 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon)) {
117
126
  * launch owns the whole page: `ctx.fullPage` tells the app to lay out full-page
118
127
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
119
128
  * full-page shell shows beside the name, where a DSM window has a title bar.
129
+ * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
120
130
  */
121
- export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon)) {
131
+ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon), portalText) {
122
132
  const pkg = manifest.package;
123
133
  const ns = `SYNO.SDS.${pkg}`;
124
134
  const p = JSON.stringify(pkg);
125
135
  const root = manifest.executor ? renderRootParts(ns, manifest) : undefined;
126
136
  const size = manifest.ui.window ?? DEFAULT_WINDOW;
127
137
  const requestTimeout = requestTimeoutSeconds(manifest.backend) * 1000;
138
+ const clientMargin = appSdkTimeouts(manifest.backend).browserMarginMs;
139
+ if (manifest.portal && !portalText)
140
+ throw new Error("A portal App's window needs the SDK's portal panel text.");
128
141
  return `/* Generated by synopkgland build. Edit your UI in the project's ui folder. */
129
142
  Ext.ns(${JSON.stringify(ns)});
130
143
 
131
144
  // The SDK's clientTimeout: past the backend's request deadline
132
145
  // (ctx.requestTimeout, from backend.timeout_seconds), so its request_timeout
133
146
  // reply arrives first. options.signal aborts the fetch, for a page the user leaves.
134
- ${ns}.call = function (method, params, options, requestTimeout) {
147
+ ${ns}.callOnce = function (method, params, options, requestTimeout) {
135
148
  var signal = options && options.signal;
136
149
  var canceled = { code: "request_canceled", message: "The request was canceled" };
137
150
  if (signal && signal.aborted) {
@@ -151,7 +164,7 @@ ${ns}.call = function (method, params, options, requestTimeout) {
151
164
  var leave = function () { stop(canceled); };
152
165
  var timer = setTimeout(function () {
153
166
  stop({ code: "request_timeout", message: "The NAS did not answer in time" });
154
- }, (requestTimeout || ${requestTimeout}) + ${CLIENT_TIMEOUT_MARGIN});
167
+ }, (requestTimeout || ${requestTimeout}) + ${clientMargin});
155
168
  if (signal) signal.addEventListener("abort", leave);
156
169
  var done = function () {
157
170
  clearTimeout(timer);
@@ -178,6 +191,39 @@ ${ns}.call = function (method, params, options, requestTimeout) {
178
191
  });
179
192
  };
180
193
 
194
+ // APP_STOPPED, APP_STARTING and EXECUTOR_STARTING mean the executor op was not
195
+ // sent: the package is stopped, or starting. The call repeats on its own, half a
196
+ // second, then doubling up to 8 s, for 30 s of waiting in all; each attempt keeps
197
+ // its own deadline. Past that it rejects with the last refusal, and leaving the
198
+ // page during a wait rejects request_canceled at once.
199
+ ${ns}.retryCodes = ["APP_STOPPED", "APP_STARTING", "EXECUTOR_STARTING"];
200
+ ${ns}.call = function (method, params, options, requestTimeout) {
201
+ var signal = options && options.signal;
202
+ var left = 30000;
203
+ var delay = 500;
204
+ var attempt = function () {
205
+ return ${ns}.callOnce(method, params, options, requestTimeout).catch(function (error) {
206
+ if (left <= 0 || !error || ${ns}.retryCodes.indexOf(error.code) < 0) throw error;
207
+ var wait = Math.min(delay, left);
208
+ left -= wait;
209
+ delay = Math.min(delay * 2, 8000);
210
+ return new Promise(function (resolve, reject) {
211
+ var leave = function () {
212
+ clearTimeout(timer);
213
+ reject({ code: "request_canceled", message: "The request was canceled" });
214
+ };
215
+ var timer = setTimeout(function () {
216
+ if (signal) signal.removeEventListener("abort", leave);
217
+ resolve(attempt());
218
+ }, wait);
219
+ if (signal && signal.aborted) leave();
220
+ else if (signal) signal.addEventListener("abort", leave);
221
+ });
222
+ });
223
+ };
224
+ return attempt();
225
+ };
226
+
181
227
  ${ns}.messages = ${JSON.stringify(manifest.i18n?.catalogs ?? { en: {} })};
182
228
  ${ns}.createI18n = (function () {
183
229
  ${I18N_RUNTIME}
@@ -201,7 +247,7 @@ ${ns}.sessionUser = function () {
201
247
  return { name: typeof session.user === "string" ? session.user : "", isAdmin: session.is_admin === true };
202
248
  };
203
249
 
204
- ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns) : ""}${ns}.loadApp = function (done) {
250
+ ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns, portalText) : ""}${ns}.loadApp = function (done) {
205
251
  var apps = window.DsmPackageApps || {};
206
252
  if (apps[${p}]) {
207
253
  done(apps[${p}]);
@@ -321,9 +367,9 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
321
367
  });
322
368
  `;
323
369
  }
324
- function renderPortalDefinition(ns) {
370
+ function renderPortalDefinition(ns, text) {
325
371
  return `${ns}.createPortalAccess = (function () {
326
- ${PORTAL_RUNTIME}
372
+ ${portalRuntime(text)}
327
373
  return createPortalAccess;
328
374
  })();
329
375