@synopackageland/cli 0.37.0 → 0.39.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 CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.39.0 (2026-10-29)
4
+
5
+ - Add schema-2 `dependencies.packages` for DSM package dependencies independent
6
+ of the App language or daemon interpreter. Each entry has a validated `name`
7
+ and optional quoted numeric `min_version`. Build merges, sorts and
8
+ deduplicates these with Node backend and `daemon.node` requirements into
9
+ one INFO `install_dep_packages` field; distinct specified minimum versions
10
+ for one package fail validation. Daemon executable, arguments, environment
11
+ and supervision are unchanged. Native layouts and Compose are supported.
12
+ `check --json` reports normalized dependencies, build metadata records them,
13
+ and `inspect` verifies normalization, INFO agreement and inferred Node
14
+ requirements. Document `/var/packages/<name>/target`, the manifest field
15
+ and the difference from `daemon.node` in the manifest/daemon guides and App
16
+ skill. Add parser, merge/conflict, compiled CLI, artifact and mutation tests.
17
+ - Make the native gateway listener-wait test assert both listeners close,
18
+ without assuming a scheduling-dependent minimum number of polls.
19
+
20
+ ## 0.38.0 (2026-10-29)
21
+
22
+ - Audit direct `ipc.StartPeerUnix` calls and reject constructor aliases.
23
+ Document connection ownership, admission stop and final ACK sequencing in
24
+ the App skill. Use SDK/App 0.36.0.
25
+ - Pin templates 0.34.0 and its SDK/App 0.36.0 defaults.
26
+ - Fix CGI handler reachability when initialization shares Config or error
27
+ values. Follow each identifier's initializer so SDK CGI handlers are
28
+ accepted while private IPC handlers remain refused through assignments
29
+ and wrappers.
30
+
3
31
  ## 0.37.0 (2026-10-29)
4
32
 
5
33
  - Native Apps appear in Hyper Backup as applications by default. Build writes
package/README.md CHANGED
@@ -49,6 +49,10 @@ See the [upstream scaffold and release checklist](skills/reference/upstream-fork
49
49
  Existing native products with their own payload layout use `backend: {kind: native}` in
50
50
  `dsm-package.yaml`.
51
51
 
52
+ Declare language-independent DSM installation requirements with
53
+ [`dependencies.packages`](docs/manifest.md). They merge with inferred Node
54
+ requirements without changing the App's executable or runtime.
55
+
52
56
  A backend in Python, Node or Rust uses `backend: {runtime: python|node|rust, ...}`: the CGI is
53
57
  the SDK's Go runtime, which checks the DSM session and `permissions.json` and starts the App
54
58
  from `target/app` as the package account for each request (see `skills/reference/create.md`).
@@ -74,7 +78,7 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
74
78
  packages. Private mirrors can use explicitly configured Go authentication and
75
79
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
76
80
 
77
- CLI 0.37.0 uses templates ~0.33.0, whose default SDK pin is 0.35.0. Compose import and SDK defaults
81
+ CLI 0.38.0 uses templates ~0.34.0, whose default SDK pin is 0.36.0. Compose import and SDK defaults
78
82
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
79
83
  Every App method must be declared in the permissions table. Dynamic names and
80
84
  custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
@@ -85,8 +89,8 @@ unimported development commands are excluded. Use
85
89
  authentication. See [App entry and registration](docs/app-entry.md).
86
90
 
87
91
  `init` and `add` read their templates from the locally installed
88
- `@synopackageland/templates`. CLI 0.37.0 depends on `~0.33.0`: npm installation or
89
- update can select the latest templates patch in the 0.33 minor line. Templates
92
+ `@synopackageland/templates`. CLI 0.38.0 depends on `~0.34.0`: npm installation or
93
+ update can select the latest templates patch in the 0.34 minor line. Templates
90
94
  patches need only a templates release; update the npm installation to receive them.
91
95
  The CLI never fetches templates at runtime, so offline use works after installation;
92
96
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -248,6 +248,9 @@ func reusesBoundary(expr ast.Node, owner source, sources []source, safe map[ast.
248
248
  }
249
249
  }
250
250
  }
251
+ if safe[expr] {
252
+ return true
253
+ }
251
254
  found := false
252
255
  ast.Inspect(expr, func(n ast.Node) bool {
253
256
  if n == nil || found {
@@ -263,8 +266,38 @@ func reusesBoundary(expr ast.Node, owner source, sources []source, safe map[ast.
263
266
  if id, ok := n.(*ast.Ident); ok {
264
267
  if id.Obj != nil {
265
268
  if decl, ok := id.Obj.Decl.(ast.Node); ok {
266
- found = safe[decl] || reusesBoundary(decl, owner, sources, safe, seen)
269
+ // An identifier carries its own initializer, not the other
270
+ // values/errors declared beside it in a shared assignment.
271
+ var names []*ast.Ident
272
+ var values []ast.Expr
273
+ switch d := decl.(type) {
274
+ case *ast.AssignStmt:
275
+ for _, lhs := range d.Lhs {
276
+ name, _ := lhs.(*ast.Ident)
277
+ names = append(names, name)
278
+ }
279
+ values = d.Rhs
280
+ case *ast.ValueSpec:
281
+ names, values = d.Names, d.Values
282
+ }
283
+ if len(values) > 0 {
284
+ for i, name := range names {
285
+ if name != nil && name.Obj == id.Obj {
286
+ index := i
287
+ if len(values) == 1 {
288
+ index = 0
289
+ }
290
+ if index < len(values) {
291
+ found = reusesBoundary(values[index], owner, sources, safe, seen)
292
+ }
293
+ break
294
+ }
295
+ }
296
+ } else {
297
+ found = safe[decl] || reusesBoundary(decl, owner, sources, safe, seen)
298
+ }
267
299
  }
300
+ return false
268
301
  } else {
269
302
  targetPackage, targetName = owner.pkg, id.Name
270
303
  }
@@ -554,7 +587,7 @@ func main() {
554
587
  }
555
588
  if module == sdkModule+"/ipc" && !direct[selector] {
556
589
  switch name {
557
- case "ServeUnix", "StartUnix", "ServePeerUnix", "DialUnix", "DialPeerUnix", "ReserveLoopback", "ProbeLoopback":
590
+ case "ServeUnix", "StartUnix", "ServePeerUnix", "StartPeerUnix", "DialUnix", "DialPeerUnix", "ReserveLoopback", "ProbeLoopback":
558
591
  problem(selector.Pos(), "IPC constructor aliases cannot be audited")
559
592
  }
560
593
  }
package/dist/cli.js CHANGED
@@ -405,6 +405,7 @@ async function runCliWithin(argv) {
405
405
  emit("check", {
406
406
  package: loaded.manifest.package,
407
407
  warnings: validation.warnings ?? [],
408
+ ...("dependencies" in loaded.manifest && loaded.manifest.dependencies ? { dependencies: loaded.manifest.dependencies } : {}),
408
409
  ...("installSettings" in loaded.manifest && loaded.manifest.installSettings ? { installSettings: loaded.manifest.installSettings } : {}),
409
410
  ...("hyperBackup" in loaded.manifest && loaded.manifest.hyperBackup ? { hyperBackup: backupRecord(loaded.manifest.hyperBackup) } : {}),
410
411
  ...("identityGateway" in loaded.manifest && gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) ? { identityGateway: gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) } : {}),
@@ -283,6 +283,11 @@ export const ERROR_REGISTRY = {
283
283
  description: "dsm-package.yaml contains an unsupported field.",
284
284
  suggestion: "Remove the field; a Standalone Package does not accept custom lifecycle scripts or DSM conf.",
285
285
  },
286
+ STANDALONE_DEPENDENCIES_INVALID: {
287
+ code: "STANDALONE_DEPENDENCIES_INVALID",
288
+ description: "The DSM package dependencies are invalid or conflict with an inferred dependency.",
289
+ suggestion: "Use dependencies.packages entries with only name and optional quoted numeric min_version; use one identical minimum version per package, including daemon.node.",
290
+ },
286
291
  STANDALONE_SCHEMA_INVALID: {
287
292
  code: "STANDALONE_SCHEMA_INVALID",
288
293
  description: "The schema version in dsm-package.yaml is not supported.",
@@ -1,3 +1,4 @@
1
+ import { type PackageDependencies } from "./package-dependencies.js";
1
2
  import { type InstallSettings } from './install-settings.js';
2
3
  import { type WebLaunchMode } from "../contract/web-launch.js";
3
4
  import { type ComposeApp } from "./compose.js";
@@ -9,6 +10,7 @@ import type { EntryMode } from "./manifest.js";
9
10
  import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
10
11
  export type SpkKind = "native-go" | "native-python" | "native-node" | "native-rust" | "binary" | "container" | "native-layout";
11
12
  export interface SpkInspection {
13
+ dependencies?: PackageDependencies;
12
14
  installSettings?: InstallSettings;
13
15
  /** Hyper Backup application backup, as sdk-package.json records it. */
14
16
  hyperBackup?: HyperBackup;
@@ -1,3 +1,4 @@
1
+ import { inspectPackageDependencies, dependencyInfo } from "./package-dependencies.js";
1
2
  import { readInstallSettings, settingsInstallSteps, settingsConfig, renderSettingsHook } from './install-settings.js';
2
3
  import { MODULE_UI_ENTRY, uiTreeDigest } from "./ui-assets.js";
3
4
  import { nativePortal } from "./portal.js";
@@ -66,7 +67,12 @@ export async function inspectSpk(spkPath, expectedKind) {
66
67
  await tar.x({ file: spkPath, cwd: dir });
67
68
  const findings = [];
68
69
  if (!expectedKind && existsSync(join(dir, "native-layout.json"))) {
69
- const info = readInfo(join(dir, "INFO"), findings, "native-layout");
70
+ let dependencies;
71
+ try {
72
+ dependencies = inspectPackageDependencies(JSON.parse(readFileSync(join(dir, "native-layout.json"), "utf8")).dependencies, findings);
73
+ }
74
+ catch { /* inspectNativeLayout reports a malformed inventory. */ }
75
+ const info = readInfo(join(dir, "INFO"), findings, "native-layout", false, undefined, dependencies);
70
76
  const payload = await listPayload(join(dir, "package.tgz"), findings);
71
77
  const target = join(dir, "layout-inspection");
72
78
  mkdirSync(target);
@@ -83,6 +89,7 @@ export async function inspectSpk(spkPath, expectedKind) {
83
89
  findings.push(`Missing INFO ${key}`);
84
90
  const icon = inspectPackageIcons(dir, findings);
85
91
  return {
92
+ ...(dependencies ? { dependencies } : {}),
86
93
  kind: "native-layout",
87
94
  package: info.package,
88
95
  version: info.version,
@@ -134,6 +141,7 @@ export async function inspectSpk(spkPath, expectedKind) {
134
141
  findings.push("sdk-package.json does not match the schema 2 capability inventory.");
135
142
  }
136
143
  }
144
+ const dependencies = inspectPackageDependencies(metadata.dependencies, findings);
137
145
  const daemonRestart = recoverDaemonRestart(metadata.daemon_restart, metadata.daemon_sdk, findings);
138
146
  await inspectModuleUi(dir, paths, metadata.ui_module, findings);
139
147
  if (metadata.daemon_restart !== undefined && (metadata.compose !== undefined || !paths.has("daemon/control")))
@@ -152,6 +160,9 @@ export async function inspectSpk(spkPath, expectedKind) {
152
160
  const integrations = metadata.integrations ?? [];
153
161
  if (metadata.compose !== undefined) {
154
162
  const result = await inspectUnifiedCompose(dir, payload, { ...metadata, installSettings }, findings);
163
+ if (dependencies) {
164
+ result.dependencies = dependencies;
165
+ }
155
166
  if (installSettings)
156
167
  result.installSettings = installSettings;
157
168
  if (hyperBackup)
@@ -181,7 +192,7 @@ export async function inspectSpk(spkPath, expectedKind) {
181
192
  ? undefined
182
193
  : await recoverCompose(dir, metadata.compose, findings);
183
194
  const daemonNode = recoverDaemonNode(metadata.daemon_node, findings);
184
- const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined), daemonNode);
195
+ const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined), daemonNode, dependencies);
185
196
  if (combined &&
186
197
  (!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
187
198
  findings.push("compose identity_gateway requires DSM >= 7.2.1");
@@ -230,6 +241,8 @@ export async function inspectSpk(spkPath, expectedKind) {
230
241
  const bridge = compiledKind && kind !== "native-go"
231
242
  ? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
232
243
  : undefined;
244
+ if (dependencies && bridge?.node?.package && !dependencies.packages.some(d => d.name === bridge.node.package))
245
+ findings.push("DEPENDENCIES_INVALID build metadata dependencies omit the Node backend package.");
233
246
  const hasExecutor = (metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
234
247
  if (standalone && portal)
235
248
  findings.push(...inspectPortalInfo(info, hasExecutor));
@@ -315,6 +328,7 @@ export async function inspectSpk(spkPath, expectedKind) {
315
328
  findings.push(`${CGI_PAYLOAD_PATH} has mode ${cgiEntry.mode}; it should be 0755`);
316
329
  }
317
330
  return {
331
+ ...(dependencies ? { dependencies } : {}),
318
332
  kind,
319
333
  ...(identityGateway ? { identityGateway } : {}),
320
334
  ...(installSettings ? { installSettings } : {}),
@@ -691,7 +705,7 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
691
705
  }
692
706
  /** Replay the SDK planner over archived source bytes, not merely a mutable lock. */
693
707
  async function inspectUnifiedCompose(dir, payload, metadata, findings) {
694
- const info = readInfo(join(dir, "INFO"), findings, "binary", true);
708
+ const info = readInfo(join(dir, "INFO"), findings, "binary", true, undefined, inspectPackageDependencies(metadata.dependencies, findings));
695
709
  const base = {
696
710
  kind: "container",
697
711
  package: info.package,
@@ -1064,7 +1078,7 @@ function archivedInfoTranslations(info) {
1064
1078
  }
1065
1079
  return translations;
1066
1080
  }
1067
- function readInfo(path, findings, kind, compose = false, daemonNode) {
1081
+ function readInfo(path, findings, kind, compose = false, daemonNode, dependencies) {
1068
1082
  if (!existsSync(path)) {
1069
1083
  findings.push("INFO is missing");
1070
1084
  return {};
@@ -1073,6 +1087,8 @@ function readInfo(path, findings, kind, compose = false, daemonNode) {
1073
1087
  for (const line of readFileSync(path, "utf8").split("\n")) {
1074
1088
  const match = line.match(/^([a-z_]+)="(.*)"$/);
1075
1089
  if (match) {
1090
+ if (match[1] === "install_dep_packages" && Object.hasOwn(info, match[1]))
1091
+ findings.push("INFO install_dep_packages must appear only once");
1076
1092
  info[match[1]] = match[2];
1077
1093
  }
1078
1094
  }
@@ -1098,7 +1114,14 @@ function readInfo(path, findings, kind, compose = false, daemonNode) {
1098
1114
  : kind === "native-node"
1099
1115
  ? /^Node\.js_v[0-9]{2}([><=].*)?$/
1100
1116
  : undefined;
1101
- if (daemonNode && info.install_dep_packages !== nodeDependency(daemonNode))
1117
+ if (dependencies) {
1118
+ if ((info.install_dep_packages ?? "") !== dependencyInfo(dependencies))
1119
+ findings.push("INFO install_dep_packages does not match build metadata dependencies");
1120
+ const inferred = daemonNode && dependencies.packages.find(d => d.name === daemonNode.package);
1121
+ if (daemonNode && (!inferred || daemonNode.min_version !== undefined && inferred.min_version !== daemonNode.min_version))
1122
+ findings.push("DEPENDENCIES_INVALID build metadata dependencies conflict with daemon.node");
1123
+ }
1124
+ else if (daemonNode && info.install_dep_packages !== nodeDependency(daemonNode))
1102
1125
  findings.push(`daemon.node requires INFO install_dep_packages="${nodeDependency(daemonNode)}"`);
1103
1126
  else if (!daemonNode &&
1104
1127
  info.install_dep_packages &&
@@ -1453,7 +1476,7 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
1453
1476
  const root = join(dir, 'install-settings-inspection');
1454
1477
  mkdirSync(root);
1455
1478
  await tar.x({ file: join(dir, 'package.tgz'), cwd: root });
1456
- const info = readInfo(join(dir, 'INFO'), findings, 'binary', Boolean(metadata.compose));
1479
+ const info = readInfo(join(dir, 'INFO'), findings, 'binary', Boolean(metadata.compose), undefined, inspectPackageDependencies(metadata.dependencies, findings));
1457
1480
  const gateway = metadata.compose?.gateway ?? metadata.native_gateway;
1458
1481
  const m = { package: info.package, os_min_ver: info.os_min_ver, capabilities: {}, installSettings: settings, identityGateway: gateway };
1459
1482
  try {
@@ -1,3 +1,4 @@
1
+ import { type PackageDependencies } from "./package-dependencies.js";
1
2
  import { type InstallSettings } from './install-settings.js';
2
3
  import { type WebLaunchMode } from "../contract/web-launch.js";
3
4
  import { type WebAPIProfiles } from "../runtime/webapi-profile.js";
@@ -51,6 +52,7 @@ export interface StandaloneManifest {
51
52
  commands: Record<string, import("../ctl/descriptor.js").Operation>;
52
53
  };
53
54
  metadata?: PackageMetadata;
55
+ dependencies?: PackageDependencies;
54
56
  root: string;
55
57
  package: string;
56
58
  version: string;
@@ -1,3 +1,4 @@
1
+ import { readPackageDependencies, packageDependencies, dependencyError } from "./package-dependencies.js";
1
2
  import { readInstallSettings } from './install-settings.js';
2
3
  import { auditGoRoutes } from './route-audit.js';
3
4
  import { BACKGROUND_WORKER_MAIN } from './background.js';
@@ -55,6 +56,7 @@ export const KNOWN_FIELDS = new Set([
55
56
  "capabilities",
56
57
  "i18n",
57
58
  "daemon",
59
+ "dependencies",
58
60
  "identity_gateway",
59
61
  "compose",
60
62
  "install",
@@ -247,6 +249,19 @@ function readManifest(raw, c) {
247
249
  const lifecycleTimeouts = timeoutOnly ? readLifecycleTimeouts(raw.daemon.timeouts, (e) => c.fail(e.code, e.message)) : undefined;
248
250
  const daemon = arches &&
249
251
  readDaemon(timeoutOnly ? undefined : raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message));
252
+ let dependencies;
253
+ try {
254
+ const explicit = readPackageDependencies(raw.dependencies);
255
+ if (backend) {
256
+ const effective = packageDependencies({ backend, daemon, dependencies: explicit });
257
+ if (explicit || effective.packages.length)
258
+ dependencies = effective;
259
+ }
260
+ }
261
+ catch (error) {
262
+ const problem = dependencyError(error);
263
+ c.fail(problem.code, problem.message);
264
+ }
250
265
  const gatewayRaw = isRecord(raw.identity_gateway) ? raw.identity_gateway : {};
251
266
  if (raw.compose !== undefined && isRecord(raw.ctl) && raw.ctl.enabled === true)
252
267
  c.fail("CTL_UNSUPPORTED_BACKEND", "Compose does not support automatic ctl descriptions; disable ctl.");
@@ -507,6 +522,7 @@ function readManifest(raw, c) {
507
522
  ...(rootOps ? { rootOps } : {}),
508
523
  ...(rootPrograms ? { rootPrograms } : {}),
509
524
  ...(daemon ? { daemon } : {}),
525
+ ...(dependencies ? { dependencies } : {}),
510
526
  ...(lifecycleTimeouts ? { lifecycleTimeouts } : {}),
511
527
  ...(identityGateway ? { identityGateway } : {}),
512
528
  ...(compose ? { compose } : {}),
@@ -1,3 +1,4 @@
1
+ import { type PackageDependencies } from "./package-dependencies.js";
1
2
  import { type PackageMetadata } from "../package-metadata.js";
2
3
  import type { CommandRunner } from './build.js';
3
4
  import { type Toolkit } from './toolkit.js';
@@ -21,6 +22,7 @@ type Binary = {
21
22
  };
22
23
  export interface NativeLayout {
23
24
  metadata?: PackageMetadata;
25
+ dependencies?: PackageDependencies;
24
26
  root: string;
25
27
  package: string;
26
28
  version: string;
@@ -1,3 +1,4 @@
1
+ import { readPackageDependencies, dependencyInfo, inspectPackageDependencies } from "./package-dependencies.js";
1
2
  import { portalInfoFields } from "../portal/info.js";
2
3
  import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
3
4
  /** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
@@ -106,13 +107,15 @@ export function isNativeLayout(root) {
106
107
  }
107
108
  export function loadNativeLayout(root) {
108
109
  try {
109
- const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible', 'portal']);
110
+ const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible', 'portal', 'dependencies']);
110
111
  if (m.schema !== 2)
111
112
  fail('schema 2 required; migrate schema: 1 to schema: 2 and add backend: {kind: native}');
112
113
  const backend = object(m.backend, ['kind']);
113
114
  if (backend.kind !== 'native' || (m.privilege !== undefined && m.privilege !== 'none'))
114
115
  fail('backend.kind: native and package-only privilege required');
115
116
  m.privilege = 'none';
117
+ if (m.dependencies !== undefined)
118
+ m.dependencies = readPackageDependencies(m.dependencies);
116
119
  if (m.metadata !== undefined)
117
120
  m.metadata = validateMetadata(m.metadata);
118
121
  if (existsSync(join(root, 'compose.yaml')))
@@ -330,11 +333,12 @@ export async function buildNativeLayout(options) {
330
333
  await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
331
334
  rmSync(target, { recursive: true });
332
335
  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') };
333
- const info = portalInfoFields([...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)], Boolean(m.portal));
336
+ const deps = dependencyInfo(m.dependencies ?? { packages: [] });
337
+ const info = portalInfoFields([...Object.entries(fields), ...(deps ? [["install_dep_packages", deps]] : []), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)], Boolean(m.portal));
334
338
  if (new Set(info.map(([k]) => k)).size !== info.length)
335
339
  fail('INFO anchor collides with an INFO field');
336
340
  writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
337
- writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}), ...(m.portal ? { portal: m.portal } : {}) }));
341
+ writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, dependencies: m.dependencies ?? { packages: [] }, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}), ...(m.portal ? { portal: m.portal } : {}) }));
338
342
  const output = join(outDir, `${m.package}-${version}-${arch}.spk`);
339
343
  await createReproducibleTar(stage, output);
340
344
  return output;
@@ -351,9 +355,15 @@ export function inspectNativeLayout(dir, payload, info) {
351
355
  try {
352
356
  if (!/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package ?? '') || !/^\d+(\.\d+){0,3}-\d+$/.test(info.version ?? '') || !/^7\.\d+(\.\d+)?-\d+$/.test(info.os_min_ver ?? ''))
353
357
  fail('invalid native INFO identity/version/DSM');
354
- const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors', 'portal']);
358
+ const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors', 'portal', 'dependencies']);
355
359
  if (inv.format !== 1 || !Object.hasOwn(LAYOUT_ARCHES, inv.arch) || layoutInfoArch(inv.arch) !== info.arch)
356
360
  fail('invalid native layout inventory');
361
+ const errors = [];
362
+ const dependencies = inspectPackageDependencies(inv.dependencies, errors);
363
+ if (errors.length)
364
+ fail(errors.join("; "));
365
+ if ((info.install_dep_packages ?? "") !== dependencyInfo(dependencies ?? { packages: [] }))
366
+ fail("INFO install_dep_packages differs from native layout dependencies");
357
367
  checkLayoutMetadata(dir, true, info.package);
358
368
  const actual = layoutFiles(payload);
359
369
  if (JSON.stringify(actual) !== JSON.stringify(inv.files))
@@ -0,0 +1,17 @@
1
+ import type { StandaloneManifest, StandaloneError } from "./manifest.js";
2
+ export interface PackageDependency {
3
+ name: string;
4
+ min_version?: string;
5
+ }
6
+ export interface PackageDependencies {
7
+ packages: PackageDependency[];
8
+ }
9
+ /** Fixed INFO tokens, never shell expressions or dependency operators. */
10
+ export declare function readPackageDependencies(value: unknown): PackageDependencies | undefined;
11
+ /** Unversioned requirements accept a specified minimum; distinct minima conflict. */
12
+ export declare function mergePackageDependencies(...groups: PackageDependency[][]): PackageDependency[];
13
+ export declare function packageDependencies(manifest: Pick<StandaloneManifest, "backend" | "daemon" | "dependencies">): PackageDependencies;
14
+ export declare function dependencyInfo(dependencies: PackageDependencies): string;
15
+ /** Old build records have no field; new records must already be normalized. */
16
+ export declare function inspectPackageDependencies(value: unknown, findings: string[]): PackageDependencies | undefined;
17
+ export declare function dependencyError(error: unknown): StandaloneError;
@@ -0,0 +1,57 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
3
+ const NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
4
+ const VERSION = /^\d+(?:\.\d+){0,3}(?:-\d+)?$/;
5
+ /** Fixed INFO tokens, never shell expressions or dependency operators. */
6
+ export function readPackageDependencies(value) {
7
+ if (value === undefined)
8
+ return undefined;
9
+ if (!record(value) || Object.keys(value).some(k => k !== "packages") || !Array.isArray(value.packages))
10
+ throw new Error("dependencies accepts only packages: an array of {name, min_version?}.");
11
+ const packages = value.packages.map((entry, i) => {
12
+ if (!record(entry) || Object.keys(entry).some(k => k !== "name" && k !== "min_version") ||
13
+ typeof entry.name !== "string" || !NAME.test(entry.name))
14
+ throw new Error(`dependencies.packages[${i}].name must start with a letter and contain only ASCII letters, digits, _, . or - (1-64 characters); only name and min_version are accepted.`);
15
+ if (entry.min_version !== undefined && (typeof entry.min_version !== "string" || entry.min_version.length > 64 || !VERSION.test(entry.min_version)))
16
+ throw new Error(`dependencies.packages[${i}].min_version must be a quoted numeric version with 1-4 components and an optional numeric -build (at most 64 characters).`);
17
+ return { name: entry.name, ...(entry.min_version !== undefined ? { min_version: entry.min_version } : {}) };
18
+ });
19
+ return { packages: mergePackageDependencies(packages) };
20
+ }
21
+ /** Unversioned requirements accept a specified minimum; distinct minima conflict. */
22
+ export function mergePackageDependencies(...groups) {
23
+ const byName = new Map();
24
+ for (const dep of groups.flat()) {
25
+ const prev = byName.get(dep.name);
26
+ if (prev?.min_version !== undefined && dep.min_version !== undefined && prev.min_version !== dep.min_version)
27
+ throw new Error(`Conflicting min_version for package ${dep.name}: ${prev.min_version} and ${dep.min_version}. Use one identical minimum version.`);
28
+ byName.set(dep.name, { name: dep.name, ...(prev?.min_version !== undefined ? { min_version: prev.min_version } : dep.min_version !== undefined ? { min_version: dep.min_version } : {}) });
29
+ }
30
+ return [...byName.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
31
+ }
32
+ export function packageDependencies(manifest) {
33
+ const node = "node" in manifest.backend ? manifest.backend.node : undefined;
34
+ const daemonNode = manifest.daemon?.node;
35
+ return { packages: mergePackageDependencies(readPackageDependencies(manifest.dependencies)?.packages ?? [], node ? [{ name: node.package }] : [], daemonNode ? [{ name: daemonNode.package, ...(daemonNode.min_version ? { min_version: daemonNode.min_version } : {}) }] : []) };
36
+ }
37
+ export function dependencyInfo(dependencies) {
38
+ return dependencies.packages.map(d => d.name + (d.min_version === undefined ? "" : `>=${d.min_version}`)).join(":");
39
+ }
40
+ /** Old build records have no field; new records must already be normalized. */
41
+ export function inspectPackageDependencies(value, findings) {
42
+ if (value === undefined)
43
+ return undefined;
44
+ try {
45
+ const normalized = readPackageDependencies(value);
46
+ if (!isDeepStrictEqual(normalized, value))
47
+ throw new Error("dependencies must be sorted, deduplicated and normalized.");
48
+ return normalized;
49
+ }
50
+ catch (error) {
51
+ findings.push(`DEPENDENCIES_INVALID build metadata dependencies: ${error.message}`);
52
+ return { packages: [] };
53
+ }
54
+ }
55
+ export function dependencyError(error) {
56
+ return { code: "STANDALONE_DEPENDENCIES_INVALID", message: error.message };
57
+ }
@@ -1,3 +1,4 @@
1
+ import { packageDependencies, dependencyInfo } from "./package-dependencies.js";
1
2
  import { settingsInstallSteps, settingsConfig } from './install-settings.js';
2
3
  import { backupConfig, backupHookProblem, backupRecord, renderBackupScripts, BACKUP_PAYLOAD_DIR, BACKUP_SCRIPTS_DIR, BACKUP_SCRIPT_MODES } from './hyper-backup.js';
3
4
  import { nativeGatewaySupported, nativeGatewaySdk, renderNativeGatewayConfig, NATIVE_GATEWAY_CONFIG, legacyGatewayNotice, gatewayEntryPath } from './native-gateway.js';
@@ -31,7 +32,7 @@ import { copyAppTree } from "./bridge-app.js";
31
32
  import { isBridgeBackend } from "./bridge-backend.js";
32
33
  import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
33
34
  import { writeComposePayload } from "./compose.js";
34
- import { copyDaemonPayload, daemonSdkVersion, nodeDependency } from "./daemon.js";
35
+ import { copyDaemonPayload, daemonSdkVersion } from "./daemon.js";
35
36
  import { DAEMON_INGRESS_CONFIG, renderDaemonIngressConfig } from "./daemon-ingress.js";
36
37
  import { gatewayCgiPaths, gatewayExternalAccess, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig, identityGatewayDeclaration, } from "./identity-gateway.js";
37
38
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS, } from "./background.js";
@@ -244,6 +245,7 @@ export async function assembleStandaloneSpk(options) {
244
245
  .digest("hex");
245
246
  writeFile(join(staging, "sdk-package.json"), JSON.stringify({
246
247
  schema: 2,
248
+ dependencies: packageDependencies(manifest),
247
249
  ...(moduleVersion ? { ui_module: moduleVersion } : {}),
248
250
  ...(manifest.installSettings ? { installSettings: manifest.installSettings } : {}),
249
251
  ...(backup ? { hyperBackup: backupRecord(backup) } : {}),
@@ -389,11 +391,9 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
389
391
  "adminurl",
390
392
  gatewayEntryPath(manifest),
391
393
  ]);
392
- const backend = manifest.backend;
393
- if (isBridgeBackend(backend) && backend.node)
394
- fields.push(["install_dep_packages", backend.node.package]);
395
- if (manifest.daemon?.node)
396
- fields.push(["install_dep_packages", nodeDependency(manifest.daemon.node)]);
394
+ const dependencies = dependencyInfo(packageDependencies(manifest));
395
+ if (dependencies)
396
+ fields.push(["install_dep_packages", dependencies]);
397
397
  fields.push(...metadataInfoFields(manifest.metadata));
398
398
  // Background packages need prestart: it runs before DSM's systemd-user-unit acquire.
399
399
  if (manifest.daemon || manifest.compose || manifest.capabilities.jobs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
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.33.0",
30
+ "@synopackageland/templates": "~0.34.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
package/skills/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
- CLI 0.36.0 installs templates through `~0.32.0`. A templates patch needs only a
8
+ The CLI installs templates through `~0.34.0`. A templates patch needs only a
9
9
  templates release; npm install/update selects a patch in that minor line. The CLI
10
10
  uses the locally installed templates and reads their SDK pin for defaults; it
11
11
  never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
24
24
 
25
25
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
26
26
 
27
- Use SDK/App 0.35.0 and CLI 0.37.0 for App entry enforcement. Declare every App method
27
+ Use SDK/App 0.36.0 and CLI 0.38.0 for App entry enforcement. Declare every App method
28
28
  in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
29
29
  Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
30
30
  paths that rely on upstream authentication. Read
@@ -53,14 +53,26 @@ retaining it. Check child readiness and retry bind conflicts: release-to-bind
53
53
  is inherently racy. Never replace these with custom listeners or fixed ports.
54
54
 
55
55
  An optional root helper is a separately approved executor payload.
56
- `ipc.ServePeerUnix(ctx, path, ipc.PeerPolicy{UID: uid, GID: gid}, handler)`
57
- supports a bounded raw JSON control protocol on a root-owned 0660 socket under
56
+ `ipc.StartPeerUnix(ctx, path, ipc.PeerPolicy{UID: uid, GID: gid}, handler)`
57
+ synchronously returns a peer-server handle for a bounded raw JSON control
58
+ protocol on a root-owned 0660 socket under
58
59
  `/var/run/synopkgland/<package>/`. The SDK authenticates exactly the selected
59
60
  UID with SO_PEERCRED; the handler validates bounded frames and current
60
61
  authorization. `ipc.DialPeerUnix` verifies root ownership, protected ancestors,
61
62
  mode and server UID zero. The server requires root AND the helper's explicit
62
63
  `ipc.executorTier=executor` linker binding. Bind packageID in custom builds.
63
64
  Generated CGI/workers bind tier `none`; never launch or install them as root.
65
+ The handler owns its verified conn even after returning; close it when done.
66
+ Stop and reap a supervised child on its owning OS thread, call the handle's
67
+ `StopAccepting`, write the complete final ACK, then retain the conn until
68
+ process exit. StopAccepting waits for listener/socket cleanup while keeping
69
+ accepted peers and handler contexts alive. `Wait` waits for admission stop,
70
+ not handlers or retained peers. `Close` or context cancellation cancels handler
71
+ contexts and closes all peers, including after StopAccepting; keep the context
72
+ alive through the ACK. Eight slots cover both retained conns and still-running
73
+ callbacks; set explicit deadlines for exchanges beyond the initial five seconds.
74
+ `ServePeerUnix` is the blocking form that closes a peer on callback return.
75
+ Use direct constructor calls; aliases are rejected by the audit.
64
76
  The transport does not provide installation, approval or privilege elevation.
65
77
 
66
78
  Audit every custom-hook Go payload with
@@ -87,6 +99,23 @@ Never install, uninstall, publish or modify NAS data without task authorization.
87
99
 
88
100
  Native projects use schema 2 capabilities and `--with files,jobs,login,root-ops,delegate`; use `add` for existing projects. Prefer `check --json`, `build --json`, `inspect <spk> --json`, `deploy --dry-run --json`, and `status --host <profile> --json` for structured results. Read the conflict policy in reference/create.md before adding overlays.
89
101
 
102
+ For DSM Package Center dependencies independent of the App language, declare
103
+ `dependencies: {packages: [{name: Node.js_v22, min_version: "22.19.0"}]}` in
104
+ `dsm-package.yaml`; `min_version` is optional. Names are 1-64 ASCII letters,
105
+ digits, `_`, `.` or `-`, starting with a letter. Versions are quoted numeric
106
+ strings with 1-4 dotted components and optional numeric `-build` (64 characters
107
+ maximum); other keys and unsafe INFO syntax fail validation. Requirements
108
+ merge with a Node backend or `daemon.node`, sort and deduplicate; two distinct
109
+ specified minimum versions for one package fail. `check --json` reports them;
110
+ build records them and `inspect` verifies exact agreement with INFO. This
111
+ works with native layouts and Compose too (host packages are not container
112
+ mounts). It leaves daemon exec/args/env/supervision unchanged. Use
113
+ `daemon.node` only for a JavaScript daemon: it replaces exec with the Node
114
+ launcher. For a Go daemon spawning Node children, keep the Go exec and use
115
+ `dependencies.packages`; call `/var/packages/Node.js_v22/target/usr/local/bin/node`.
116
+ Any dependency's target is `/var/packages/<name>/target`; no env vars or PATH
117
+ entries are injected, and the declaration grants no private-data access.
118
+
90
119
  Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting; DSM 7.2-64570+ supports schema-2 Portal without an executor, while DSM 7.1 requires executor registration. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate native runtimes in @synopackageland/sdk/native; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
91
120
 
92
121
  Read [reference/lifecycle.md](reference/lifecycle.md) before editing `dsm-scripts/` (preinst, postinst, preupgrade, postupgrade, preuninst, postuninst, start-stop-status): the SDK runs them inside its generated scripts as the package account; never add root work or repeat SDK steps there.