@synopackageland/cli 0.15.4 → 0.17.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 (54) hide show
  1. package/CHANGELOG.md +22 -8
  2. package/README.md +8 -1
  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/add.js +3 -3
  9. package/dist/build-spk/uninstall-wizard.d.ts +1 -1
  10. package/dist/build-spk/uninstall-wizard.js +1 -1
  11. package/dist/build-spk/wizard-messages.js +9 -9
  12. package/dist/cli.js +16 -2
  13. package/dist/contract/validate.js +2 -2
  14. package/dist/e2e/dsm.d.ts +2 -2
  15. package/dist/e2e/dsm.js +3 -3
  16. package/dist/error-registry.js +8 -4
  17. package/dist/host/share-permission.d.ts +1 -1
  18. package/dist/host/share-permission.js +1 -1
  19. package/dist/init-upstream.d.ts +7 -0
  20. package/dist/init-upstream.js +64 -0
  21. package/dist/init.d.ts +4 -0
  22. package/dist/init.js +37 -1
  23. package/dist/portal/alias.d.ts +2 -6
  24. package/dist/portal/alias.js +2 -6
  25. package/dist/portal/info.d.ts +4 -0
  26. package/dist/portal/info.js +32 -0
  27. package/dist/runtime/dsm-version.d.ts +1 -1
  28. package/dist/runtime/dsm-version.js +1 -1
  29. package/dist/runtime/executor-digests.json +4 -26
  30. package/dist/runtime/sdk-batch.d.ts +2 -0
  31. package/dist/runtime/sdk-batch.js +23 -0
  32. package/dist/runtime/uninstall-cleanup.js +1 -1
  33. package/dist/skill.js +1 -0
  34. package/dist/standalone/build.js +8 -1
  35. package/dist/standalone/compose-errors.js +15 -0
  36. package/dist/standalone/compose.d.ts +8 -0
  37. package/dist/standalone/compose.js +13 -3
  38. package/dist/standalone/dsm-window.js +25 -4
  39. package/dist/standalone/identity-gateway.js +1 -0
  40. package/dist/standalone/inspect-spk.js +17 -3
  41. package/dist/standalone/lifecycle.js +4 -1
  42. package/dist/standalone/manifest.js +7 -2
  43. package/dist/standalone/native-layout.js +6 -2
  44. package/dist/standalone/portal-window.js +1 -1
  45. package/dist/standalone/spk.d.ts +3 -0
  46. package/dist/standalone/spk.js +9 -2
  47. package/package.json +3 -3
  48. package/skills/SKILL.md +8 -14
  49. package/skills/reference/compose.md +25 -8
  50. package/skills/reference/create.md +4 -4
  51. package/skills/reference/portal.md +10 -6
  52. package/skills/reference/ui-vue.md +12 -0
  53. package/skills/reference/upstream-fork.md +165 -0
  54. package/skills/reference/wrap.md +3 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 (2026-10-19)
4
+
5
+ - Pin SDK/App 0.17.0 and templates 0.17.1, with UI 0.12.1 in Vue scaffolds; package the executor fixes for directory users and request batches.
6
+ - Fetch SynoToken early in standalone and Portal windows so the first API request can start sooner.
7
+ - Allow Portal without an executor from DSM 7.2-64570. Request DSM nginx reload through INFO during install, upgrade and uninstall; DSM 7.1 still needs an executor for registration.
8
+
9
+ - Expose ctx.batch in generated SDK windows, sharing the existing request guards and token recovery. Keep single-call retries unchanged; never replay a batch after partial execution.
10
+
11
+ ## 0.16.0 (2026-10-19)
12
+
13
+ - Pin SDK/templates 0.16.0; generated Shared Folder flows use the shared SDK readback and settings components.
14
+ - Add the upstream-fork recipe/init/check workflow, Container Manager preflight and architecture-matched storage initializer packaging while preserving official images.
15
+ - Remove internal deployment details from published content and scan actual npm tarballs before release.
16
+
3
17
  ## 0.15.4 (2026-10-18)
4
18
 
5
19
  - 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 +188,8 @@ only its protocol README's wording, and nothing in the CLI depends on it.
174
188
 
175
189
  - `ctx.call` rejects `APP_STOPPED` at once: a stopped package waits for the
176
190
  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
191
+ page's next read instead of 30 s later (Download Hub 29.3 s; Reclaim
192
+ showed its old content or loading). `APP_STARTING` and
179
193
  `EXECUTOR_STARTING` repeat for 4 s of waiting (0.5, 1, 2, 0.5 s), so a short
180
194
  start still shows the page nothing and the SDK's starting copy follows a
181
195
  longer one within its own 4 s recheck. `executor.control`, which the window's
@@ -225,7 +239,7 @@ e2e 新增 `openUntilLoaded`(`signIn` 遇 `net::ERR_ABORTED` 最多重開約 3
225
239
  the newest one it read (`lastStatus()`); a `status()` asked for while a read
226
240
  runs shares it. The SDK's `PortalSettings` starts from it, so a settings
227
241
  page no longer moves when its placeholder rows become the real section
228
- (Reclaim: 100 px, t-0520).
242
+ (Reclaim: 100 px).
229
243
 
230
244
  ## 0.10.6 (2026-10-15)
231
245
 
@@ -244,7 +258,7 @@ digest 與 0.10.5 相同;SDK 下限維持 0.10.5。e2e 新增 `openPath`、`ha
244
258
 
245
259
  - `signIn`, `openDesktop` and `openPortal` open their page once more when
246
260
  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
261
+ ten met as DSM's desktop navigated on its own; any other
248
262
  failure, or a second abort, still fails. The helper is exported as
249
263
  `openPath(app, path)`.
250
264
  - The DSM window's `status()` answers a pinned executor that reports
@@ -330,7 +344,7 @@ digest 與 0.10.4 相同;新增 `@synopackageland/cli/e2e`。
330
344
  `authorization.status`, `executor.status`) while the package is stopped or
331
345
  starting: they reject with the code at once, and SDK 0.10.5's panels show the
332
346
  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
347
+ shows nothing for the first seconds after an upgrade (6.7 s in the regression test). Calls
334
348
  that change something, approvals and controls among them, still repeat for up
335
349
  to 30 s. `ctx.executor.status()` for a user who is not an administrator says
336
350
  the package is stopped or starting instead of `admin_required`.
@@ -362,12 +376,12 @@ before its timeout.
362
376
  - The window approval of an upgrade (outdated tasks) makes 11 scheduler calls
363
377
  instead of 12: the outdated boot task is deleted by the approval's own first
364
378
  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
379
+ get (2.9 s on the test NAS in the regression test). The control task is still read again by id
366
380
  before its delete (spec/scheduler/v1). A fresh approval stays at 8.
367
381
  繁中:升級核准的排程呼叫由 12 降為 11(boot 任務不再於刪除前重讀)。
368
382
  - A scope approval reads the executor's selection back every second at most
369
383
  (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
384
+ of `scope.confirm` (the Dialog closed 4.8 s after it in the regression test). Removing
371
385
  the temporary selection task still lists Task Scheduler first, as
372
386
  spec/scheduler/v1 requires of a delete.
373
387
  繁中:範圍核准讀回間隔上限 2 s → 1 s,confirmed 後 1 s 內看到結果。
@@ -376,7 +390,7 @@ before its timeout.
376
390
  before any request: the pinned, running executor reads `ready`; anything
377
391
  else stays `blocked` with `admin_required`. Task Scheduler is still read only
378
392
  for administrators. A user no longer sees an approved executor as waiting
379
- for approval (t-0482 F7).
393
+ for approval.
380
394
  繁中:非管理員的 `ctx.executor.status()` 會向 App 的 `executor.status` 詢問,已核准且
381
395
  執行中即為 `ready`,不再一律回 `admin_required`。
382
396
 
package/README.md CHANGED
@@ -95,7 +95,7 @@ Add them with `init --with …` or later with `synopkgland add <capability…>`.
95
95
  | `login` | Sign users in with their DSM account, without the DSM desktop | Nobody; requires HTTPS for password sign-in |
96
96
  | `delegate` | Move files into a user's chosen folder while the user is offline | An administrator, once |
97
97
  | `root-ops` | Run fixed `root/*.sh` scripts for administrators | An administrator, once |
98
- | `portal` | Open the app from its own Application Portal alias, with a login page style (`login_style` changes the colour, wallpaper and title) | An administrator sets the alias; needs `root-ops` or `delegate` and DSM 7.1-42661+ (below 7.2-64570 the executor registers the entry) |
98
+ | `portal` | Open the app from its own Application Portal alias, with a login page style (`login_style` changes the colour, wallpaper and title) | An administrator applies the alias; DSM 7.2-64570+ needs no executor (INFO asks DSM to reload nginx on uninstall). DSM 7.1-42661+ needs an executor to register the entry |
99
99
 
100
100
  Delegate is implemented; its default operation is `files.moveFromStaging`.
101
101
  Device lifecycle acceptance on real hardware is still pending for delegate and root operations.
@@ -311,3 +311,10 @@ Read `synopkgland skill show ctl` for the AI workflow, batch discovery approval,
311
311
  cache refresh, host removal/revocation and explicit `ctl.commands` authoring.
312
312
  API descriptions are private SPK files named `synopkgland-api.json`, served
313
313
  only by the authenticated fixed `synopkgland.describe` CGI operation.
314
+
315
+ Generated SDK windows expose `ctx.batch([{method, params}, ...])` when the
316
+ selected SDK supplies its batch factory. One to eight independent JSON reads
317
+ share a request; results keep input order and retain per-method errors. A batch
318
+ uses the same token recovery, request deadline and cancellation as `ctx.call`,
319
+ and is never replayed automatically. Older SDKs and binary backends retain the
320
+ single-call client. Rebuild the window with the current CLI/SDK to adopt it.
@@ -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.17.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/add.js CHANGED
@@ -8,7 +8,7 @@ import { initFromTemplate, nativeCapabilitySettings } from './init.js';
8
8
  import { templatesRoot } from './template-paths.js';
9
9
  import { borrowedFiles, borrowedTarget, capabilityOverlays } from './capability-overlays.js';
10
10
  import { API_PERMISSIONS_FILE } from './standalone/api-permissions.js';
11
- import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM } from './build-spk/dsm-version.js';
11
+ import { DEFAULT_MIN_DSM, isDsmAtLeast, portalByExecutor, PORTAL_EXECUTOR_MIN_DSM } from './build-spk/dsm-version.js';
12
12
  import { loadStandaloneManifest } from './standalone/manifest.js';
13
13
  import { PORTAL_REQUIRES_EXECUTOR } from './portal/alias.js';
14
14
  function files(root, dir = root) {
@@ -66,13 +66,13 @@ function addPortal(root, result) {
66
66
  writeFileSync(manifestPath, setTopLevel(readFileSync(manifestPath, 'utf8'), { ...values, portal: {} }));
67
67
  return withNotes({ ...result, added: [...result.added, 'portal'] }, notes);
68
68
  }
69
- /** A portal needs the executor tier, already declared or added in the same call; nothing is written otherwise. */
69
+ /** Before DSM 7.2 a portal needs an executor, already declared or added in the same call. */
70
70
  function refuseNoPrivilegePortal(root, rest) {
71
71
  const loaded = loadStandaloneManifest(root);
72
72
  if (!loaded.ok)
73
73
  throw new Error(loaded.errors.map(e => `[${e.code}] ${e.message}`).join('\n'));
74
74
  const { rootOps, delegate, webapi } = loaded.manifest.capabilities;
75
- if (!rootOps && !delegate && !webapi && !rest.some(name => name === 'root-ops' || name === 'delegate')) {
75
+ if (portalByExecutor(loaded.manifest.os_min_ver) && !rootOps && !delegate && !webapi && !rest.some(name => name === 'root-ops' || name === 'delegate')) {
76
76
  throw new Error(`[STANDALONE_PORTAL_REQUIRES_EXECUTOR] ${PORTAL_REQUIRES_EXECUTOR}`);
77
77
  }
78
78
  }
@@ -7,7 +7,7 @@ export type UninstallWizardKind = "native" | "container";
7
7
  interface UninstallWizardOptions {
8
8
  /** The App runs the root executor (rootOps or delegate), which purges DSM's leftovers on uninstall. */
9
9
  executor?: boolean;
10
- /** The App declares a Login Portal alias (web-config app-portal); only the executor tier can. */
10
+ /** The App declares a Login Portal alias (web-config app-portal); DSM reloads nginx through INFO. */
11
11
  portal?: boolean;
12
12
  /** Below DSM 7.2 the executor registered the App's Application Portal entry. */
13
13
  portalEntry?: boolean;
@@ -26,7 +26,7 @@ function renderUninstallWizard(t, locale, packageName, kind, { executor = false,
26
26
  ...(kind === "native" ? [{ desc: t("uninstall.retained.native", vars) }] : []),
27
27
  ...(executor ? [{ desc: t("uninstall.executor", vars) }] : []),
28
28
  { desc: t(executor ? "uninstall.appPrivilege.auto" : "uninstall.appPrivilege", vars) },
29
- ...(executor && portal ? [{ desc: t("uninstall.portal.auto", vars) }] : []),
29
+ ...(portal ? [{ desc: t("uninstall.portal.auto", vars) }] : []),
30
30
  ...(executor && portalEntry ? [
31
31
  { desc: t("uninstall.portalEntry", vars) },
32
32
  { desc: portalEntryCleanupCommands(packageName).map((line) => `<code>${html(line)}</code>`).join("<br>") },
@@ -407,15 +407,15 @@ export const WIZARD_MESSAGES = {
407
407
  it: "La disinstallazione rimuove automaticamente le regole di {package} in Pannello di controllo > Privilegi applicazione, così una successiva installazione con lo stesso nome non le applica di nuovo. Solo se questa pulizia non riesce, come segnala la voce synopkgland-executor del registro di sistema, un amministratore deve rimuovere le regole da lì.",
408
408
  },
409
409
  "uninstall.portal.auto": {
410
- en: "After DSM removes the Login Portal alias of {package}, uninstall reloads the web server automatically, so the alias stops at once. Only if this reload failed, as the system log entry synopkgland-executor reports, an administrator can save an application alias in Control Panel > Login Portal > Applications, which reloads the web server, or restart the NAS.",
411
- "zh-TW": "DSM 移除 {package} 的登入入口別名後,解除安裝會自動重新載入網頁伺服器,讓該別名立即失效。只有在重新載入失敗時(系統日誌 synopkgland-executor 會記錄),管理員才需要在「控制台」>「登入入口」>「應用程式」儲存任一應用程式別名(會重新載入網頁伺服器),或重新啟動 NAS。",
412
- "zh-CN": "DSM 删除 {package} 的登录门户别名后,卸载会自动重新加载网页服务器,使该别名立即失效。只有在重新加载失败时(系统日志 synopkgland-executor 会记录),管理员才需要在“控制面板”>“登录门户”>“应用程序”保存任一应用程序别名(会重新加载网页服务器),或重新启动 NAS。",
413
- ja: "DSM が {package} のログイン ポータルのエイリアスを削除した後、アンインストール時に Web サーバーを自動的に再読み込みするため、エイリアスはすぐに無効になります。再読み込みに失敗した場合 (システム ログの synopkgland-executor に記録されます) のみ、管理者が [コントロール パネル] > [ログイン ポータル] > [アプリケーション] でいずれかのアプリケーションのエイリアスを保存する (Web サーバーが再読み込みされます) か、NAS を再起動してください。",
414
- ko: "DSM이 {package}의 로그인 포털 별칭을 삭제한 후 제거 과정에서 웹 서버를 자동으로 다시 로드하므로 별칭이 즉시 중지됩니다. 다시 로드가 실패한 경우에만(시스템 로그 synopkgland-executor에 기록됨) 관리자가 제어판 > 로그인 포털 > 응용 프로그램에서 응용 프로그램 별칭을 저장하거나(웹 서버가 다시 로드됨) NAS를 다시 시작하십시오.",
415
- de: "Nachdem DSM den Anmeldeportal-Alias von {package} entfernt hat, lädt die Deinstallation den Webserver automatisch neu, sodass der Alias sofort endet. Nur wenn dieses Neuladen fehlschlägt, was der Systemprotokolleintrag synopkgland-executor meldet, muss ein Administrator unter Systemsteuerung > Anmeldeportal > Anwendungen einen Anwendungsalias speichern, wodurch der Webserver neu geladen wird, oder das NAS neu starten.",
416
- fr: "Après que DSM a supprimé l’alias du Portail de connexion de {package}, la désinstallation recharge automatiquement le serveur web, si bien que l’alias cesse immédiatement. Seulement si ce rechargement échoue, ce que signale l’entrée synopkgland-executor du journal système, un administrateur doit enregistrer un alias d’application dans Panneau de configuration > Portail de connexion > Applications, ce qui recharge le serveur web, ou redémarrer le NAS.",
417
- es: "Después de que DSM elimine el alias del Portal de inicio de sesión de {package}, la desinstalación recarga automáticamente el servidor web, de modo que el alias deja de funcionar al instante. Solo si esta recarga falla, como indica la entrada synopkgland-executor del registro del sistema, un administrador debe guardar un alias de aplicación en Panel de control > Portal de inicio de sesión > Aplicaciones, lo que recarga el servidor web, o reiniciar el NAS.",
418
- it: "Dopo che DSM ha rimosso l’alias del Portale di accesso di {package}, la disinstallazione ricarica automaticamente il server web, così l’alias smette subito di funzionare. Solo se questo ricaricamento non riesce, come segnala la voce synopkgland-executor del registro di sistema, un amministratore deve salvare un alias di applicazione in Pannello di controllo > Portale di accesso > Applicazioni, che ricarica il server web, oppure riavviare il NAS.",
410
+ en: "DSM removes the Login Portal alias of {package} and reloads the web server automatically. The reload can finish after uninstall. If the alias still opens a DSM page, an administrator can save an application alias in Control Panel > Login Portal > Applications to reload the web server.",
411
+ "zh-TW": "DSM 會移除 {package} 的登入入口別名並自動重新載入網頁伺服器;重新載入可能在解除安裝結束後才完成。如果別名仍開啟 DSM 頁面,管理員可在「控制台」>「登入入口」>「應用程式」儲存任一應用程式別名,以重新載入網頁伺服器。",
412
+ "zh-CN": "DSM 会删除 {package} 的登录门户别名并自动重新加载网页服务器;重新加载可能在卸载结束后才完成。如果别名仍打开 DSM 页面,管理员可在“控制面板”>“登录门户”>“应用程序”保存任一应用程序别名,以重新加载网页服务器。",
413
+ ja: "DSM は {package} のログイン ポータルのエイリアスを削除し、Web サーバーを自動的に再読み込みします。再読み込みはアンインストール完了後に終了する場合があります。エイリアスで DSM ページが開く場合、管理者は [コントロール パネル] > [ログイン ポータル] > [アプリケーション] でアプリケーションのエイリアスを保存して再読み込みできます。",
414
+ ko: "DSM은 {package}의 로그인 포털 별칭을 삭제하고 웹 서버를 자동으로 다시 로드합니다. 다시 로드는 제거 완료 후에 끝날 수 있습니다. 별칭이 여전히 DSM 페이지를 열면 관리자는 제어판 > 로그인 포털 > 응용 프로그램에서 응용 프로그램 별칭을 저장하여 웹 서버를 다시 로드할 수 있습니다.",
415
+ de: "DSM entfernt den Anmeldeportal-Alias von {package} und lädt den Webserver automatisch neu. Das Neuladen kann nach der Deinstallation enden. Öffnet der Alias weiterhin eine DSM-Seite, kann ein Administrator unter Systemsteuerung > Anmeldeportal > Anwendungen einen Anwendungsalias speichern, um den Webserver neu zu laden.",
416
+ fr: "DSM supprime l’alias du Portail de connexion de {package} et recharge automatiquement le serveur web. Le rechargement peut se terminer après la désinstallation. Si l’alias ouvre encore une page DSM, un administrateur peut enregistrer un alias d’application dans Panneau de configuration > Portail de connexion > Applications pour recharger le serveur web.",
417
+ es: "DSM elimina el alias del Portal de inicio de sesión de {package} y recarga automáticamente el servidor web. La recarga puede terminar después de la desinstalación. Si el alias aún abre una página DSM, un administrador puede guardar un alias de aplicación en Panel de control > Portal de inicio de sesión > Aplicaciones para recargar el servidor web.",
418
+ it: "DSM rimuove l’alias del Portale di accesso di {package} e ricarica automaticamente il server web. Il ricaricamento può terminare dopo la disinstallazione. Se l’alias apre ancora una pagina DSM, un amministratore può salvare un alias di applicazione in Pannello di controllo > Portale di accesso > Applicazioni per ricaricare il server web.",
419
419
  },
420
420
  "uninstall.portalEntry": {
421
421
  en: "Uninstall asks the root executor to remove the Application Portal entry of {package}. If the executor is not running or was never approved, uninstall does not remove the entry; an administrator then runs these commands as root:",
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);
@@ -131,9 +131,9 @@ function validateWebPortal(parsed, contractFile) {
131
131
  return [];
132
132
  }
133
133
  const pos = getValuePosition(doc, ["integration", "web", "portal"]) ?? { line: 1, column: 1 };
134
- // A Compose App has no root executor, and only its purge can stop nginx serving the alias after uninstall.
134
+ // The legacy Compose contract does not generate Application Portal resources.
135
135
  return [
136
- makeError("CONTRACT_PORTAL_REQUIRES_EXECUTOR", contractSpan(contractFile, pos.line, pos.column, "integration.web.portal"), "A Compose App has no executor and cannot declare an Application Portal: after uninstall DSM does not reload nginx, and only root can remove the alias.", "Remove integration.web.portal; for a sign-in alias, ask an administrator to configure a reverse proxy in DSM."),
136
+ makeError("CONTRACT_PORTAL_REQUIRES_EXECUTOR", contractSpan(contractFile, pos.line, pos.column, "integration.web.portal"), "The legacy Compose contract does not support integration.web.portal. Use a schema-2 App with a top-level portal declaration and DSM 7.2-64570 or later.", "Migrate to schema 2 and top-level portal, or remove integration.web.portal."),
137
137
  ];
138
138
  }
139
139
  function validateDeveloperString(value, contractFile, path, pos) {
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." },
@@ -71,8 +75,8 @@ export const ERROR_REGISTRY = {
71
75
  },
72
76
  CONTRACT_PORTAL_REQUIRES_EXECUTOR: {
73
77
  code: "CONTRACT_PORTAL_REQUIRES_EXECUTOR",
74
- description: "The Compose App declares integration.web.portal, but a Compose App has no executor; removing the sign-in alias after uninstall needs root so nginx stops serving it.",
75
- suggestion: "Remove integration.web.portal; for a sign-in alias, ask an administrator to configure a reverse proxy in DSM instead.",
78
+ description: "The legacy Compose contract declares unsupported integration.web.portal.",
79
+ suggestion: "Use schema 2 with a top-level portal declaration and DSM 7.2-64570+, or remove integration.web.portal.",
76
80
  },
77
81
  CONTRACT_ACCESS_INVALID: {
78
82
  code: "CONTRACT_ACCESS_INVALID",
@@ -526,8 +530,8 @@ export const ERROR_REGISTRY = {
526
530
  },
527
531
  STANDALONE_PORTAL_REQUIRES_EXECUTOR: {
528
532
  code: "STANDALONE_PORTAL_REQUIRES_EXECUTOR",
529
- description: "An App without an executor (capabilities.rootOps or delegate) declares portal.",
530
- suggestion: "Removing the sign-in alias after uninstall needs root so nginx stops serving it, but an App without escalation uses no root at any stage. Add capabilities.rootOps or delegate (executor level), or remove portal and let an administrator configure a reverse proxy in DSM.",
533
+ description: "An App targeting DSM below 7.2-64570 declares portal without an executor.",
534
+ suggestion: "Raise os_min_ver to 7.2-64570 or later to use DSM web-config and INFO nginx reload without root. On DSM 7.1, add an executor capability to register the entry, or remove portal.",
531
535
  },
532
536
  STANDALONE_LOGIN_STYLE_INVALID: {
533
537
  code: "STANDALONE_LOGIN_STYLE_INVALID",
@@ -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.17.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";