@synopackageland/cli 0.15.4 → 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 (38) hide show
  1. package/CHANGELOG.md +14 -8
  2. package/assets/init-compose.md +33 -0
  3. package/assets/init-upstream/build-source.sh +10 -0
  4. package/assets/init-upstream/update-image.mjs +26 -0
  5. package/assets/init-upstream/upgrade.sh +47 -0
  6. package/assets/init-upstream/version.mjs +12 -0
  7. package/dist/cli.js +16 -2
  8. package/dist/e2e/dsm.d.ts +2 -2
  9. package/dist/e2e/dsm.js +3 -3
  10. package/dist/error-registry.js +4 -0
  11. package/dist/host/share-permission.d.ts +1 -1
  12. package/dist/host/share-permission.js +1 -1
  13. package/dist/init-upstream.d.ts +7 -0
  14. package/dist/init-upstream.js +64 -0
  15. package/dist/init.d.ts +4 -0
  16. package/dist/init.js +37 -1
  17. package/dist/runtime/dsm-version.d.ts +1 -1
  18. package/dist/runtime/dsm-version.js +1 -1
  19. package/dist/runtime/executor-digests.json +1 -23
  20. package/dist/skill.js +1 -0
  21. package/dist/standalone/build.js +3 -1
  22. package/dist/standalone/compose-errors.js +15 -0
  23. package/dist/standalone/compose.d.ts +8 -0
  24. package/dist/standalone/compose.js +13 -3
  25. package/dist/standalone/dsm-window.js +2 -2
  26. package/dist/standalone/identity-gateway.js +1 -0
  27. package/dist/standalone/inspect-spk.js +11 -0
  28. package/dist/standalone/lifecycle.js +4 -1
  29. package/dist/standalone/manifest.js +6 -1
  30. package/dist/standalone/portal-window.js +1 -1
  31. package/dist/standalone/spk.d.ts +1 -0
  32. package/dist/standalone/spk.js +6 -0
  33. package/package.json +3 -3
  34. package/skills/SKILL.md +7 -13
  35. package/skills/reference/compose.md +25 -8
  36. package/skills/reference/create.md +2 -2
  37. package/skills/reference/upstream-fork.md +165 -0
  38. package/skills/reference/wrap.md +3 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 (2026-10-19)
4
+
5
+ - Pin SDK/templates 0.16.0; generated Shared Folder flows use the shared SDK readback and settings components.
6
+ - Add the upstream-fork recipe/init/check workflow, Container Manager preflight and architecture-matched storage initializer packaging while preserving official images.
7
+ - Remove internal deployment details from published content and scan actual npm tarballs before release.
8
+
3
9
  ## 0.15.4 (2026-10-18)
4
10
 
5
11
  - Fix Compose import and undeclared SDK defaults after CLI-only patch releases. Resolve the SDK from the exactly pinned templates' Go module instead of assuming the CLI and SDK share a patch version; daemon-control defaults use the same source.
@@ -174,8 +180,8 @@ only its protocol README's wording, and nothing in the CLI depends on it.
174
180
 
175
181
  - `ctx.call` rejects `APP_STOPPED` at once: a stopped package waits for the
176
182
  user to start it in Package Center, so the SDK's stopped copy shows within a
177
- page's next read instead of 30 s later (Download Hub 29.3 s, t-0527; Reclaim
178
- showed its old content or loading, t-0528). `APP_STARTING` and
183
+ page's next read instead of 30 s later (Download Hub 29.3 s; Reclaim
184
+ showed its old content or loading). `APP_STARTING` and
179
185
  `EXECUTOR_STARTING` repeat for 4 s of waiting (0.5, 1, 2, 0.5 s), so a short
180
186
  start still shows the page nothing and the SDK's starting copy follows a
181
187
  longer one within its own 4 s recheck. `executor.control`, which the window's
@@ -225,7 +231,7 @@ e2e 新增 `openUntilLoaded`(`signIn` 遇 `net::ERR_ABORTED` 最多重開約 3
225
231
  the newest one it read (`lastStatus()`); a `status()` asked for while a read
226
232
  runs shares it. The SDK's `PortalSettings` starts from it, so a settings
227
233
  page no longer moves when its placeholder rows become the real section
228
- (Reclaim: 100 px, t-0520).
234
+ (Reclaim: 100 px).
229
235
 
230
236
  ## 0.10.6 (2026-10-15)
231
237
 
@@ -244,7 +250,7 @@ digest 與 0.10.5 相同;SDK 下限維持 0.10.5。e2e 新增 `openPath`、`ha
244
250
 
245
251
  - `signIn`, `openDesktop` and `openPortal` open their page once more when
246
252
  the first navigation fails with `net::ERR_ABORTED`, which about one run in
247
- ten met as DSM's desktop navigated on its own (t-0499, t-0500); any other
253
+ ten met as DSM's desktop navigated on its own; any other
248
254
  failure, or a second abort, still fails. The helper is exported as
249
255
  `openPath(app, path)`.
250
256
  - The DSM window's `status()` answers a pinned executor that reports
@@ -330,7 +336,7 @@ digest 與 0.10.4 相同;新增 `@synopackageland/cli/e2e`。
330
336
  `authorization.status`, `executor.status`) while the package is stopped or
331
337
  starting: they reject with the code at once, and SDK 0.10.5's panels show the
332
338
  stopped or starting package and check again on their own. A page no longer
333
- shows nothing for the first seconds after an upgrade (6.7 s in t-0503). Calls
339
+ shows nothing for the first seconds after an upgrade (6.7 s in the regression test). Calls
334
340
  that change something, approvals and controls among them, still repeat for up
335
341
  to 30 s. `ctx.executor.status()` for a user who is not an administrator says
336
342
  the package is stopped or starting instead of `admin_required`.
@@ -362,12 +368,12 @@ before its timeout.
362
368
  - The window approval of an upgrade (outdated tasks) makes 11 scheduler calls
363
369
  instead of 12: the outdated boot task is deleted by the approval's own first
364
370
  EventScheduler get, made moments before the password, instead of a second
365
- get (2.9 s on the test NAS in t-0484). The control task is still read again by id
371
+ get (2.9 s on the test NAS in the regression test). The control task is still read again by id
366
372
  before its delete (spec/scheduler/v1). A fresh approval stays at 8.
367
373
  繁中:升級核准的排程呼叫由 12 降為 11(boot 任務不再於刪除前重讀)。
368
374
  - A scope approval reads the executor's selection back every second at most
369
375
  (it backed off to 2 s), so the window sees the landed scope within a second
370
- of `scope.confirm` (the Dialog closed 4.8 s after it in t-0482). Removing
376
+ of `scope.confirm` (the Dialog closed 4.8 s after it in the regression test). Removing
371
377
  the temporary selection task still lists Task Scheduler first, as
372
378
  spec/scheduler/v1 requires of a delete.
373
379
  繁中:範圍核准讀回間隔上限 2 s → 1 s,confirmed 後 1 s 內看到結果。
@@ -376,7 +382,7 @@ before its timeout.
376
382
  before any request: the pinned, running executor reads `ready`; anything
377
383
  else stays `blocked` with `admin_required`. Task Scheduler is still read only
378
384
  for administrators. A user no longer sees an approved executor as waiting
379
- for approval (t-0482 F7).
385
+ for approval.
380
386
  繁中:非管理員的 `ctx.executor.status()` 會向 App 的 `executor.status` 詢問,已核准且
381
387
  執行中即為 `ready`,不再一律回 `admin_required`。
382
388
 
@@ -0,0 +1,33 @@
1
+ # Compose App development
2
+
3
+ Run `synopkgland skill list`, then `synopkgland skill show orchestrator`,
4
+ `synopkgland skill show compose` and `synopkgland skill show upstream-fork`.
5
+ Prefer `check --json`, `build --json`, `inspect <spk> --json` for reviewable results.
6
+
7
+ Never write `AppPortal.json` or `@appconf` directly, or relay QuickConnect.
8
+ Compose has no native Application Portal alias; use its supported web entry.
9
+
10
+ Use dsm-package.yaml schema 2. Keep upstream Compose bytes unchanged; declare DSM
11
+ policy in compose.files and compose.web. Never use file/targets/address_env or
12
+ identity_gateway.upstream: the SDK planner derives ingress.
13
+
14
+ ## Compose health and launch
15
+
16
+ Select compose.web.health.path and its actual expected 2xx status before
17
+ check/build. Redirects are not followed; a 302 to login is unhealthy. check is
18
+ static, not an HTTP or arbitrary-UID/storage probe. Test the selected official image as
19
+ the package UID, including writable volumes, first setup and upgrade.
20
+ Omitted ui.launch means new-page via the SDK standalone launcher, without
21
+ Application Portal. Headless Apps omit compose.web and have no launcher.
22
+ Compose ctl is unsupported.
23
+
24
+ Use supported upstream config files, environment, argv or reverse proxy for
25
+ login/settings. For integrated upstream Apps, select supported trusted-header authentication or disable upstream login behind the DSM admin gate; or use the existing SDK 0.16.0 token adapter: OpenList uses mode: token, access: dsm-admins and entry: admin-only. Its unmodified admin token CLI supplies a package-private 0600 credential; the gateway injects Authorization server-side after DSM admission, without delivering it to the browser. All DSM admins share one upstream admin; token reset requires package restart; WebDAV keeps its own credentials. Verify each App adapter and DSM admission separately.
26
+ Containers use the package account and a dedicated bridge; they do not inherit
27
+ an interactive user's NAS ACL. Do not use root/chown during any App lifecycle
28
+ phase. Preserve licenses and dependency/source provenance. Read the CLI skill
29
+ reference/compose.md and reference/upstream-fork.md for the full recipe.
30
+
31
+ Public npm access does not grant anonymous Go SDK access. Obtain authorised
32
+ source/cache access or a matching SYNOPKGLAND_SDK_DIR checkout first. Never copy
33
+ credentials into the App. Do not install, publish or modify NAS data without task authorization.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ # Native App payload hook. SPL_UPSTREAM_SOURCE is the merged fork root.
4
+ # Stage official release binaries only after verifying pinned SHA-256 values
5
+ # (the OpenList route), or compile the merged source with a source build hook.
6
+ # Stage daemon.targets under synology/; pin toolchains/dependencies for builds.
7
+ # Keep upstream source unchanged.
8
+ # Every App lifecycle phase uses the package UID. Do not add root/chown hooks.
9
+ echo 'SOURCE_BUILD_REQUIRED: implement the native synology/build-source.sh.' >&2
10
+ exit 2
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // Use the CLI's existing OCI registry resolver; never build or publish an image.
3
+ import {mkdtempSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+ import {spawnSync} from 'node:child_process';
7
+ const [tag] = process.argv.slice(2);
8
+ const match = /^v?((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$/.exec(tag ?? '');
9
+ if (!match) throw new Error('Use the selected stable upstream tag.');
10
+ const config = JSON.parse(readFileSync(new URL('./upstream-recipe.json', import.meta.url), 'utf8'));
11
+ if (config.kind !== 'official-image' || !['tag', 'version'].includes(config.imageTag)) throw new Error('Invalid official image recipe.');
12
+ const imageTag = config.imageTag === 'version' ? match[1] : tag;
13
+ const reference = `${config.repository}:${imageTag}`;
14
+ const temp = mkdtempSync(join(tmpdir(), 'synopkgland-image-pin-'));
15
+ try {
16
+ // images pin edits only compose.yaml; use a bounded temporary project so the
17
+ // App's base Compose and upstream source remain unchanged on lookup failure.
18
+ writeFileSync(join(temp, 'dsm-package.yaml'), 'schema: 2\n');
19
+ writeFileSync(join(temp, 'compose.yaml'), JSON.stringify({services: {[config.service]: {image: reference}}}, null, 2) + '\n');
20
+ const result = spawnSync('synopkgland', ['--project', temp, 'images', 'pin'], {stdio: 'inherit'});
21
+ if (result.error || result.status !== 0) throw new Error('Official image digest lookup failed; no Compose override was changed.');
22
+ const pinned = JSON.parse(readFileSync(join(temp, 'compose.yaml'), 'utf8'));
23
+ const image = pinned.services[config.service].image;
24
+ if (!image.startsWith(`${reference}@sha256:`) || !/@sha256:[0-9a-f]{64}$/.test(image)) throw new Error('Registry lookup did not produce the selected tag plus digest.');
25
+ writeFileSync(new URL('./compose.image.yaml', import.meta.url), JSON.stringify(pinned, null, 2) + '\n');
26
+ } finally {rmSync(temp, {recursive: true, force: true});}
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # One explicit stable tag; no latest guessing, push, image publication or NAS access.
3
+ set -euo pipefail
4
+ packaging=$(cd -- "$(dirname -- "$0")" && pwd)
5
+ repo=$(cd -- "$packaging/.." && pwd)
6
+ cd "$repo"
7
+ [[ $# == 2 && $1 =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
8
+ echo 'Usage: bash synology/upgrade.sh <[v]X.Y.Z> <increasing-build-number>' >&2; exit 2;
9
+ }
10
+ [[ $(git rev-parse --show-toplevel) == "$repo" ]] || { echo 'Run inside a source fork.' >&2; exit 2; }
11
+ [[ -z $(git status --porcelain --untracked-files=normal) ]] || { echo 'Commit changes before upgrading.' >&2; exit 2; }
12
+ git remote get-url upstream >/dev/null
13
+ node "$packaging/version.mjs" "$1" "$2" --validate
14
+ # Fetch the named remote tag into a private ref: a local tag can never substitute for it.
15
+ git fetch --no-tags upstream "+refs/tags/$1:refs/synopkgland/selected-upstream"
16
+ commit=$(git rev-parse 'refs/synopkgland/selected-upstream^{commit}')
17
+ [[ -z $(git ls-tree "$commit" -- synology) ]] || { echo 'Upstream owns synology/; choose another integration layout.' >&2; exit 2; }
18
+ previous=$(git describe --tags --abbrev=0 --match '[0-9]*' --match 'v[0-9]*' HEAD 2>/dev/null || true)
19
+ if [[ -f "$packaging/upstream-source.json" ]]; then
20
+ previous=$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1])).commit)' "$packaging/upstream-source.json")
21
+ fi
22
+ if [[ -n $previous ]]; then
23
+ git merge-base --is-ancestor "$previous" "$commit" || { echo 'Refusing upstream downgrade/divergent history.' >&2; exit 2; }
24
+ [[ -z $(git diff "$previous" HEAD -- . ':(exclude)synology') ]] || { echo 'Wrapper changed upstream files outside synology/.' >&2; exit 2; }
25
+ else
26
+ # First adoption must already be based on the selected source tag.
27
+ git merge-base --is-ancestor "$commit" HEAD
28
+ [[ -z $(git diff "$commit" HEAD -- . ':(exclude)synology') ]] || { echo 'Initial fork must preserve upstream source.' >&2; exit 2; }
29
+ fi
30
+ git merge --no-ff --no-edit "$commit"
31
+ [[ -z $(git diff "$commit" HEAD -- . ':(exclude)synology') ]] || { echo 'Upstream files differ after merge; resolve before building.' >&2; exit 2; }
32
+ kind=$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1])).kind)' "$packaging/upstream-recipe.json")
33
+ if [[ $kind == official-image ]]; then
34
+ node "$packaging/update-image.mjs" "$1"
35
+ elif [[ $kind != native-source ]]; then
36
+ echo 'Unknown upstream recipe kind.' >&2; exit 2
37
+ fi
38
+ node "$packaging/version.mjs" "$1" "$2"
39
+ node -e 'const fs=require("fs");fs.writeFileSync(process.argv[1],JSON.stringify({tag:process.argv[2],commit:process.argv[3],build:Number(process.argv[4])},null,2)+"\n")' "$packaging/upstream-source.json" "$1" "$commit" "$2"
40
+ export SPL_UPSTREAM_SOURCE="$repo"
41
+ export SPL_UPSTREAM_TAG="$1"
42
+ export SPL_UPSTREAM_COMMIT="$commit"
43
+ cd "$packaging"
44
+ if [[ $kind == native-source ]]; then bash ./build-source.sh; fi
45
+ synopkgland check --json
46
+ synopkgland build --json
47
+ # Review version/provenance and commit synology/ before the next upgrade.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Stable tag -> DSM version. Keep all wrapper-owned changes under synology/.
3
+ import {readFileSync, writeFileSync} from 'node:fs';
4
+ const [tag, build, mode] = process.argv.slice(2);
5
+ const match = /^v?((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$/.exec(tag ?? '');
6
+ const file = new URL('./dsm-package.yaml', import.meta.url);
7
+ const source = readFileSync(file, 'utf8');
8
+ const current = /^build: (\d+)\s*$/m.exec(source);
9
+ if (!match || !/^[1-9][0-9]{0,3}$/.test(build ?? '') || !current || Number(build) <= Number(current[1]))
10
+ throw new Error('Use a stable [v]X.Y.Z tag and an increasing packaging build (1–9999).');
11
+ if (!/^version: .+$/m.test(source)) throw new Error('Missing scaffold version.');
12
+ if (mode !== '--validate') writeFileSync(file, source.replace(/^version: .+$/m, `version: ${match[1]}`).replace(/^build: \d+\s*$/m, `build: ${Number(build)}`));
package/dist/cli.js CHANGED
@@ -421,6 +421,10 @@ export async function runCli(argv) {
421
421
  .option("-t, --template <name>", `Template: ${listTemplates().join(" | ")}`)
422
422
  .option("--package <id>", "INFO.package (defaults to the directory name)")
423
423
  .option("--name <display>", "Display name")
424
+ .option("--upstream-image <repository>", "compose fork: official image repository; upgrade pins its matching tag and index digest")
425
+ .option("--upstream-fork", "Create only synology/ inside an upstream source fork (compose or native-go)")
426
+ .option("--health-path <path>", "compose: actual readiness endpoint; redirects are not followed")
427
+ .option("--health-status <status>", "compose: expected 2xx readiness status (no default)", Number)
424
428
  .option("--ui <vue|plain>", `Native UI ${JSON.parse(readFileSync(join(templatesRoot(), "native-go-vue/frontend/package.json"), "utf8")).dependencies["@synopackageland/ui"]} by default (vue); plain uses ui/app.js`)
425
429
  .option("--with <caps>", `Capabilities (comma-separated): ${CAPABILITY_NAMES.join(",")}`)
426
430
  .option("--from <compose.yaml>", "compose: import an existing Compose; omit to create an example")
@@ -430,7 +434,7 @@ export async function runCli(argv) {
430
434
  .option("--shared-folder-permission <ro|rw>", "Package account permission, defaults to rw")
431
435
  .option("--min-dsm <version>", `Minimum DSM version (native templates and binary default to ${DEFAULT_MIN_DSM}, compose to ${CONTAINER_MIN_DSM})`)
432
436
  .argument("[dir]", "Target directory", ".")
433
- .addHelpText("after", "\nNative projects: native-go, native-python, native-node, native-rust and binary use dsm-package.yaml schema 2.\nCompose projects: compose uses synology-app.yaml; --from imports compose.yaml.\nQuick start: synopkgland init -t native-go --with files,jobs,login my-app\n cd my-app && synopkgland build\n")
437
+ .addHelpText("after", "\nNative projects: native-go, native-python, native-node, native-rust and binary use dsm-package.yaml schema 2.\nCompose projects: compose uses dsm-package.yaml schema 2; --from imports compose.yaml.\nQuick start: synopkgland init -t native-go --with files,jobs,login my-app\n cd my-app && synopkgland build\n")
434
438
  .action((dir, options, command) => {
435
439
  if (command.parent?.opts().project)
436
440
  throw failure("CLI_OPTION_INVALID", "--project does not apply to init.");
@@ -440,6 +444,10 @@ export async function runCli(argv) {
440
444
  throw failure("INIT_TEMPLATE_UNKNOWN", `Unknown template: ${options.template}`);
441
445
  const targetDir = resolve(dir);
442
446
  initFromTemplate(options.template, targetDir, {
447
+ upstreamFork: options.upstreamFork,
448
+ upstreamImage: options.upstreamImage,
449
+ healthPath: options.healthPath,
450
+ healthStatus: options.healthStatus,
443
451
  packageId: options.package,
444
452
  displayName: options.name,
445
453
  minDsm: options.minDsm,
@@ -457,7 +465,13 @@ export async function runCli(argv) {
457
465
  permission: options.sharedFolderPermission,
458
466
  },
459
467
  });
460
- stdout(`Created the ${options.template} project at ${targetDir}`);
468
+ stdout(`Created the ${options.template} project at ${options.upstreamFork ? join(targetDir, "synology") : targetDir}`);
469
+ if (options.template === "compose")
470
+ stdout("Select actual compose.web.health path/status before check/build. Redirects are not followed; check does not probe runtime health.");
471
+ if (options.upstreamFork) {
472
+ stdout("Read synology/AGENTS.md; configure the official image recipe or the native source hook, then run bash synology/upgrade.sh <upstream-tag> <build-number>.");
473
+ return;
474
+ }
461
475
  const next = initNextSteps(options.template, dir, existsSync(join(targetDir, "frontend", "package.json")), Boolean(process.env.SYNOPKGLAND_SDK_DIR));
462
476
  if (next)
463
477
  stdout(next);
package/dist/e2e/dsm.d.ts CHANGED
@@ -105,7 +105,7 @@ type SessionPage = {
105
105
  /**
106
106
  * Registers the signed-in session's cookies (id, did, _SSID) and SynoToken as
107
107
  * e2e secrets, so e2e masks them in its report and output, as a request's
108
- * Playwright call log carries them (t-0498). Returns how many were registered.
108
+ * Playwright call log carries them. Returns how many were registered.
109
109
  */
110
110
  export declare function registerDsmSecrets(page?: SessionPage, ledger?: SecretLedger): Promise<number>;
111
111
  /**
@@ -136,7 +136,7 @@ export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate">): Pro
136
136
  /**
137
137
  * `app.open(path)`, once more after net::ERR_ABORTED: about one first
138
138
  * navigation in ten was aborted as DSM's desktop navigated on its own, and the
139
- * next one loaded (t-0499, t-0500). Any other failure, or a second abort, is
139
+ * next one loaded. Any other failure, or a second abort, is
140
140
  * thrown.
141
141
  */
142
142
  export declare function openPath(app: Pick<App, "open">, path: string): Promise<void>;
package/dist/e2e/dsm.js CHANGED
@@ -135,7 +135,7 @@ export async function runWithTunnel(command, outer = process.env) {
135
135
  // ---- Secrets in output ----------------------------------------------------
136
136
  // e2e redacts only the secrets its config declares. A Playwright call log in a
137
137
  // run error lists a request's headers, DSM's session cookie among them, and e2e
138
- // writes it to the report and stdout (t-0498). A value runs to a quote, a
138
+ // writes it to the report and stdout. A value runs to a quote, a
139
139
  // backslash or a line end, so a JSON string's escapes end it too.
140
140
  const mask = "***";
141
141
  // A Cookie or Set-Cookie header, and in it DSM's session (id, _SSID) and device (did) cookies.
@@ -324,7 +324,7 @@ async function e2eSecretLedger() {
324
324
  /**
325
325
  * Registers the signed-in session's cookies (id, did, _SSID) and SynoToken as
326
326
  * e2e secrets, so e2e masks them in its report and output, as a request's
327
- * Playwright call log carries them (t-0498). Returns how many were registered.
327
+ * Playwright call log carries them. Returns how many were registered.
328
328
  */
329
329
  export async function registerDsmSecrets(page = dsmPage(), ledger) {
330
330
  const target = ledger ?? await e2eSecretLedger();
@@ -410,7 +410,7 @@ function accountFile(user, env) {
410
410
  /**
411
411
  * `app.open(path)`, once more after net::ERR_ABORTED: about one first
412
412
  * navigation in ten was aborted as DSM's desktop navigated on its own, and the
413
- * next one loaded (t-0499, t-0500). Any other failure, or a second abort, is
413
+ * next one loaded. Any other failure, or a second abort, is
414
414
  * thrown.
415
415
  */
416
416
  export async function openPath(app, path) {
@@ -1,7 +1,11 @@
1
1
  import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
2
2
  export const ERROR_REGISTRY = {
3
3
  ...COMPOSE_ERRORS,
4
+ INIT_HEALTH_INVALID: { code: "INIT_HEALTH_INVALID", description: "The Compose readiness selection is invalid.", suggestion: "Supply both --health-path and --health-status with the actual endpoint and expected 2xx response; redirects are not followed." },
5
+ INIT_UPSTREAM_IMAGE_REQUIRED: { code: "INIT_UPSTREAM_IMAGE_REQUIRED", description: "The official upstream image repository was not selected.", suggestion: "Use init --upstream-fork --upstream-image <official-repository> for Compose." },
6
+ INIT_UPSTREAM_IMAGE_INVALID: { code: "INIT_UPSTREAM_IMAGE_INVALID", description: "The upstream image repository is invalid.", suggestion: "Supply the official repository without a tag or digest; upgrade.sh selects the matching release and resolves its digest." },
4
7
  CTL_TRUST_REQUIRED: { "code": "CTL_TRUST_REQUIRED", "description": "App description access requires approval.", "suggestion": "Run synopkgland ctl list with explicit host and approve the candidate batch." },
8
+ COMPOSE_HEALTH_REQUIRED: { code: "COMPOSE_HEALTH_REQUIRED", description: "Compose web readiness was not selected.", suggestion: "Set compose.web.health.path and expected 2xx status for the actual App; redirects are not followed." },
5
9
  CTL_UNSUPPORTED_BACKEND: { "code": "CTL_UNSUPPORTED_BACKEND", "description": "Automatic descriptions require a native SDK backend.", "suggestion": "Disable ctl for binary/Compose wrappers or implement the public contract separately." },
6
10
  CTL_DESCRIBE_REQUIRED: { "code": "CTL_DESCRIBE_REQUIRED", "description": "The Go backend has no build-only registry exporter.", "suggestion": "Update the native template and implement --describe using api.DescribeRoutes." },
7
11
  CTL_DESCRIBE_FAILED: { "code": "CTL_DESCRIBE_FAILED", "description": "The backend registry export failed.", "suggestion": "Check the backend --describe output and build diagnostics." },
@@ -10,7 +10,7 @@
10
10
  * extra_values directly, and share permission can change after install.
11
11
  *
12
12
  * The rows are SYNO.Core.Share.Permission v1 output verbatim. Read the tests
13
- * before changing the predicate: on the test NAS, `newadmin` has is_writable=false on
13
+ * before changing the predicate: on the test NAS, `fixtureadmin` has is_writable=false on
14
14
  * its own row for `pkgland-p0b` and still writes there, because the
15
15
  * `administrators` group row carries the grant. A user-row-only check rejects
16
16
  * the only configuration with live proof behind it.
@@ -10,7 +10,7 @@
10
10
  * extra_values directly, and share permission can change after install.
11
11
  *
12
12
  * The rows are SYNO.Core.Share.Permission v1 output verbatim. Read the tests
13
- * before changing the predicate: on the test NAS, `newadmin` has is_writable=false on
13
+ * before changing the predicate: on the test NAS, `fixtureadmin` has is_writable=false on
14
14
  * its own row for `pkgland-p0b` and still writes there, because the
15
15
  * `administrators` group row carries the grant. A user-row-only check rejects
16
16
  * the only configuration with live proof behind it.
@@ -0,0 +1,7 @@
1
+ export interface ForkRecipeOptions {
2
+ upstreamImage?: string;
3
+ from?: string;
4
+ }
5
+ export declare function validateForkRecipe(template: 'compose' | 'native-go', options: ForkRecipeOptions): void;
6
+ /** CLI-owned packaging recipe; upstream files and history stay untouched. */
7
+ export declare function writeForkScaffold(packaging: string, template: 'compose' | 'native-go', options: ForkRecipeOptions): void;
@@ -0,0 +1,64 @@
1
+ import { cpSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parseDocument } from 'yaml';
5
+ import { API_PERMISSIONS_FILE } from './standalone/api-permissions.js';
6
+ import { parseImageReference } from './images/pin.js';
7
+ export function validateForkRecipe(template, options) {
8
+ if (template === 'native-go' && options.upstreamImage)
9
+ throw new Error('[INIT_OPTIONS_UNSUPPORTED] Native forks stage official release binaries or use a source build hook; image options apply only to compose.');
10
+ if (template !== 'compose')
11
+ return;
12
+ if (!options.upstreamImage)
13
+ throw new Error('[INIT_UPSTREAM_IMAGE_REQUIRED] Compose forks use official images: supply --upstream-image <official-repository>.');
14
+ const parsed = parseImageReference(options.upstreamImage);
15
+ if (!parsed || parsed.explicitTag || parsed.digest || !/^[a-z0-9][a-z0-9._:/-]*$/.test(options.upstreamImage) || options.upstreamImage.includes('//'))
16
+ throw new Error('[INIT_UPSTREAM_IMAGE_INVALID] Supply the official image repository without a tag or digest; upgrade.sh selects the matching upstream release tag.');
17
+ }
18
+ /** CLI-owned packaging recipe; upstream files and history stay untouched. */
19
+ export function writeForkScaffold(packaging, template, options) {
20
+ const official = template === 'compose';
21
+ const assets = fileURLToPath(new URL('../assets/init-upstream/', import.meta.url));
22
+ for (const file of ['upgrade.sh', 'version.mjs', official ? 'update-image.mjs' : 'build-source.sh'])
23
+ cpSync(join(assets, file), join(packaging, file));
24
+ const manifest = join(packaging, 'dsm-package.yaml');
25
+ const doc = parseDocument(readFileSync(manifest, 'utf8'));
26
+ const service = official ? (doc.toJS().compose.web?.service ?? 'web') : undefined;
27
+ const recipe = official
28
+ ? { kind: 'official-image', repository: options.upstreamImage, service, imageTag: 'tag' }
29
+ : { kind: 'native-source' };
30
+ writeFileSync(join(packaging, 'upstream-recipe.json'), JSON.stringify(recipe, null, 2) + '\n');
31
+ doc.set('entry', 'admin-only');
32
+ if (official) {
33
+ doc.setIn(['identity_gateway', 'access'], 'dsm-admins');
34
+ if (!options.from) {
35
+ const path = join(packaging, 'compose.yaml');
36
+ const base = parseDocument(readFileSync(path, 'utf8'));
37
+ base.deleteIn(['services', service, 'command']);
38
+ base.setIn(['services', service, 'image'], `${options.upstreamImage}:SELECT_UPSTREAM_TAG`);
39
+ writeFileSync(path, String(base));
40
+ }
41
+ doc.setIn(['compose', 'files'], [...doc.toJS().compose.files, 'compose.image.yaml']);
42
+ writeFileSync(join(packaging, 'compose.image.yaml'), JSON.stringify({ services: { [service]: { image: `${options.upstreamImage}:SELECT_UPSTREAM_TAG` } } }, null, 2) + '\n');
43
+ }
44
+ if (!official) {
45
+ const path = join(packaging, 'backend', API_PERMISSIONS_FILE.go);
46
+ const policy = JSON.parse(readFileSync(path, 'utf8'));
47
+ for (const method of Object.keys(policy.methods))
48
+ policy.methods[method] = 'administrators';
49
+ writeFileSync(path, JSON.stringify(policy, null, 2) + '\n');
50
+ }
51
+ writeFileSync(manifest, String(doc));
52
+ const guidance = `
53
+ ## Integrating an existing open-source App
54
+
55
+ Keep the upstream fork/history and remote upstream. All wrapper-owned files belong in synology/; never edit upstream files or treat the App as an SDK component. Read synopkgland skill show upstream-fork.
56
+
57
+ Commit the scaffold, then run bash synology/upgrade.sh <upstream-tag> <build-number>. It fetches the exact tag, verifies ancestry/untouched upstream files, merges, derives version/provenance, checks and builds. Commit synology/ before upgrading again. It does not publish or touch a NAS.
58
+
59
+ ${official ? 'Default: use the upstream official image. upgrade.sh updates its release tag and resolves an OCI index/manifest digest through synopkgland images pin, writing only synology/compose.image.yaml. Container Manager downloads that pinned official image at install. Configure upstream-recipe.json repository/service and imageTag: tag (exact upstream tag) or version (strip v only when upstream image naming requires it). Review multi-architecture support and upstream source/license correspondence. The SELECT_UPSTREAM_TAG placeholder must be replaced by a successful upgrade before check/build.' : 'Native App: implement build-source.sh as the native payload hook; it deliberately fails until implemented. Stage official release binaries pinned by SHA-256 (the OpenList route), or compile the merged source with a source build hook. Verify the release digest before staging architecture-specific daemon.targets; keep the SDK management shell separate.'}
60
+
61
+ Use DSM administrator identity without an additional App password, an OIDC/SSO server or upstream source changes. Prefer supported trusted-header/proxy auth (FreshRSS), otherwise disable upstream login/enable guest behind the administrator-only gateway after a bounded first bootstrap (Uptime Kuma disableAuth). The third shipped pattern is the existing SDK 0.16.0 token adapter: mode: token, access: dsm-admins, entry: admin-only. OpenList obtains its native API token with the unmodified admin token CLI while stopped, stores it package-private 0600 under a 0700 directory, and the gateway injects Authorization server-side after DSM admission; packaging never delivers it to the browser. All DSM admins share one upstream admin; token reset requires package restart; WebDAV keeps its own native credentials. The upstream admin token/settings APIs remain unchanged. Verify actual DSM admission/revocation separately. Use supported config, environment, argv or proxy adapters. Every App lifecycle phase runs as the package UID. For upstream fixed-UID data directories, currently provide a synology/ startup adapter or writable path without root/chown; replace it with the framework ownership mechanism when available. Preserve licenses and the full source/component inventory.
62
+ `;
63
+ writeFileSync(join(packaging, 'AGENTS.md'), readFileSync(join(packaging, 'AGENTS.md'), 'utf8') + guidance);
64
+ }
package/dist/init.d.ts CHANGED
@@ -16,6 +16,10 @@ export interface InitOptions {
16
16
  ui?: "plain" | "vue";
17
17
  with?: string[];
18
18
  from?: string;
19
+ upstreamFork?: boolean;
20
+ upstreamImage?: string;
21
+ healthPath?: string;
22
+ healthStatus?: number;
19
23
  /** native-go: `install.sharedFolder` (select from the NAS, or create one). key defaults to "shared", permission to "rw". */
20
24
  sharedFolder?: {
21
25
  mode: "select" | "create";
package/dist/init.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateForkRecipe, writeForkScaffold } from "./init-upstream.js";
1
2
  import { goChildEnv } from "./runtime/go-env.js";
2
3
  import { spawnSync } from "node:child_process";
3
4
  import { parse, parseDocument } from "yaml";
@@ -112,6 +113,30 @@ function isTemplateBuildArtifact(path) {
112
113
  return (name === "node_modules" || name === "dist" || name === ".go-results.json");
113
114
  }
114
115
  export function initFromTemplate(template, targetDir, options = {}) {
116
+ if (options.upstreamFork) {
117
+ if (template !== "compose" && template !== "native-go")
118
+ throw new Error("[INIT_OPTIONS_UNSUPPORTED] --upstream-fork supports compose or native-go.");
119
+ validateForkRecipe(template, options);
120
+ const packaging = join(targetDir, "synology");
121
+ if (existsSync(packaging))
122
+ throw new Error("[INIT_TARGET_NOT_EMPTY] synology/ already exists.");
123
+ initFromTemplate(template, packaging, {
124
+ ...options,
125
+ packageId: options.packageId ?? defaultPackageId(targetDir),
126
+ displayName: options.displayName ?? defaultDisplayName(targetDir),
127
+ upstreamFork: false,
128
+ upstreamImage: undefined,
129
+ });
130
+ writeForkScaffold(packaging, template, options);
131
+ return;
132
+ }
133
+ if (options.upstreamImage)
134
+ throw new Error("[INIT_OPTIONS_UNSUPPORTED] Image recipe options require --upstream-fork.");
135
+ const hasHealth = options.healthPath !== undefined || options.healthStatus !== undefined;
136
+ if (hasHealth && (template !== "compose" || typeof options.healthPath !== "string" ||
137
+ !/^\/(?!\/)[^\s#\\]*$/.test(options.healthPath) || !Number.isInteger(options.healthStatus) ||
138
+ options.healthStatus < 200 || options.healthStatus > 299))
139
+ throw new Error("[INIT_HEALTH_INVALID] Compose health needs both --health-path /actual-endpoint and --health-status 2xx; redirects are not followed.");
115
140
  if (!isTemplateName(template))
116
141
  throw new Error(`[INIT_TEMPLATE_UNKNOWN] Unknown template: ${template}`);
117
142
  const requested = capabilityNames(options.with ?? []);
@@ -261,6 +286,18 @@ export function initFromTemplate(template, targetDir, options = {}) {
261
286
  }
262
287
  if (template === "compose" && options.from)
263
288
  importCompose(options.from, targetDir);
289
+ if (template === "compose") {
290
+ const path = join(targetDir, "dsm-package.yaml");
291
+ const doc = parseDocument(readFileSync(path, "utf8"));
292
+ if (doc.hasIn(["compose", "web"])) {
293
+ doc.deleteIn(["compose", "web", "health"]);
294
+ if (hasHealth)
295
+ doc.setIn(["compose", "web", "health"], { path: options.healthPath, status: options.healthStatus });
296
+ }
297
+ writeFileSync(path, String(doc));
298
+ const guidance = readFileSync(new URL("../assets/init-compose.md", import.meta.url), "utf8");
299
+ writeFileSync(join(targetDir, "AGENTS.md"), guidance);
300
+ }
264
301
  if (template === "binary") {
265
302
  mkdirSync(join(targetDir, "prebuilt"), { recursive: true });
266
303
  for (const [arch, goarch] of [
@@ -356,7 +393,6 @@ function importCompose(from, targetDir) {
356
393
  if (candidates.length === 1)
357
394
  doc.setIn(["compose", "web"], {
358
395
  ...candidates[0],
359
- health: { path: "/", status: 200 },
360
396
  });
361
397
  else {
362
398
  doc.deleteIn(["compose", "web"]);
@@ -1,6 +1,6 @@
1
1
  /** Self-contained: the same function is embedded in the DSM window. True only
2
2
  * when DSM refused a request because this method version is unavailable, the
3
- * one refusal that may be retried with an older version. DSM 7.1.1 (t-0234)
3
+ * one refusal that may be retried with an older version. DSM 7.1.1
4
4
  * returns 104 for a version above maxVersion and 103 for a method absent at an
5
5
  * existing version. Accepts the numeric code and the window's dsm_ form. */
6
6
  export declare function versionUnsupported(code: unknown): boolean;
@@ -1,6 +1,6 @@
1
1
  /** Self-contained: the same function is embedded in the DSM window. True only
2
2
  * when DSM refused a request because this method version is unavailable, the
3
- * one refusal that may be retried with an older version. DSM 7.1.1 (t-0234)
3
+ * one refusal that may be retried with an older version. DSM 7.1.1
4
4
  * returns 104 for a version above maxVersion and 103 for a method absent at an
5
5
  * existing version. Accepts the numeric code and the window's dsm_ form. */
6
6
  export function versionUnsupported(code) {
@@ -1,27 +1,5 @@
1
1
  {
2
- "0.15.0": {
3
- "x86_64": "efe409287770f6b70e7f8465e8767eeb2413b9ab8775020cf4dffb1555d7bf1f",
4
- "aarch64": "d68c9889ed2391e918b9c278679ea0a01cd04425528c11090700a3b6341fc543",
5
- "armv7": "9149a05efb9f2d7d86a400707099b3d657382b1c0dbad6588be8c500e90445cc",
6
- "lifecycle": {
7
- "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
8
- "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
9
- "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
10
- },
11
- "goToolchain": "go1.26.9"
12
- },
13
- "0.15.1": {
14
- "x86_64": "4f47fb43f6e60dbad064afb0bbec96dd009fa046670e2c4b590b128a855cb404",
15
- "aarch64": "c903b09ad657a838f0c62b6688e65e9094572b22b969770fca92fcc3640a5364",
16
- "armv7": "8178d90de7ea720530bb9496e50ed118086b0e57382d84675aadc68f482d219f",
17
- "lifecycle": {
18
- "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
19
- "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
20
- "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
21
- },
22
- "goToolchain": "go1.26.9"
23
- },
24
- "0.15.2": {
2
+ "0.16.0": {
25
3
  "x86_64": "4f47fb43f6e60dbad064afb0bbec96dd009fa046670e2c4b590b128a855cb404",
26
4
  "aarch64": "c903b09ad657a838f0c62b6688e65e9094572b22b969770fca92fcc3640a5364",
27
5
  "armv7": "8178d90de7ea720530bb9496e50ed118086b0e57382d84675aadc68f482d219f",
package/dist/skill.js CHANGED
@@ -14,6 +14,7 @@ const SKILL_CATALOG = [
14
14
  file: "reference/compose.md",
15
15
  when: "Upstream Compose with the SDK planner, fixed identity and support boundary",
16
16
  },
17
+ { id: "upstream-fork", file: "reference/upstream-fork.md", when: "Package an upstream source fork without changing upstream files" },
17
18
  {
18
19
  id: "wrap",
19
20
  file: "reference/wrap.md",
@@ -10,7 +10,7 @@ import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM,
10
10
  import { checkCgiBinary } from "./binary.js";
11
11
  import { loadStandaloneManifest, } from "./manifest.js";
12
12
  import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
13
- import { buildComposeControl, lockComposeImages, dsmComposeProblem, } from "./compose.js";
13
+ import { buildComposeControl, buildComposeStorageInit, lockComposeImages, dsmComposeProblem, } from "./compose.js";
14
14
  import { buildDaemonControl } from "./daemon.js";
15
15
  import { buildIdentityGateway } from "./identity-gateway.js";
16
16
  import { buildExecutor } from "../runtime/executor-build.js";
@@ -148,6 +148,7 @@ export async function buildStandalone(options) {
148
148
  const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
149
149
  const identityGateway = await buildIdentityGateway(manifest, arch, archWork, run);
150
150
  const composeControl = await buildComposeControl(manifest, arch, archWork, run);
151
+ const composeStorageInit = await buildComposeStorageInit(manifest, arch, archWork, run);
151
152
  // A portal needs the executor, so the executor's SDK carries the panel text.
152
153
  const portalText = manifest.portal && executor
153
154
  ? portalCatalogs(sdkSource(executor.sdk).goDir)
@@ -160,6 +161,7 @@ export async function buildStandalone(options) {
160
161
  daemonControl,
161
162
  identityGateway,
162
163
  composeControl,
164
+ composeStorageInit,
163
165
  backgroundWorker,
164
166
  executor,
165
167
  app,
@@ -1,5 +1,20 @@
1
1
  /** SDK planner errors accepted at this versioned tool boundary. */
2
2
  export const COMPOSE_ERRORS = {
3
+ COMPOSE_STORAGE_INIT_INVALID: {
4
+ code: "COMPOSE_STORAGE_INIT_INVALID",
5
+ description: "The container storage initialization declaration is invalid.",
6
+ suggestion: "Declare private subdirectories on writable mounts and explicit upstream entrypoint/command argv; keep the service nonroot. The SDK initializer is supplied by the SPK.",
7
+ },
8
+ COMPOSE_STORAGE_NOT_WRITABLE: {
9
+ code: "COMPOSE_STORAGE_NOT_WRITABLE",
10
+ description: "The package UID cannot initialize or write its private container data.",
11
+ suggestion: "Use a writable mount base with a package-owned private subtree; inspect ownership, permissions, read-only storage, quota and free space. Do not add root or chown hooks.",
12
+ },
13
+ COMPOSE_STORAGE_COMMAND_INVALID: {
14
+ code: "COMPOSE_STORAGE_COMMAND_INVALID",
15
+ description: "The application command after storage preflight cannot be executed.",
16
+ suggestion: "Declare the actual upstream executable and arguments. Keep the upstream official image unchanged; the SPK supplies the SDK initializer.",
17
+ },
3
18
  COMPOSE_SUBNET_STATE_INVALID: {
4
19
  code: "COMPOSE_SUBNET_STATE_INVALID",
5
20
  description: "The private ingress history is invalid or has foreign ownership.",
@@ -23,6 +23,12 @@ export interface ComposePlan {
23
23
  mounts_blocked: boolean;
24
24
  acceptance_blockers: string[];
25
25
  services: Array<{
26
+ storage_init?: {
27
+ directories: Array<{
28
+ base: string;
29
+ path: string;
30
+ }>;
31
+ };
26
32
  service: string;
27
33
  user: string;
28
34
  container_root: boolean;
@@ -96,6 +102,8 @@ export declare function writeComposePayload(c: ComposeApp, root: string, target:
96
102
  export declare function renderComposeInstall(pkg: string): string;
97
103
  /** The same SDK entry point supplies the package-account install materializer. */
98
104
  export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
105
+ /** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
106
+ export declare function buildComposeStorageInit(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
99
107
  /** Only the build resolves registry metadata; check and inspect remain offline. */
100
108
  export declare function lockComposeImages(m: StandaloneManifest, arch: NativeArchFamily, resolveImages: boolean, imageProofs?: unknown): StandaloneManifest;
101
109
  export declare function composeInputs(root: string, file: string, envFile?: string): ComposePlan["inputs"];
@@ -194,6 +194,7 @@ export function writeComposePayload(c, root, target, gateway) {
194
194
  export function renderComposeInstall(pkg) {
195
195
  return `#!/bin/sh
196
196
  set -eu
197
+ "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" preflight || exit $?
197
198
  exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
198
199
  `;
199
200
  }
@@ -201,10 +202,19 @@ exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNO
201
202
  export async function buildComposeControl(m, arch, work, run) {
202
203
  if (!m.compose)
203
204
  return;
205
+ return buildComposeTool(m, arch, work, run, "plan");
206
+ }
207
+ /** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
208
+ export async function buildComposeStorageInit(m, arch, work, run) {
209
+ if (!m.compose?.plan.services.some((s) => s.storage_init))
210
+ return;
211
+ return buildComposeTool(m, arch, work, run, "storage-init");
212
+ }
213
+ async function buildComposeTool(m, arch, work, run, tool) {
204
214
  const { pinnedGoBuildEnv, pinnedGoProblem } = await import("../build-spk/go-toolchain.js");
205
215
  const { checkCgiBinary } = await import("./binary.js");
206
216
  const { StandaloneBuildError } = await import("./spk.js");
207
- const out = join(work, `compose-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);
@@ -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, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.15.4",
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.4. 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.