@synopackageland/cli 0.15.4 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -8
- package/README.md +8 -1
- package/assets/init-compose.md +33 -0
- package/assets/init-upstream/build-source.sh +10 -0
- package/assets/init-upstream/update-image.mjs +26 -0
- package/assets/init-upstream/upgrade.sh +47 -0
- package/assets/init-upstream/version.mjs +12 -0
- package/dist/add.js +3 -3
- package/dist/build-spk/uninstall-wizard.d.ts +1 -1
- package/dist/build-spk/uninstall-wizard.js +1 -1
- package/dist/build-spk/wizard-messages.js +9 -9
- package/dist/cli.js +16 -2
- package/dist/contract/validate.js +2 -2
- package/dist/e2e/dsm.d.ts +2 -2
- package/dist/e2e/dsm.js +3 -3
- package/dist/error-registry.js +8 -4
- package/dist/host/share-permission.d.ts +1 -1
- package/dist/host/share-permission.js +1 -1
- package/dist/init-upstream.d.ts +7 -0
- package/dist/init-upstream.js +64 -0
- package/dist/init.d.ts +4 -0
- package/dist/init.js +37 -1
- package/dist/portal/alias.d.ts +2 -6
- package/dist/portal/alias.js +2 -6
- package/dist/portal/info.d.ts +4 -0
- package/dist/portal/info.js +32 -0
- package/dist/runtime/dsm-version.d.ts +1 -1
- package/dist/runtime/dsm-version.js +1 -1
- package/dist/runtime/executor-digests.json +4 -26
- package/dist/runtime/sdk-batch.d.ts +2 -0
- package/dist/runtime/sdk-batch.js +23 -0
- package/dist/runtime/uninstall-cleanup.js +1 -1
- package/dist/skill.js +1 -0
- package/dist/standalone/build.js +8 -1
- package/dist/standalone/compose-errors.js +15 -0
- package/dist/standalone/compose.d.ts +8 -0
- package/dist/standalone/compose.js +13 -3
- package/dist/standalone/dsm-window.js +25 -4
- package/dist/standalone/identity-gateway.js +1 -0
- package/dist/standalone/inspect-spk.js +17 -3
- package/dist/standalone/lifecycle.js +4 -1
- package/dist/standalone/manifest.js +7 -2
- package/dist/standalone/native-layout.js +6 -2
- package/dist/standalone/portal-window.js +1 -1
- package/dist/standalone/spk.d.ts +3 -0
- package/dist/standalone/spk.js +9 -2
- package/package.json +3 -3
- package/skills/SKILL.md +8 -14
- package/skills/reference/compose.md +25 -8
- package/skills/reference/create.md +4 -4
- package/skills/reference/portal.md +10 -6
- package/skills/reference/ui-vue.md +12 -0
- package/skills/reference/upstream-fork.md +165 -0
- package/skills/reference/wrap.md +3 -1
package/dist/init.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateForkRecipe, writeForkScaffold } from "./init-upstream.js";
|
|
1
2
|
import { goChildEnv } from "./runtime/go-env.js";
|
|
2
3
|
import { spawnSync } from "node:child_process";
|
|
3
4
|
import { parse, parseDocument } from "yaml";
|
|
@@ -112,6 +113,30 @@ function isTemplateBuildArtifact(path) {
|
|
|
112
113
|
return (name === "node_modules" || name === "dist" || name === ".go-results.json");
|
|
113
114
|
}
|
|
114
115
|
export function initFromTemplate(template, targetDir, options = {}) {
|
|
116
|
+
if (options.upstreamFork) {
|
|
117
|
+
if (template !== "compose" && template !== "native-go")
|
|
118
|
+
throw new Error("[INIT_OPTIONS_UNSUPPORTED] --upstream-fork supports compose or native-go.");
|
|
119
|
+
validateForkRecipe(template, options);
|
|
120
|
+
const packaging = join(targetDir, "synology");
|
|
121
|
+
if (existsSync(packaging))
|
|
122
|
+
throw new Error("[INIT_TARGET_NOT_EMPTY] synology/ already exists.");
|
|
123
|
+
initFromTemplate(template, packaging, {
|
|
124
|
+
...options,
|
|
125
|
+
packageId: options.packageId ?? defaultPackageId(targetDir),
|
|
126
|
+
displayName: options.displayName ?? defaultDisplayName(targetDir),
|
|
127
|
+
upstreamFork: false,
|
|
128
|
+
upstreamImage: undefined,
|
|
129
|
+
});
|
|
130
|
+
writeForkScaffold(packaging, template, options);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (options.upstreamImage)
|
|
134
|
+
throw new Error("[INIT_OPTIONS_UNSUPPORTED] Image recipe options require --upstream-fork.");
|
|
135
|
+
const hasHealth = options.healthPath !== undefined || options.healthStatus !== undefined;
|
|
136
|
+
if (hasHealth && (template !== "compose" || typeof options.healthPath !== "string" ||
|
|
137
|
+
!/^\/(?!\/)[^\s#\\]*$/.test(options.healthPath) || !Number.isInteger(options.healthStatus) ||
|
|
138
|
+
options.healthStatus < 200 || options.healthStatus > 299))
|
|
139
|
+
throw new Error("[INIT_HEALTH_INVALID] Compose health needs both --health-path /actual-endpoint and --health-status 2xx; redirects are not followed.");
|
|
115
140
|
if (!isTemplateName(template))
|
|
116
141
|
throw new Error(`[INIT_TEMPLATE_UNKNOWN] Unknown template: ${template}`);
|
|
117
142
|
const requested = capabilityNames(options.with ?? []);
|
|
@@ -261,6 +286,18 @@ export function initFromTemplate(template, targetDir, options = {}) {
|
|
|
261
286
|
}
|
|
262
287
|
if (template === "compose" && options.from)
|
|
263
288
|
importCompose(options.from, targetDir);
|
|
289
|
+
if (template === "compose") {
|
|
290
|
+
const path = join(targetDir, "dsm-package.yaml");
|
|
291
|
+
const doc = parseDocument(readFileSync(path, "utf8"));
|
|
292
|
+
if (doc.hasIn(["compose", "web"])) {
|
|
293
|
+
doc.deleteIn(["compose", "web", "health"]);
|
|
294
|
+
if (hasHealth)
|
|
295
|
+
doc.setIn(["compose", "web", "health"], { path: options.healthPath, status: options.healthStatus });
|
|
296
|
+
}
|
|
297
|
+
writeFileSync(path, String(doc));
|
|
298
|
+
const guidance = readFileSync(new URL("../assets/init-compose.md", import.meta.url), "utf8");
|
|
299
|
+
writeFileSync(join(targetDir, "AGENTS.md"), guidance);
|
|
300
|
+
}
|
|
264
301
|
if (template === "binary") {
|
|
265
302
|
mkdirSync(join(targetDir, "prebuilt"), { recursive: true });
|
|
266
303
|
for (const [arch, goarch] of [
|
|
@@ -356,7 +393,6 @@ function importCompose(from, targetDir) {
|
|
|
356
393
|
if (candidates.length === 1)
|
|
357
394
|
doc.setIn(["compose", "web"], {
|
|
358
395
|
...candidates[0],
|
|
359
|
-
health: { path: "/", status: 200 },
|
|
360
396
|
});
|
|
361
397
|
else {
|
|
362
398
|
doc.deleteIn(["compose", "web"]);
|
package/dist/portal/alias.d.ts
CHANGED
|
@@ -11,12 +11,8 @@ export declare const PORTAL_ALIAS_MAX = 64;
|
|
|
11
11
|
* of Synology Apps, as observed on DSM 7.4-90069. DSM stays the authority.
|
|
12
12
|
*/
|
|
13
13
|
export declare const RESERVED_PORTAL_ALIASES: readonly string[];
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
* but leaves nginx serving the alias, and only the root executor purge can
|
|
17
|
-
* reload it. A no-privilege App never runs anything as root.
|
|
18
|
-
*/
|
|
19
|
-
export declare const PORTAL_REQUIRES_EXECUTOR = "portal needs the executor level (capabilities.rootOps or delegate): after uninstall DSM does not reload nginx, and a root executor must remove the sign-in alias. An App without an executor should remove portal and let an administrator configure a reverse proxy in DSM.";
|
|
14
|
+
/** DSM below 7.2 refuses unsigned web-config; only an executor can register the entry there. */
|
|
15
|
+
export declare const PORTAL_REQUIRES_EXECUTOR = "portal before DSM 7.2-64570 needs an executor (capabilities.rootOps, delegate or webapi) to register its entry: DSM 7.1 rejects the unsigned web-config worker. Raise os_min_ver to 7.2-64570 or later, add an executor capability, or remove portal. DSM reloads nginx through INFO; a no-privilege App never runs root code.";
|
|
20
16
|
export interface PortalDeclaration {
|
|
21
17
|
alias: string;
|
|
22
18
|
}
|
package/dist/portal/alias.js
CHANGED
|
@@ -15,12 +15,8 @@ export const RESERVED_PORTAL_ALIASES = [
|
|
|
15
15
|
"dsfile", "indexdb", "mobile", "oauth", "scripts", "sharing", "ssdp", "synosdsjslib", "synohdpack", "webapi", "webman",
|
|
16
16
|
"calendar", "chatplus", "contacts", "drive", "file", "photo", "sso", "synologymeet",
|
|
17
17
|
];
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
* but leaves nginx serving the alias, and only the root executor purge can
|
|
21
|
-
* reload it. A no-privilege App never runs anything as root.
|
|
22
|
-
*/
|
|
23
|
-
export const PORTAL_REQUIRES_EXECUTOR = "portal needs the executor level (capabilities.rootOps or delegate): after uninstall DSM does not reload nginx, and a root executor must remove the sign-in alias. An App without an executor should remove portal and let an administrator configure a reverse proxy in DSM.";
|
|
18
|
+
/** DSM below 7.2 refuses unsigned web-config; only an executor can register the entry there. */
|
|
19
|
+
export const PORTAL_REQUIRES_EXECUTOR = "portal before DSM 7.2-64570 needs an executor (capabilities.rootOps, delegate or webapi) to register its entry: DSM 7.1 rejects the unsigned web-config worker. Raise os_min_ver to 7.2-64570 or later, add an executor capability, or remove portal. DSM reloads nginx through INFO; a no-privilege App never runs root code.";
|
|
24
20
|
export function isPortalAlias(value) {
|
|
25
21
|
return typeof value === "string" && PORTAL_ALIAS_PATTERN.test(value) && !RESERVED_PORTAL_ALIASES.includes(value);
|
|
26
22
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** DSM reloads these services after install, upgrade and uninstall; hooks stay unprivileged. */
|
|
2
|
+
export declare function portalInfoFields(fields: Array<[string, string]>, portal: boolean): Array<[string, string]>;
|
|
3
|
+
/** Check a resource-worker Portal. The legacy app.d registration still needs an executor. */
|
|
4
|
+
export declare function inspectPortalInfo(info: Record<string, string>, hasExecutor: boolean): string[];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { portalByExecutor } from "../build-spk/dsm-version.js";
|
|
2
|
+
const RELOAD_SERVICES = "instuninst_restart_services";
|
|
3
|
+
const NGINX = "nginx.service";
|
|
4
|
+
/** DSM reloads these services after install, upgrade and uninstall; hooks stay unprivileged. */
|
|
5
|
+
export function portalInfoFields(fields, portal) {
|
|
6
|
+
if (!portal)
|
|
7
|
+
return fields;
|
|
8
|
+
const services = [...new Set([...fields.filter(([key]) => key === RELOAD_SERVICES)
|
|
9
|
+
.flatMap(([, value]) => value.split(/\s+/).filter(Boolean)), NGINX])].join(" ");
|
|
10
|
+
const result = [];
|
|
11
|
+
let written = false;
|
|
12
|
+
for (const [key, value] of fields) {
|
|
13
|
+
if (key !== RELOAD_SERVICES)
|
|
14
|
+
result.push([key, value]);
|
|
15
|
+
else if (!written) {
|
|
16
|
+
result.push([key, services]);
|
|
17
|
+
written = true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (!written)
|
|
21
|
+
result.push([RELOAD_SERVICES, services]);
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
/** Check a resource-worker Portal. The legacy app.d registration still needs an executor. */
|
|
25
|
+
export function inspectPortalInfo(info, hasExecutor) {
|
|
26
|
+
const findings = [];
|
|
27
|
+
if (!(info[RELOAD_SERVICES] ?? "").split(/\s+/).includes(NGINX))
|
|
28
|
+
findings.push(`PORTAL_RELOAD_MISSING INFO ${RELOAD_SERVICES} must include ${NGINX} so DSM reloads nginx after uninstall`);
|
|
29
|
+
if (info.os_min_ver && portalByExecutor(info.os_min_ver) && !hasExecutor)
|
|
30
|
+
findings.push("PORTAL_REQUIRES_EXECUTOR before DSM 7.2-64570 the portal entry must be registered by an executor; raise os_min_ver or declare an executor capability");
|
|
31
|
+
return findings;
|
|
32
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Self-contained: the same function is embedded in the DSM window. True only
|
|
2
2
|
* when DSM refused a request because this method version is unavailable, the
|
|
3
|
-
* one refusal that may be retried with an older version. DSM 7.1.1
|
|
3
|
+
* one refusal that may be retried with an older version. DSM 7.1.1
|
|
4
4
|
* returns 104 for a version above maxVersion and 103 for a method absent at an
|
|
5
5
|
* existing version. Accepts the numeric code and the window's dsm_ form. */
|
|
6
6
|
export declare function versionUnsupported(code: unknown): boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Self-contained: the same function is embedded in the DSM window. True only
|
|
2
2
|
* when DSM refused a request because this method version is unavailable, the
|
|
3
|
-
* one refusal that may be retried with an older version. DSM 7.1.1
|
|
3
|
+
* one refusal that may be retried with an older version. DSM 7.1.1
|
|
4
4
|
* returns 104 for a version above maxVersion and 103 for a method absent at an
|
|
5
5
|
* existing version. Accepts the numeric code and the window's dsm_ form. */
|
|
6
6
|
export function versionUnsupported(code) {
|
|
@@ -1,30 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"0.
|
|
3
|
-
"x86_64": "
|
|
4
|
-
"aarch64": "
|
|
5
|
-
"armv7": "
|
|
6
|
-
"lifecycle": {
|
|
7
|
-
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
8
|
-
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
9
|
-
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
10
|
-
},
|
|
11
|
-
"goToolchain": "go1.26.9"
|
|
12
|
-
},
|
|
13
|
-
"0.15.1": {
|
|
14
|
-
"x86_64": "4f47fb43f6e60dbad064afb0bbec96dd009fa046670e2c4b590b128a855cb404",
|
|
15
|
-
"aarch64": "c903b09ad657a838f0c62b6688e65e9094572b22b969770fca92fcc3640a5364",
|
|
16
|
-
"armv7": "8178d90de7ea720530bb9496e50ed118086b0e57382d84675aadc68f482d219f",
|
|
17
|
-
"lifecycle": {
|
|
18
|
-
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
19
|
-
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
20
|
-
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
21
|
-
},
|
|
22
|
-
"goToolchain": "go1.26.9"
|
|
23
|
-
},
|
|
24
|
-
"0.15.2": {
|
|
25
|
-
"x86_64": "4f47fb43f6e60dbad064afb0bbec96dd009fa046670e2c4b590b128a855cb404",
|
|
26
|
-
"aarch64": "c903b09ad657a838f0c62b6688e65e9094572b22b969770fca92fcc3640a5364",
|
|
27
|
-
"armv7": "8178d90de7ea720530bb9496e50ed118086b0e57382d84675aadc68f482d219f",
|
|
2
|
+
"0.17.0": {
|
|
3
|
+
"x86_64": "68f372684997258adc61312f2c353e1a286a6b5aaa556f716b4e6ba6f33893d0",
|
|
4
|
+
"aarch64": "f6950301e201a4ebc14577a4554fa1b671f90d0f354d1e8809f4d1dafb57c723",
|
|
5
|
+
"armv7": "e77c9b6ae22ecfa85d9834af3350192e445987265a7ca6c02ca9b01d88a1b4f7",
|
|
28
6
|
"lifecycle": {
|
|
29
7
|
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
30
8
|
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
@@ -0,0 +1,23 @@
|
|
|
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 plain browser factory, embedded without imposing a TS build on Go Apps. */
|
|
5
|
+
export function appSdkBatchRuntime(backend, env = process.env) {
|
|
6
|
+
if (backend.kind === 'binary')
|
|
7
|
+
return '';
|
|
8
|
+
const version = appSdkVersion(backend);
|
|
9
|
+
if (!version || sdkBelowFloor(version))
|
|
10
|
+
return '';
|
|
11
|
+
const sdk = sdkSource(version, env);
|
|
12
|
+
let source;
|
|
13
|
+
try {
|
|
14
|
+
source = readFileSync(join(sdk.goDir, 'ts/src/batch.js'), 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
// Older supported SDKs retain their desktop-only carrier behavior.
|
|
18
|
+
if (error.code === 'ENOENT')
|
|
19
|
+
return '';
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
return source.replace(/^export function createBatch\(/m, 'function createBatch(');
|
|
23
|
+
}
|
|
@@ -16,7 +16,7 @@ export function uninstallCleanup(pkg, { executor = false, portal = false, portal
|
|
|
16
16
|
executor
|
|
17
17
|
? { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: AUTOMATIC_FAILED, action: 'The root executor removes these rules automatically through the Application Privileges API on uninstall. Administrator: remove surviving rules in Control Panel > Application Privileges; DSM would apply them again to a later install under the same name.' }
|
|
18
18
|
: { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: 'After uninstall, if this package will not be reinstalled.', action: 'DSM keeps these rules in /etc/synoappprivilege.db and applies them again to a later install under the same name. Administrator: remove them in Control Panel > Application Privileges.' },
|
|
19
|
-
...(
|
|
19
|
+
...(portal ? [{ target: `${pkg} Login Portal alias`, condition: 'Only if automatic cleanup failed and the alias still opens a DSM page after the asynchronous reload.', action: 'DSM removes the alias configuration and reloads the web server automatically through INFO instuninst_restart_services. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
|
|
20
20
|
...(executor && portalEntry ? [{ target: `${pkg} Application Portal entry`, condition: 'After uninstall, if the executor was not running or was never approved, so it could not remove the entry.', action: `Administrator, as root: ${portalEntryCleanupCommands(pkg).join('; ')}` }] : []),
|
|
21
21
|
{ target: `${pkg} package account/group`, condition: 'After uninstall, only if this package will not be reinstalled and the identity is unused.', action: 'Administrator: review DSM internal account/group and associated ACLs before cleanup. Package scripts do not delete system identities.' },
|
|
22
22
|
];
|
package/dist/skill.js
CHANGED
|
@@ -14,6 +14,7 @@ const SKILL_CATALOG = [
|
|
|
14
14
|
file: "reference/compose.md",
|
|
15
15
|
when: "Upstream Compose with the SDK planner, fixed identity and support boundary",
|
|
16
16
|
},
|
|
17
|
+
{ id: "upstream-fork", file: "reference/upstream-fork.md", when: "Package an upstream source fork without changing upstream files" },
|
|
17
18
|
{
|
|
18
19
|
id: "wrap",
|
|
19
20
|
file: "reference/wrap.md",
|
package/dist/standalone/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
|
|
1
2
|
import { buildApiDescription } from "./api-description.js";
|
|
2
3
|
import { goChildEnv } from "../runtime/go-env.js";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
@@ -10,7 +11,7 @@ import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM,
|
|
|
10
11
|
import { checkCgiBinary } from "./binary.js";
|
|
11
12
|
import { loadStandaloneManifest, } from "./manifest.js";
|
|
12
13
|
import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
|
|
13
|
-
import { buildComposeControl, lockComposeImages, dsmComposeProblem, } from "./compose.js";
|
|
14
|
+
import { buildComposeControl, buildComposeStorageInit, lockComposeImages, dsmComposeProblem, } from "./compose.js";
|
|
14
15
|
import { buildDaemonControl } from "./daemon.js";
|
|
15
16
|
import { buildIdentityGateway } from "./identity-gateway.js";
|
|
16
17
|
import { buildExecutor } from "../runtime/executor-build.js";
|
|
@@ -113,6 +114,10 @@ export async function buildStandalone(options) {
|
|
|
113
114
|
!isDsmAtLeast(manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
|
|
114
115
|
throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
|
|
115
116
|
}
|
|
117
|
+
if (manifest.portal && portalByExecutor(manifest.os_min_ver) &&
|
|
118
|
+
!manifest.capabilities.rootOps && !manifest.capabilities.delegate && !manifest.capabilities.webapi) {
|
|
119
|
+
throw new StandaloneBuildError("STANDALONE_PORTAL_REQUIRES_EXECUTOR", PORTAL_REQUIRES_EXECUTOR);
|
|
120
|
+
}
|
|
116
121
|
if (manifest.compose && !isDsmAtLeast(manifest.os_min_ver, "7.2.1-69057"))
|
|
117
122
|
throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose identity_gateway requires DSM >= 7.2.1.");
|
|
118
123
|
const composeProblem = manifest.compose && dsmComposeProblem(manifest.compose);
|
|
@@ -148,6 +153,7 @@ export async function buildStandalone(options) {
|
|
|
148
153
|
const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
|
|
149
154
|
const identityGateway = await buildIdentityGateway(manifest, arch, archWork, run);
|
|
150
155
|
const composeControl = await buildComposeControl(manifest, arch, archWork, run);
|
|
156
|
+
const composeStorageInit = await buildComposeStorageInit(manifest, arch, archWork, run);
|
|
151
157
|
// A portal needs the executor, so the executor's SDK carries the panel text.
|
|
152
158
|
const portalText = manifest.portal && executor
|
|
153
159
|
? portalCatalogs(sdkSource(executor.sdk).goDir)
|
|
@@ -160,6 +166,7 @@ export async function buildStandalone(options) {
|
|
|
160
166
|
daemonControl,
|
|
161
167
|
identityGateway,
|
|
162
168
|
composeControl,
|
|
169
|
+
composeStorageInit,
|
|
163
170
|
backgroundWorker,
|
|
164
171
|
executor,
|
|
165
172
|
app,
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
/** SDK planner errors accepted at this versioned tool boundary. */
|
|
2
2
|
export const COMPOSE_ERRORS = {
|
|
3
|
+
COMPOSE_STORAGE_INIT_INVALID: {
|
|
4
|
+
code: "COMPOSE_STORAGE_INIT_INVALID",
|
|
5
|
+
description: "The container storage initialization declaration is invalid.",
|
|
6
|
+
suggestion: "Declare private subdirectories on writable mounts and explicit upstream entrypoint/command argv; keep the service nonroot. The SDK initializer is supplied by the SPK.",
|
|
7
|
+
},
|
|
8
|
+
COMPOSE_STORAGE_NOT_WRITABLE: {
|
|
9
|
+
code: "COMPOSE_STORAGE_NOT_WRITABLE",
|
|
10
|
+
description: "The package UID cannot initialize or write its private container data.",
|
|
11
|
+
suggestion: "Use a writable mount base with a package-owned private subtree; inspect ownership, permissions, read-only storage, quota and free space. Do not add root or chown hooks.",
|
|
12
|
+
},
|
|
13
|
+
COMPOSE_STORAGE_COMMAND_INVALID: {
|
|
14
|
+
code: "COMPOSE_STORAGE_COMMAND_INVALID",
|
|
15
|
+
description: "The application command after storage preflight cannot be executed.",
|
|
16
|
+
suggestion: "Declare the actual upstream executable and arguments. Keep the upstream official image unchanged; the SPK supplies the SDK initializer.",
|
|
17
|
+
},
|
|
3
18
|
COMPOSE_SUBNET_STATE_INVALID: {
|
|
4
19
|
code: "COMPOSE_SUBNET_STATE_INVALID",
|
|
5
20
|
description: "The private ingress history is invalid or has foreign ownership.",
|
|
@@ -23,6 +23,12 @@ export interface ComposePlan {
|
|
|
23
23
|
mounts_blocked: boolean;
|
|
24
24
|
acceptance_blockers: string[];
|
|
25
25
|
services: Array<{
|
|
26
|
+
storage_init?: {
|
|
27
|
+
directories: Array<{
|
|
28
|
+
base: string;
|
|
29
|
+
path: string;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
26
32
|
service: string;
|
|
27
33
|
user: string;
|
|
28
34
|
container_root: boolean;
|
|
@@ -96,6 +102,8 @@ export declare function writeComposePayload(c: ComposeApp, root: string, target:
|
|
|
96
102
|
export declare function renderComposeInstall(pkg: string): string;
|
|
97
103
|
/** The same SDK entry point supplies the package-account install materializer. */
|
|
98
104
|
export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
|
|
105
|
+
/** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
|
|
106
|
+
export declare function buildComposeStorageInit(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
|
|
99
107
|
/** Only the build resolves registry metadata; check and inspect remain offline. */
|
|
100
108
|
export declare function lockComposeImages(m: StandaloneManifest, arch: NativeArchFamily, resolveImages: boolean, imageProofs?: unknown): StandaloneManifest;
|
|
101
109
|
export declare function composeInputs(root: string, file: string, envFile?: string): ComposePlan["inputs"];
|
|
@@ -194,6 +194,7 @@ export function writeComposePayload(c, root, target, gateway) {
|
|
|
194
194
|
export function renderComposeInstall(pkg) {
|
|
195
195
|
return `#!/bin/sh
|
|
196
196
|
set -eu
|
|
197
|
+
"\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" preflight || exit $?
|
|
197
198
|
exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
|
|
198
199
|
`;
|
|
199
200
|
}
|
|
@@ -201,10 +202,19 @@ exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNO
|
|
|
201
202
|
export async function buildComposeControl(m, arch, work, run) {
|
|
202
203
|
if (!m.compose)
|
|
203
204
|
return;
|
|
205
|
+
return buildComposeTool(m, arch, work, run, "plan");
|
|
206
|
+
}
|
|
207
|
+
/** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
|
|
208
|
+
export async function buildComposeStorageInit(m, arch, work, run) {
|
|
209
|
+
if (!m.compose?.plan.services.some((s) => s.storage_init))
|
|
210
|
+
return;
|
|
211
|
+
return buildComposeTool(m, arch, work, run, "storage-init");
|
|
212
|
+
}
|
|
213
|
+
async function buildComposeTool(m, arch, work, run, tool) {
|
|
204
214
|
const { pinnedGoBuildEnv, pinnedGoProblem } = await import("../build-spk/go-toolchain.js");
|
|
205
215
|
const { checkCgiBinary } = await import("./binary.js");
|
|
206
216
|
const { StandaloneBuildError } = await import("./spk.js");
|
|
207
|
-
const out = join(work, `compose
|
|
217
|
+
const out = join(work, `compose-${tool === "plan" ? "control" : tool}-${arch}`);
|
|
208
218
|
const source = sdkSource(m.compose.sdk);
|
|
209
219
|
const result = await run("go", [
|
|
210
220
|
"build",
|
|
@@ -214,13 +224,13 @@ export async function buildComposeControl(m, arch, work, run) {
|
|
|
214
224
|
"-s -w -buildid=",
|
|
215
225
|
"-o",
|
|
216
226
|
out,
|
|
217
|
-
|
|
227
|
+
`./compose/cmd/${tool}`,
|
|
218
228
|
], {
|
|
219
229
|
cwd: source.goDir,
|
|
220
230
|
env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
|
|
221
231
|
});
|
|
222
232
|
if (result.status !== 0 || result.missing)
|
|
223
|
-
throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose
|
|
233
|
+
throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose ${tool}: ${result.stderr}`);
|
|
224
234
|
const error = checkCgiBinary(work, out, arch);
|
|
225
235
|
if (error)
|
|
226
236
|
throw new StandaloneBuildError(error.code, error.message);
|
|
@@ -12,6 +12,7 @@ import { packageIcons, windowIconFile, windowIconTemplate, } from "../build-spk/
|
|
|
12
12
|
import { DEFAULT_WINDOW, FULL_PAGE_MIN_WIDTH } from "./window-size.js";
|
|
13
13
|
import { requestTimeoutSeconds } from "./request-timeout.js";
|
|
14
14
|
import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
|
|
15
|
+
import { appSdkBatchRuntime } from "../runtime/sdk-batch.js";
|
|
15
16
|
import { appSdkSessionRuntime } from "../runtime/sdk-session.js";
|
|
16
17
|
/** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
|
|
17
18
|
export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
|
|
@@ -171,6 +172,7 @@ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(
|
|
|
171
172
|
const requestTimeout = requestTimeoutSeconds(manifest.backend) * 1000;
|
|
172
173
|
const clientMargin = appSdkTimeouts(manifest.backend).browserMarginMs;
|
|
173
174
|
const sessionRuntime = appSdkSessionRuntime(manifest.backend);
|
|
175
|
+
const batchRuntime = appSdkBatchRuntime(manifest.backend);
|
|
174
176
|
if (manifest.portal && !portalText)
|
|
175
177
|
throw new Error("A portal App's window needs the SDK's portal panel text.");
|
|
176
178
|
return `/* Generated by synopkgland build. Edit your UI in the project's ui folder. */
|
|
@@ -186,13 +188,17 @@ ${sessionRuntime}
|
|
|
186
188
|
fetch: function (url, options) { return fetch(url, options); }
|
|
187
189
|
});
|
|
188
190
|
})();
|
|
191
|
+
// DSM loads this script when the App launches. A page it gave no SynoToken
|
|
192
|
+
// (Portal, standalone) asks for one now, while app.js downloads, so the
|
|
193
|
+
// first call does not wait a round trip for it after the App mounts.
|
|
194
|
+
if (${ns}.session && !${ns}.session.token()) ${ns}.session.ready();
|
|
189
195
|
`
|
|
190
196
|
: ""}
|
|
191
197
|
|
|
192
198
|
// The SDK's clientTimeout: past the backend's request deadline
|
|
193
199
|
// (ctx.requestTimeout, from backend.timeout_seconds), so its request_timeout
|
|
194
200
|
// reply arrives first. options.signal aborts the fetch, for a page the user leaves.
|
|
195
|
-
${ns}.callOnce = function (method, params, options, requestTimeout) {
|
|
201
|
+
${ns}.callOnce = function (method, params, options, requestTimeout, envelope) {
|
|
196
202
|
var signal = options && options.signal;
|
|
197
203
|
var canceled = { code: "request_canceled", message: "The request was canceled" };
|
|
198
204
|
if (signal && signal.aborted) {
|
|
@@ -224,7 +230,7 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
|
|
|
224
230
|
method: "POST",
|
|
225
231
|
credentials: "same-origin",
|
|
226
232
|
headers: headers,
|
|
227
|
-
body: JSON.stringify({ method: method, params: params || {} }),
|
|
233
|
+
body: JSON.stringify(envelope || { method: method, params: params || {} }),
|
|
228
234
|
signal: controller.signal
|
|
229
235
|
}).then(function (response) {
|
|
230
236
|
return response.json().catch(function () {
|
|
@@ -254,9 +260,21 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
|
|
|
254
260
|
});
|
|
255
261
|
};
|
|
256
262
|
|
|
263
|
+
${batchRuntime ? `
|
|
264
|
+
// A batch is one request and is never replayed after a partial result.
|
|
265
|
+
${ns}.batch = (function () {
|
|
266
|
+
${batchRuntime}
|
|
267
|
+
return function (calls, options, requestTimeout) {
|
|
268
|
+
return createBatch(function (envelope, callOptions) {
|
|
269
|
+
return ${ns}.callOnce(null, null, callOptions, requestTimeout, envelope);
|
|
270
|
+
})(calls, options);
|
|
271
|
+
};
|
|
272
|
+
})();
|
|
273
|
+
` : ""}
|
|
274
|
+
|
|
257
275
|
// APP_STOPPED, APP_STARTING and EXECUTOR_STARTING mean the executor op was not
|
|
258
276
|
// sent. A stopped package waits for the user to start it in Package Center, so
|
|
259
|
-
// APP_STOPPED rejects at once
|
|
277
|
+
// APP_STOPPED rejects at once. A start ends by itself: a call repeats
|
|
260
278
|
// half a second, then doubling, for 4 s of waiting in all, so a short start
|
|
261
279
|
// shows the page nothing and the SDK's starting copy follows a long one within
|
|
262
280
|
// its own 4 s recheck; executor.control, which the window's approval starts the
|
|
@@ -264,7 +282,7 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
|
|
|
264
282
|
// deadline; past the wait it rejects with the last refusal, and leaving the
|
|
265
283
|
// page during a wait rejects request_canceled at once. The SDK's status reads
|
|
266
284
|
// are not repeated: its panels show the stopped or starting package and check
|
|
267
|
-
// again on their own
|
|
285
|
+
// again on their own.
|
|
268
286
|
${ns}.retryCodes = ["APP_STARTING", "EXECUTOR_STARTING"];
|
|
269
287
|
${ns}.statusReads = ["delegate.status", "authorization.status", "executor.status"];
|
|
270
288
|
${ns}.call = function (method, params, options, requestTimeout) {
|
|
@@ -402,6 +420,7 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
|
|
|
402
420
|
try { if (typeof _S === "function") lang = _S("lang"); } catch (error) {}
|
|
403
421
|
var ctx = { requestTimeout: ${requestTimeout} };
|
|
404
422
|
var call = function (method, params, options) { return ${ns}.call(method, params, options, ctx.requestTimeout); };
|
|
423
|
+
var batch = ${batchRuntime ? `function (calls, options) { return ${ns}.batch(calls, options, ctx.requestTimeout); }` : "undefined"};
|
|
405
424
|
${root ? root.mount : ""} ${ns}.loadApp(function (app) {
|
|
406
425
|
if (me.mountToken !== mountToken) return;
|
|
407
426
|
me.mountPending = false;
|
|
@@ -415,6 +434,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
|
|
|
415
434
|
me.mounted = app.mount(root, Ext.apply(ctx, {
|
|
416
435
|
displayName: ${ns}.createI18n(lang, ${JSON.stringify(displayNameCatalogs(manifest))}).t("app_title"),
|
|
417
436
|
call: call,
|
|
437
|
+
batch: batch,
|
|
418
438
|
files: {endpoint: ${JSON.stringify(cgiUrl(pkg))}, token: function () { return SYNO.SDS.Session && SYNO.SDS.Session.SynoToken; }},
|
|
419
439
|
reopen: me.reopenEvents.subscribe,
|
|
420
440
|
launchParams: me.launchParams,
|
|
@@ -495,6 +515,7 @@ function renderPortalAccess(ns, manifest) {
|
|
|
495
515
|
isAdmin: function () { return ${ns}.sessionUser().isAdmin; },
|
|
496
516
|
send: ${ns}.webApi(me),
|
|
497
517
|
call: call,
|
|
518
|
+
batch: batch,
|
|
498
519
|
launch: function (app, params) { SYNO.SDS.AppLaunch(app, params); },
|
|
499
520
|
location: window.location,
|
|
500
521
|
locale: ${ns}.createI18n(lang, ${JSON.stringify(Object.fromEntries(Object.keys(DSM_LANGS).map((locale) => [locale, {}])))}).locale
|
|
@@ -313,6 +313,7 @@ export function identityGatewayConfig(m) {
|
|
|
313
313
|
access: g.access ?? "dsm-users",
|
|
314
314
|
...(g.groups ? { groups: g.groups } : {}),
|
|
315
315
|
...(m.entry ? { entry: m.entry } : {}),
|
|
316
|
+
...(m.compose ? { container_app: true } : {}),
|
|
316
317
|
...(m.compose?.plan.mounts_blocked ? { compose_blocked: true } : {}),
|
|
317
318
|
...(g.max_upload_bytes ? { max_upload_bytes: g.max_upload_bytes } : {}),
|
|
318
319
|
...(g.response_header_timeout_seconds
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspectPortalInfo } from "../portal/info.js";
|
|
1
2
|
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import { inspectApiDescription } from "./api-description-inspect.js";
|
|
3
4
|
import { dsmLauncherType } from "../contract/app-launch.js";
|
|
@@ -152,7 +153,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
152
153
|
const sharedFolder = standalone
|
|
153
154
|
? recoverSharedFolder(readOptional(join(dir, "scripts/postinst")))
|
|
154
155
|
: undefined;
|
|
155
|
-
//
|
|
156
|
+
// Resource-worker portals use DSM-owned INFO reload, with no executor requirement.
|
|
156
157
|
const portal = standalone
|
|
157
158
|
? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings)
|
|
158
159
|
: undefined;
|
|
@@ -187,8 +188,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
187
188
|
? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
|
|
188
189
|
: undefined;
|
|
189
190
|
const hasExecutor = (metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
|
|
190
|
-
if (standalone && portal
|
|
191
|
-
findings.push(
|
|
191
|
+
if (standalone && portal)
|
|
192
|
+
findings.push(...inspectPortalInfo(info, hasExecutor));
|
|
192
193
|
const daemon = standalone
|
|
193
194
|
? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined)
|
|
194
195
|
: undefined;
|
|
@@ -205,6 +206,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
205
206
|
executor = inspected.executor;
|
|
206
207
|
if (executor)
|
|
207
208
|
findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
|
|
209
|
+
if (executor?.policy.portal && !portal)
|
|
210
|
+
findings.push(...inspectPortalInfo(info, true));
|
|
208
211
|
if (executor?.policy.portal &&
|
|
209
212
|
info.os_min_ver &&
|
|
210
213
|
OS_MIN_VER.test(info.os_min_ver) &&
|
|
@@ -668,6 +671,17 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
|
|
|
668
671
|
throw new Error(materializer.message);
|
|
669
672
|
if (payload.find((p) => p.path === "compose/control")?.mode !== "0755")
|
|
670
673
|
throw new Error("Compose materializer must be executable");
|
|
674
|
+
const initializer = join(root, "compose/storage-init");
|
|
675
|
+
if (c.plan.services.some((s) => s.storage_init)) {
|
|
676
|
+
const error = checkCgiBinary(root, initializer, arch);
|
|
677
|
+
if (error)
|
|
678
|
+
throw new Error(error.message);
|
|
679
|
+
if (payload.find((p) => p.path === "compose/storage-init")?.mode !== "0755")
|
|
680
|
+
throw new Error("Compose storage initializer must be executable");
|
|
681
|
+
}
|
|
682
|
+
else if (existsSync(initializer)) {
|
|
683
|
+
throw new Error("unexpected Compose storage initializer");
|
|
684
|
+
}
|
|
671
685
|
const binaries = [
|
|
672
686
|
"daemon/control",
|
|
673
687
|
"daemon/payload/_sso/gateway",
|
|
@@ -21,12 +21,15 @@ export function renderStandaloneScript(name, manifest) {
|
|
|
21
21
|
: renderStandaloneScript(name, { ...native, appScripts: undefined });
|
|
22
22
|
const control = `"/var/packages/${manifest.package}/target/daemon/control"`;
|
|
23
23
|
const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${manifest.package}/var}"`;
|
|
24
|
+
const preflight = name === "start-stop-status"
|
|
25
|
+
? `case "$1" in prestart|start|restart)\n "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/compose/control" preflight || exit $? ;;\nesac\n`
|
|
26
|
+
: "";
|
|
24
27
|
const remember = name === "postinst" &&
|
|
25
28
|
manifest.compose.plan.ingress &&
|
|
26
29
|
!manifest.compose.plan.mounts_blocked
|
|
27
30
|
? `"/var/packages/${manifest.package}/target/compose/control" remember-ingress --project "/var/packages/${manifest.package}/target/compose" --package '${manifest.package}' || exit $?\n`
|
|
28
31
|
: "";
|
|
29
|
-
const ledger = remember +
|
|
32
|
+
const ledger = preflight + remember +
|
|
30
33
|
(manifest.identityGateway && !manifest.compose.plan.mounts_blocked
|
|
31
34
|
? name === "start-stop-status"
|
|
32
35
|
? `if [ "$1" = restart ]; then\n "$0" stop || exit $?\n exec "$0" start\nfi\nif [ "$1" = start ]; then\n ${control} acquire ${paths} || exit $?\nelif [ "$1" = stop ]; then\n ${control} release ${paths} || exit $?\nfi\n`
|
|
@@ -234,6 +234,10 @@ function readManifest(raw, c) {
|
|
|
234
234
|
const daemon = arches &&
|
|
235
235
|
readDaemon(raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message));
|
|
236
236
|
const gatewayRaw = isRecord(raw.identity_gateway) ? raw.identity_gateway : {};
|
|
237
|
+
if (raw.compose !== undefined && isRecord(raw.ctl) && raw.ctl.enabled === true)
|
|
238
|
+
c.fail("CTL_UNSUPPORTED_BACKEND", "Compose does not support automatic ctl descriptions; disable ctl.");
|
|
239
|
+
if (isRecord(raw.compose) && isRecord(raw.compose.web) && !isRecord(raw.compose.web.health))
|
|
240
|
+
c.fail("COMPOSE_HEALTH_REQUIRED", "Select compose.web.health.path and expected 2xx status for the actual App. Readiness does not follow redirects; check is static and does not probe HTTP.");
|
|
237
241
|
const compose = arches &&
|
|
238
242
|
readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
|
|
239
243
|
package: packageId,
|
|
@@ -260,7 +264,8 @@ function readManifest(raw, c) {
|
|
|
260
264
|
: raw.identity_gateway;
|
|
261
265
|
if (compose?.web && gatewayRaw.upstream !== undefined)
|
|
262
266
|
c.fail("COMPOSE_WEB_INVALID", "Compose upstream is derived from the SDK Compose ingress endpoint; remove identity_gateway.upstream.");
|
|
263
|
-
|
|
267
|
+
// A failed Compose plan already explains the invalid upstream declaration.
|
|
268
|
+
const identityGateway = raw.compose !== undefined && !compose ? undefined : readIdentityGateway(gatewayValue, daemon || (compose ? { targets: compose.targets } : undefined), (e) => c.fail(e.code, e.message), false, Boolean(compose?.plan.ingress));
|
|
264
269
|
if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
|
|
265
270
|
c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
|
|
266
271
|
if (compose &&
|
|
@@ -369,7 +374,7 @@ function readManifest(raw, c) {
|
|
|
369
374
|
? undefined
|
|
370
375
|
: (readPortalDeclaration(raw.portal, packageId) ??
|
|
371
376
|
c.fail("STANDALONE_PORTAL_INVALID", "portal must be {} or { alias: <1-64 lowercase alphanumerics or -, not a DSM reserved path> }."));
|
|
372
|
-
if (portal &&
|
|
377
|
+
if (portal && osMinVer && portalByExecutor(osMinVer) &&
|
|
373
378
|
!capabilities.rootOps &&
|
|
374
379
|
!capabilities.delegate &&
|
|
375
380
|
!capabilities.webapi) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { portalInfoFields } from "../portal/info.js";
|
|
1
2
|
import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
|
|
2
3
|
/** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
@@ -329,7 +330,7 @@ export async function buildNativeLayout(options) {
|
|
|
329
330
|
await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
|
|
330
331
|
rmSync(target, { recursive: true });
|
|
331
332
|
const fields = { package: m.package, version, arch: layoutInfoArch(arch), os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
|
|
332
|
-
const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)];
|
|
333
|
+
const info = portalInfoFields([...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)], Boolean(m.portal));
|
|
333
334
|
if (new Set(info.map(([k]) => k)).size !== info.length)
|
|
334
335
|
fail('INFO anchor collides with an INFO field');
|
|
335
336
|
writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
|
|
@@ -370,8 +371,11 @@ export function inspectNativeLayout(dir, payload, info) {
|
|
|
370
371
|
fail('package checksum mismatch');
|
|
371
372
|
checkInfoAnchors(readFileSync(join(dir, 'INFO'), 'utf8'), inv.anchors === undefined ? {} : anchors(inv.anchors), actual);
|
|
372
373
|
const declared = inv.portal === undefined ? undefined : portal(inv.portal, info.package);
|
|
373
|
-
if (declared)
|
|
374
|
+
if (declared) {
|
|
374
375
|
checkPortal(declared, info.package, info.displayname ?? '', info.os_min_ver);
|
|
376
|
+
if (!(info.instuninst_restart_services ?? '').split(/\s+/).includes('nginx.service'))
|
|
377
|
+
fail('Portal INFO instuninst_restart_services must include nginx.service');
|
|
378
|
+
}
|
|
375
379
|
checkInfoPortal(readFileSync(join(dir, 'INFO'), 'utf8'), declared);
|
|
376
380
|
return [];
|
|
377
381
|
}
|
|
@@ -204,7 +204,7 @@ export const portalRuntime = (catalogs) => `function createPortalAccess(options)
|
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
206
|
// The window reads the status when it opens, so a settings page shows the
|
|
207
|
-
// portal section as it is
|
|
207
|
+
// portal section as it is.
|
|
208
208
|
status();
|
|
209
209
|
return access;
|
|
210
210
|
}`;
|