@synopackageland/cli 0.15.3 → 0.16.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +22 -8
  2. package/README.md +3 -0
  3. package/assets/init-compose.md +33 -0
  4. package/assets/init-upstream/build-source.sh +10 -0
  5. package/assets/init-upstream/update-image.mjs +26 -0
  6. package/assets/init-upstream/upgrade.sh +47 -0
  7. package/assets/init-upstream/version.mjs +12 -0
  8. package/dist/cli.js +16 -2
  9. package/dist/e2e/dsm.d.ts +2 -2
  10. package/dist/e2e/dsm.js +3 -3
  11. package/dist/error-registry.js +4 -0
  12. package/dist/host/share-permission.d.ts +1 -1
  13. package/dist/host/share-permission.js +1 -1
  14. package/dist/init-upstream.d.ts +7 -0
  15. package/dist/init-upstream.js +64 -0
  16. package/dist/init.d.ts +4 -0
  17. package/dist/init.js +37 -1
  18. package/dist/runtime/dsm-version.d.ts +1 -1
  19. package/dist/runtime/dsm-version.js +1 -1
  20. package/dist/runtime/executor-digests.json +1 -23
  21. package/dist/skill.js +1 -0
  22. package/dist/standalone/build.js +3 -1
  23. package/dist/standalone/compose-errors.js +15 -0
  24. package/dist/standalone/compose.d.ts +8 -0
  25. package/dist/standalone/compose.js +16 -6
  26. package/dist/standalone/daemon.js +2 -2
  27. package/dist/standalone/dsm-window.js +2 -2
  28. package/dist/standalone/identity-gateway.js +1 -0
  29. package/dist/standalone/inspect-spk.js +11 -0
  30. package/dist/standalone/lifecycle.js +4 -1
  31. package/dist/standalone/manifest.js +6 -1
  32. package/dist/standalone/portal-window.js +1 -1
  33. package/dist/standalone/spk.d.ts +1 -0
  34. package/dist/standalone/spk.js +6 -0
  35. package/dist/template-paths.d.ts +4 -0
  36. package/dist/template-paths.js +11 -0
  37. package/package.json +3 -3
  38. package/skills/SKILL.md +7 -13
  39. package/skills/reference/compose.md +25 -8
  40. package/skills/reference/create.md +2 -2
  41. package/skills/reference/upstream-fork.md +165 -0
  42. package/skills/reference/wrap.md +3 -1
@@ -23,6 +23,12 @@ export interface ComposePlan {
23
23
  mounts_blocked: boolean;
24
24
  acceptance_blockers: string[];
25
25
  services: Array<{
26
+ storage_init?: {
27
+ directories: Array<{
28
+ base: string;
29
+ path: string;
30
+ }>;
31
+ };
26
32
  service: string;
27
33
  user: string;
28
34
  container_root: boolean;
@@ -96,6 +102,8 @@ export declare function writeComposePayload(c: ComposeApp, root: string, target:
96
102
  export declare function renderComposeInstall(pkg: string): string;
97
103
  /** The same SDK entry point supplies the package-account install materializer. */
98
104
  export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
105
+ /** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
106
+ export declare function buildComposeStorageInit(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
99
107
  /** Only the build resolves registry metadata; check and inspect remain offline. */
100
108
  export declare function lockComposeImages(m: StandaloneManifest, arch: NativeArchFamily, resolveImages: boolean, imageProofs?: unknown): StandaloneManifest;
101
109
  export declare function composeInputs(root: string, file: string, envFile?: string): ComposePlan["inputs"];
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { parse } from "yaml";
8
- import { CLI_VERSION } from "../cli-version.js";
8
+ import { templateSdkVersion } from "../template-paths.js";
9
9
  import { goChildEnv } from "../runtime/go-env.js";
10
10
  import { sdkSource } from "../runtime/sdk-source.js";
11
11
  import { readDaemon } from "./daemon.js";
@@ -69,7 +69,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
69
69
  if (value.sdk !== undefined &&
70
70
  (typeof value.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(value.sdk)))
71
71
  return invalid("compose.sdk must name the matching SDK release.", "SDK_COMPOSE_UNSUPPORTED");
72
- const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? CLI_VERSION);
72
+ const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? templateSdkVersion());
73
73
  let targets = {};
74
74
  if (value.helpers !== undefined) {
75
75
  const d = readDaemon({ exec: "_sso/gateway", targets: value.helpers }, root, arches, fail);
@@ -194,6 +194,7 @@ export function writeComposePayload(c, root, target, gateway) {
194
194
  export function renderComposeInstall(pkg) {
195
195
  return `#!/bin/sh
196
196
  set -eu
197
+ "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" preflight || exit $?
197
198
  exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
198
199
  `;
199
200
  }
@@ -201,10 +202,19 @@ exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNO
201
202
  export async function buildComposeControl(m, arch, work, run) {
202
203
  if (!m.compose)
203
204
  return;
205
+ return buildComposeTool(m, arch, work, run, "plan");
206
+ }
207
+ /** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
208
+ export async function buildComposeStorageInit(m, arch, work, run) {
209
+ if (!m.compose?.plan.services.some((s) => s.storage_init))
210
+ return;
211
+ return buildComposeTool(m, arch, work, run, "storage-init");
212
+ }
213
+ async function buildComposeTool(m, arch, work, run, tool) {
204
214
  const { pinnedGoBuildEnv, pinnedGoProblem } = await import("../build-spk/go-toolchain.js");
205
215
  const { checkCgiBinary } = await import("./binary.js");
206
216
  const { StandaloneBuildError } = await import("./spk.js");
207
- const out = join(work, `compose-control-${arch}`);
217
+ const out = join(work, `compose-${tool === "plan" ? "control" : tool}-${arch}`);
208
218
  const source = sdkSource(m.compose.sdk);
209
219
  const result = await run("go", [
210
220
  "build",
@@ -214,13 +224,13 @@ export async function buildComposeControl(m, arch, work, run) {
214
224
  "-s -w -buildid=",
215
225
  "-o",
216
226
  out,
217
- "./compose/cmd/plan",
227
+ `./compose/cmd/${tool}`,
218
228
  ], {
219
229
  cwd: source.goDir,
220
230
  env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
221
231
  });
222
232
  if (result.status !== 0 || result.missing)
223
- throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose control: ${result.stderr}`);
233
+ throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose ${tool}: ${result.stderr}`);
224
234
  const error = checkCgiBinary(work, out, arch);
225
235
  if (error)
226
236
  throw new StandaloneBuildError(error.code, error.message);
@@ -250,7 +260,7 @@ export function lockComposeImages(m, arch, resolveImages, imageProofs) {
250
260
  return { ...m, compose };
251
261
  }
252
262
  export function composeInputs(root, file, envFile) {
253
- const result = spawnSync(planner(CLI_VERSION), [
263
+ const result = spawnSync(planner(templateSdkVersion()), [
254
264
  "sources",
255
265
  "--root",
256
266
  root,
@@ -3,7 +3,7 @@ import { chmodSync, copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSyn
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem, } from "../build-spk/go-toolchain.js";
5
5
  import { sdkSource, appSdkVersion } from "../runtime/sdk-source.js";
6
- import { CLI_VERSION } from "../cli-version.js";
6
+ import { templateSdkVersion } from "../template-paths.js";
7
7
  import { checkCgiBinary } from "./binary.js";
8
8
  import { composeDaemon, composeNetwork } from "./compose.js";
9
9
  import { gatewayDaemon } from "./identity-gateway.js";
@@ -141,7 +141,7 @@ export async function buildDaemonControl(manifest, arch, work, run) {
141
141
  (manifest.backend.kind === "go"
142
142
  ? appSdkVersion(manifest.backend)
143
143
  : undefined) ??
144
- CLI_VERSION).goDir,
144
+ templateSdkVersion()).goDir,
145
145
  env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
146
146
  });
147
147
  if (result.missing || result.status !== 0)
@@ -256,7 +256,7 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
256
256
 
257
257
  // APP_STOPPED, APP_STARTING and EXECUTOR_STARTING mean the executor op was not
258
258
  // sent. A stopped package waits for the user to start it in Package Center, so
259
- // APP_STOPPED rejects at once (t-0529). A start ends by itself: a call repeats
259
+ // APP_STOPPED rejects at once. A start ends by itself: a call repeats
260
260
  // half a second, then doubling, for 4 s of waiting in all, so a short start
261
261
  // shows the page nothing and the SDK's starting copy follows a long one within
262
262
  // its own 4 s recheck; executor.control, which the window's approval starts the
@@ -264,7 +264,7 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
264
264
  // deadline; past the wait it rejects with the last refusal, and leaving the
265
265
  // page during a wait rejects request_canceled at once. The SDK's status reads
266
266
  // are not repeated: its panels show the stopped or starting package and check
267
- // again on their own (t-0503 R1).
267
+ // again on their own.
268
268
  ${ns}.retryCodes = ["APP_STARTING", "EXECUTOR_STARTING"];
269
269
  ${ns}.statusReads = ["delegate.status", "authorization.status", "executor.status"];
270
270
  ${ns}.call = function (method, params, options, requestTimeout) {
@@ -313,6 +313,7 @@ export function identityGatewayConfig(m) {
313
313
  access: g.access ?? "dsm-users",
314
314
  ...(g.groups ? { groups: g.groups } : {}),
315
315
  ...(m.entry ? { entry: m.entry } : {}),
316
+ ...(m.compose ? { container_app: true } : {}),
316
317
  ...(m.compose?.plan.mounts_blocked ? { compose_blocked: true } : {}),
317
318
  ...(g.max_upload_bytes ? { max_upload_bytes: g.max_upload_bytes } : {}),
318
319
  ...(g.response_header_timeout_seconds
@@ -668,6 +668,17 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
668
668
  throw new Error(materializer.message);
669
669
  if (payload.find((p) => p.path === "compose/control")?.mode !== "0755")
670
670
  throw new Error("Compose materializer must be executable");
671
+ const initializer = join(root, "compose/storage-init");
672
+ if (c.plan.services.some((s) => s.storage_init)) {
673
+ const error = checkCgiBinary(root, initializer, arch);
674
+ if (error)
675
+ throw new Error(error.message);
676
+ if (payload.find((p) => p.path === "compose/storage-init")?.mode !== "0755")
677
+ throw new Error("Compose storage initializer must be executable");
678
+ }
679
+ else if (existsSync(initializer)) {
680
+ throw new Error("unexpected Compose storage initializer");
681
+ }
671
682
  const binaries = [
672
683
  "daemon/control",
673
684
  "daemon/payload/_sso/gateway",
@@ -21,12 +21,15 @@ export function renderStandaloneScript(name, manifest) {
21
21
  : renderStandaloneScript(name, { ...native, appScripts: undefined });
22
22
  const control = `"/var/packages/${manifest.package}/target/daemon/control"`;
23
23
  const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${manifest.package}/var}"`;
24
+ const preflight = name === "start-stop-status"
25
+ ? `case "$1" in prestart|start|restart)\n "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/compose/control" preflight || exit $? ;;\nesac\n`
26
+ : "";
24
27
  const remember = name === "postinst" &&
25
28
  manifest.compose.plan.ingress &&
26
29
  !manifest.compose.plan.mounts_blocked
27
30
  ? `"/var/packages/${manifest.package}/target/compose/control" remember-ingress --project "/var/packages/${manifest.package}/target/compose" --package '${manifest.package}' || exit $?\n`
28
31
  : "";
29
- const ledger = remember +
32
+ const ledger = preflight + remember +
30
33
  (manifest.identityGateway && !manifest.compose.plan.mounts_blocked
31
34
  ? name === "start-stop-status"
32
35
  ? `if [ "$1" = restart ]; then\n "$0" stop || exit $?\n exec "$0" start\nfi\nif [ "$1" = start ]; then\n ${control} acquire ${paths} || exit $?\nelif [ "$1" = stop ]; then\n ${control} release ${paths} || exit $?\nfi\n`
@@ -234,6 +234,10 @@ function readManifest(raw, c) {
234
234
  const daemon = arches &&
235
235
  readDaemon(raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message));
236
236
  const gatewayRaw = isRecord(raw.identity_gateway) ? raw.identity_gateway : {};
237
+ if (raw.compose !== undefined && isRecord(raw.ctl) && raw.ctl.enabled === true)
238
+ c.fail("CTL_UNSUPPORTED_BACKEND", "Compose does not support automatic ctl descriptions; disable ctl.");
239
+ if (isRecord(raw.compose) && isRecord(raw.compose.web) && !isRecord(raw.compose.web.health))
240
+ c.fail("COMPOSE_HEALTH_REQUIRED", "Select compose.web.health.path and expected 2xx status for the actual App. Readiness does not follow redirects; check is static and does not probe HTTP.");
237
241
  const compose = arches &&
238
242
  readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
239
243
  package: packageId,
@@ -260,7 +264,8 @@ function readManifest(raw, c) {
260
264
  : raw.identity_gateway;
261
265
  if (compose?.web && gatewayRaw.upstream !== undefined)
262
266
  c.fail("COMPOSE_WEB_INVALID", "Compose upstream is derived from the SDK Compose ingress endpoint; remove identity_gateway.upstream.");
263
- const identityGateway = readIdentityGateway(gatewayValue, daemon || (compose ? { targets: compose.targets } : undefined), (e) => c.fail(e.code, e.message), false, Boolean(compose?.plan.ingress));
267
+ // A failed Compose plan already explains the invalid upstream declaration.
268
+ const identityGateway = raw.compose !== undefined && !compose ? undefined : readIdentityGateway(gatewayValue, daemon || (compose ? { targets: compose.targets } : undefined), (e) => c.fail(e.code, e.message), false, Boolean(compose?.plan.ingress));
264
269
  if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
265
270
  c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
266
271
  if (compose &&
@@ -204,7 +204,7 @@ export const portalRuntime = (catalogs) => `function createPortalAccess(options)
204
204
  };
205
205
  }
206
206
  // The window reads the status when it opens, so a settings page shows the
207
- // portal section as it is (t-0520).
207
+ // portal section as it is.
208
208
  status();
209
209
  return access;
210
210
  }`;
@@ -16,6 +16,7 @@ export interface AssembleOptions {
16
16
  executor?: ExecutorArtifact;
17
17
  daemonControl?: string;
18
18
  composeControl?: string;
19
+ composeStorageInit?: string;
19
20
  /** The SDK identity gateway, required exactly when the manifest declares identity_gateway. */
20
21
  identityGateway?: string;
21
22
  /** The compiled background worker, required exactly when capabilities.jobs is enabled. */
@@ -190,6 +190,12 @@ export async function assembleStandaloneSpk(options) {
190
190
  throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", "Compose runtime artifact required.");
191
191
  copyFileSync(options.composeControl, join(target, "control"));
192
192
  chmodSync(join(target, "control"), EXEC_MODE);
193
+ if (manifest.compose.plan.services.some((s) => s.storage_init)) {
194
+ if (!options.composeStorageInit)
195
+ throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", "Compose storage initializer required.");
196
+ copyFileSync(options.composeStorageInit, join(target, "storage-init"));
197
+ chmodSync(join(target, "storage-init"), EXEC_MODE);
198
+ }
193
199
  }
194
200
  const packageTgz = join(staging, "package.tgz");
195
201
  await createReproducibleTar(join(staging, "package"), packageTgz, {
@@ -3,3 +3,7 @@
3
3
  * package; SYNOPKGLAND_TEMPLATES_DIR points at a templates checkout instead.
4
4
  */
5
5
  export declare function templatesRoot(env?: NodeJS.ProcessEnv, from?: string): string;
6
+ /** The installed templates' SDK pin also supplies CLI-owned runtime defaults.
7
+ * CLI-only patch releases must never turn their own version into an SDK tag.
8
+ */
9
+ export declare function templateSdkVersion(env?: NodeJS.ProcessEnv): string;
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { dirname, join } from "node:path";
3
+ import { sdkRequirement } from "./runtime/sdk-source.js";
3
4
  /**
4
5
  * The scaffold templates come from the exactly pinned @synopackageland/templates
5
6
  * package; SYNOPKGLAND_TEMPLATES_DIR points at a templates checkout instead.
@@ -14,3 +15,13 @@ export function templatesRoot(env = process.env, from = import.meta.url) {
14
15
  throw new Error("[TEMPLATES_MISSING] @synopackageland/templates is not installed; reinstall @synopackageland/cli, or set SYNOPKGLAND_TEMPLATES_DIR to a templates checkout.");
15
16
  }
16
17
  }
18
+ /** The installed templates' SDK pin also supplies CLI-owned runtime defaults.
19
+ * CLI-only patch releases must never turn their own version into an SDK tag.
20
+ */
21
+ export function templateSdkVersion(env = process.env) {
22
+ const version = sdkRequirement(join(templatesRoot(env), "native-go/backend"));
23
+ if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
24
+ throw new Error("[TEMPLATES_MISSING] The installed templates have no valid SDK pin; reinstall @synopackageland/templates.");
25
+ }
26
+ return version;
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "build": "rm -rf dist && tsc",
21
- "test": "npm run build && node --import tsx --test test/*.test.ts test/ctl/*.test.ts",
21
+ "test": "npm run build && node --import tsx --test --test-concurrency=${SYNOPKGLAND_GATE_JOBS:-3} test/*.test.ts test/ctl/*.test.ts",
22
22
  "test:glossary": "node --test scripts/test/glossary-drift.test.mjs",
23
23
  "test:scripts": "node --test scripts/test/gate.test.mjs scripts/test/release.test.mjs scripts/test/pack-docker.test.mjs scripts/test/npm-pack.test.mjs scripts/test/executor-bump.test.mjs scripts/test/smoke-exports.test.mjs scripts/test/executor-digests.test.mjs scripts/test/parallel.test.mjs scripts/test/scaffold-matrix.test.mjs scripts/test/frontend-deps.test.mjs scripts/test/docker-gate.test.mjs scripts/test/gate-plan.test.mjs scripts/test/gates.test.mjs",
24
24
  "gates": "bash scripts/ci/gates.sh",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "0.15.2",
30
+ "@synopackageland/templates": "0.16.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
@@ -7,6 +7,8 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
7
7
 
8
8
  Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
9
9
 
10
+ 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.16.0 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.
11
+
10
12
  Start with `synopkgland init -t native-go`, `synopkgland init -t binary`, or `synopkgland init -t compose`. Read reference/create.md or reference/wrap.md, then reference/deploy.md. Native CGI session checks belong to the package. For wrapping upstream OSS with DSM accounts, read [reference/identity-gateway.md](reference/identity-gateway.md) first. For all Compose work, read [reference/compose.md](reference/compose.md): schema-2 upstream files, generated override, SDK planner, fixed package identity and current DSM blockers. Containers do not inherit a signed-in user's File Station ACL. Existing Shared Folder mounts are blocked until official protected ACL/project control is verified.
11
13
 
12
14
  Never install, uninstall, publish or modify NAS data without task authorization. Prefer check, inspect and deploy --dry-run for reviewable artifacts.
@@ -149,18 +151,10 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
149
151
  `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
150
152
  不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
151
153
 
152
- Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
153
- 省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
154
- 省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
155
- Web service 主選單使用
156
- `type: "url"` 與明確的 `url` 指向既有 DSM gateway。明確 `access: public` 才保留 Web Station
157
- alias;這是服務入口,不是 SDK Application Portal。DSM 會將 standalone URL 改寫成 launchApp 頁面,
158
- 需要 AppInstance/AppWindow JS class;schema 1 web service 沒有該 class,因此使用 url。需要 DSM 視窗時明寫 `dsm-window`。
159
-
160
- Schema 2 的 `dsm-package.yaml` + `identity_gateway` + `compose` 同樣視為
161
- web service:省略 `ui.launch` 時預設 `new-page`,使用 standalone launcher,
162
- 不帶 Application Portal。兩條 Compose 路線共用 `contract/app-launch.ts`
163
- 解析開啟方式與選擇 launcher 型別;明確的 `ui.launch` 可覆寫預設。
164
- 只有沒有 web service 的 SDK 原生 App 省略時才預設 `dsm-window`。
154
+ Schema 2 Compose 使用 `dsm-package.yaml` 的 `compose.files`、`compose.web` 與
155
+ `identity_gateway`;不使用 file/targets/address_env 或手寫 upstream。
156
+ 省略 `ui.launch` 時預設 `new-page`,使用 SDK standalone launcher,不帶 Application Portal。
157
+ Headless Compose 不產生 launcher。health 必須選實際 endpoint 與 2xx status;
158
+ readiness 不跟隨 redirect,check 不會發 HTTP。Compose 不支援 ctl。
165
159
 
166
160
  For controlling installed Apps or designing task-level AI commands, read [reference/ctl.md](reference/ctl.md). Use ctl host list, register/renew explicitly, discover all commands, and always name the host when agents may operate multiple NAS machines.
@@ -23,7 +23,7 @@ compose:
23
23
  network: {egress: isolated}
24
24
  # Private package binds are rejected by DSM build; keep compatible upstream named volumes.
25
25
  identity_gateway:
26
- sdk: 0.15.2
26
+ sdk: 0.16.0
27
27
  mode: none
28
28
  access: dsm-users
29
29
  listen: {http: 28000}
@@ -151,7 +151,7 @@ declared availability readiness, LAN isolation/egress, protected grant lifecycle
151
151
  ACLs, two-user visibility and clean install/upgrade/reboot/stop/uninstall before
152
152
  calling the package supported on a NAS. No NAS is accessed by the build gate.
153
153
 
154
- The unified Compose contract uses SDK/templates 0.15.2 with CLI 0.15.3. Use the published, matching versions together; the legacy packaging route and bridge publish mode are removed.
154
+ The unified Compose contract uses SDK/templates 0.16.0 with CLI 0.16.0. Use the published, matching versions together; the legacy packaging route and bridge publish mode are removed.
155
155
 
156
156
  Primary override files, and each included override set, must share a directory. Cross-directory override bases are rejected before loader I/O. Included implicit .env files are confined, inventoried and preserved; symlinks are rejected.
157
157
 
@@ -187,13 +187,28 @@ bridge-unverified admission blocker is replaced by `COMPOSE_READINESS_INVALID`,
187
187
 
188
188
  Compose SPKs intentionally omit `install_dep_packages=ContainerManager`: DSM can
189
189
  report that dependency not ready even while its systemd unit and Docker daemon
190
- are active. The official `docker-project` worker checks actual availability;
191
- this does not authorize starting, modifying or repairing Container Manager.
192
-
193
- For disposable environments, optional `compose.resource_prefix: t-0661-` prefixes
190
+ are active. The SDK's package-account preflight runs before install preparation
191
+ and before project startup. It reads fixed Container Manager package/executable
192
+ paths and the kernel's listening Unix-socket inventory; it never opens the Docker
193
+ socket, needs root, starts services or repairs Container Manager. Missing software
194
+ fails with `COMPOSE_CONTAINER_MANAGER_REQUIRED`; installed but unavailable or
195
+ unverifiable Docker fails with `COMPOSE_CONTAINER_MANAGER_NOT_READY`. Stable
196
+ message keys carry English defaults telling the user to install/start Container
197
+ Manager from Package Center. The install hook writes the DSM temporary error log
198
+ when provided; start returns the same error; first-open CGI displays an i18n-ready
199
+ 503 page even when the App gateway is stopped. This is availability evidence,
200
+ not Docker/project identity or a successful container API call. DSM wizard display
201
+ and permission behavior still need device acceptance.
202
+
203
+ Exclude NAS platforms which cannot run Container Manager through the package's
204
+ supported `arches`/INFO arch declarations. Do not advertise an unsupported
205
+ architecture merely because the App image has it; review model compatibility
206
+ within the declared family too.
207
+
208
+ For disposable environments, optional `compose.resource_prefix: sandbox-` prefixes
194
209
  all generated project/network/volume names while retaining the package ID as the
195
210
  namespace suffix. Keep it stable across upgrades: changing it selects new
196
- resources and does not migrate or delete retained data.
211
+ resources and does not move or delete retained data.
197
212
 
198
213
  Bridge web ingress uses a dedicated `sdk_ingress` internal bridge. The web service
199
214
  receives a fixed IPv4 address, and the package-account gateway connects directly
@@ -204,7 +219,7 @@ still adds the existing outbound bridge; other App services retain their interna
204
219
 
205
220
  The subnet is a deterministic /29 candidate derived from package ID and
206
221
  `compose.network.ingress_candidate` (integer 0-255, default 0). It comes from
207
- private 10.128.0.0/9; this pool is not globally reserved. Before acquisition, the
222
+ the upper half of the RFC 1918 10/8 private address block; this pool is not globally reserved. Before acquisition, the
208
223
  package-account preparer reads kernel IPv4 routes and all interface prefixes, and rejects overlap with
209
224
  `COMPOSE_SUBNET_CONFLICT`. Choose another candidate and rebuild; no silent
210
225
  renumbering occurs on upgrade. Choose a different candidate for a clean
@@ -347,3 +362,5 @@ verified loopback address setting, cannot remap ports or enforce isolated
347
362
  egress, and is not a general replacement for Header/native-cookie bridge Apps.
348
363
  Do not silently switch networking, add Docker socket access, run an App hook as
349
364
  root or delete DSM configuration to claim compatibility.
365
+
366
+ Compose web health is required before check/build; init has no guessed endpoint or status. Select both `--health-path` and `--health-status`, or edit `compose.web.health`. Readiness does not follow redirects. Static check is not an HTTP or arbitrary-UID/storage preflight. For official-image upstream packaging and DSM login, read [upstream-fork.md](upstream-fork.md).
@@ -40,8 +40,8 @@ rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected exec
40
40
  backend 寫 `runtime: python|node|rust`(`runtime: go` 等同 `kind: go`)。CGI 是 SDK 的 Go runtime(`bridge/cmd/runtime`),它驗證 DSM session、entry 與 `permissions.json` 後,以套件帳號(不是 root)每個請求啟動一次 App。App 放在 ui 以外的目錄,打包成 `target/app/`,build 寫入 `bridge.json`(spec/bridge/v1)並把它與 `permissions.json` 的 sha256 編進 runtime。
41
41
 
42
42
  ```yaml
43
- backend: {runtime: python, dir: app, entry: main.py, sdk: 0.15.2}
44
- backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.15.2, node: {package: Node.js_v20}}
43
+ backend: {runtime: python, dir: app, entry: main.py, sdk: 0.16.0}
44
+ backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.16.0, node: {package: Node.js_v20}}
45
45
  backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
46
46
  ```
47
47
 
@@ -0,0 +1,165 @@
1
+ # Integrating an existing open-source App
2
+
3
+ Preserve the upstream fork, full history and `upstream` remote. Keep **every**
4
+ wrapper-owned manifest, adapter, script, notice and document in `synology/`.
5
+ Never edit upstream README, Dockerfile, source, config, workflows or ignore files.
6
+ Do not rewrite the App or treat it as an SDK component. Use `.git/info/exclude`
7
+ for local output when needed.
8
+
9
+ ## Container: official image and a matching source tag
10
+
11
+ Use the upstream **official image**, pinned as `repository:tag@sha256:digest`.
12
+ For a multi-architecture image, pin its index digest so Container Manager can
13
+ select the NAS architecture. Container Manager downloads the pinned image at
14
+ install. Retain the upstream source fork to match releases and support required
15
+ source/license delivery, including Corresponding Source where applicable. A
16
+ matching source tag alone does not prove a complete license/source inventory.
17
+
18
+ ```sh
19
+ git clone <your-fork-url> app
20
+ cd app
21
+ git remote add upstream <upstream-url>
22
+ synopkgland init -t compose --upstream-fork --package MyApp \
23
+ --upstream-image owner/app --health-path /actual-readiness --health-status 200 .
24
+ ```
25
+
26
+ Replace `owner/app` with the verified official repository. `init` does not guess
27
+ its publisher or make network requests. Configure `synology/upstream-recipe.json`:
28
+ `repository`, the actual Compose `service`, and `imageTag: tag` (exact upstream
29
+ tag) or `version` (strip the leading `v` only if the official image uses that
30
+ spelling). Adjust the App's port, storage and DSM login adapter before committing.
31
+ The initial `SELECT_UPSTREAM_TAG` marker must be replaced by a successful upgrade.
32
+
33
+ ```sh
34
+ bash synology/upgrade.sh v1.2.3 2
35
+ ```
36
+
37
+ The script requires a clean tree, fetches the exact upstream tag into a private
38
+ ref, verifies ancestry and untouched upstream files, then merges. It updates the
39
+ official image tag and calls the existing `synopkgland images pin` registry
40
+ resolver, recording `tag@digest` in `synology/compose.image.yaml`. That wrapper
41
+ Compose override is listed after the base file; upstream files remain unchanged.
42
+ The resolver requests OCI index/manifest-list media types. Verify the official
43
+ image supports every declared architecture; a single-platform manifest does not
44
+ become multi-architecture merely because it has a digest. A failed lookup leaves
45
+ the previous override intact and stops the upgrade.
46
+
47
+ Next, the script derives `version: 1.2.3` and explicit increasing packaging build
48
+ `0002`, records source tag/commit/build in `upstream-source.json`, then runs
49
+ `check --json` and `build --json`. It performs no image compilation or publication.
50
+ Stable `[v]X.Y.Z` only. Build numbers increase globally (1–9999), even when upstream
51
+ versions change. Review/inspect the SPK and commit `synology/` before another
52
+ upgrade. Resolve or revert a failed merge explicitly; rehearse retained-data
53
+ upgrades separately. No Git push, Release, NAS installation or deployment occurs.
54
+
55
+ For fixed-UID image data directories, currently supply a startup adapter or an
56
+ upstream-supported writable path under the control of `synology/`. Verify the
57
+ package UID can create first-run config/cache/data and retain it across upgrades.
58
+ Do not use root/chown lifecycle hooks or substitute a fixed UID. Replace this
59
+ workaround with the framework ownership mechanism once available and verified.
60
+
61
+ ## Native: official release binaries or a source build hook
62
+
63
+ ```sh
64
+ synopkgland init -t native-go --ui plain --upstream-fork --package MyApp .
65
+ ```
66
+
67
+ Native Apps can stage official release binaries pinned by **SHA-256**, or compile
68
+ the merged tree with a source build hook. OpenList uses the official release
69
+ binary route: select the binary for the matching upstream tag and architecture,
70
+ verify its pinned SHA-256 before staging it, and retain the source fork for
71
+ version/source correspondence. Do not execute an unverified download.
72
+
73
+ Implement `synology/build-source.sh` as the native payload hook; it deliberately
74
+ fails until implemented. For official release binaries, use that hook to acquire,
75
+ verify and stage the pinned release payload. For a source build, compile the
76
+ merged source and its frontend with pinned toolchains/dependencies. The hook
77
+ receives `SPL_UPSTREAM_SOURCE`, `SPL_UPSTREAM_TAG`, `SPL_UPSTREAM_COMMIT`; stage
78
+ architecture-specific `daemon.targets` below `synology/` and keep the small SDK
79
+ CGI as a separate management shell. The upgrade command fetches/merges the tag,
80
+ derives the version, runs the selected native payload hook, checks and builds.
81
+
82
+ ## Use DSM login
83
+
84
+ Every integrated App must admit **DSM administrators directly**, without a separate
85
+ App password, an OIDC/SSO server, or upstream source changes. The scaffold declares
86
+ `entry: admin-only`; Compose also declares gateway `access: dsm-admins`. This gates
87
+ the entry but does not itself create an upstream session. Choose these supported
88
+ upstream mechanisms in order:
89
+
90
+ 1. **Trusted header / reverse-proxy authentication**: configure gateway header
91
+ mode and the upstream's documented proxy-auth setting. FreshRSS supports this:
92
+ use its supported server-side `REMOTE_USER` configuration; the `synology/`
93
+ adapter may map admitted DSM administrators to a fixed shared upstream admin.
94
+ Clear incoming competing identity headers and admit upstream traffic only
95
+ through the gateway's dedicated bridge. Document shared data/audit identity.
96
+ 2. **Disable native login / allow guests**: configure the supported option and
97
+ let the DSM administrator gateway protect the sole reachable entry.
98
+ Uptime Kuma's `disableAuth` is the example. First bootstrap must complete native setup,
99
+ enable `disableAuth` via the upstream UI/API and read the state back before
100
+ declaring ready. An app-specific `synology/` adapter should generate any
101
+ required one-time setup secret, use it only for bootstrap and clear it; do not
102
+ ask users to remember/type an additional App password or edit the DB. The
103
+ existing prototype's setup rehearsal is not a generic shipped bootstrap SDK.
104
+ If manual App login is still necessary, label bootstrap incomplete. Verify
105
+ subsequent DSM entry, restart and upgrade all retain password-free access,
106
+ while anonymous/nonadmin requests and direct upstream ports remain blocked.
107
+ 3. **Existing SDK token adapter**: SDK **0.16.0** supports `mode: token`, with
108
+ `access: dsm-admins` and `entry: admin-only`. OpenList uses its unmodified
109
+ upstream `admin token` CLI while the server is stopped to obtain the native
110
+ administrator API token. The package stores it in a package-private **0600**
111
+ file under a 0700 directory. After DSM administrator admission, the gateway
112
+ runs the package's token adapter and injects `Authorization` server-side,
113
+ replacing client credentials. Packaging never delivers the token to the
114
+ browser, URL, localStorage or page script. No new SDK authentication API,
115
+ OIDC/SSO server, upstream source change or direct database edit is required.
116
+ All admitted DSM administrators share one upstream admin account; this does
117
+ not provide per-user isolation. After OpenList **Reset Token**, restart the
118
+ package to refresh the cached credential; failure refuses startup/requests
119
+ rather than falling back to anonymous access. WebDAV retains its own native
120
+ credentials: API-token injection does not make WebDAV password-free. The
121
+ upstream admin's own token/settings APIs remain unchanged, so an admitted
122
+ administrator may still inspect or rotate the native token through them.
123
+ Recheck `admin token`, API header and frontend `/api/me` behavior on upgrades;
124
+ verify actual DSM admission/revocation separately. The OpenList authentication
125
+ adapter is implemented and tested with the existing SDK; generic provisioning
126
+ automation and complete device acceptance are separate work.
127
+
128
+ ## Health, prerequisites and prototype boundaries
129
+
130
+ Choose the actual unauthenticated health endpoint and its expected 2xx status
131
+ with both init flags, or fill `compose.web.health` before check/build. No guessed
132
+ `/` + 200. Readiness **does not follow redirects**: a 302 to a dashboard/login is
133
+ unhealthy even if its destination is 200. A favicon/robots probe proves HTTP
134
+ availability only; verify database setup and full readiness separately. `check`
135
+ is static, not an HTTP or UID/storage probe. Compose ctl is rejected at check;
136
+ dedicated origin `/` is supported, automatic subpath rewriting is not.
137
+
138
+ Public npm availability does not grant anonymous Go SDK access. Obtain authorised
139
+ source access or an exact matching module cache with original checksums, or use
140
+ `SYNOPKGLAND_SDK_DIR` with an authorised matching checkout. Go source distribution
141
+ is undecided. Never copy credentials into generated projects.
142
+
143
+ | Prototype | Reusable lesson and remaining boundary |
144
+ | --- | --- |
145
+ | OpenList | Official release binary pinned by SHA-256, `/ping` and SDK 0.16.0 server-side token adapter; shared upstream admin, restart after token reset, native WebDAV credentials. Share selection does not grant package ACL/register Local storage. |
146
+ | FreshRSS | Official image, package-UID Apache/config adapter in `synology/`, explicit health; supported proxy authentication with fixed admin mapping and header clearing. |
147
+ | Uptime Kuma | Official image, writable-path adapter, nonredirecting health and `disableAuth` after first bootstrap; WebSocket expiry/revocation need separate tests. |
148
+
149
+ Historical bounded Linux/prototype proofs are not DSM acceptance. Recheck the
150
+ selected official image, arbitrary UID, persistent data, bootstrap and login
151
+ boundaries; preserve complete third-party notices and source correspondence.
152
+
153
+ ## Container Manager prerequisite
154
+
155
+ Container SPKs omit `install_dep_packages=ContainerManager` because DSM dependency
156
+ status can report not-ready while Docker is active. SDK preflight checks fixed
157
+ Container Manager files plus a listening Docker socket in the kernel inventory,
158
+ as the package UID without socket/API access or root. It fails before install
159
+ preparation/project startup with stable translated-message keys and an English
160
+ instruction to install/start Container Manager from Package Center. DSM's supplied
161
+ installer log, start failure and first-open CGI carry that diagnosis; the CGI
162
+ returns 503 even when the gateway is down. Availability is not identity attestation.
163
+ Declare only Container Manager-capable `arches`/INFO platforms; exclude unsupported
164
+ NAS architectures and review models in each family. Actual missing-package wizard
165
+ behavior and DSM's dependency not-ready behavior remain device acceptance items.
@@ -3,7 +3,7 @@
3
3
  只使用 schema-2 `dsm-package.yaml`。先讀 [compose.md](compose.md) 的宣告、支援表及 DSM 阻擋條件,再讀 [identity-gateway.md](identity-gateway.md) 決定 none/header/token/OIDC。不架 SSO Server,也不需要假 backend 或 wrapper container。
4
4
 
5
5
  ```sh
6
- synopkgland init -t compose --from ./compose.yaml my-app
6
+ synopkgland init -t compose --from ./compose.yaml --health-path /actual-readiness --health-status 200 my-app
7
7
  synopkgland check --json
8
8
  synopkgland build --arch x86_64
9
9
  synopkgland inspect dist/MyApp-x86_64.spk --json
@@ -14,3 +14,5 @@ synopkgland inspect dist/MyApp-x86_64.spk --json
14
14
  容器使用固定套件身分,不繼承登入者 File Station ACL。Shared Folder 需 composeMounts 明確 opt-in,安裝後由 admin 確認;官方 protected ACL/project control 未驗證前只啟動 SDK 設定頁,不 acquire 上游,不用 host root 繞過。每使用者檔案權限需 session/Broker Adapter,本次不做。
15
15
 
16
16
  交付 inspect 的 upstream/effective hashes、image digests、run-as、mount、egress 與 acceptance blockers。Linux 通過不代表 DSM 已驗收。部署請讀 [deploy.md](deploy.md),保留資料並由專用 thread 驗 final SPK digest。
17
+
18
+ For an upstream fork, use [the upstream-fork recipe](upstream-fork.md) and `--upstream-fork` to keep all wrapper files in `synology/`. Container forks require `--upstream-image <official-repository>` and use official tag+digest images; native forks stage official release binaries pinned by SHA-256 or use a source build hook. Health flags require an actual App endpoint and expected 2xx response; readiness does not follow redirects.