@synopackageland/cli 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.37.0 (2026-10-29)
4
+
5
+ - Native Apps appear in Hyper Backup as applications by default. Build writes
6
+ `scripts/backup/{version,info,export,import}` from a fixed template and ships
7
+ the SDK backup control as `backup/control` with `backup/config.json`.
8
+ Hyper Backup runs the scripts as root; they only switch to the owner of the
9
+ App's data folder (refusing root), run the control as that package account
10
+ and move one archive they never read. Offline backup is the default
11
+ (Hyper Backup stops and restarts the App); `backup.online`,
12
+ `backup.data_version` and `backup.hook` are optional, and
13
+ `backup: {hyper_backup: false}` opts out. Compose Apps are not covered.
14
+ `check --json` reports `hyperBackup`; `inspect` verifies the exact script
15
+ bytes, modes, control and config, and refuses any other file in
16
+ `scripts/backup/`, such as `can_export` or `info.dynamic`, which Hyper
17
+ Backup would run as root while only listing applications. An SDK without
18
+ the control skips the default with `SDK_APP_BACKUP_UNSUPPORTED`; without Go
19
+ (prebuilt backends) it is skipped with `SDK_APP_BACKUP_BUILD_FAILED`; an
20
+ explicit `backup` fails in both cases. New
21
+ errors: `BACKUP_INVALID`, `BACKUP_COMPOSE_UNSUPPORTED`,
22
+ `BACKUP_HOOK_MISSING`, `SDK_APP_BACKUP_UNSUPPORTED`,
23
+ `SDK_APP_BACKUP_BUILD_FAILED`. New skill reference `backup`.
24
+ - Rebuilt native Apps gain a fixed `scripts/backup/` shim that Hyper Backup
25
+ runs as root. The App itself still runs as the package user, and its
26
+ privilege tier is unchanged. Opt out with `backup: { hyper_backup: false }`.
27
+ - Pin SDK/App 0.35.0.
28
+
3
29
  ## 0.36.0 (2026-10-29)
4
30
 
5
31
  - Stop treating `net.Listener` and `net.ListenConfig` types as listeners.
package/README.md CHANGED
@@ -74,7 +74,7 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
74
74
  packages. Private mirrors can use explicitly configured Go authentication and
75
75
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
76
76
 
77
- CLI 0.35.0 uses templates ~0.32.0, whose default SDK pin remains 0.32.0. Compose import and SDK defaults
77
+ CLI 0.37.0 uses templates ~0.33.0, whose default SDK pin is 0.35.0. Compose import and SDK defaults
78
78
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
79
79
  Every App method must be declared in the permissions table. Dynamic names and
80
80
  custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
@@ -85,8 +85,8 @@ unimported development commands are excluded. Use
85
85
  authentication. See [App entry and registration](docs/app-entry.md).
86
86
 
87
87
  `init` and `add` read their templates from the locally installed
88
- `@synopackageland/templates`. CLI 0.35.0 depends on `~0.32.0`: npm installation or
89
- update can select the latest templates patch in the 0.32 minor line. Templates
88
+ `@synopackageland/templates`. CLI 0.37.0 depends on `~0.33.0`: npm installation or
89
+ update can select the latest templates patch in the 0.33 minor line. Templates
90
90
  patches need only a templates release; update the npm installation to receive them.
91
91
  The CLI never fetches templates at runtime, so offline use works after installation;
92
92
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -173,6 +173,18 @@ template changes and executor-only handover eligibility. Without a comparable
173
173
  baseline, approval retention is unknown. Eligibility still requires live protected
174
174
  runtime and exact DSM task readback. See [the approval guide](skills/reference/approval-upgrades.md).
175
175
 
176
+ ## Hyper Backup
177
+
178
+ Native Apps appear in Hyper Backup's backup wizard as applications. By default Hyper Backup stops
179
+ the App, the SDK archives its data folder (the package's `var`) and Hyper Backup starts it again;
180
+ a restore replaces that folder. `backup: {online: true}` keeps the App running and snapshots
181
+ SQLite databases; `data_version` guards restores of newer data; `hook` runs a packaged
182
+ `freeze`/`thaw` executable; `backup: {hyper_backup: false}` opts out. Compose Apps are not
183
+ covered. The build compiles the SDK backup control with Go, also for a prebuilt backend; without
184
+ Go it skips the default backup with a warning. Hyper Backup runs the generated `scripts/backup/` as root: a fixed script that only
185
+ switches to the package account and moves one archive it never reads. This DSM-owned hook does
186
+ not change how the App runs or its privilege tier. See `synopkgland skill show backup`.
187
+
176
188
  ## Commands
177
189
 
178
190
  | Command | Purpose |
package/dist/cli.js CHANGED
@@ -38,6 +38,7 @@ import { checkJobs, commitOutputs, forEachArch, } from "./standalone/arch-jobs.j
38
38
  import { checkReproducible } from "./reproducible.js";
39
39
  import { spkApprovalDiff, formatApprovalDiff } from "./runtime/approval-diff.js";
40
40
  import { inspectAnySpk } from "./spk/inspect.js";
41
+ import { backupRecord } from "./standalone/hyper-backup.js";
41
42
  import { terminalSafe } from "./terminal-text.js";
42
43
  import { auditGoRoutes } from "./standalone/route-audit.js";
43
44
  import { parseApiPermissions } from "./standalone/api-permissions.js";
@@ -125,10 +126,14 @@ function formatRootSteps(report) {
125
126
  function formatInstallSettings(settings) {
126
127
  return settings ? Object.entries(settings).map(([key, s]) => `Installation setting ${key}: ${s.type}, ${s.required ? 'required' : 'optional'} (${s.label}); values are private`) : [];
127
128
  }
129
+ function formatHyperBackup(b) {
130
+ return [b ? `Hyper Backup: application backup on, ${b.online ? 'online (App keeps running; SQLite snapshots)' : 'offline (App stopped during export)'}, data version ${b.dataVersion}${b.hook ? `, hook ${b.hook}` : ''}. Hyper Backup runs the fixed backup scripts as root; they only switch to the package account.` : 'Hyper Backup: application backup off'];
131
+ }
128
132
  function formatSpkReport(spk, report) {
129
133
  return [
130
134
  `SPK: ${spk}`,
131
135
  ...formatInstallSettings(report.installSettings),
136
+ ...(report.synopkgland ? formatHyperBackup(report.hyperBackup) : []),
132
137
  ...(report.identityGateway ? [report.identityGateway.notice, `OIDC issuer access: ${report.identityGateway.issuerAccess}`] : []),
133
138
  `package: ${report.package ?? "?"} version: ${report.version ?? "?"}`,
134
139
  report.synopkgland
@@ -401,6 +406,7 @@ async function runCliWithin(argv) {
401
406
  package: loaded.manifest.package,
402
407
  warnings: validation.warnings ?? [],
403
408
  ...("installSettings" in loaded.manifest && loaded.manifest.installSettings ? { installSettings: loaded.manifest.installSettings } : {}),
409
+ ...("hyperBackup" in loaded.manifest && loaded.manifest.hyperBackup ? { hyperBackup: backupRecord(loaded.manifest.hyperBackup) } : {}),
404
410
  ...("identityGateway" in loaded.manifest && gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) ? { identityGateway: gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) } : {}),
405
411
  capabilities: "capabilities" in loaded.manifest
406
412
  ? loaded.manifest.capabilities
@@ -423,6 +429,7 @@ async function runCliWithin(argv) {
423
429
  }, [
424
430
  "Validation passed.",
425
431
  ...formatInstallSettings("installSettings" in loaded.manifest ? loaded.manifest.installSettings : undefined),
432
+ ...("capabilities" in loaded.manifest ? formatHyperBackup(loaded.manifest.hyperBackup) : []),
426
433
  ...("identityGateway" in loaded.manifest && gatewayAdmissionReport(loaded.manifest.identityGateway) ? [gatewayAdmissionReport(loaded.manifest.identityGateway).notice] : []),
427
434
  ...("compose" in loaded.manifest && loaded.manifest.compose
428
435
  ? [
@@ -15,6 +15,11 @@ export const ERROR_REGISTRY = {
15
15
  INSTALL_SETTINGS_INVALID: { "code": "INSTALL_SETTINGS_INVALID", "description": "The installation setting declaration is invalid.", "suggestion": "Declare 1–32 lowercase identifier keys in install.settings, each with type https-origin, a plain label and an explicit boolean required; omit defaults and values." },
16
16
  SDK_INSTALL_SETTINGS_UNSUPPORTED: { "code": "SDK_INSTALL_SETTINGS_UNSUPPORTED", "description": "The selected SDK does not provide native installation settings.", "suggestion": "Pin SDK 0.24.0 or a matching supported release with installsettings/cmd/control, then rebuild with the matching CLI." },
17
17
  SDK_INSTALL_SETTINGS_BUILD_FAILED: { "code": "SDK_INSTALL_SETTINGS_BUILD_FAILED", "description": "The SDK installation settings controller could not be built.", "suggestion": "Install the pinned Go toolchain, inspect the compiler diagnostics and rebuild with a complete matching SDK release." },
18
+ BACKUP_INVALID: { code: "BACKUP_INVALID", description: "The Hyper Backup application backup declaration or its packaged scripts are invalid.", suggestion: "Use backup.hyper_backup: false to opt out, or only online, data_version (\"major.minor\") and hook; rebuild so scripts/backup holds exactly the fixed export, import, info and version files." },
19
+ BACKUP_COMPOSE_UNSUPPORTED: { code: "BACKUP_COMPOSE_UNSUPPORTED", description: "Compose Apps keep their data in container volumes, which application backup does not cover.", suggestion: "Remove backup from a Compose App or set backup.hyper_backup: false." },
20
+ BACKUP_HOOK_MISSING: { code: "BACKUP_HOOK_MISSING", description: "backup.hook does not name an executable file in the package.", suggestion: "Package the hook (for example in the daemon payload or the App folder) and give its path relative to the package target." },
21
+ SDK_APP_BACKUP_UNSUPPORTED: { code: "SDK_APP_BACKUP_UNSUPPORTED", description: "The selected SDK does not provide Hyper Backup application backup.", suggestion: "Pin an SDK release with appbackup/cmd/control, or remove the backup declaration; without one the build skips application backup." },
22
+ SDK_APP_BACKUP_BUILD_FAILED: { code: "SDK_APP_BACKUP_BUILD_FAILED", description: "The SDK backup control could not be built.", suggestion: "Install the pinned Go toolchain, inspect the compiler diagnostics and rebuild with a complete matching SDK release." },
18
23
  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." },
19
24
  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." },
20
25
  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." },
package/dist/skill.js CHANGED
@@ -52,6 +52,11 @@ const SKILL_CATALOG = [
52
52
  file: "reference/lifecycle.md",
53
53
  when: "Add work to install, upgrade, uninstall, start or stop (dsm-scripts/)",
54
54
  },
55
+ {
56
+ id: "backup",
57
+ file: "reference/backup.md",
58
+ when: "Change or opt out of Hyper Backup application backup",
59
+ },
55
60
  { id: "deploy", file: "reference/deploy.md", when: "Deploy to a NAS" },
56
61
  {
57
62
  id: "diagnose",
@@ -1,4 +1,5 @@
1
1
  import { buildInstallSettings } from './install-settings.js';
2
+ import { buildAppBackup } from './hyper-backup.js';
2
3
  import { auditGoRoutes, httpRoutesDigest } from './route-audit.js';
3
4
  import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
4
5
  import { buildApiDescription } from "./api-description.js";
@@ -156,6 +157,7 @@ export async function buildStandalone(options) {
156
157
  const identityGateway = await buildIdentityGateway(manifest, arch, archWork, run);
157
158
  const composeControl = await buildComposeControl(manifest, arch, archWork, run);
158
159
  const installSettingsControl = await buildInstallSettings(manifest, arch, archWork, run);
160
+ const appBackupControl = await buildAppBackup(manifest, arch, archWork, run, (w) => warnings.add(w));
159
161
  const composeStorageInit = await buildComposeStorageInit(manifest, arch, archWork, run);
160
162
  // A portal needs the executor, so the executor's SDK carries the panel text.
161
163
  const portalText = manifest.portal && executor
@@ -171,6 +173,7 @@ export async function buildStandalone(options) {
171
173
  composeControl,
172
174
  composeStorageInit,
173
175
  installSettingsControl,
176
+ appBackupControl,
174
177
  backgroundWorker,
175
178
  executor,
176
179
  app,
@@ -0,0 +1,59 @@
1
+ import type { StandaloneError, StandaloneManifest } from './manifest.js';
2
+ import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
3
+ import type { CommandRunner } from './build.js';
4
+ /**
5
+ * `backup`: Hyper Backup application backup. On by default for native Apps.
6
+ * Hyper Backup runs a package's backup scripts as root, so the scripts are a
7
+ * fixed template that only switches to the package account and moves one
8
+ * archive; the SDK control does the rest as the package account.
9
+ */
10
+ export interface HyperBackup {
11
+ /** Export while the App runs; SQLite databases are then snapshotted. */
12
+ online: boolean;
13
+ /** "major.minor"; a backup restores into the same major and an equal or newer minor. */
14
+ dataVersion: string;
15
+ /** A package-relative executable run as `<hook> freeze` and `<hook> thaw` around an export. */
16
+ hook?: string;
17
+ /** The App declared `backup`; an SDK without the control is then an error, not a skip. */
18
+ explicit?: true;
19
+ }
20
+ export declare const DEFAULT_HYPER_BACKUP: HyperBackup;
21
+ /** Hyper Backup's plugin folder inside the SPK. */
22
+ export declare const BACKUP_SCRIPTS_DIR = "scripts/backup";
23
+ /** The SDK control and its config inside the package payload. */
24
+ export declare const BACKUP_PAYLOAD_DIR = "backup";
25
+ export declare function readHyperBackup(value: unknown, context: {
26
+ compose?: boolean;
27
+ }, fail: (e: StandaloneError) => void): HyperBackup | undefined;
28
+ /** scripts/backup: framework 1.0, the offline/online choice and the two fixed scripts. */
29
+ export declare function renderBackupScripts(b: Pick<HyperBackup, 'online' | 'dataVersion' | 'hook'>): {
30
+ version: string;
31
+ info: string;
32
+ export: string;
33
+ import: string;
34
+ };
35
+ export declare const BACKUP_SCRIPT_MODES: Record<string, number>;
36
+ export declare function backupConfig(pkg: string, b: HyperBackup): string;
37
+ /** What sdk-package.json records, without the build-only `explicit`. */
38
+ export declare function backupRecord(b: HyperBackup): HyperBackup;
39
+ export declare function readBackupRecord(value: unknown): HyperBackup | undefined;
40
+ /**
41
+ * Builds the SDK backup control. An SDK release without it skips the default
42
+ * with a warning; an explicit `backup` declaration then fails.
43
+ */
44
+ /** The Go package of the SDK backup control; tests use it to tell its build apart. */
45
+ export declare const APP_BACKUP_CONTROL_PACKAGE = "./appbackup/cmd/control";
46
+ export declare function buildAppBackup(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner, warn: (w: string) => void): Promise<string | undefined>;
47
+ /** The hook must be a packaged executable. */
48
+ export declare function backupHookProblem(payload: string, b: HyperBackup): string | undefined;
49
+ /**
50
+ * Inspect: the record, scripts/backup and payload backup/ must be exactly
51
+ * what build writes, or all absent. Any other file in scripts/backup — such
52
+ * as can_export or info.dynamic, which Hyper Backup runs as root while it only
53
+ * lists applications — is refused. The extracted scripts/backup is removed
54
+ * afterwards so later script checks see the lifecycle scripts alone.
55
+ */
56
+ export declare function inspectHyperBackup(dir: string, payloadDir: () => Promise<string>, payload: Array<{
57
+ path: string;
58
+ mode: string;
59
+ }>, raw: unknown, pkg: string, arch: NativeArchFamily, findings: string[]): Promise<HyperBackup | undefined>;
@@ -0,0 +1,180 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { sdkSource } from '../runtime/sdk-source.js';
4
+ import { goChildEnv } from '../runtime/go-env.js';
5
+ import { pinnedGoBuildEnv, pinnedGoProblem } from '../build-spk/go-toolchain.js';
6
+ import { checkCgiBinary } from './binary.js';
7
+ import { StandaloneBuildError } from './spk.js';
8
+ import { daemonSdkVersion, relativeExec } from './daemon.js';
9
+ export const DEFAULT_HYPER_BACKUP = Object.freeze({ online: false, dataVersion: '1.0' });
10
+ /** Hyper Backup's plugin folder inside the SPK. */
11
+ export const BACKUP_SCRIPTS_DIR = 'scripts/backup';
12
+ /** The SDK control and its config inside the package payload. */
13
+ export const BACKUP_PAYLOAD_DIR = 'backup';
14
+ const FIELDS = new Set(['hyper_backup', 'online', 'data_version', 'hook']);
15
+ const DATA_VERSION = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/;
16
+ const INVALID = 'backup accepts hyper_backup: false (opt out) or online (boolean), data_version ("major.minor", default "1.0") and hook (a fixed executable inside the package).';
17
+ const record = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
18
+ export function readHyperBackup(value, context, fail) {
19
+ const bad = () => { fail({ code: 'BACKUP_INVALID', message: INVALID }); return undefined; };
20
+ if (value === undefined)
21
+ return context.compose ? undefined : DEFAULT_HYPER_BACKUP;
22
+ if (!record(value) || Object.keys(value).some(k => !FIELDS.has(k)))
23
+ return bad();
24
+ if (value.hyper_backup !== undefined && typeof value.hyper_backup !== 'boolean')
25
+ return bad();
26
+ if (value.hyper_backup === false)
27
+ return Object.keys(value).length === 1 ? undefined : bad();
28
+ if ((value.online !== undefined && typeof value.online !== 'boolean') ||
29
+ (value.data_version !== undefined && (typeof value.data_version !== 'string' || !DATA_VERSION.test(value.data_version))) ||
30
+ (value.hook !== undefined && !relativeExec(value.hook)))
31
+ return bad();
32
+ if (context.compose) {
33
+ fail({ code: 'BACKUP_COMPOSE_UNSUPPORTED', message: 'Hyper Backup application backup covers the package data folder; Compose App data lives in container volumes, so remove backup or set hyper_backup: false.' });
34
+ return undefined;
35
+ }
36
+ return { online: value.online === true, dataVersion: value.data_version ?? '1.0', ...(value.hook ? { hook: value.hook } : {}), explicit: true };
37
+ }
38
+ /**
39
+ * The root-run part. It reads Hyper Backup's temporary folder, refuses a
40
+ * root-owned data folder, switches to the data folder's owner (the package
41
+ * account) and lets that account write or read one archive. It never runs
42
+ * package content as root and never looks inside the archive. When Hyper
43
+ * Backup already runs it as another account, it skips the switch.
44
+ */
45
+ function shim(action) {
46
+ const io = action === 'export' ? '> "$T/payload.tar"' : '< "$T/payload.tar"';
47
+ return `#!/bin/sh
48
+ # Generated by synopkgland. Hyper Backup runs this as root. It only switches
49
+ # to the package account and moves one archive; it never reads the archive.
50
+ T=$(printf '%s' "$SYNOPKG_BKP_INPUT" | /usr/bin/jq -er .temp_path) || exit 1
51
+ U=$(/usr/bin/stat -c %u:%g "$SYNOPKG_PKGVAR") || exit 1
52
+ case $U in 0:*|*:0) exit 1 ;; esac
53
+ if [ "$(/usr/bin/id -u)" = 0 ]; then RUN="/usr/bin/chroot --userspec=$U /"; else RUN=; fi
54
+ $RUN /bin/sh -c 'exec "$SYNOPKG_PKGDEST/backup/control" ${action}' ${io} || exit 1
55
+ printf '{"app_data_version":"1.0"}' > "$SYNOPKG_BKP_OUTPUT_PATH"
56
+ `;
57
+ }
58
+ /** scripts/backup: framework 1.0, the offline/online choice and the two fixed scripts. */
59
+ export function renderBackupScripts(b) {
60
+ return { version: '1.0\n', info: `{"online_backup":${b.online}}\n`, export: shim('export'), import: shim('import') };
61
+ }
62
+ export const BACKUP_SCRIPT_MODES = { version: 0o644, info: 0o644, export: 0o755, import: 0o755 };
63
+ export function backupConfig(pkg, b) {
64
+ return JSON.stringify({ schema: 1, package: pkg, dataVersion: b.dataVersion, ...(b.online ? { online: true } : {}), ...(b.hook ? { hook: b.hook } : {}) }, null, 2) + '\n';
65
+ }
66
+ /** What sdk-package.json records, without the build-only `explicit`. */
67
+ export function backupRecord(b) {
68
+ return { online: b.online, dataVersion: b.dataVersion, ...(b.hook ? { hook: b.hook } : {}) };
69
+ }
70
+ export function readBackupRecord(value) {
71
+ if (!record(value) || Object.keys(value).some(k => !['online', 'dataVersion', 'hook'].includes(k)) || typeof value.online !== 'boolean' ||
72
+ typeof value.dataVersion !== 'string' || !DATA_VERSION.test(value.dataVersion) || (value.hook !== undefined && !relativeExec(value.hook)))
73
+ return undefined;
74
+ return backupRecord(value);
75
+ }
76
+ /**
77
+ * Builds the SDK backup control. An SDK release without it skips the default
78
+ * with a warning; an explicit `backup` declaration then fails.
79
+ */
80
+ /** The Go package of the SDK backup control; tests use it to tell its build apart. */
81
+ export const APP_BACKUP_CONTROL_PACKAGE = './appbackup/cmd/control';
82
+ export async function buildAppBackup(m, arch, work, run, warn) {
83
+ if (!m.hyperBackup)
84
+ return;
85
+ const source = sdkSource(daemonSdkVersion(m));
86
+ if (!existsSync(join(source.goDir, 'appbackup/cmd/control/main.go'))) {
87
+ const message = 'The selected SDK has no Hyper Backup application backup; use an SDK release with appbackup.';
88
+ if (m.hyperBackup.explicit)
89
+ throw new StandaloneBuildError('SDK_APP_BACKUP_UNSUPPORTED', message);
90
+ warn(`SDK_APP_BACKUP_UNSUPPORTED ${message} The package is built without application backup.`);
91
+ return;
92
+ }
93
+ const out = join(work, `app-backup-${arch}`);
94
+ const result = await run('go', ['build', '-trimpath', '-buildvcs=false', '-ldflags', '-s -w -buildid=', '-o', out, APP_BACKUP_CONTROL_PACKAGE], { cwd: source.goDir, env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }) });
95
+ if (result.missing && !m.hyperBackup.explicit) {
96
+ warn('SDK_APP_BACKUP_BUILD_FAILED Go was not found, so the package is built without application backup; install Go to include it.');
97
+ return;
98
+ }
99
+ if (result.status !== 0)
100
+ throw new StandaloneBuildError('SDK_APP_BACKUP_BUILD_FAILED', `Cannot build the SDK backup control: ${result.stderr}`);
101
+ const error = checkCgiBinary(work, out, arch), toolchain = pinnedGoProblem(out);
102
+ if (error || toolchain)
103
+ throw new StandaloneBuildError('SDK_APP_BACKUP_BUILD_FAILED', error?.message ?? toolchain);
104
+ return out;
105
+ }
106
+ /** The hook must be a packaged executable. */
107
+ export function backupHookProblem(payload, b) {
108
+ if (!b.hook)
109
+ return;
110
+ const path = join(payload, b.hook);
111
+ try {
112
+ const s = lstatSync(path);
113
+ if (s.isFile() && (s.mode & 0o111) !== 0)
114
+ return;
115
+ }
116
+ catch { /* reported below */ }
117
+ return `backup.hook ${b.hook} is not an executable file in the package.`;
118
+ }
119
+ /**
120
+ * Inspect: the record, scripts/backup and payload backup/ must be exactly
121
+ * what build writes, or all absent. Any other file in scripts/backup — such
122
+ * as can_export or info.dynamic, which Hyper Backup runs as root while it only
123
+ * lists applications — is refused. The extracted scripts/backup is removed
124
+ * afterwards so later script checks see the lifecycle scripts alone.
125
+ */
126
+ export async function inspectHyperBackup(dir, payloadDir, payload, raw, pkg, arch, findings) {
127
+ const fail = (m) => { findings.push(`BACKUP_INVALID ${m}`); };
128
+ const scripts = join(dir, BACKUP_SCRIPTS_DIR);
129
+ const present = payload.filter(p => p.path.startsWith(`${BACKUP_PAYLOAD_DIR}/`));
130
+ const b = raw === undefined ? undefined : readBackupRecord(raw);
131
+ if (raw !== undefined && !b)
132
+ fail('the hyperBackup record in sdk-package.json is not the one synopkgland build writes');
133
+ if (!b) {
134
+ if (lstatExists(scripts))
135
+ fail('scripts/backup is present without a backup declaration');
136
+ if (present.length)
137
+ fail('the payload has a backup folder without a backup declaration');
138
+ rmSync(scripts, { recursive: true, force: true });
139
+ return undefined;
140
+ }
141
+ const expected = renderBackupScripts(b);
142
+ const names = lstatExists(scripts) && lstatSync(scripts).isDirectory() ? readdirSync(scripts).sort() : [];
143
+ if (names.join() !== Object.keys(expected).sort().join())
144
+ fail(`scripts/backup must hold exactly export, import, info and version, found: ${names.join(', ') || 'none'}`);
145
+ for (const [name, text] of Object.entries(expected)) {
146
+ const path = join(scripts, name);
147
+ let s;
148
+ try {
149
+ s = lstatSync(path);
150
+ }
151
+ catch {
152
+ continue;
153
+ }
154
+ if (!s.isFile() || (s.mode & 0o777) !== BACKUP_SCRIPT_MODES[name] || readFileSync(path, 'utf8') !== text)
155
+ fail(`scripts/backup/${name} differs from the fixed script synopkgland build writes`);
156
+ }
157
+ const files = present.map(p => `${p.path}:${p.mode}`).sort();
158
+ const wanted = [`${BACKUP_PAYLOAD_DIR}/config.json:0644`, `${BACKUP_PAYLOAD_DIR}/control:0755`];
159
+ if (files.join() !== wanted.join())
160
+ fail('the payload backup folder must hold exactly the SDK control and config.json');
161
+ else {
162
+ const root = await payloadDir();
163
+ if (readFileSync(join(root, BACKUP_PAYLOAD_DIR, 'config.json'), 'utf8') !== backupConfig(pkg, b))
164
+ fail('backup/config.json differs from the backup declaration');
165
+ const error = checkCgiBinary(root, join(root, BACKUP_PAYLOAD_DIR, 'control'), arch);
166
+ if (error)
167
+ fail(error.message);
168
+ }
169
+ rmSync(scripts, { recursive: true, force: true });
170
+ return b;
171
+ }
172
+ function lstatExists(path) {
173
+ try {
174
+ lstatSync(path);
175
+ return true;
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
@@ -3,12 +3,15 @@ import { type WebLaunchMode } from "../contract/web-launch.js";
3
3
  import { type ComposeApp } from "./compose.js";
4
4
  import { type ExecutorInspection } from "../runtime/executor-inspect.js";
5
5
  import { type AppScriptName } from "./app-scripts.js";
6
+ import { type HyperBackup } from "./hyper-backup.js";
6
7
  import { type GatewayAdmissionReport } from "./identity-gateway.js";
7
8
  import type { EntryMode } from "./manifest.js";
8
9
  import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
9
10
  export type SpkKind = "native-go" | "native-python" | "native-node" | "native-rust" | "binary" | "container" | "native-layout";
10
11
  export interface SpkInspection {
11
12
  installSettings?: InstallSettings;
13
+ /** Hyper Backup application backup, as sdk-package.json records it. */
14
+ hyperBackup?: HyperBackup;
12
15
  identityGateway?: GatewayAdmissionReport;
13
16
  kind?: SpkKind;
14
17
  package?: string;
@@ -15,6 +15,7 @@ import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
15
15
  import { inspectExecutor, inspectExecutorAnchor, } from "../runtime/executor-inspect.js";
16
16
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
17
17
  import { sdkScriptOf, unwrapAppScripts } from "./app-scripts.js";
18
+ import { inspectHyperBackup } from "./hyper-backup.js";
18
19
  import { tmpdir } from "node:os";
19
20
  import { join } from "node:path";
20
21
  import * as tar from "tar";
@@ -140,11 +141,21 @@ export async function inspectSpk(spkPath, expectedKind) {
140
141
  const identityGateway = await inspectProviderOnlyAdmission(dir, paths, metadata.provider_only_oidc, findings);
141
142
  await inspectExternalGateway(dir, paths, payload, metadata.gateway_external, findings);
142
143
  const installSettings = await inspectInstallSettings(dir, payload, metadata, findings);
144
+ const hyperBackup = metadataText === undefined ? undefined : await inspectHyperBackup(dir, async () => {
145
+ const root = join(dir, "backup-inspection");
146
+ if (!existsSync(root)) {
147
+ mkdirSync(root);
148
+ await tar.x({ file: join(dir, "package.tgz"), cwd: root });
149
+ }
150
+ return root;
151
+ }, payload, metadata.hyperBackup, /^package="([^"]*)"$/m.exec(readOptional(join(dir, "INFO")) ?? "")?.[1] ?? "", familyOfArchToken(/^arch="([^"]*)"$/m.exec(readOptional(join(dir, "INFO")) ?? "")?.[1]?.split(/\s+/)[0] ?? "") ?? "x86_64", findings);
143
152
  const integrations = metadata.integrations ?? [];
144
153
  if (metadata.compose !== undefined) {
145
154
  const result = await inspectUnifiedCompose(dir, payload, { ...metadata, installSettings }, findings);
146
155
  if (installSettings)
147
156
  result.installSettings = installSettings;
157
+ if (hyperBackup)
158
+ result.hyperBackup = hyperBackup;
148
159
  if (identityGateway)
149
160
  result.identityGateway = identityGateway;
150
161
  result.icon = inspectPackageIcons(dir, findings);
@@ -307,6 +318,7 @@ export async function inspectSpk(spkPath, expectedKind) {
307
318
  kind,
308
319
  ...(identityGateway ? { identityGateway } : {}),
309
320
  ...(installSettings ? { installSettings } : {}),
321
+ ...(hyperBackup ? { hyperBackup } : {}),
310
322
  ...(launch ? { launch } : {}),
311
323
  icon,
312
324
  ...(executor ? { executor } : {}),
@@ -13,6 +13,7 @@ import { type ComposeApp } from "./compose.js";
13
13
  import { type Daemon } from "./daemon.js";
14
14
  import { type IdentityGateway } from "./identity-gateway.js";
15
15
  import { type Uninstall } from "./uninstall.js";
16
+ import { type HyperBackup } from "./hyper-backup.js";
16
17
  import { type AppScriptName } from "./app-scripts.js";
17
18
  import { type Background } from "./background.js";
18
19
  import { type SharedFolder } from "./shared-folder.js";
@@ -96,6 +97,8 @@ export interface StandaloneManifest {
96
97
  /** `install.sharedFolder`: the Shared Folder the install wizard selects or `data-share` creates. */
97
98
  sharedFolder?: SharedFolder;
98
99
  installSettings?: InstallSettings;
100
+ /** `backup`: Hyper Backup application backup; on by default for native Apps, absent when opted out. */
101
+ hyperBackup?: HyperBackup;
99
102
  /** Who may use the app at all; absent keeps the route policy alone. */
100
103
  entry?: EntryMode;
101
104
  /**
@@ -17,6 +17,7 @@ import { daemonRestartSdkProblem } from "./daemon-restart.js";
17
17
  import { daemonIngressMinDsmProblem } from "./daemon-ingress.js";
18
18
  import { readIdentityGateway, } from "./identity-gateway.js";
19
19
  import { readUninstall } from "./uninstall.js";
20
+ import { readHyperBackup } from "./hyper-backup.js";
20
21
  import { readAppScripts } from "./app-scripts.js";
21
22
  import { readBackground } from "./background.js";
22
23
  import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage, } from "../build-spk/dsm-version.js";
@@ -58,6 +59,7 @@ export const KNOWN_FIELDS = new Set([
58
59
  "compose",
59
60
  "install",
60
61
  "uninstall",
62
+ "backup",
61
63
  "entry",
62
64
  "integrations",
63
65
  "portal",
@@ -254,6 +256,7 @@ function readManifest(raw, c) {
254
256
  ? undefined
255
257
  : readSharedFolder(raw.install, (e) => c.fail(e.code, e.message));
256
258
  const installSettings = readInstallSettings(isRecord(raw.install) ? raw.install.settings : undefined, e => c.fail(e.code, e.message));
259
+ const hyperBackup = readHyperBackup(raw.backup, { compose: raw.compose !== undefined }, e => c.fail(e.code, e.message));
257
260
  const compose = arches &&
258
261
  readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
259
262
  package: packageId,
@@ -510,6 +513,7 @@ function readManifest(raw, c) {
510
513
  ...(uninstall ? { uninstall } : {}),
511
514
  ...(sharedFolder ? { sharedFolder } : {}),
512
515
  ...(installSettings ? { installSettings } : {}),
516
+ ...(hyperBackup ? { hyperBackup } : {}),
513
517
  ...(entry ? { entry } : {}),
514
518
  ...(integrations?.length ? { integrations } : {}),
515
519
  ...(portal ? { portal } : {}),
@@ -18,6 +18,8 @@ export interface AssembleOptions {
18
18
  composeControl?: string;
19
19
  composeStorageInit?: string;
20
20
  installSettingsControl?: string;
21
+ /** The SDK backup control; absent builds without Hyper Backup application backup. */
22
+ appBackupControl?: string;
21
23
  /** The SDK identity gateway, required exactly when the manifest declares identity_gateway. */
22
24
  identityGateway?: string;
23
25
  /** The compiled background worker, required exactly when capabilities.jobs is enabled. */
@@ -1,4 +1,5 @@
1
1
  import { settingsInstallSteps, settingsConfig } from './install-settings.js';
2
+ import { backupConfig, backupHookProblem, backupRecord, renderBackupScripts, BACKUP_PAYLOAD_DIR, BACKUP_SCRIPTS_DIR, BACKUP_SCRIPT_MODES } from './hyper-backup.js';
2
3
  import { nativeGatewaySupported, nativeGatewaySdk, renderNativeGatewayConfig, NATIVE_GATEWAY_CONFIG, legacyGatewayNotice, gatewayEntryPath } from './native-gateway.js';
3
4
  import { portalInfoFields } from "../portal/info.js";
4
5
  import { sdkSource } from "../runtime/sdk-source.js";
@@ -225,6 +226,14 @@ export async function assembleStandaloneSpk(options) {
225
226
  copyExecutable(options.installSettingsControl, join(target, 'control'));
226
227
  writeFile(join(target, 'settings.json'), JSON.stringify(settingsConfig(manifest), null, 2) + '\n');
227
228
  }
229
+ const backup = options.appBackupControl ? manifest.hyperBackup : undefined;
230
+ if (backup) {
231
+ const hook = backupHookProblem(join(staging, "package"), backup);
232
+ if (hook)
233
+ throw new StandaloneBuildError("BACKUP_HOOK_MISSING", hook);
234
+ copyExecutable(options.appBackupControl, join(staging, "package", BACKUP_PAYLOAD_DIR, "control"));
235
+ writeFile(join(staging, "package", BACKUP_PAYLOAD_DIR, "config.json"), backupConfig(manifest.package, backup));
236
+ }
228
237
  const packageTgz = join(staging, "package.tgz");
229
238
  await createReproducibleTar(join(staging, "package"), packageTgz, {
230
239
  gzip: true,
@@ -237,6 +246,7 @@ export async function assembleStandaloneSpk(options) {
237
246
  schema: 2,
238
247
  ...(moduleVersion ? { ui_module: moduleVersion } : {}),
239
248
  ...(manifest.installSettings ? { installSettings: manifest.installSettings } : {}),
249
+ ...(backup ? { hyperBackup: backupRecord(backup) } : {}),
240
250
  ...(manifest.integrations ? { integrations: manifest.integrations } : {}),
241
251
  ...(manifest.lifecycleTimeouts ? { lifecycleTimeouts: manifest.lifecycleTimeouts } : {}),
242
252
  ...("timeoutSeconds" in manifest.backend && manifest.backend.timeoutSeconds ? { operationSeconds: manifest.backend.timeoutSeconds } : {}),
@@ -283,6 +293,11 @@ export async function assembleStandaloneSpk(options) {
283
293
  for (const name of LIFECYCLE_SCRIPTS) {
284
294
  writeFile(join(staging, "scripts", name), renderStandaloneScript(name, manifest), EXEC_MODE);
285
295
  }
296
+ if (backup) {
297
+ mkdirSync(join(staging, BACKUP_SCRIPTS_DIR));
298
+ for (const [name, text] of Object.entries(renderBackupScripts(backup)))
299
+ writeFile(join(staging, BACKUP_SCRIPTS_DIR, name), text, BACKUP_SCRIPT_MODES[name]);
300
+ }
286
301
  for (const name of manifest.appScripts ?? [])
287
302
  copyExecutable(join(manifest.root, APP_SCRIPTS_DIR, name), join(staging, APP_SCRIPTS_SPK_DIR, name));
288
303
  const folder = manifest.sharedFolder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "~0.32.0",
30
+ "@synopackageland/templates": "~0.33.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
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
24
24
 
25
25
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
26
26
 
27
- Use SDK/App 0.34.0 and CLI 0.36.0 for App entry enforcement. Declare every App method
27
+ Use SDK/App 0.35.0 and CLI 0.37.0 for App entry enforcement. Declare every App method
28
28
  in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
29
29
  Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
30
30
  paths that rely on upstream authentication. Read
@@ -91,6 +91,8 @@ Read reference/portal.md before declaring or changing a DSM Application Portal a
91
91
 
92
92
  Read [reference/lifecycle.md](reference/lifecycle.md) before editing `dsm-scripts/` (preinst, postinst, preupgrade, postupgrade, preuninst, postuninst, start-stop-status): the SDK runs them inside its generated scripts as the package account; never add root work or repeat SDK steps there.
93
93
 
94
+ Native Apps appear in Hyper Backup as applications by default. Read [reference/backup.md](reference/backup.md) before changing `backup` in dsm-package.yaml (opt out with `hyper_backup: false`, online mode, data version, hook). Hyper Backup runs the generated `scripts/backup/` as root; it is a fixed template that only switches to the package account, so never add or edit files there.
95
+
94
96
  Read [reference/ui-vue.md](reference/ui-vue.md) when editing Vue components in the native-go Vue template; use the installed SDK contracts, keep DSM embedding boundaries, and verify the actual app.
95
97
 
96
98
  Buttons follow DSM's policy ([reference/ui-vue.md](reference/ui-vue.md#buttons), the UI package's `docs/button-policy.md`): only the bar that closes a dialog, wizard or form draws capsules, with Cancel/Reset first and one primary (or one danger to confirm a deletion) last; toolbar, page, section and row actions are grey tool buttons, Delete included; links go to `Link`. Choose the variant, never the shape.
@@ -0,0 +1,70 @@
1
+ # Hyper Backup application backup
2
+
3
+ Native Apps (Go, Python, Node, Rust and binary backends) appear as applications
4
+ in Hyper Backup's backup wizard by default. An administrator selects the App in
5
+ a backup task and restores it from that task later. Compose Apps keep their
6
+ data in container volumes, which this does not cover, so it is off for them
7
+ and `backup` is refused for a Compose App.
8
+
9
+ ## Declaration
10
+
11
+ Nothing to declare for the default: offline backup of the whole data folder,
12
+ data version `1.0`.
13
+
14
+ ```yaml
15
+ backup:
16
+ online: false # default; true keeps the App running during a backup
17
+ data_version: "1.0" # "major.minor", see below
18
+ hook: bin/backup-hook # optional, relative to the package target
19
+ ```
20
+
21
+ Opt out with exactly:
22
+
23
+ ```yaml
24
+ backup:
25
+ hyper_backup: false
26
+ ```
27
+
28
+ - **Offline** (default): Hyper Backup stops the App, the SDK archives the data
29
+ folder, and Hyper Backup starts the App again.
30
+ - **Online**: the App keeps running; SQLite databases are copied through
31
+ SQLite's backup API, their journals are left out, other files are copied as
32
+ they are. Use it only when other files stay consistent while the App runs,
33
+ or add a hook.
34
+ - **Hook**: a packaged executable run as the package account as `<hook> freeze`
35
+ before the export and `<hook> thaw` after it. It writes only to standard
36
+ error. The build fails with `BACKUP_HOOK_MISSING` when the file is not an
37
+ executable in the package.
38
+ - **Data version**: a restore accepts the same package with the same major
39
+ version and an equal or lower minor version; otherwise it refuses and keeps
40
+ the current data. Raise the minor version when the App migrates older data
41
+ itself, the major version when it cannot.
42
+
43
+ Restore always runs with the App stopped and replaces the whole data folder
44
+ (the package's `var`). It needs the App installed first.
45
+
46
+ ## What runs as root
47
+
48
+ Hyper Backup runs a package's backup scripts as root. This is a DSM-owned hook,
49
+ not App runtime: the App still runs as its package account, and the SDK
50
+ privilege tiers for App code are unchanged. The CLI writes the root part from a
51
+ fixed template. It switches to the owner of the App's data folder (refusing
52
+ root), runs the SDK backup control as that account, and moves one archive
53
+ between that account and Hyper Backup's temporary folder without reading it.
54
+ The package ships no script that Hyper Backup would run while only listing
55
+ applications.
56
+
57
+ Never add files to `scripts/backup/` or edit them. `check` validates the
58
+ `backup` declaration and reports it as `hyperBackup` in `--json`; `inspect`
59
+ verifies the exact bytes of the scripts, the SDK control and its config, and
60
+ refuses any other file there, such as `can_export` or `info.dynamic`.
61
+
62
+ ## Errors
63
+
64
+ | Code | Meaning |
65
+ | --- | --- |
66
+ | `BACKUP_INVALID` | Bad `backup` declaration, or packaged backup files differ from what build writes |
67
+ | `BACKUP_COMPOSE_UNSUPPORTED` | `backup` on a Compose App |
68
+ | `BACKUP_HOOK_MISSING` | `backup.hook` is not an executable in the package |
69
+ | `SDK_APP_BACKUP_UNSUPPORTED` | The SDK has no backup control; a default build skips backup with this warning, an explicit `backup` fails |
70
+ | `SDK_APP_BACKUP_BUILD_FAILED` | The SDK backup control did not build; when Go is not installed (a prebuilt backend), a default build skips backup with this warning, an explicit `backup` fails |