@synopackageland/cli 0.19.1 → 0.21.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0 (2026-10-21)
4
+
5
+ - Validate and preserve `compose.devices.gpu` in check, build and inspect for optional Intel render-node access and software fallback.
6
+ - Pin SDK/templates 0.21.0 and reuse immutable executor/v0.20.0 with verified unchanged executor/lifecycle digests.
7
+
8
+ ## 0.20.0 (2026-10-21)
9
+
10
+ - Align CLI/templates and the supported SDK minor line at 0.20; retain published UI 0.12.5.
11
+ - Compare upgrade approval requirements with `build/inspect --baseline`.
12
+ - Package native Compose shared-folder mounts with `select: install` and pass host health readiness to the daemon.
13
+ - Pin the 0.20.0 executor/lifecycle digest inventory and refresh canonical approval fixtures. Rebuilt executor Apps require administrator reapproval after upgrade.
14
+
3
15
  ## 0.19.1 (2026-10-20)
4
16
 
5
17
  - Record SDK 0.19.1 executor and lifecycle digests for the executor fix that accepts `deadline_ms` on delegated `webapi.call`; SDK 0.19.0 digests remain. Executor Apps rebuilt with SDK 0.19.1 require one administrator reapproval after upgrading.
package/README.md CHANGED
@@ -367,3 +367,20 @@ unencrypted-HTTP limitations before opening it. Native resource/startup failures
367
367
  fail closed; they do not silently expose a direct listener. The native runtime
368
368
  requires the corresponding SDK release; this CLI change does not publish that
369
369
  release or change existing manifest version pins.
370
+
371
+ Compose Apps may opt in to Intel render devices with
372
+ `compose.devices.gpu.services: [media]`. The matching SDK discovers exact devices
373
+ and the native `videodriver` GID at installation; the CLI emits DSM's
374
+ `video-driver` resource. `check` rejects arbitrary paths/GIDs and elevated GPU
375
+ services. `inspect --json` includes `compose.devices.gpu` with the runtime path
376
+ policy, GID source and software fallback, plus outstanding DSM acceptance
377
+ blockers. Keep the package UID and let the App validate its upstream encoder;
378
+ absence of a GPU produces no device mapping. See the matching SDK's Compose GPU
379
+ documentation for the security boundary and hardware validation limits.
380
+
381
+ DSM's native provider adds NET_BIND_SERVICE to the bounding set for Compose
382
+ packages. The SDK still emits `cap_drop: [ALL]` and requests no `cap_add`; the
383
+ nonroot package process with no-new-privileges has zero effective, permitted and
384
+ ambient capabilities on the tested provider. This general platform behavior is
385
+ not a GPU blocker. Plan metadata describes SDK policy, not an attestation of the
386
+ provider's final container configuration; see the SDK Compose security boundary.
package/dist/cli.js CHANGED
@@ -344,6 +344,7 @@ export async function runCli(argv) {
344
344
  ...("compose" in loaded.manifest && loaded.manifest.compose
345
345
  ? {
346
346
  compose: {
347
+ ...(loaded.manifest.compose.plan.devices ? { devices: loaded.manifest.compose.plan.devices } : {}),
347
348
  images: loaded.manifest.compose.plan.images,
348
349
  services: loaded.manifest.compose.plan.services,
349
350
  mountsBlocked: loaded.manifest.compose.plan.mounts_blocked,
@@ -688,15 +689,18 @@ export async function runCli(argv) {
688
689
  outDir: undefined,
689
690
  }),
690
691
  });
692
+ const builtManifest = standalone && !isNativeLayout(standalone) ? loadStandaloneManifest(standalone) : undefined;
693
+ const devices = builtManifest && builtManifest.ok ? builtManifest.manifest.compose?.plan.devices : undefined;
691
694
  const approval = await Promise.all(outputs.map(async (output) => ({ output, ...await spkApprovalDiff(output, options.baseline && resolve(options.baseline)) })));
692
695
  const lines = [
693
696
  ...approval.map(item => item.output + "\n" + formatApprovalDiff(item)),
694
697
  ...outputs.map((output) => `Built ${output}`),
698
+ ...(devices?.gpu ? [`GPU: ${devices.gpu.paths}; GID source: ${devices.gpu.gid_source}; fallback: ${devices.gpu.fallback}`] : []),
695
699
  ...(reproducible ? reproducibleLines(reproducible) : []),
696
700
  ];
697
701
  if (reproducible && !reproducible.identical)
698
702
  throw Object.assign(failure("BUILD_NOT_REPRODUCIBLE", lines.join("\n"), "Find the payload files that differ between builds (timestamps, random paths, unpinned dependencies) and retry after fixing them."), { report: { outputs, reproducible } });
699
- emit("build", { outputs, approval, ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
703
+ emit("build", { outputs, approval, ...(devices ? { compose: { devices } } : {}), ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
700
704
  });
701
705
  if (json)
702
706
  console.log = (...args) => console.error(...args);
@@ -8,9 +8,9 @@ export type NativeInstallValuesResult = {
8
8
  message: string;
9
9
  };
10
10
  /**
11
- * Install wizard values for a native-go/binary deploy. Only an install-selected
12
- * delegate asks the wizard for anything; preinst refuses an empty selection on
13
- * the first install, so refuse it here before building or uploading.
11
+ * Install wizard values for native and Compose deployment. An install-selected
12
+ * delegate or install.sharedFolder asks the wizard for a selection. Reject
13
+ * missing first-install answers before building or uploading.
14
14
  */
15
15
  export declare function resolveNativeInstallValues(manifest: StandaloneManifest, provided: Record<string, string>, state: {
16
16
  installed: boolean;
@@ -6,20 +6,34 @@ function validShareName(name) {
6
6
  !/^homes?$/i.test(name) && name.trim() === name && name.length <= 256);
7
7
  }
8
8
  /**
9
- * Install wizard values for a native-go/binary deploy. Only an install-selected
10
- * delegate asks the wizard for anything; preinst refuses an empty selection on
11
- * the first install, so refuse it here before building or uploading.
9
+ * Install wizard values for native and Compose deployment. An install-selected
10
+ * delegate or install.sharedFolder asks the wizard for a selection. Reject
11
+ * missing first-install answers before building or uploading.
12
12
  */
13
13
  export function resolveNativeInstallValues(manifest, provided, state) {
14
14
  const fail = (code, message) => ({ ok: false, code, message });
15
15
  const selected = manifest.capabilities.delegate?.scope === "install-selected";
16
+ const folder = manifest.sharedFolder?.mode === "select" ? manifest.sharedFolder : undefined;
17
+ const key = folder?.key;
18
+ const changeKey = key ? `CHANGE_${key}` : undefined;
16
19
  const names = Object.keys(provided);
17
- if (!selected && names.length)
20
+ if (!selected && !folder && names.length)
18
21
  return fail("DEPLOY_PARAM_UNDECLARED", `${manifest.package} has no install wizard values; remove --param ${names[0]}.`);
19
- const unknown = names.find((name) => name !== "delegate_share" && name !== "delegate_home");
22
+ const unknown = names.find((name) => !(selected && (name === "delegate_share" || name === "delegate_home")) && name !== key && name !== changeKey);
20
23
  if (unknown)
21
- return fail("DEPLOY_PARAM_UNDECLARED", `--param ${unknown} is not an install wizard value. Use: ${USAGE}.`);
22
- const { delegate_share: share, delegate_home: home } = provided;
24
+ return fail("DEPLOY_PARAM_UNDECLARED", `--param ${unknown} is not an install wizard value. Use: ${folder ? `--param ${folder.key}=<shared folder>` : USAGE}.`);
25
+ if (key && changeKey) {
26
+ const name = provided[key], change = provided[changeKey];
27
+ if (change !== undefined && change !== "true" && change !== "false")
28
+ return fail("DEPLOY_PARAM_TYPE_INVALID", `--param ${changeKey} must be true or false.`);
29
+ if (name !== undefined && (name === "" || /[/\\"\x00-\x1f\x7f]/.test(name) || name.includes("..") || /^[.@]/.test(name)))
30
+ return fail("DEPLOY_PARAM_TYPE_INVALID", `--param ${key} must be one DSM Shared Folder name, never a path.`);
31
+ if ((!state.installed || change === "true") && name === undefined)
32
+ return fail("DEPLOY_PARAM_REQUIRED", `Select the native Shared Folder with --param ${key}=<shared folder>.`);
33
+ if (state.installed && name !== undefined && change !== "true")
34
+ return fail("DEPLOY_PARAM_REQUIRED", `Changing the selected folder requires --param ${changeKey}=true and --param ${key}=<shared folder>.`);
35
+ }
36
+ const share = selected ? provided.delegate_share : undefined, home = selected ? provided.delegate_home : undefined;
23
37
  if (home !== undefined && home !== "true" && home !== "false")
24
38
  return fail("DEPLOY_PARAM_TYPE_INVALID", "--param delegate_home must be true or false.");
25
39
  if (share !== undefined && !validShareName(share))
@@ -1,24 +1 @@
1
- {
2
- "0.19.0": {
3
- "x86_64": "3b23252a4d2ad188e8920aee3c022d054a85f31797dfb9f34a576449f8b08f41",
4
- "aarch64": "024a8a01977324645b475330371afb8b8c8387eac6a11ec3d9d30a4a0e445174",
5
- "armv7": "fae3266f65863815f1417332a3c73d12507941029077e0ddc118da8461e8cff3",
6
- "lifecycle": {
7
- "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972",
8
- "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb",
9
- "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
10
- },
11
- "goToolchain": "go1.26.9"
12
- },
13
- "0.19.1": {
14
- "x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa",
15
- "aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191",
16
- "armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc",
17
- "lifecycle": {
18
- "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972",
19
- "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb",
20
- "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
21
- },
22
- "goToolchain": "go1.26.9"
23
- }
24
- }
1
+ { "0.21.0": { "x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa", "aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191", "armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc", "lifecycle": { "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972", "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb", "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681" }, "goToolchain": "go1.26.9" } }
@@ -36,8 +36,8 @@ export declare function sdkRequirement(backendDir: string): string | undefined;
36
36
  export declare function goModulePath(backendDir: string): string | undefined;
37
37
  /**
38
38
  * The module directories of one SDK version. SYNOPKGLAND_SDK_DIR names a
39
- * local sdk checkout (development and gates only); otherwise both modules
40
- * come from `go mod download` of exactly that version.
39
+ * local sdk checkout (development and gates only); otherwise modules come from
40
+ * `go mod download`; unchanged executor releases are pinned above.
41
41
  */
42
42
  export declare function sdkSource(version: string, env?: NodeJS.ProcessEnv, download?: (spec: string) => string): SdkSource;
43
43
  /** The module cache directory of module@version; the cache is read-only, so builds copy it first. */
@@ -75,16 +75,18 @@ function goMod(backendDir) {
75
75
  return '';
76
76
  }
77
77
  }
78
+ /** SDK releases that reuse an immutable executor release with identical payload bytes. */
79
+ const EXECUTOR_RELEASES = { '0.21.0': '0.20.0' };
78
80
  /**
79
81
  * The module directories of one SDK version. SYNOPKGLAND_SDK_DIR names a
80
- * local sdk checkout (development and gates only); otherwise both modules
81
- * come from `go mod download` of exactly that version.
82
+ * local sdk checkout (development and gates only); otherwise modules come from
83
+ * `go mod download`; unchanged executor releases are pinned above.
82
84
  */
83
85
  export function sdkSource(version, env = process.env, download = (spec) => goModDownload(spec, env)) {
84
86
  const dir = env.SYNOPKGLAND_SDK_DIR;
85
87
  if (dir)
86
88
  return { version, goDir: dir, executorDir: join(dir, 'executor'), local: true };
87
- return { version, goDir: download(`${SDK_MODULE}@v${version}`), executorDir: download(`${EXECUTOR_MODULE}@v${version}`), local: false };
89
+ return { version, goDir: download(`${SDK_MODULE}@v${version}`), executorDir: download(`${EXECUTOR_MODULE}@v${EXECUTOR_RELEASES[version] ?? version}`), local: false };
88
90
  }
89
91
  const downloaded = new Map();
90
92
  /** The module cache directory of module@version; the cache is read-only, so builds copy it first. */
@@ -1,5 +1,10 @@
1
1
  /** SDK planner errors accepted at this versioned tool boundary. */
2
2
  export const COMPOSE_ERRORS = {
3
+ COMPOSE_GPU_INVALID: {
4
+ code: "COMPOSE_GPU_INVALID",
5
+ description: "The GPU declaration requires bounded package-UID services.",
6
+ suggestion: "Declare compose.devices.gpu.services with distinct active service names. Keep package UID, no added capabilities, and let the SDK discover Intel render devices and the native videodriver GID.",
7
+ },
3
8
  COMPOSE_STARTUP_INVALID: {
4
9
  code: "COMPOSE_STARTUP_INVALID",
5
10
  description: "The container startup budget is invalid.",
@@ -212,13 +217,13 @@ export const COMPOSE_ERRORS = {
212
217
  },
213
218
  COMPOSE_SHARE_INVALID: {
214
219
  code: "COMPOSE_SHARE_INVALID",
215
- description: "Shares.the declared value needs admin selection, ro/rw and fixed mounts.",
216
- suggestion: "Keep the project disabled until an official protected mount/project adapter is verified; never use a destination grant or host root workaround.",
220
+ description: "Shared Folder selection, permission or fixed mount declaration is invalid.",
221
+ suggestion: "For select: install, match install.sharedFolder mode: select, key and permission; choose an existing unambiguous folder. Dynamic admin-after-install remains blocked. Never use a destination grant or host root workaround.",
217
222
  },
218
223
  COMPOSE_SHARE_NEEDS_APPROVAL: {
219
224
  code: "COMPOSE_SHARE_NEEDS_APPROVAL",
220
225
  description: "Declare capabilities.composeMounts: true; user destination grants cannot authorize package mounts.",
221
- suggestion: "Keep the project disabled until an official protected mount/project adapter is verified; never use a destination grant or host root workaround.",
226
+ suggestion: "Declare the package-wide composeMounts opt-in. Native install selection requires DSM package-user permission; dynamic admin-after-install remains blocked.",
222
227
  },
223
228
  COMPOSE_SOURCE_UNSAFE: {
224
229
  code: "COMPOSE_SOURCE_UNSAFE",
@@ -1,8 +1,18 @@
1
1
  import { type ComposeAppData } from "../runtime/compose-data.js";
2
2
  import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
3
3
  import { type Daemon } from "./daemon.js";
4
+ import type { SharedFolder } from "./shared-folder.js";
4
5
  import type { StandaloneError, StandaloneManifest } from "./manifest.js";
5
6
  export interface ComposePlan {
7
+ devices?: {
8
+ gpu?: {
9
+ services: string[];
10
+ resource: string;
11
+ gid_source: string;
12
+ paths: string;
13
+ fallback: string;
14
+ };
15
+ };
6
16
  app_data?: ComposeAppData;
7
17
  startup_timeout_seconds?: number;
8
18
  ingress?: {
@@ -71,12 +81,13 @@ export interface ComposeApp {
71
81
  document: Record<string, any>;
72
82
  plan: ComposePlan;
73
83
  }
74
- export declare const COMPOSE_FIELDS: readonly ["startup_timeout_seconds", "resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
84
+ export declare const COMPOSE_FIELDS: readonly ["devices", "startup_timeout_seconds", "resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
75
85
  export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void, context?: {
76
86
  package?: string;
77
87
  sdk?: string;
78
88
  mode?: string;
79
89
  composeMounts?: boolean;
90
+ sharedFolder?: SharedFolder;
80
91
  resolveImages?: boolean;
81
92
  architecture?: NativeArchFamily;
82
93
  imageProofs?: unknown;
@@ -85,12 +96,12 @@ export declare function composeDaemon(m: Pick<StandaloneManifest, "compose" | "l
85
96
  export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "identityGateway" | "spkVersion">): {
86
97
  public_ports: number[];
87
98
  readiness?: {
88
- version: string;
89
- plan_sha256: string;
90
99
  health: {
91
100
  path: string;
92
101
  status: number;
93
102
  } | undefined;
103
+ version?: string | undefined;
104
+ plan_sha256?: string | undefined;
94
105
  } | undefined;
95
106
  ingress?: {
96
107
  candidate: number;
@@ -101,7 +112,7 @@ export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "
101
112
  export declare function composeSource(c: ComposeApp): string;
102
113
  /** Retain upstream bytes, the true override and a lock without secret values. */
103
114
  export declare function writeComposePayload(c: ComposeApp, root: string, target: string, gateway?: unknown): void;
104
- /** Only package identity values are materialized, in the incoming tree. */
115
+ /** Materialize fixed package identity and native wizard share names in the incoming tree. */
105
116
  export declare function renderComposeInstall(pkg: string): string;
106
117
  /** The same SDK entry point supplies the package-account install materializer. */
107
118
  export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
@@ -118,3 +129,4 @@ export declare function dsmComposeProblem(c: ComposeApp): {
118
129
  message: string;
119
130
  } | undefined;
120
131
  export declare const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";
132
+ export declare const COMPOSE_HOST_READINESS_LIMITATION = "Host availability only: package UID and loopback listener bindings, plus declared App HTTP health when configured. This does not attest the actual Docker image, project or generation.";
@@ -12,6 +12,7 @@ import { goChildEnv } from "../runtime/go-env.js";
12
12
  import { sdkSource } from "../runtime/sdk-source.js";
13
13
  import { readDaemon } from "./daemon.js";
14
14
  export const COMPOSE_FIELDS = [
15
+ "devices",
15
16
  "startup_timeout_seconds",
16
17
  "resource_prefix",
17
18
  "files",
@@ -68,7 +69,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
68
69
  if (!record(value) ||
69
70
  Object.keys(value).some((k) => !COMPOSE_FIELDS.includes(k)) ||
70
71
  !Array.isArray(value.files))
71
- return invalid("compose requires files, with optional env_files, profiles, web, data, shares, network, services, helpers and sdk; old file/targets syntax is removed.");
72
+ return invalid("compose requires files, with optional env_files, profiles, web, data, shares, devices, network, services, helpers and sdk; old file/targets syntax is removed.");
72
73
  if (value.sdk !== undefined &&
73
74
  (typeof value.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(value.sdk)))
74
75
  return invalid("compose.sdk must name the matching SDK release.", "SDK_COMPOSE_UNSUPPORTED");
@@ -93,6 +94,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
93
94
  package: context.package ?? "ComposeApp",
94
95
  identity_mode: context.mode ?? "none",
95
96
  compose_mounts: context.composeMounts === true,
97
+ ...(context.sharedFolder?.mode === "select" ? { install_shared_folder: context.sharedFolder } : {}),
96
98
  resolve_images: context.resolveImages === true,
97
99
  registry_mirrors: context.resolveImages &&
98
100
  process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS
@@ -171,11 +173,10 @@ export function composeNetwork(m) {
171
173
  ...(c.plan.ingress
172
174
  ? { ingress: { candidate: c.plan.ingress.candidate } }
173
175
  : {}),
174
- ...(!host && c.web && !c.plan.mounts_blocked
176
+ ...(c.web && (!host || c.web.health) && !c.plan.mounts_blocked
175
177
  ? {
176
178
  readiness: {
177
- version: m.spkVersion,
178
- plan_sha256: c.plan.sha256,
179
+ ...(!host ? { version: m.spkVersion, plan_sha256: c.plan.sha256 } : {}),
179
180
  health: c.web.health,
180
181
  },
181
182
  }
@@ -199,6 +200,7 @@ export function writeComposePayload(c, root, target, gateway) {
199
200
  mkdirSync(target, { recursive: true });
200
201
  for (const [name, content] of Object.entries({
201
202
  "docker-compose.yml": c.plan.effective,
203
+ ...(c.plan.devices?.gpu ? { "gpu.template.yaml": c.plan.effective } : {}),
202
204
  "generated.override.yaml": c.plan.override,
203
205
  "compose.lock.json": JSON.stringify(c.plan.lock, null, 2) + "\n",
204
206
  "request.json": JSON.stringify({ declaration: c.declaration, sdk: c.sdk, gateway }, null, 2) + "\n",
@@ -207,7 +209,7 @@ export function writeComposePayload(c, root, target, gateway) {
207
209
  }))
208
210
  writeFileSync(join(target, name), content, { mode: 0o600 });
209
211
  }
210
- /** Only package identity values are materialized, in the incoming tree. */
212
+ /** Materialize fixed package identity and native wizard share names in the incoming tree. */
211
213
  export function renderComposeInstall(pkg) {
212
214
  return `#!/bin/sh
213
215
  set -eu
@@ -268,6 +270,7 @@ export function lockComposeImages(m, arch, resolveImages, imageProofs) {
268
270
  sdk: m.compose.sdk,
269
271
  mode: m.identityGateway?.mode ?? "none",
270
272
  composeMounts: m.capabilities.composeMounts,
273
+ sharedFolder: m.sharedFolder,
271
274
  resolveImages,
272
275
  architecture: arch,
273
276
  imageProofs,
@@ -346,3 +349,4 @@ export function dsmComposeProblem(c) {
346
349
  };
347
350
  }
348
351
  export const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";
352
+ export const COMPOSE_HOST_READINESS_LIMITATION = "Host availability only: package UID and loopback listener bindings, plus declared App HTTP health when configured. This does not attest the actual Docker image, project or generation.";
@@ -25,6 +25,7 @@ export interface SpkInspection {
25
25
  requiresAdminApproval: boolean;
26
26
  }>;
27
27
  compose?: {
28
+ devices?: ComposeApp["plan"]["devices"];
28
29
  ingress?: ComposeApp["plan"]["ingress"];
29
30
  images: unknown[];
30
31
  services: unknown[];
@@ -6,7 +6,7 @@ import { inspectApiDescription } from "./api-description-inspect.js";
6
6
  import { dsmLauncherType } from "../contract/app-launch.js";
7
7
  import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway, } from "../contract/web-launch.js";
8
8
  import { loadContract } from "../contract/load.js";
9
- import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, } from "./compose.js";
9
+ import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, COMPOSE_HOST_READINESS_LIMITATION, } from "./compose.js";
10
10
  import { createHash } from "node:crypto";
11
11
  import { inspectNativeLayout } from "./native-layout.js";
12
12
  import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
@@ -575,6 +575,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
575
575
  requiresAdminApproval: true,
576
576
  })),
577
577
  };
578
+ const appScripts = unwrapAppScripts(join(dir, "scripts"), findings);
578
579
  const root = join(dir, "unified-compose-inspection");
579
580
  mkdirSync(root);
580
581
  await tar.x({ file: join(dir, "package.tgz"), cwd: root });
@@ -596,6 +597,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
596
597
  sdk: saved.sdk,
597
598
  mode: cfg?.mode ?? "none",
598
599
  composeMounts: metadata.capabilities?.includes("composeMounts"),
600
+ sharedFolder: recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))),
599
601
  architecture: arch,
600
602
  imageProofs: JSON.parse(readFileSync(join(root, "compose/images.json"), "utf8")),
601
603
  });
@@ -606,6 +608,8 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
606
608
  if (composeSource(c) !==
607
609
  readFileSync(join(root, "compose/docker-compose.yml"), "utf8"))
608
610
  throw new Error("worker Compose differs from the SDK plan");
611
+ if (c.plan.devices?.gpu && readFileSync(join(root, "compose/gpu.template.yaml"), "utf8") !== c.plan.effective)
612
+ throw new Error("GPU template differs from the SDK plan");
609
613
  if (c.plan.override !==
610
614
  readFileSync(join(root, "compose/generated.override.yaml"), "utf8"))
611
615
  throw new Error("generated override differs from the SDK plan");
@@ -641,6 +645,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
641
645
  : {}),
642
646
  },
643
647
  compose: c,
648
+ sharedFolder: recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))),
644
649
  ...(g ? { identityGateway: g } : {}),
645
650
  ...(metadata.entry ? { entry: metadata.entry } : {}),
646
651
  };
@@ -650,6 +655,11 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
650
655
  throw new Error("gateway configuration differs from the declared package contract");
651
656
  if (readOptional(join(dir, "conf/privilege")) !== renderNativePrivilege())
652
657
  throw new Error("Compose hooks must run as the package account");
658
+ if (manifest.sharedFolder) {
659
+ for (const [name, text] of Object.entries(expectedWizardFiles(info.package, manifest.sharedFolder)))
660
+ if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
661
+ throw new Error(`unexpected WIZARD_UIFILES/${name}; native share selection must match the declaration`);
662
+ }
653
663
  const resource = renderStandaloneResource(manifest);
654
664
  if (readOptional(join(dir, "conf/resource")) !== resource)
655
665
  throw new Error("unexpected worker resource; unapproved Shares must never auto-acquire");
@@ -671,7 +681,6 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
671
681
  if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
672
682
  throw new Error(`unexpected WIZARD_UIFILES/${name}; retained-volume guidance must match the plan`);
673
683
  }
674
- const appScripts = unwrapAppScripts(join(dir, "scripts"), findings);
675
684
  manifest.appScripts = undefined;
676
685
  for (const hook of [
677
686
  "preinst",
@@ -725,6 +734,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
725
734
  if (storageProblem)
726
735
  throw new Error(`[${storageProblem.code}] ${storageProblem.message}`);
727
736
  base.compose = {
737
+ ...(c.plan.devices ? { devices: c.plan.devices } : {}),
728
738
  ...(c.plan.ingress ? { ingress: c.plan.ingress } : {}),
729
739
  images: c.plan.images,
730
740
  services: c.plan.services,
@@ -737,9 +747,10 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
737
747
  ? "none"
738
748
  : c.plan.services.find((s) => s.service === c.web?.service)
739
749
  ?.network === "host"
740
- ? "host-uid-listener"
750
+ ? c.web.health ? "host-uid-listener-health" : "host-uid-listener"
741
751
  : "health-fixed-ip-lifecycle",
742
- limitation: COMPOSE_READINESS_LIMITATION,
752
+ limitation: c.plan.services.find((s) => s.service === c.web?.service)?.network === "host"
753
+ ? COMPOSE_HOST_READINESS_LIMITATION : COMPOSE_READINESS_LIMITATION,
743
754
  },
744
755
  };
745
756
  }
@@ -1033,7 +1044,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
1033
1044
  if (!resource["docker-project"])
1034
1045
  findings.push("the container is missing the docker-project resource");
1035
1046
  for (const key of Object.keys(resource)) {
1036
- if (!["docker-project", "webservice"].includes(key))
1047
+ if (!["docker-project", "webservice", ...(compose?.plan.devices?.gpu ? ["video-driver"] : [])].includes(key))
1037
1048
  findings.push(`Unknown container resource: ${key}`);
1038
1049
  }
1039
1050
  }
@@ -33,7 +33,8 @@ function renderStandaloneScriptInternal(name, manifest) {
33
33
  const control = `"/var/packages/${manifest.package}/target/daemon/control"`;
34
34
  const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${manifest.package}/var}"`;
35
35
  const preflight = name === "start-stop-status"
36
- ? `case "$1" in prestart|start|restart)\n "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/compose/control" preflight || exit $? ;;\nesac\n`
36
+ ? `case "$1" in prestart|start|restart)\n "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/compose/control" preflight || exit $? ;;\nesac\n` +
37
+ (!manifest.identityGateway ? `case "$1" in prestart|prestop) exit 0 ;; esac\n` : "")
37
38
  : "";
38
39
  const remember = name === "postinst" &&
39
40
  manifest.compose.plan.ingress &&
@@ -240,12 +240,16 @@ function readManifest(raw, c) {
240
240
  c.fail("CTL_UNSUPPORTED_BACKEND", "Compose does not support automatic ctl descriptions; disable ctl.");
241
241
  if (isRecord(raw.compose) && isRecord(raw.compose.web) && !isRecord(raw.compose.web.health))
242
242
  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.");
243
+ const sharedFolder = raw.install === undefined
244
+ ? undefined
245
+ : readSharedFolder(raw.install, (e) => c.fail(e.code, e.message));
243
246
  const compose = arches &&
244
247
  readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
245
248
  package: packageId,
246
249
  sdk: typeof gatewayRaw.sdk === "string" ? gatewayRaw.sdk : undefined,
247
250
  mode: typeof gatewayRaw.mode === "string" ? gatewayRaw.mode : "none",
248
251
  composeMounts: capabilities.composeMounts,
252
+ sharedFolder,
249
253
  });
250
254
  if (raw.compose !== undefined &&
251
255
  ((raw.daemon !== undefined && !timeoutOnly) ||
@@ -306,9 +310,6 @@ function readManifest(raw, c) {
306
310
  if (permissions)
307
311
  checkBridgeMethods(backend, permissions, c);
308
312
  }
309
- const sharedFolder = raw.install === undefined
310
- ? undefined
311
- : readSharedFolder(raw.install, (e) => c.fail(e.code, e.message));
312
313
  const entry = raw.entry === undefined
313
314
  ? undefined
314
315
  : raw.compose !== undefined &&
@@ -11,6 +11,7 @@ export function renderStandaloneResource(m, portal) {
11
11
  const dataShare = m.sharedFolder && sharedFolderResource(m.package, m.sharedFolder);
12
12
  const webConfig = { ...(portal ? portalResource()['web-config'] : {}), ...nativeGatewayResource(m) };
13
13
  const resource = {
14
+ ...(m.compose?.plan.devices?.gpu && !m.compose.plan.mounts_blocked ? { "video-driver": {} } : {}),
14
15
  ...(m.compose && !m.compose.plan.mounts_blocked
15
16
  ? renderDockerProjectResource(m.package, "compose", false, m.compose.plan.project_name)
16
17
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.19.1",
3
+ "version": "0.21.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.19.0",
30
+ "@synopackageland/templates": "0.21.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -98,7 +98,7 @@ a build operator trust decision. `check`/`inspect` remain offline.
98
98
  | Private bind data | DSM build rejects COMPOSE_PRIVATE_BIND_TIMING_UNSUPPORTED: clean-install preinst precedes pkgvar creation, while worker acquire precedes postinst; no root initialization or automatic volume conversion |
99
99
  | Image VOLUME | Build inventories verified image config and assigns a stable named volume |
100
100
  | Existing external volume | Rejected without trusted admin ownership/driver readback |
101
- | Existing Shared Folder | Explicit opt-in intent only; blocked settings page until official protected ACL/project control exists |
101
+ | Existing Shared Folder | Native install selection plus DSM package-user ACL; dynamic admin-after-install authority stays blocked |
102
102
  | Bridge | Default no-publish dedicated internal bridge with a package-derived fixed IP; availability requires App health, TCP and the current lifecycle acquire record; no Docker identity attestation |
103
103
  | Host | Explicit verified OIDC web consumer with address_env forced to 127.0.0.1; no container-root; internet access is visible |
104
104
  | Egress | isolated internal networks, or explicit internet outbound bridge; fine allowlist declarations fail |
@@ -122,7 +122,48 @@ modification and deletion. DSM identity/account bridge and file authority are
122
122
  separate features. Per-DSM-user filesystem access requires a File Station session
123
123
  or Broker Adapter; it is outside this change and cannot be obtained through a mount.
124
124
 
125
- Declare `capabilities: {composeMounts: true}` and, for example:
125
+ For one existing Shared Folder, reuse the native install/upgrade wizard:
126
+
127
+ ```yaml
128
+ capabilities: {composeMounts: true}
129
+ install:
130
+ sharedFolder: {mode: select, key: media, permission: ro}
131
+ compose:
132
+ shares:
133
+ media:
134
+ select: install
135
+ # Omitted permission defaults to ro. For rw, set rw in both declarations.
136
+ mounts: [{service: web, target: /media}]
137
+ ```
138
+
139
+ The share key and permission must match `install.sharedFolder` exactly. This
140
+ route supports `mode: select`; `create` resource ordering is not supported.
141
+ The package-account preinst resolves the chosen name on `/volumeN` and writes
142
+ only the fixed source placeholder into the worker's private `.env`. Missing,
143
+ ambiguous or symlinked sources fail installation. Host paths are never wizard
144
+ answers. Every mounted service uses the fixed package UID:GID, drops all Linux
145
+ capabilities and has no-new-privileges. Container-root cannot mount Shares.
146
+
147
+ **Selecting an existing share does not grant permission.** An administrator must
148
+ grant the package account access in **Control Panel > Shared Folder > Edit >
149
+ Permissions > System internal user**. Until then, container file operations fail
150
+ under the kernel's ACL checks. No root helper, ACL writer or Docker socket is
151
+ given to the App. Grant directly to the package account; supplementary DSM groups
152
+ are not added to the container. See the SDK Shared Folder permission guide.
153
+
154
+ The upgrade wizard keeps the saved name unless its change checkbox is selected.
155
+ CLI deployment uses `--param media=Media` on first install, and
156
+ `--param CHANGE_media=true --param media=OtherMedia` to change it on upgrade.
157
+ Stop/disable and uninstall use DSM's official project worker; source data and
158
+ administrator-managed ACL entries are retained. A moved share needs a new
159
+ upgrade to resolve its volume path; start does not rematerialize the bind.
160
+
161
+ ACL changes affect subsequent filesystem operations. Already-open file
162
+ descriptors and data already read are not recalled. To stop existing streams,
163
+ stop the package before removing its access. This native route does not promise
164
+ automatic container termination or recreation on ACL changes.
165
+
166
+ For dynamic selection after install, declare the separate, blocked authority:
126
167
 
127
168
  ```yaml
128
169
  compose:
@@ -140,13 +181,25 @@ upstream docker-project is acquired. The independent authority purpose is
140
181
  `compose-mount`; a user destination grant from Download Hub/delegate never
141
182
  permits a package-wide mount.
142
183
 
143
- The current implementation does **not** issue a mount grant, invoke ACL tools,
184
+ The dynamic implementation does **not** issue a mount grant, invoke ACL tools,
144
185
  install a protected executor for Compose, or activate/revoke an upstream project.
145
186
  Confirmation returns `COMPOSE_PROJECT_CONTROL_UNAVAILABLE`. It must remain
146
187
  unsupported until an official ACL plus fixed project-control adapter can use
147
188
  protected policy/digest/generation authority, stop/recreate/read back and revoke
148
189
  without reviving stale grants. No app runs as host root to bypass this boundary.
149
190
 
191
+ | Acceptance blocker | Native install selection |
192
+ | --- | --- |
193
+ | `DSM_DOCKER_PROJECT_MODEL` | Uses the existing official docker-project worker; device acceptance still applies. |
194
+ | `DSM_STORAGE_OWNERSHIP_TIMING` | Existing shares need no package-created directory or chown; private package binds remain unsupported. |
195
+ | `DSM_EGRESS_BOUNDARY` | Unchanged independent network acceptance condition. |
196
+ | `COMPOSE_PROJECT_CONTROL_UNAVAILABLE` | Bypassed through native install/upgrade/stop/uninstall, without a dynamic controller. |
197
+ | `DSM_MOUNT_ACL_REVOKE` | Native kernel ACL semantics above; immediate stream cancellation remains outside this route. |
198
+
199
+ The first three general device acceptance labels remain in the plan. Dynamic
200
+ `admin-after-install` and `install-draft` keep all five blockers and
201
+ `mounts_blocked: true`; mixing dynamic authority with native selection is refused.
202
+
150
203
  ## Gateway transport
151
204
 
152
205
  `none` keeps native app login; `header`, `token` and embedded `oidc` use supported
@@ -5,7 +5,7 @@
5
5
  3. Review `synopkgland deploy --dry-run --arch x86_64`, which does not connect to a NAS.
6
6
  4. Only when installation is authorized, run `synopkgland deploy --host <profile>`.
7
7
 
8
- Deployment uploads directly to DSM. It does not publish artifacts. Compose uses schema-2 differences and fixed package identity. Existing Shared Folder intent requires admin confirmation after install; official protected ACL/project control is currently blocked. No install answer or user destination grant activates a package mount. See compose.md and its device acceptance blockers.
8
+ Deployment uploads directly to DSM. It does not publish artifacts. Compose uses schema-2 differences and fixed package identity. For `compose.shares` with `select: install`, use matching `install.sharedFolder: {mode: select, key: media, permission: ro}` and pass `--param media=Media`. Upgrade keeps the selection unless `--param CHANGE_media=true --param media=OtherMedia` is provided. An administrator grants the fixed package account access through DSM; selection is not a grant. Dynamic admin-after-install protected ACL/project control remains blocked. A user destination grant never activates a package mount. See compose.md and its device acceptance blockers.
9
9
 
10
10
  For native-go root-ops, approve fixed operations in DSM after installation. Updates that change the task text require fresh approval. Uninstall removes the executor tasks, the App's Application Privileges rules and a stale portal alias itself. Never delete installed packages or data as part of a code cleanup.
11
11