@synopackageland/cli 0.35.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.
@@ -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,
@@ -254,6 +257,7 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
254
257
  `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
255
258
  `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
256
259
  `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
260
+ `-X ${SDK_MODULE}/ipc.executorTier=none`,
257
261
  ...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
258
262
  ...delegateBuildFlags(manifest),
259
263
  ...(manifest.executor
@@ -314,6 +318,7 @@ async function buildBackgroundWorker(manifest, arch, work, run) {
314
318
  "-buildid=",
315
319
  `-X main.packageID=${manifest.package}`,
316
320
  `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
321
+ `-X ${SDK_MODULE}/ipc.executorTier=none`,
317
322
  `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
318
323
  `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
319
324
  `-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
@@ -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;
@@ -50,6 +53,8 @@ export interface SpkInspection {
50
53
  };
51
54
  /** Declared `entry` mode; `admin-only` is the administrator floor of every route. */
52
55
  entry?: EntryMode;
56
+ /** Service callbacks that skip the DSM session, as listed in the install and upgrade wizards. */
57
+ integrations?: string[];
53
58
  /** The declared launch mode verified against the built launcher. */
54
59
  launch?: WebLaunchMode;
55
60
  /** The App's own scripts/app/<DSM script name>, each called from the generated script of that name. */
@@ -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";
@@ -31,8 +32,8 @@ import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
31
32
  import { DSM_LANGS } from "./i18n.js";
32
33
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit, } from "./background.js";
33
34
  import { renderStandaloneResource } from "./resource.js";
34
- import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER, } from "./shared-folder.js";
35
- import { renderInstallWizardFiles, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
35
+ import { recoverSharedFolder, renderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER, } from "./shared-folder.js";
36
+ import { renderInstallWizardFiles, integrationWizardSteps, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
36
37
  import { renderDelegateScopeSteps, } from "../build-spk/delegate-scope.js";
37
38
  import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
38
39
  import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
@@ -125,6 +126,8 @@ export async function inspectSpk(spkPath, expectedKind) {
125
126
  throw new Error();
126
127
  if (raw.operationSeconds !== undefined && (!Number.isInteger(raw.operationSeconds) || raw.operationSeconds < 1 || raw.operationSeconds > 3600))
127
128
  throw new Error();
129
+ if (raw.integrations !== undefined && (raw.backend !== 'go' || !Array.isArray(raw.integrations) || !raw.integrations.length || raw.integrations.length > 1024 || raw.integrations.some((m, i) => typeof m !== 'string' || m.length > 128 || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(m) || i > 0 && raw.integrations[i - 1] >= m)))
130
+ throw new Error();
128
131
  metadata = raw;
129
132
  }
130
133
  catch {
@@ -138,10 +141,21 @@ export async function inspectSpk(spkPath, expectedKind) {
138
141
  const identityGateway = await inspectProviderOnlyAdmission(dir, paths, metadata.provider_only_oidc, findings);
139
142
  await inspectExternalGateway(dir, paths, payload, metadata.gateway_external, findings);
140
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);
152
+ const integrations = metadata.integrations ?? [];
141
153
  if (metadata.compose !== undefined) {
142
154
  const result = await inspectUnifiedCompose(dir, payload, { ...metadata, installSettings }, findings);
143
155
  if (installSettings)
144
156
  result.installSettings = installSettings;
157
+ if (hyperBackup)
158
+ result.hyperBackup = hyperBackup;
145
159
  if (identityGateway)
146
160
  result.identityGateway = identityGateway;
147
161
  result.icon = inspectPackageIcons(dir, findings);
@@ -291,7 +305,7 @@ export async function inspectSpk(spkPath, expectedKind) {
291
305
  ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
292
306
  : undefined;
293
307
  if (standalone)
294
- checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor, nativeGateway, info.os_min_ver, installSettings);
308
+ checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor, nativeGateway, info.os_min_ver, installSettings, integrations);
295
309
  const icon = inspectPackageIcons(dir, findings);
296
310
  const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
297
311
  if (standalone && !cgiEntry) {
@@ -304,6 +318,7 @@ export async function inspectSpk(spkPath, expectedKind) {
304
318
  kind,
305
319
  ...(identityGateway ? { identityGateway } : {}),
306
320
  ...(installSettings ? { installSettings } : {}),
321
+ ...(hyperBackup ? { hyperBackup } : {}),
307
322
  ...(launch ? { launch } : {}),
308
323
  icon,
309
324
  ...(executor ? { executor } : {}),
@@ -319,6 +334,7 @@ export async function inspectSpk(spkPath, expectedKind) {
319
334
  }
320
335
  : {}),
321
336
  ...(metadata.entry ? { entry: metadata.entry } : {}),
337
+ ...(integrations.length ? { integrations } : {}),
322
338
  ...(appScripts.length ? { appScripts } : {}),
323
339
  ...(loginStyle ? { loginStyle } : {}),
324
340
  ...(standalone
@@ -1365,7 +1381,7 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
1365
1381
  * declaration (without one, any present file must be the default native
1366
1382
  * install wizard), and no other install/upgrade wizard file may exist.
1367
1383
  */
1368
- function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false, nativeGateway, osMinVer, installSettings) {
1384
+ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false, nativeGateway, osMinVer, installSettings, integrations = []) {
1369
1385
  const wizardDir = join(dir, "WIZARD_UIFILES");
1370
1386
  const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
1371
1387
  .filter((name) => /^(install|upgrade)_uifile(_|\.|$)/.test(name))
@@ -1391,16 +1407,18 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
1391
1407
  : {}),
1392
1408
  ...portalManifest(portal),
1393
1409
  };
1394
- const wizards = expectedWizardFiles(pkg, folder, selected, portal, installSettings);
1410
+ const wizards = expectedWizardFiles(pkg, folder, selected, portal, installSettings, integrations);
1395
1411
  const source = selected
1396
1412
  ? "the install-selected policy"
1397
1413
  : folder
1398
1414
  ? "the install.sharedFolder declaration"
1399
1415
  : portal
1400
1416
  ? "the portal declaration"
1401
- : "the native-go default install wizard";
1417
+ : integrations.length
1418
+ ? "the integrations declaration"
1419
+ : "the native-go default install wizard";
1402
1420
  // The default native wizard is optional; a declared shared folder or portal needs all of its files.
1403
- for (const name of folder || selected || portal || installSettings
1421
+ for (const name of folder || selected || portal || installSettings || integrations.length
1404
1422
  ? Object.keys(wizards)
1405
1423
  : present.filter((n) => n in wizards)) {
1406
1424
  if (readOptional(join(wizardDir, name)) !== wizards[name])
@@ -1461,7 +1479,7 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
1461
1479
  }
1462
1480
  return settings;
1463
1481
  }
1464
- function expectedWizardFiles(pkg, folder, selected = false, portal, installSettings) {
1482
+ function expectedWizardFiles(pkg, folder, selected = false, portal, installSettings, integrations = []) {
1465
1483
  const steps = selected
1466
1484
  ? (t) => [
1467
1485
  ...renderDelegateScopeSteps(t),
@@ -1470,11 +1488,11 @@ function expectedWizardFiles(pkg, folder, selected = false, portal, installSetti
1470
1488
  : folder
1471
1489
  ? sharedFolderInstallSteps(pkg, folder)
1472
1490
  : renderNativeInstallWizard;
1473
- const install = renderInstallWizardFiles(pkg, t => [...steps(t), ...settingsInstallSteps(installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : [])]);
1491
+ const install = renderInstallWizardFiles(pkg, t => [...steps(t), ...settingsInstallSteps(installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : []), ...integrationWizardSteps(t, integrations)]);
1474
1492
  return {
1475
1493
  ...install.static,
1476
1494
  ...install.dynamic,
1477
- ...(folder ? renderSharedFolderUpgradeWizardFiles(pkg, folder) : {}),
1495
+ ...renderUpgradeWizardFiles(pkg, folder, integrations),
1478
1496
  };
1479
1497
  }
1480
1498
  function portalManifest(portal) {
@@ -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,8 +97,16 @@ 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;
104
+ /**
105
+ * `integrations`: Go `api.HandleIntegration` methods that arrive without a
106
+ * DSM session, sorted. Exactly the `integration` permissions; the install
107
+ * and upgrade wizards list them for the administrator.
108
+ */
109
+ integrations?: string[];
101
110
  /** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
102
111
  portal?: PortalDeclaration;
103
112
  /**
@@ -17,11 +17,12 @@ 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";
23
24
  import { readSharedFolder } from "./shared-folder.js";
24
- import { API_PERMISSIONS_FILE, floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions, } from "./api-permissions.js";
25
+ import { API_PERMISSIONS_FILE, floorAtAdministrators, integrationMethods, permitsOrdinaryUsers, readApiPermissions, } from "./api-permissions.js";
25
26
  import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration, } from "../portal/alias.js";
26
27
  import { renderPackageIcons } from "../build-spk/dsm-icons.js";
27
28
  import { LOGIN_COLOR, loginImageProblem, } from "../build-spk/login-style.js";
@@ -58,7 +59,9 @@ export const KNOWN_FIELDS = new Set([
58
59
  "compose",
59
60
  "install",
60
61
  "uninstall",
62
+ "backup",
61
63
  "entry",
64
+ "integrations",
62
65
  "portal",
63
66
  "console",
64
67
  "icon",
@@ -223,9 +226,11 @@ function readManifest(raw, c) {
223
226
  c.fail("STANDALONE_REQUEST_TIMEOUT_SHORT", problem);
224
227
  }
225
228
  let permissions;
229
+ let integrations;
226
230
  if (backend && backend.kind !== "binary") {
227
231
  try {
228
232
  permissions = readApiPermissions(backend, capabilities);
233
+ integrations = readIntegrations(raw.integrations, backend, permissions.policy, raw.compose !== undefined, c);
229
234
  }
230
235
  catch (error) {
231
236
  // The SDK's own routes come from its module; a failed download or an
@@ -251,6 +256,7 @@ function readManifest(raw, c) {
251
256
  ? undefined
252
257
  : readSharedFolder(raw.install, (e) => c.fail(e.code, e.message));
253
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));
254
260
  const compose = arches &&
255
261
  readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
256
262
  package: packageId,
@@ -507,7 +513,9 @@ function readManifest(raw, c) {
507
513
  ...(uninstall ? { uninstall } : {}),
508
514
  ...(sharedFolder ? { sharedFolder } : {}),
509
515
  ...(installSettings ? { installSettings } : {}),
516
+ ...(hyperBackup ? { hyperBackup } : {}),
510
517
  ...(entry ? { entry } : {}),
518
+ ...(integrations?.length ? { integrations } : {}),
511
519
  ...(portal ? { portal } : {}),
512
520
  ...(dsmConsole ? { console: dsmConsole } : {}),
513
521
  ...(noWindow ? { sdkWindow: false } : {}),
@@ -667,6 +675,29 @@ function sdkVersionOrUndefined(backend) {
667
675
  * SDK gate and main wires it; `-X main.entry` on an older main would be
668
676
  * silently dropped and leave a registered app ungated.
669
677
  */
678
+ /**
679
+ * `integrations` repeats, in dsm-package.yaml, every permissions.json method
680
+ * declared `integration`, so a callback that skips the DSM session is never
681
+ * added by the policy file alone. Only Go SDK servers implement it.
682
+ */
683
+ function readIntegrations(value, backend, policy, compose, c) {
684
+ const declared = integrationMethods(policy);
685
+ if (value === undefined && declared.length === 0)
686
+ return undefined;
687
+ if (value === undefined)
688
+ value = [];
689
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string") || new Set(value).size !== value.length) {
690
+ return c.fail("STANDALONE_INTEGRATIONS_INVALID", "integrations must be a list of unique permissions.json method names.");
691
+ }
692
+ const listed = [...value].sort();
693
+ if (backend.kind !== "go" || compose) {
694
+ return c.fail("STANDALONE_INTEGRATIONS_INVALID", "integrations need a Go backend built with api.HandleIntegration.");
695
+ }
696
+ if (listed.join("\n") !== declared.join("\n")) {
697
+ return c.fail("STANDALONE_INTEGRATIONS_MISMATCH", `integrations must list exactly the permissions.json methods declared "integration" (${declared.join(", ") || "none"}); found ${listed.join(", ") || "none"}.`);
698
+ }
699
+ return listed;
700
+ }
670
701
  function readEntry(value, backend, policy, c) {
671
702
  if (value !== "admin-only" && value !== "app-privilege") {
672
703
  return c.fail("STANDALONE_ENTRY_INVALID", "entry must be admin-only or app-privilege; omit it to keep the existing permissions.json behavior.");
@@ -1,6 +1,6 @@
1
1
  import type { StandaloneError } from "./manifest.js";
2
2
  import { type Translate } from "../build-spk/wizard-i18n.js";
3
- import type { InstallWizardStep } from "../build-spk/install-wizard.js";
3
+ import { type InstallWizardStep } from "../build-spk/install-wizard.js";
4
4
  /**
5
5
  * `install.sharedFolder`: the Shared Folder a package keeps its files in.
6
6
  *
@@ -33,6 +33,11 @@ export declare function renderSharedFolderWizardFiles(pkg: string, folder: Share
33
33
  export declare function sharedFolderInstallSteps(pkg: string, folder: SharedFolder): (t: Translate) => InstallWizardStep[];
34
34
  /** The upgrade wizard files that let the administrator keep or change the shared folder. */
35
35
  export declare function renderSharedFolderUpgradeWizardFiles(pkg: string, folder: SharedFolder): Record<string, string>;
36
+ /**
37
+ * The upgrade wizard: the shared folder choice, then the service callbacks an
38
+ * administrator re-approves on every upgrade. Empty without either.
39
+ */
40
+ export declare function renderUpgradeWizardFiles(pkg: string, folder: SharedFolder | undefined, integrations?: readonly string[]): Record<string, string>;
36
41
  /** `conf/resource` `data-share` entry: create mode only; DSM never deletes the share on uninstall. */
37
42
  export declare function sharedFolderResource(pkg: string, folder: SharedFolder): Record<string, unknown> | undefined;
38
43
  /** The declaration an SPK's postinst carries, or undefined when it has none or it is no longer valid. */
@@ -1,5 +1,6 @@
1
1
  import { sharedFolderCombobox } from "../build-spk/share-combobox.js";
2
2
  import { renderWizardFiles } from "../build-spk/wizard-i18n.js";
3
+ import { integrationWizardSteps } from "../build-spk/install-wizard.js";
3
4
  const FIELDS = new Set(["mode", "key", "name", "permission"]);
4
5
  const KEY = /^[a-z][a-z0-9_]{0,31}$/;
5
6
  const RESERVED_KEY = /^(pkgwizard_|synopkg|change_)/;
@@ -123,6 +124,18 @@ export function sharedFolderInstallSteps(pkg, folder) {
123
124
  export function renderSharedFolderUpgradeWizardFiles(pkg, folder) {
124
125
  return renderWizardFiles("upgrade_uifile", t => [{ step_title: t("upgrade.step"), items: upgradeItems(t, pkg, folder) }]);
125
126
  }
127
+ /**
128
+ * The upgrade wizard: the shared folder choice, then the service callbacks an
129
+ * administrator re-approves on every upgrade. Empty without either.
130
+ */
131
+ export function renderUpgradeWizardFiles(pkg, folder, integrations = []) {
132
+ if (!folder && !integrations.length)
133
+ return {};
134
+ return renderWizardFiles("upgrade_uifile", t => [
135
+ ...(folder ? [{ step_title: t("upgrade.step"), items: upgradeItems(t, pkg, folder) }] : []),
136
+ ...integrationWizardSteps(t, integrations),
137
+ ]);
138
+ }
126
139
  /** `conf/resource` `data-share` entry: create mode only; DSM never deletes the share on uninstall. */
127
140
  export function sharedFolderResource(pkg, folder) {
128
141
  if (folder.mode !== "create")
@@ -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. */