@synopackageland/cli 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,8 @@ import { familyOfArchToken, NATIVE_ARCH_FAMILIES, } from "../build-spk/dsm-arch.
21
21
  import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON, } from "../build-spk/dsm-version.js";
22
22
  import { ELF_MACHINE } from "./binary.js";
23
23
  import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
24
- import { daemonConfig, readDaemon, readLifecycleTimeouts } from "./daemon.js";
24
+ import { daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
25
+ import { DAEMON_INGRESS_CONFIG, daemonIngressMinDsmProblem, readDaemonIngress, renderDaemonIngressConfig } from "./daemon-ingress.js";
25
26
  import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
26
27
  import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
27
28
  import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
@@ -36,7 +37,7 @@ import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
36
37
  import { renderUiConfig } from "./dsm-window.js";
37
38
  import { identityGatewayConfig, gatewayAdmissionReport, gatewayAdmissionPolicy, readIdentityGateway, } from "./identity-gateway.js";
38
39
  import { checkCgiBinary } from "./binary.js";
39
- import { matchesStandaloneScript, renderStandaloneScript } from "./lifecycle.js";
40
+ import { matchesStandaloneScript, renderStandaloneScript, matchesStandaloneScriptPrefix } from "./lifecycle.js";
40
41
  import { listPortExposures, loadCompose } from "../compose/analyze.js";
41
42
  import { uninstallCleanup, } from "../runtime/uninstall-cleanup.js";
42
43
  import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep, } from "../portal/package.js";
@@ -112,7 +113,8 @@ export async function inspectSpk(spkPath, expectedKind) {
112
113
  ].includes(String(name))) ||
113
114
  new Set(raw.capabilities).size !== raw.capabilities.length ||
114
115
  (raw.entry !== undefined &&
115
- !["admin-only", "app-privilege"].includes(raw.entry)))
116
+ !["admin-only", "app-privilege"].includes(raw.entry)) ||
117
+ (raw.console !== undefined && raw.console !== true))
116
118
  throw new Error();
117
119
  if (raw.lifecycleTimeouts !== undefined && (!readLifecycleTimeouts(raw.lifecycleTimeouts, () => { }) || Object.keys(raw.lifecycleTimeouts).length !== 4))
118
120
  throw new Error();
@@ -154,12 +156,14 @@ export async function inspectSpk(spkPath, expectedKind) {
154
156
  const combined = metadata.compose === undefined
155
157
  ? undefined
156
158
  : await recoverCompose(dir, metadata.compose, findings);
157
- const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined));
159
+ const daemonNode = recoverDaemonNode(metadata.daemon_node, findings);
160
+ const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined), daemonNode);
158
161
  if (combined &&
159
162
  (!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
160
163
  findings.push("compose identity_gateway requires DSM >= 7.2.1");
161
164
  await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
162
165
  const nativeGateway = await recoverNativeGateway(dir, metadata.native_gateway, info, findings);
166
+ const daemonIngress = await recoverDaemonIngress(dir, metadata.daemon_ingress, info, paths, findings);
163
167
  const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
164
168
  // Before any fixed-lifecycle comparison: those see the SDK steps alone.
165
169
  const appScripts = standalone || kind === "container"
@@ -178,7 +182,7 @@ export async function inspectSpk(spkPath, expectedKind) {
178
182
  !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
179
183
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_MIN_DSM} a portal needs; ${PORTAL_MIN_DSM_REASON}`);
180
184
  }
181
- checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose, nativeGateway, info.os_min_ver);
185
+ checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose, nativeGateway, info.os_min_ver, daemonIngress);
182
186
  checkScripts(dir, findings);
183
187
  checkUninstall(dir, findings);
184
188
  if (kind === "container")
@@ -206,7 +210,7 @@ export async function inspectSpk(spkPath, expectedKind) {
206
210
  if (standalone && portal)
207
211
  findings.push(...inspectPortalInfo(info, hasExecutor));
208
212
  const daemon = standalone
209
- ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined, nativeGateway, uninstallHook?.uninstall)
213
+ ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined, nativeGateway, uninstallHook?.uninstall, { ingress: daemonIngress, node: daemonNode })
210
214
  : undefined;
211
215
  if (background)
212
216
  checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go", metadata.lifecycleTimeouts, nativeGateway);
@@ -272,7 +276,7 @@ export async function inspectSpk(spkPath, expectedKind) {
272
276
  }
273
277
  if (metadata.entry)
274
278
  await checkEntryLauncher(dir, info.package, metadata.entry, findings);
275
- const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings);
279
+ const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings, metadata.console === true);
276
280
  const loginStyle = standalone
277
281
  ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
278
282
  : undefined;
@@ -336,7 +340,7 @@ export async function inspectSpk(spkPath, expectedKind) {
336
340
  const LAUNCHER_TYPE_MISMATCH = "LAUNCHER_TYPE_MISMATCH";
337
341
  const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
338
342
  /** Compare the actual launcher and Portal fields to the packaged declaration. */
339
- async function checkLaunchConfiguration(dir, info, native, declared, alias, findings) {
343
+ async function checkLaunchConfiguration(dir, info, native, declared, alias, findings, console = false) {
340
344
  const target = join(dir, "launch-inspection");
341
345
  mkdirSync(target);
342
346
  await tar.x({
@@ -387,6 +391,10 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
387
391
  catch {
388
392
  /* report the missing or malformed launcher below */
389
393
  }
394
+ if (console) {
395
+ checkConsoleLaunchers(target, info, native, findings);
396
+ return launch;
397
+ }
390
398
  const type = dsmLauncherType(launch, native);
391
399
  if (!launcher ||
392
400
  launcher.type !== type ||
@@ -406,6 +414,35 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
406
414
  }
407
415
  return launch;
408
416
  }
417
+ /**
418
+ * A console App's ui/config: `.Instance` is the url launcher to the gateway,
419
+ * `.Console` the administrator-only SDK window Package Center's Open starts.
420
+ */
421
+ function checkConsoleLaunchers(target, info, native, findings) {
422
+ const ns = `SYNO.SDS.${info.package}`;
423
+ let config = {};
424
+ try {
425
+ config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
426
+ }
427
+ catch {
428
+ /* reported below as a missing launcher */
429
+ }
430
+ const find = (id) => Object.values(config).map((entries) => entries?.[id]).find(Boolean);
431
+ const instance = find(`${ns}.Instance`);
432
+ const console = find(`${ns}.Console`);
433
+ if (!native ||
434
+ instance?.type !== "url" ||
435
+ instance.url !== `/webman/3rdparty/${info.package}/sso/open.cgi` ||
436
+ console?.type !== "app" ||
437
+ console.appWindow !== `${ns}.MainWindow` ||
438
+ console.allUsers !== false ||
439
+ console.grantPrivilege !== undefined ||
440
+ info.dsmuidir !== "ui" ||
441
+ info.dsmappname !== `${ns}.Instance ${ns}.Console` ||
442
+ info.dsmapplaunchname !== `${ns}.Console`) {
443
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} console requires a url launcher at ${ns}.Instance to sso/open.cgi, an administrator-only app launcher at ${ns}.Console and INFO dsmapplaunchname=${ns}.Console; rebuild with synopkgland build`);
444
+ }
445
+ }
409
446
  /** The launcher in ui/config must carry exactly the access the declared entry mode fixes. */
410
447
  async function checkEntryLauncher(dir, pkg, entry, findings) {
411
448
  const target = join(dir, "entry-inspection");
@@ -813,7 +850,7 @@ async function recoverCompose(dir, value, findings) {
813
850
  return undefined;
814
851
  }
815
852
  }
816
- async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined, nativeGateway, uninstall) {
853
+ async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined, nativeGateway, uninstall, extras = {}) {
817
854
  if (![...paths].some((path) => path.startsWith("daemon/")))
818
855
  return;
819
856
  if (info.precheckstartstop !== "yes")
@@ -827,7 +864,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
827
864
  await tar.x({
828
865
  file: join(dir, "package.tgz"),
829
866
  cwd: unpacked,
830
- filter: (path) => path.replace(/^\.\//, "") === "daemon/config.json",
867
+ filter: (path) => ["daemon/config.json", `daemon/payload/${NODE_LAUNCHER}`].includes(path.replace(/^\.\//, "")),
831
868
  });
832
869
  try {
833
870
  const config = JSON.parse(readFileSync(join(unpacked, "daemon/config.json"), "utf8"));
@@ -853,6 +890,14 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
853
890
  }, dir, ["x86_64"], () => { });
854
891
  if (!daemon)
855
892
  throw new Error("invalid config");
893
+ // The packaged config already carries the launcher argv; only the launcher itself is checked here.
894
+ if (extras.node &&
895
+ (daemon.exec !== NODE_LAUNCHER ||
896
+ !daemon.args[0]?.startsWith("${SYNOPKG_PKGDEST}/daemon/payload/") ||
897
+ readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
898
+ throw new Error("Node launcher differs from its build declaration");
899
+ if (extras.ingress)
900
+ daemon.ingress = extras.ingress;
856
901
  if (config.operation_seconds !== undefined && (!Number.isInteger(config.operation_seconds) || config.operation_seconds < 1 || config.operation_seconds > 3600))
857
902
  throw new Error('invalid operation budget');
858
903
  if (combined &&
@@ -921,7 +966,7 @@ function archivedInfoTranslations(info) {
921
966
  }
922
967
  return translations;
923
968
  }
924
- function readInfo(path, findings, kind, compose = false) {
969
+ function readInfo(path, findings, kind, compose = false, daemonNode) {
925
970
  if (!existsSync(path)) {
926
971
  findings.push("INFO is missing");
927
972
  return {};
@@ -950,7 +995,10 @@ function readInfo(path, findings, kind, compose = false) {
950
995
  : kind === "native-node"
951
996
  ? /^Node\.js_v[0-9]{2}([><=].*)?$/
952
997
  : undefined;
953
- if (info.install_dep_packages &&
998
+ if (daemonNode && info.install_dep_packages !== nodeDependency(daemonNode))
999
+ findings.push(`daemon.node requires INFO install_dep_packages="${nodeDependency(daemonNode)}"`);
1000
+ else if (!daemonNode &&
1001
+ info.install_dep_packages &&
954
1002
  !(allowed &&
955
1003
  info.install_dep_packages.split(":").every((dep) => allowed.test(dep)))) {
956
1004
  findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
@@ -1054,7 +1102,44 @@ async function recoverNativeGateway(dir, value, info, findings) {
1054
1102
  return undefined;
1055
1103
  }
1056
1104
  }
1057
- function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose, nativeGateway, osMinVer) {
1105
+ function recoverDaemonNode(value, findings) {
1106
+ if (value === undefined)
1107
+ return undefined;
1108
+ const daemon = readDaemon({ exec: "x", targets: {}, node: value }, tmpdir(), [], () => { });
1109
+ if (!daemon?.node || !isDeepStrictEqual(daemon.node, value)) {
1110
+ findings.push("DAEMON_NODE_INVALID sdk-package.json daemon_node is not a Node.js package declaration build writes");
1111
+ return undefined;
1112
+ }
1113
+ return daemon.node;
1114
+ }
1115
+ async function recoverDaemonIngress(dir, value, info, paths, findings) {
1116
+ if (value === undefined)
1117
+ return undefined;
1118
+ try {
1119
+ const ingress = readDaemonIngress(value, () => { });
1120
+ if (!ingress || !isDeepStrictEqual(ingress, value))
1121
+ throw new Error("invalid ingress record");
1122
+ const minDsm = daemonIngressMinDsmProblem(info.os_min_ver);
1123
+ if (minDsm)
1124
+ throw new Error(minDsm);
1125
+ if (!paths.has("daemon/config.json"))
1126
+ throw new Error("ingress needs the package daemon");
1127
+ const root = join(dir, "daemon-ingress-inspection");
1128
+ mkdirSync(root);
1129
+ await tar.x({ file: join(dir, "package.tgz"), cwd: root, filter: (path) => path.replace(/^\.\//, "") === DAEMON_INGRESS_CONFIG });
1130
+ if (readOptional(join(root, DAEMON_INGRESS_CONFIG)) !== renderDaemonIngressConfig(info.package, ingress))
1131
+ throw new Error("ingress location differs from its fixed proxy");
1132
+ for (const key of ["instuninst_restart_services", "startstop_restart_services"])
1133
+ if (!(info[key] ?? "").split(/\s+/).includes("nginx.service"))
1134
+ throw new Error(`ingress requires INFO ${key}`);
1135
+ return ingress;
1136
+ }
1137
+ catch (e) {
1138
+ findings.push(`DAEMON_INGRESS_INVALID ${e.message}`);
1139
+ return undefined;
1140
+ }
1141
+ }
1142
+ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose, nativeGateway, osMinVer, daemonIngress) {
1058
1143
  const confDir = join(dir, "conf");
1059
1144
  const entries = existsSync(confDir) ? readdirSync(confDir) : [];
1060
1145
  const nativeResource = kind !== "container"
@@ -1067,6 +1152,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
1067
1152
  ? { jobs: { worker: "sha256-v1" } }
1068
1153
  : {},
1069
1154
  ...(sharedFolder ? { sharedFolder } : {}),
1155
+ ...(daemonIngress ? { daemon: { ingress: daemonIngress } } : {}),
1070
1156
  }, portal)
1071
1157
  : undefined;
1072
1158
  if (nativeResource !== undefined &&
@@ -1229,8 +1315,8 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
1229
1315
  mkdirSync(root);
1230
1316
  await tar.x({ file: join(dir, 'package.tgz'), cwd: root });
1231
1317
  const info = readInfo(join(dir, 'INFO'), findings, 'binary', Boolean(metadata.compose));
1232
- const gateway = metadata.compose?.gateway ?? (metadata.native_gateway ? { listen: metadata.native_gateway.listen } : undefined);
1233
- const m = { package: info.package, installSettings: settings, identityGateway: gateway };
1318
+ const gateway = metadata.compose?.gateway ?? metadata.native_gateway;
1319
+ const m = { package: info.package, os_min_ver: info.os_min_ver, capabilities: {}, installSettings: settings, identityGateway: gateway };
1234
1320
  try {
1235
1321
  if (readOptional(join(root, 'install/settings.json')) !== JSON.stringify(settingsConfig(m), null, 2) + '\n')
1236
1322
  throw new Error('settings inventory differs from its declaration or gateway port');
@@ -1243,7 +1329,8 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
1243
1329
  // App wrappers remain intact here; remove exactly the SDK prefix within them.
1244
1330
  for (const name of ['preinst', 'postinst', 'postupgrade', 'preuninst']) {
1245
1331
  const file = join(dir, 'scripts', name), text = readOptional(file), prefix = renderSettingsHook(name, m);
1246
- if (!text || !sdkScriptOf(name, text)?.sdk.startsWith('#!/bin/sh\n' + prefix) || text.split(prefix).length !== 2)
1332
+ const sdk = text && sdkScriptOf(name, text)?.sdk;
1333
+ if (!sdk || !matchesStandaloneScriptPrefix(name, m, sdk) || text.split(prefix).length !== 2)
1247
1334
  throw new Error(`scripts/${name} lacks the exact SDK settings hook`);
1248
1335
  writeFileSync(file, text.replace(prefix, ''));
1249
1336
  }
@@ -1,5 +1,8 @@
1
1
  import type { StandaloneManifest } from "./manifest.js";
2
2
  /** Package-account lifecycle hooks for CGI, daemon and background packages. */
3
3
  export declare function renderStandaloneScript(name: string, manifest: StandaloneManifest): string;
4
+ /** Settings inspection peels its hook only at the position build's renderer writes.
5
+ * The backend inspector then replays the entire remaining lifecycle script. */
6
+ export declare function matchesStandaloneScriptPrefix(name: string, manifest: StandaloneManifest, actual: string): boolean;
4
7
  /** Accept the exact current, diagnostic-only, and released lifecycle variants. */
5
8
  export declare function matchesStandaloneScript(name: string, manifest: StandaloneManifest, actual: string | undefined): boolean;
@@ -15,23 +15,37 @@ const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_u
15
15
  /** Package-account lifecycle hooks for CGI, daemon and background packages. */
16
16
  export function renderStandaloneScript(name, manifest) {
17
17
  const script = renderStandaloneScriptInternal(name, manifest);
18
- const prepared = ['postinst', 'postupgrade'].includes(name) ? renderNativeGatewayPrepare(manifest) : '';
19
- const prefix = renderNativeGatewayDiagnostic(manifest, name) + renderNativeGatewayPortWait(manifest, name) + renderSettingsHook(name, manifest) + prepared;
18
+ const prefix = renderStandaloneScriptPrefix(name, manifest);
20
19
  const sdk = prefix ? script.replace('#!/bin/sh\n', () => '#!/bin/sh\n' + prefix) : script;
21
20
  return manifest.appScripts?.some((app) => app === name)
22
21
  ? composeAppScript(name, sdk)
23
22
  : sdk;
24
23
  }
24
+ function renderStandaloneScriptPrefix(name, manifest) {
25
+ const prepared = ['postinst', 'postupgrade'].includes(name) ? renderNativeGatewayPrepare(manifest) : '';
26
+ return renderNativeGatewayDiagnostic(manifest, name) + renderNativeGatewayPortWait(manifest, name) + renderSettingsHook(name, manifest) + prepared;
27
+ }
28
+ /** Settings inspection peels its hook only at the position build's renderer writes.
29
+ * The backend inspector then replays the entire remaining lifecycle script. */
30
+ export function matchesStandaloneScriptPrefix(name, manifest, actual) {
31
+ const blocks = [renderNativeGatewayDiagnostic(manifest, name), renderNativeGatewayPortWait(manifest, name)].filter(Boolean);
32
+ return standaloneScriptVariants(name, manifest, renderStandaloneScriptPrefix(name, manifest)).some(prefix => actual.startsWith('#!/bin/sh\n' + prefix) &&
33
+ // A released prefix omits these blocks entirely; moving one after the
34
+ // settings hook must not masquerade as that released variant.
35
+ blocks.every(block => prefix.includes(block) || !actual.includes(block)));
36
+ }
25
37
  /** Accept the exact current, diagnostic-only, and released lifecycle variants. */
26
38
  export function matchesStandaloneScript(name, manifest, actual) {
27
- const expected = renderStandaloneScript(name, manifest);
28
- if (actual === expected)
29
- return true;
39
+ return actual !== undefined && standaloneScriptVariants(name, manifest, renderStandaloneScript(name, manifest)).includes(actual);
40
+ }
41
+ function standaloneScriptVariants(name, manifest, expected) {
30
42
  const diagnostic = renderNativeGatewayDiagnostic(manifest, name);
31
43
  const wait = renderNativeGatewayPortWait(manifest, name);
32
44
  const diagnosticOnly = wait ? expected.replace(wait, '') : expected;
33
- return (wait !== '' && actual === diagnosticOnly) ||
34
- (diagnostic !== '' && actual === diagnosticOnly.replace(diagnostic, ''));
45
+ const variants = [expected, ...(wait ? [diagnosticOnly] : []), ...(diagnostic ? [diagnosticOnly.replace(diagnostic, '')] : [])];
46
+ // Released packages stop the daemon in preupgrade without the first-daemon guard.
47
+ const unguarded = (v) => v.replace(daemonGuard(manifest.package), '').replace(delegatedDaemonGuard(manifest.package), '');
48
+ return name === 'preupgrade' ? [...new Set([...variants, ...variants.map(unguarded)])] : variants;
35
49
  }
36
50
  function renderStandaloneScriptInternal(name, manifest) {
37
51
  if (manifest.compose) {
@@ -201,11 +215,11 @@ function renderDelegatedDaemonScript(name, manifest) {
201
215
  const worker = (action) => `/usr/syno/bin/synosystemctl ${action} ${backgroundUnitName(pkg)}`;
202
216
  // Try every owner even on failure; never leave protected ingress open merely
203
217
  // because a producer could not stop. A partial teardown remains a failure.
204
- const stop = (daemonAction, executorAction, executorStop = executor(executorAction)) => `result=0\nstop_background_worker || result=1\n${daemon} ${daemonAction} ${paths} || result=1\n${executorStop} || result=1\nexit "$result"\n`;
218
+ const stop = (daemonAction, executorAction, executorStop = executor(executorAction), guard = "") => `result=0\nstop_background_worker || result=1\n${guard}${daemon} ${daemonAction} ${paths} || result=1\n${executorStop} || result=1\nexit "$result"\n`;
205
219
  const workerStop = renderBackgroundWorkerStop(pkg);
206
220
  if (name !== "start-stop-status") {
207
221
  if (name === "preupgrade")
208
- return `#!/bin/sh\n${renderExecutorUpgradeStop(pkg)}${workerStop}${stop("stop", "stop", UPGRADE_STOP)}`;
222
+ return `#!/bin/sh\n${renderExecutorUpgradeStop(pkg)}${workerStop}${stop("stop", "stop", UPGRADE_STOP, delegatedDaemonGuard(pkg))}`;
209
223
  const purge = manifest.uninstall
210
224
  ? stop(manifest.daemon?.uninstall_args ? "uninstall" : "stop", "purge $synopkgland_purge_args").replace(executor("purge $synopkgland_purge_args"), () => `synopkgland_uninstall_hook\n${executor("purge $synopkgland_purge_args")}`)
211
225
  : stop(manifest.daemon?.uninstall_args ? "uninstall" : "stop", "purge");
@@ -262,6 +276,17 @@ function renderExecutorUpgradeStop(pkg) {
262
276
  }
263
277
  `;
264
278
  }
279
+ /**
280
+ * preupgrade runs before DSM replaces the payload, so it sees the outgoing
281
+ * package. Only a payload without any daemon/ is an upgrade that introduces
282
+ * the daemon; a partial or symlinked daemon/ still stops or fails closed.
283
+ */
284
+ const daemonAbsent = (pkg) => {
285
+ const dir = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}/daemon"`;
286
+ return `[ ! -e ${dir} ] && [ ! -L ${dir} ]`;
287
+ };
288
+ const daemonGuard = (pkg) => `if ${daemonAbsent(pkg)}; then exit 0; fi\n`;
289
+ const delegatedDaemonGuard = (pkg) => `${daemonAbsent(pkg)} || `;
265
290
  function renderDaemonScript(name, manifest) {
266
291
  const pkg = manifest.package;
267
292
  const control = `"/var/packages/${pkg}/target/daemon/control"`;
@@ -272,7 +297,7 @@ function renderDaemonScript(name, manifest) {
272
297
  const uninstall = name === "preuninst" && manifest.daemon?.uninstall_args;
273
298
  const upgrade = uninstall
274
299
  ? `if [ "\${SYNOPKG_PKG_STATUS:-}" = "UPGRADE" ]; then\n exec ${control} stop ${paths}\nfi\n`
275
- : "";
300
+ : name === "preupgrade" ? daemonGuard(pkg) : "";
276
301
  return `#!/bin/sh\n${upgrade}exec ${control} ${uninstall ? "uninstall" : "stop"} ${paths}\n`;
277
302
  }
278
303
  if (name !== "start-stop-status")
@@ -100,6 +100,12 @@ export interface StandaloneManifest {
100
100
  entry?: EntryMode;
101
101
  /** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
102
102
  portal?: PortalDeclaration;
103
+ /**
104
+ * `console`: the main menu gets two icons, the gateway's native UI in a new
105
+ * tab and the SDK UI in an administrator-only DSM window titled `title`
106
+ * (default "<displayname> for DSM"). Absent keeps the single window icon.
107
+ */
108
+ console?: ConsoleDeclaration;
103
109
  /** The project's dsm-scripts/<DSM script name> files, each run inside the generated script of that name. */
104
110
  appScripts?: AppScriptName[];
105
111
  /** `icon`: a square PNG of at least 256 px, or a directory of icon_<size>.png; absent uses the SDK default. */
@@ -112,6 +118,9 @@ export interface StandaloneManifest {
112
118
  * accounts DSM's Control Panel > Application Privileges allows for the app.
113
119
  */
114
120
  export type EntryMode = "admin-only" | "app-privilege";
121
+ export interface ConsoleDeclaration {
122
+ title?: string;
123
+ }
115
124
  export interface Capabilities {
116
125
  composeMounts?: true;
117
126
  webapi?: WebAPIProfiles;
@@ -11,6 +11,7 @@ import { checkGlossary, loadCatalogs, readInfoTranslations, } from "./i18n.js";
11
11
  import { loadRootOps, readRootSettings, rootBinaryProblem, ROOT_OPS_DIR, } from "./root-ops.js";
12
12
  import { readComposeApp } from "./compose.js";
13
13
  import { readDaemon, readLifecycleTimeouts } from "./daemon.js";
14
+ import { daemonIngressMinDsmProblem } from "./daemon-ingress.js";
14
15
  import { readIdentityGateway, } from "./identity-gateway.js";
15
16
  import { readUninstall } from "./uninstall.js";
16
17
  import { readAppScripts } from "./app-scripts.js";
@@ -56,6 +57,7 @@ export const KNOWN_FIELDS = new Set([
56
57
  "uninstall",
57
58
  "entry",
58
59
  "portal",
60
+ "console",
59
61
  "icon",
60
62
  "reproducible",
61
63
  "root",
@@ -65,6 +67,7 @@ const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
65
67
  const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
66
68
  const VERSION = /^\d+(\.\d+){0,3}$/;
67
69
  const OS_MIN_VER = /^\d+\.\d+(\.\d+)?-\d+$/;
70
+ const CONSOLE_TITLE = /^[^"\\\x00-\x1f\x7f]{1,64}$/;
68
71
  const GO_MAIN = /^\.\/[A-Za-z0-9_./-]*$/;
69
72
  function isRecord(value) {
70
73
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -275,6 +278,13 @@ function readManifest(raw, c) {
275
278
  c.fail("COMPOSE_WEB_INVALID", "Compose upstream is derived from the SDK Compose ingress endpoint; remove identity_gateway.upstream.");
276
279
  // A failed Compose plan already explains the invalid upstream declaration.
277
280
  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));
281
+ if (daemon?.ingress) {
282
+ const problem = daemonIngressMinDsmProblem(osMinVer);
283
+ if (problem)
284
+ c.fail("STANDALONE_DAEMON_INGRESS_MIN_DSM", problem);
285
+ }
286
+ if ((daemon?.ingress || daemon?.node) && raw.identity_gateway !== undefined)
287
+ c.fail("STANDALONE_DAEMON_INVALID", "daemon.node and daemon.ingress run the App's own daemon; identity_gateway runs its gateway as the daemon. Use one or the other.");
278
288
  if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
279
289
  c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
280
290
  if (compose &&
@@ -399,6 +409,7 @@ function readManifest(raw, c) {
399
409
  : undefined;
400
410
  if (entryProblem)
401
411
  c.fail("STANDALONE_PORTAL_ENTRY_INVALID", entryProblem);
412
+ const dsmConsole = raw.console === undefined ? undefined : readConsole(raw, ui?.launch, c);
402
413
  const loginStyle = raw.login_style === undefined
403
414
  ? undefined
404
415
  : readLoginStyle(raw.login_style, raw.portal !== undefined, c);
@@ -454,6 +465,7 @@ function readManifest(raw, c) {
454
465
  ...(installSettings ? { installSettings } : {}),
455
466
  ...(entry ? { entry } : {}),
456
467
  ...(portal ? { portal } : {}),
468
+ ...(dsmConsole ? { console: dsmConsole } : {}),
457
469
  ...(icon ? { icon } : {}),
458
470
  ...(loginStyle ? { loginStyle } : {}),
459
471
  ...(appScripts ? { appScripts } : {}),
@@ -503,6 +515,30 @@ const LOGIN_STYLE_FIELDS = [
503
515
  "wallpaper_thumbnail",
504
516
  "title",
505
517
  ];
518
+ /**
519
+ * `console`: {} or { title }. The native icon is the gateway's url launcher,
520
+ * so the App needs identity_gateway and its own SDK window; that window is the
521
+ * console, never a new page or a portal.
522
+ */
523
+ function readConsole(raw, launch, c) {
524
+ const value = raw.console;
525
+ if (!isRecord(value) ||
526
+ Object.keys(value).some((k) => k !== "title") ||
527
+ (value.title !== undefined &&
528
+ (typeof value.title !== "string" || !CONSOLE_TITLE.test(value.title)))) {
529
+ c.fail("STANDALONE_CONSOLE_INVALID", 'console must be {} or { title: <1-64 characters, no quotes, backslashes or control characters> }.');
530
+ return undefined;
531
+ }
532
+ if (raw.identity_gateway === undefined || raw.compose !== undefined) {
533
+ c.fail("STANDALONE_CONSOLE_REQUIRES_GATEWAY", "console needs identity_gateway on an App with its own SDK UI: the native icon opens the gateway, the console opens the SDK UI. Compose and Apps without a gateway keep one icon.");
534
+ return undefined;
535
+ }
536
+ if (launch !== undefined)
537
+ c.fail("STANDALONE_CONSOLE_LAUNCH", "console always opens in a DSM window and the native icon in a new tab; remove ui.launch.");
538
+ if (raw.portal !== undefined)
539
+ c.fail("STANDALONE_CONSOLE_PORTAL", "console and portal both claim the App's SDK UI; declare one of them.");
540
+ return value.title === undefined ? {} : { title: value.title };
541
+ }
506
542
  /** `login_style`: a portal's colour, wallpaper (with its thumbnail) and title image, each optional. */
507
543
  function readLoginStyle(value, portal, c) {
508
544
  const invalid = (message) => c.fail("STANDALONE_LOGIN_STYLE_INVALID", message);
@@ -3,6 +3,7 @@ import type { Capabilities } from "./manifest.js";
3
3
  import { type SharedFolder } from "./shared-folder.js";
4
4
  import { type PortalPackage } from "../portal/package.js";
5
5
  import { type NativeGatewayPlan } from './native-gateway.js';
6
+ import { type DaemonIngress } from "./daemon-ingress.js";
6
7
  /**
7
8
  * `conf/resource` for a native package: only DSM's official `data-share`,
8
9
  * `systemd-user-unit` and `web-config` workers, in a fixed key order.
@@ -15,4 +16,7 @@ export declare function renderStandaloneResource(m: {
15
16
  capabilities: Capabilities;
16
17
  compose?: ComposeApp;
17
18
  sharedFolder?: SharedFolder;
19
+ daemon?: {
20
+ ingress?: DaemonIngress;
21
+ };
18
22
  }, portal?: PortalPackage): string | undefined;
@@ -2,6 +2,7 @@ import { renderDockerProjectResource } from "../build-spk/resource-worker.js";
2
2
  import { sharedFolderResource } from "./shared-folder.js";
3
3
  import { portalResource } from "../portal/package.js";
4
4
  import { nativeGatewayResource } from './native-gateway.js';
5
+ import { daemonIngressResource } from "./daemon-ingress.js";
5
6
  /**
6
7
  * `conf/resource` for a native package: only DSM's official `data-share`,
7
8
  * `systemd-user-unit` and `web-config` workers, in a fixed key order.
@@ -9,7 +10,10 @@ import { nativeGatewayResource } from './native-gateway.js';
9
10
  */
10
11
  export function renderStandaloneResource(m, portal) {
11
12
  const dataShare = m.sharedFolder && sharedFolderResource(m.package, m.sharedFolder);
12
- const webConfig = { ...(portal ? portalResource()['web-config'] : {}), ...nativeGatewayResource(m) };
13
+ const nginx = [nativeGatewayResource(m), m.daemon?.ingress && daemonIngressResource()]
14
+ .flatMap((r) => r?.["nginx-static-config"]?.enable ?? []);
15
+ const webConfig = { ...(portal ? portalResource()['web-config'] : {}),
16
+ ...(nginx.length ? { 'nginx-static-config': { enable: nginx } } : {}) };
13
17
  const resource = {
14
18
  ...(m.compose?.plan.devices?.gpu && !m.compose.plan.mounts_blocked ? { "video-driver": {} } : {}),
15
19
  ...(m.compose && !m.compose.plan.mounts_blocked
@@ -36,4 +36,9 @@ export interface AssembleOptions {
36
36
  */
37
37
  export declare function assembleStandaloneSpk(options: AssembleOptions): Promise<void>;
38
38
  /** Manifest validation already refused quotes, backslashes and newlines. */
39
+ /**
40
+ * INFO `dsmappname` lists the App's launchable classes. With a console,
41
+ * Package Center's Open (`dsmapplaunchname`) opens the console window.
42
+ */
43
+ export declare function consoleInfo(manifest: Pick<StandaloneManifest, "package" | "console">): Array<[string, string]>;
39
44
  export declare function renderInfo(manifest: StandaloneManifest, arch: NativeArchFamily, checksum: string, executorDigest?: string): string;
@@ -29,7 +29,8 @@ import { copyAppTree } from "./bridge-app.js";
29
29
  import { isBridgeBackend } from "./bridge-backend.js";
30
30
  import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
31
31
  import { writeComposePayload } from "./compose.js";
32
- import { copyDaemonPayload } from "./daemon.js";
32
+ import { copyDaemonPayload, nodeDependency } from "./daemon.js";
33
+ import { DAEMON_INGRESS_CONFIG, renderDaemonIngressConfig } from "./daemon-ingress.js";
33
34
  import { GATEWAY_CGI_PATH, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig, identityGatewayDeclaration, } from "./identity-gateway.js";
34
35
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS, } from "./background.js";
35
36
  import { renderStandaloneResource } from "./resource.js";
@@ -91,7 +92,7 @@ export async function assembleStandaloneSpk(options) {
91
92
  writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons, options.portalText));
92
93
  writeFile(join(uiRoot, hostScriptName(manifest.build)), renderHostAdapterScript(manifest.package));
93
94
  writeDsmWindowIcons(uiRoot, icons);
94
- writeUiTexts(uiRoot, appTitle(manifest));
95
+ writeUiTexts(uiRoot, appTitle(manifest), manifest.console);
95
96
  if (options.apiDescription)
96
97
  writeFile(join(staging, "package", "synopkgland-api.json"), JSON.stringify(options.apiDescription) + "\n");
97
98
  const cgiTarget = join(staging, "package", CGI_PAYLOAD_PATH);
@@ -104,6 +105,8 @@ export async function assembleStandaloneSpk(options) {
104
105
  if (!options.daemonControl)
105
106
  throw new StandaloneBuildError("STANDALONE_DAEMON_ARTIFACT_REQUIRED", "daemon controller artifact required.");
106
107
  copyDaemonPayload(manifest, options.arch, join(staging, "package", "daemon"), options.daemonControl);
108
+ if (manifest.daemon?.ingress)
109
+ writeFile(join(staging, "package", DAEMON_INGRESS_CONFIG), renderDaemonIngressConfig(manifest.package, manifest.daemon.ingress));
107
110
  }
108
111
  if (manifest.identityGateway) {
109
112
  if (!options.identityGateway)
@@ -230,6 +233,8 @@ export async function assembleStandaloneSpk(options) {
230
233
  backend: manifest.backend.kind,
231
234
  ...(gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) ? { provider_only_oidc: gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) } : {}),
232
235
  ...(nativeGatewaySupported(manifest) ? { native_gateway: { sdk: manifest.identityGateway.sdk, listen: manifest.identityGateway.listen } } : {}),
236
+ ...(manifest.daemon?.ingress ? { daemon_ingress: manifest.daemon.ingress } : {}),
237
+ ...(manifest.daemon?.node ? { daemon_node: manifest.daemon.node } : {}),
233
238
  launch: resolvedSdkLaunch(manifest),
234
239
  capabilities: Object.keys(manifest.capabilities).sort(),
235
240
  ...(manifest.compose
@@ -242,6 +247,7 @@ export async function assembleStandaloneSpk(options) {
242
247
  }
243
248
  : {}),
244
249
  ...(manifest.entry ? { entry: manifest.entry } : {}),
250
+ ...(manifest.console ? { console: true } : {}),
245
251
  ...(manifest.uninstall
246
252
  ? {
247
253
  uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs),
@@ -319,6 +325,16 @@ function quote(value) {
319
325
  return `"${value.replace(/([\\"$`])/g, "\\$1")}"`;
320
326
  }
321
327
  /** Manifest validation already refused quotes, backslashes and newlines. */
328
+ /**
329
+ * INFO `dsmappname` lists the App's launchable classes. With a console,
330
+ * Package Center's Open (`dsmapplaunchname`) opens the console window.
331
+ */
332
+ export function consoleInfo(manifest) {
333
+ const ns = `SYNO.SDS.${manifest.package}`;
334
+ return manifest.console
335
+ ? [["dsmappname", `${ns}.Instance ${ns}.Console`], ["dsmapplaunchname", `${ns}.Console`]]
336
+ : [["dsmappname", `${ns}.Instance`]];
337
+ }
322
338
  export function renderInfo(manifest, arch, checksum, executorDigest) {
323
339
  const fields = [
324
340
  ["package", manifest.package],
@@ -329,7 +345,7 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
329
345
  ["arch", infoArchValue(arch)],
330
346
  ["os_min_ver", manifest.os_min_ver],
331
347
  ["dsmuidir", "ui"],
332
- ["dsmappname", `SYNO.SDS.${manifest.package}.Instance`],
348
+ ...consoleInfo(manifest),
333
349
  ["thirdparty", "yes"],
334
350
  ["startable", "yes"],
335
351
  ["checksum", checksum],
@@ -349,6 +365,8 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
349
365
  const backend = manifest.backend;
350
366
  if (isBridgeBackend(backend) && backend.node)
351
367
  fields.push(["install_dep_packages", backend.node.package]);
368
+ if (manifest.daemon?.node)
369
+ fields.push(["install_dep_packages", nodeDependency(manifest.daemon.node)]);
352
370
  fields.push(...metadataInfoFields(manifest.metadata));
353
371
  // Background packages need prestart: it runs before DSM's systemd-user-unit acquire.
354
372
  if (manifest.daemon || manifest.compose || manifest.capabilities.jobs)
@@ -360,9 +378,11 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
360
378
  if (text?.description)
361
379
  fields.push([`description_${DSM_LANGS[locale]}`, text.description]);
362
380
  }
363
- if (nativeGatewaySupported(manifest))
381
+ // DSM reloads nginx on start and stop so the location follows the package state.
382
+ const nginx = nativeGatewaySupported(manifest) || Boolean(manifest.daemon?.ingress);
383
+ if (nginx)
364
384
  fields.push(['startstop_restart_services', 'nginx.service']);
365
- return portalInfoFields(fields, Boolean(manifest.portal) || nativeGatewaySupported(manifest)).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
385
+ return portalInfoFields(fields, Boolean(manifest.portal) || nginx).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
366
386
  }
367
387
  function copyUiTree(source, target) {
368
388
  for (const entry of ["app.js", "app.css"]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "0.24.0",
30
+ "@synopackageland/templates": "0.25.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",