@synopackageland/cli 0.12.2 → 0.13.1

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.
@@ -0,0 +1,215 @@
1
+ import { existsSync, lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem } from "../build-spk/go-toolchain.js";
4
+ import { goChildEnv } from "../runtime/go-env.js";
5
+ import { sdkSource } from "../runtime/sdk-source.js";
6
+ import { checkCgiBinary } from "./binary.js";
7
+ import { relativeExec, token } from "./daemon.js";
8
+ import { StandaloneBuildError } from "./spk.js";
9
+ /** The SDK's identity gateway: the daemon wrapper and the DSM-origin authorize CGI. */
10
+ export const GATEWAY_MAIN = "./idgateway/cmd/gateway";
11
+ /** Served by DSM at /webman/3rdparty/<package>/sso/authorize.cgi. */
12
+ export const GATEWAY_CGI_PATH = "ui/sso/authorize.cgi";
13
+ /** The gateway inside the daemon payload; the App's payload must leave _sso/ free. */
14
+ export const GATEWAY_PAYLOAD_DIR = "_sso";
15
+ export const GATEWAY_EXEC = `${GATEWAY_PAYLOAD_DIR}/gateway`;
16
+ export const GATEWAY_CONFIG_PATH = "sso/gateway.json";
17
+ const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
18
+ const only = (v, keys) => Object.keys(v).every(k => keys.includes(k));
19
+ const port = (v) => Number.isInteger(v) && v > 0 && v < 65536;
20
+ /** An absolute local path without dot segments, backslashes or a query. */
21
+ const localPath = (v) => typeof v === "string" && v.length <= 512 && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(v)
22
+ && !v.startsWith("//") && !v.split("/").some(p => p === "." || p === "..");
23
+ const strings = (v, ok, max = 64) => Array.isArray(v) && v.length <= max && v.every(ok);
24
+ const name = (v) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(v);
25
+ const headerName = (v) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(v);
26
+ const claimValue = (v) => typeof v === "string" && /^[A-Za-z0-9_.:-]{1,64}$/.test(v);
27
+ /** A mobile app's custom-scheme redirect; web schemes are same-origin redirect_paths instead. */
28
+ const nativeRedirect = (v) => typeof v === "string" && v.length <= 512 && /^[a-z][a-z0-9+.-]*:[^\s]*$/.test(v)
29
+ && !/^(https?|javascript|data|vbscript|file|blob|about):/.test(v);
30
+ const DSM_PORTS = [5000, 5001];
31
+ /** Only {uid} and {name}, which the gateway expands inside one argv element or path element. */
32
+ const placeholders = (v) => (v.match(/\{[^{}]*\}/g) ?? []).every(p => p === "{uid}" || p === "{name}");
33
+ /** A clean path under the package var directory that names the account, outside the gateway's sso/. */
34
+ const removePath = (v) => typeof v === "string" && v.length <= 512 && placeholders(v)
35
+ && /^[A-Za-z0-9_{}][A-Za-z0-9_.{}-]*(\/[A-Za-z0-9_.{}-]+)*$/.test(v) && !v.split("/").some(p => p === "." || p === "..")
36
+ && v.split("/")[0] !== "sso" && /\{(uid|name)\}/.test(v);
37
+ function readAccount(v, daemon) {
38
+ if (!record(v) || !only(v, ["on_delete"]) || !Array.isArray(v.on_delete) || v.on_delete.length > 16)
39
+ return "account accepts on_delete, a list of at most 16 actions.";
40
+ const actions = [];
41
+ for (const a of v.on_delete) {
42
+ if (record(a) && only(a, ["remove"]) && removePath(a.remove)) {
43
+ actions.push({ remove: a.remove });
44
+ continue;
45
+ }
46
+ if (!record(a) || !only(a, ["exec"]) || !Array.isArray(a.exec) || !relativeExec(a.exec[0]) || !strings(a.exec.slice(1), s => token(s) && placeholders(s))) {
47
+ return "each account.on_delete action is { remove: <path under the var directory naming {uid} or {name}> } or { exec: [<program in the daemon payload>, args...] }; {uid} and {name} are the only placeholders.";
48
+ }
49
+ const argv = a.exec;
50
+ if (!Object.values(daemon.targets).every(dir => existsSync(join(dir, argv[0]))))
51
+ return `account.on_delete exec ${argv[0]} is not in the daemon payload.`;
52
+ actions.push({ exec: argv });
53
+ }
54
+ return { on_delete: actions };
55
+ }
56
+ function readRoles(v) {
57
+ if (v === undefined)
58
+ return undefined;
59
+ if (!record(v) || !only(v, ["claim", "admin", "user"]))
60
+ return false;
61
+ const roles = { claim: v.claim ?? "groups", admin: v.admin ?? "admin", ...(v.user !== undefined ? { user: v.user } : {}) };
62
+ return claimValue(roles.claim) && claimValue(roles.admin) && (roles.user === undefined || claimValue(roles.user)) ? roles : false;
63
+ }
64
+ function readClient(v) {
65
+ if (!record(v) || !only(v, ["id", "redirect_paths", "native_redirects", "pkce", "roles"]))
66
+ return undefined;
67
+ const native = v.native_redirects ?? [], roles = readRoles(v.roles);
68
+ if (!name(v.id) || !strings(v.redirect_paths, localPath) || !strings(native, nativeRedirect) || (v.pkce !== undefined && typeof v.pkce !== "boolean") || roles === false)
69
+ return undefined;
70
+ if (!v.redirect_paths.length && !native.length)
71
+ return undefined;
72
+ return { id: v.id, redirect_paths: v.redirect_paths, native_redirects: native, require_pkce: v.pkce === true, ...(roles ? { roles } : {}) };
73
+ }
74
+ export function readIdentityGateway(value, daemon, fail) {
75
+ if (value === undefined)
76
+ return undefined;
77
+ const invalid = (why) => { fail({ code: "STANDALONE_IDENTITY_GATEWAY_INVALID", message: `identity_gateway: ${why}` }); return undefined; };
78
+ if (!daemon)
79
+ return invalid("needs daemon (native child) or compose (Container Manager upstream).");
80
+ if (!record(value) || !only(value, ["sdk", "mode", "upstream", "listen", "passthrough", "redirects", "email_domain", "session_ttl", "roles", "headers", "oidc", "token", "account"])) {
81
+ return invalid("accepts only sdk, mode, upstream, listen, passthrough, redirects, email_domain, session_ttl, roles, headers, oidc, token and account.");
82
+ }
83
+ const v = value;
84
+ if (typeof v.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(v.sdk))
85
+ return invalid("sdk must be the SDK release (x.y.z) to build the gateway from.");
86
+ if (v.mode !== "oidc" && v.mode !== "header" && v.mode !== "token")
87
+ return invalid("mode must be oidc, header or token.");
88
+ if (!port(v.upstream))
89
+ return invalid("upstream must be the App's loopback port.");
90
+ const listen = v.listen;
91
+ if (!record(listen) || !only(listen, ["http", "https"]) || (listen.http === undefined && listen.https === undefined)
92
+ || ![listen.http, listen.https].every(p => p === undefined || port(p)))
93
+ return invalid("listen needs an http and/or https port.");
94
+ const issuer = record(v.oidc) ? v.oidc.issuer_port : undefined;
95
+ const ports = [listen.http, listen.https, v.upstream, issuer].filter(p => p !== undefined);
96
+ if (new Set(ports).size !== ports.length || ports.some(p => DSM_PORTS.includes(p)))
97
+ return invalid("listen, upstream and oidc.issuer_port must be distinct and not DSM's 5000/5001.");
98
+ const passthrough = v.passthrough ?? [];
99
+ if (!strings(passthrough, p => localPath(p) && p !== "/" && !/^\/_sso(\/|$)/.test(p)))
100
+ return invalid("passthrough must be path prefixes such as /share/, outside /_sso.");
101
+ const redirects = v.redirects ?? {};
102
+ if (!record(redirects) || Object.entries(redirects).some(([from, to]) => !localPath(from) || !localPath(to)))
103
+ return invalid("redirects maps local paths to local paths.");
104
+ if (v.email_domain !== undefined && (typeof v.email_domain !== "string" || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(v.email_domain)))
105
+ return invalid("email_domain must be a domain name.");
106
+ if (v.session_ttl !== undefined && (!Number.isInteger(v.session_ttl) || v.session_ttl < 60 || v.session_ttl > 86400))
107
+ return invalid("session_ttl must be 60-86400 seconds.");
108
+ const roles = readRoles(v.roles);
109
+ if (roles === false)
110
+ return invalid("roles accepts claim, admin and user values.");
111
+ const headers = v.headers;
112
+ if (headers !== undefined && (!record(headers) || !only(headers, ["user", "email", "name", "groups", "uid"]) || !Object.values(headers).every(headerName))) {
113
+ return invalid("headers maps user, email, name, groups and uid to header names.");
114
+ }
115
+ if (v.mode === "header" && !(record(headers) && headers.user))
116
+ return invalid("header mode needs headers.user.");
117
+ const gateway = {
118
+ sdk: v.sdk, mode: v.mode, upstream: v.upstream, listen: { ...(listen.http ? { http: listen.http } : {}), ...(listen.https ? { https: listen.https } : {}) },
119
+ passthrough: passthrough, redirects: redirects,
120
+ ...(v.email_domain ? { email_domain: v.email_domain } : {}), ...(v.session_ttl ? { session_ttl: v.session_ttl } : {}),
121
+ ...(roles ? { roles } : {}), ...(headers ? { headers: headers } : {}),
122
+ };
123
+ if (v.mode === "oidc") {
124
+ const o = v.oidc;
125
+ if (!record(o) || !only(o, ["issuer_port", "clients", "rewrite_json"]) || !port(o.issuer_port) || !Array.isArray(o.clients) || !o.clients.length || o.clients.length > 16) {
126
+ return invalid("oidc mode needs oidc.issuer_port and clients.");
127
+ }
128
+ const clients = o.clients.map(readClient);
129
+ if (clients.some(c => !c) || new Set(clients.map(c => c.id)).size !== clients.length)
130
+ return invalid("each oidc client needs a unique id and redirect_paths (local paths) or native_redirects (custom schemes).");
131
+ const rewrite = o.rewrite_json ?? [];
132
+ if (!Array.isArray(rewrite) || !rewrite.every(r => record(r) && only(r, ["method", "path", "field"]) && /^[A-Z]{3,7}$/.test(String(r.method)) && localPath(r.path) && name(r.field))) {
133
+ return invalid("oidc.rewrite_json entries are { method, path, field }.");
134
+ }
135
+ gateway.oidc = { issuer_port: o.issuer_port, clients: clients, rewrite_json: rewrite };
136
+ }
137
+ if (v.mode === "token") {
138
+ const t = v.token;
139
+ const args = record(t) ? t.args ?? [] : [];
140
+ if (!record(t) || !only(t, ["exec", "args"]) || !relativeExec(t.exec) || !strings(args, token))
141
+ return invalid("token mode needs token.exec, a program in the daemon payload.");
142
+ if (!Object.values(daemon.targets).every(dir => existsSync(join(dir, t.exec))))
143
+ return invalid(`token.exec ${t.exec} is not in the daemon payload.`);
144
+ gateway.token = { exec: t.exec, args: args };
145
+ }
146
+ if (v.account !== undefined) {
147
+ const account = readAccount(v.account, daemon);
148
+ if (typeof account === "string")
149
+ return invalid(account);
150
+ gateway.account = account;
151
+ }
152
+ if (Object.values(daemon.targets).some(dir => { try {
153
+ lstatSync(join(dir, GATEWAY_PAYLOAD_DIR));
154
+ return true;
155
+ }
156
+ catch {
157
+ return false;
158
+ } })) {
159
+ return invalid(`the daemon payload must not contain ${GATEWAY_PAYLOAD_DIR}/, where the gateway is installed.`);
160
+ }
161
+ return gateway;
162
+ }
163
+ const packageDest = (m) => `/var/packages/${m.package}/target`;
164
+ /** target/sso/gateway.json, the SDK gateway's configuration (idgateway.Config). */
165
+ export function identityGatewayConfig(m) {
166
+ const g = m.identityGateway;
167
+ return JSON.stringify({
168
+ package: m.package, mode: g.mode, upstream: `http://127.0.0.1:${g.upstream}`, listen: g.listen,
169
+ authorize_cgi: `/webman/3rdparty/${m.package}/sso/authorize.cgi`, state_dir: `/var/packages/${m.package}/var/sso`,
170
+ passthrough: g.passthrough, redirects: g.redirects,
171
+ ...(g.email_domain ? { email_domain: g.email_domain } : {}), ...(g.session_ttl ? { session_ttl_seconds: g.session_ttl } : {}),
172
+ ...(g.roles ? { roles: g.roles } : {}), ...(g.headers ? { headers: g.headers } : {}),
173
+ ...(g.oidc ? { oidc: g.oidc } : {}),
174
+ ...(g.token ? { token: { command: [`${packageDest(m)}/daemon/payload/${g.token.exec}`, ...g.token.args] } } : {}),
175
+ ...(g.account ? { account: {
176
+ var_dir: `/var/packages/${m.package}/var`,
177
+ on_delete: g.account.on_delete.map(a => "remove" in a ? a : { exec: [`${packageDest(m)}/daemon/payload/${a.exec[0]}`, ...a.exec.slice(1)] }),
178
+ } } : {}),
179
+ }) + "\n";
180
+ }
181
+ /** The daemon as the controller runs it: the gateway, with the App as its child. */
182
+ export function gatewayDaemon(daemon) {
183
+ const app = `\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`;
184
+ return {
185
+ ...daemon, exec: GATEWAY_EXEC, args: ["run", "--", app, ...daemon.args],
186
+ ...(daemon.uninstall_args ? { uninstall_args: ["exec", "--", app, ...daemon.uninstall_args] } : {}),
187
+ };
188
+ }
189
+ /** Builds the gateway from the declared SDK release with the pinned Go, binding its config path. */
190
+ export async function buildIdentityGateway(m, arch, work, run) {
191
+ if (!m.identityGateway)
192
+ return undefined;
193
+ let sdk;
194
+ try {
195
+ sdk = sdkSource(m.identityGateway.sdk);
196
+ }
197
+ catch (error) {
198
+ throw new StandaloneBuildError("SDK_DOWNLOAD_FAILED", error.message.replace(/^\[[A-Z_]+\] /, ""));
199
+ }
200
+ const out = join(work, `identity-gateway-${arch}`);
201
+ const ldflags = `-s -w -buildid= -X main.configPath=${packageDest(m)}/${GATEWAY_CONFIG_PATH}`;
202
+ const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, GATEWAY_MAIN], {
203
+ cwd: sdk.goDir, env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
204
+ });
205
+ if (result.missing || result.status !== 0) {
206
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED", `identity gateway (SDK ${sdk.version} ${GATEWAY_MAIN}, ${arch}) build failed; that SDK version must contain the gateway. ${PINNED_GO_HINT}\n${result.stderr}`);
207
+ }
208
+ const problem = checkCgiBinary(work, out, arch);
209
+ if (problem)
210
+ throw new StandaloneBuildError(problem.code, problem.message);
211
+ const toolchain = pinnedGoProblem(out);
212
+ if (toolchain)
213
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED", toolchain);
214
+ return out;
215
+ }
@@ -1,3 +1,4 @@
1
+ import { readComposeApp } from "./compose.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { inspectNativeLayout } from "./native-layout.js";
3
4
  import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
@@ -79,7 +80,10 @@ export async function inspectSpk(spkPath, expectedKind) {
79
80
  const kind = (expectedKind === "native-go" ? compiled : expectedKind) ?? (paths.has("compose.yaml") ? "container" : binaryBackend ? "binary" : compiled);
80
81
  const compiledKind = Object.values(COMPILED_KIND).includes(kind);
81
82
  const standalone = compiledKind || kind === "binary";
82
- const info = readInfo(join(dir, "INFO"), findings, kind);
83
+ const combined = metadata.compose === undefined ? undefined : await recoverCompose(dir, metadata.compose, findings);
84
+ const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined));
85
+ if (combined && (!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
86
+ findings.push("compose identity_gateway requires DSM >= 7.2.1");
83
87
  await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
84
88
  const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
85
89
  // Before any fixed-lifecycle comparison: those see the SDK steps alone.
@@ -90,7 +94,7 @@ export async function inspectSpk(spkPath, expectedKind) {
90
94
  if (portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
91
95
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_MIN_DSM} a portal needs; ${PORTAL_MIN_DSM_REASON}`);
92
96
  }
93
- checkConf(dir, findings, kind, background, sharedFolder, info.package, portal);
97
+ checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose);
94
98
  checkScripts(dir, findings);
95
99
  checkUninstall(dir, findings);
96
100
  if (kind === "container")
@@ -99,7 +103,7 @@ export async function inspectSpk(spkPath, expectedKind) {
99
103
  const hasExecutor = (metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
100
104
  if (standalone && portal && !hasExecutor)
101
105
  findings.push("PORTAL_REQUIRES_EXECUTOR portal needs the executor level (rootOps or delegate): after uninstall only a root purge can stop nginx serving the alias");
102
- const daemon = standalone ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background) : undefined;
106
+ const daemon = standalone ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined) : undefined;
103
107
  if (background)
104
108
  checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go");
105
109
  let executor;
@@ -295,7 +299,31 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
295
299
  findings.push(`INFO arch declares ${declared}, but ${CGI_PAYLOAD_PATH} is a ${actual} executable`);
296
300
  }
297
301
  }
298
- async function checkDaemon(dir, info, paths, payload, findings, executor, background) {
302
+ async function recoverCompose(dir, value, findings) {
303
+ try {
304
+ if (!value || typeof value !== 'object' || Object.keys(value).join() !== 'services')
305
+ throw new Error('invalid compose inventory');
306
+ const root = join(dir, 'compose-inspection');
307
+ mkdirSync(root);
308
+ await tar.x({ file: join(dir, 'package.tgz'), cwd: root, filter: p => ['compose/docker-compose.yml', 'sso/gateway.json'].includes(p.replace(/^\.\//, '')) });
309
+ const compose = readComposeApp({ file: 'compose/docker-compose.yml', targets: { x86_64: 'daemon/payload' }, services: value.services }, root, ['x86_64'], e => { throw new Error(e.message); });
310
+ if (!compose)
311
+ throw new Error('invalid compose');
312
+ const config = JSON.parse(readFileSync(join(root, 'sso/gateway.json'), 'utf8'));
313
+ const upstream = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(config.upstream)?.[1];
314
+ const listen = config.listen;
315
+ if (!upstream || !Object.values(compose.services).some(s => s.port === Number(upstream)) || !listen || !Object.keys(listen).length || Object.entries(listen).some(([key, port]) => !['http', 'https'].includes(key) || !Number.isInteger(port) || Number(port) < 1024 || Number(port) > 65535 || Object.values(compose.services).some(s => s.port === port)))
316
+ throw new Error('invalid gateway network');
317
+ if (config.package !== readInfo(join(dir, 'INFO'), [], 'container').package)
318
+ throw new Error('gateway package mismatch');
319
+ return { compose, identityGateway: { listen } };
320
+ }
321
+ catch (e) {
322
+ findings.push(`compose identity gateway contract: ${String(e)}`);
323
+ return undefined;
324
+ }
325
+ }
326
+ async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined) {
299
327
  if (![...paths].some(path => path.startsWith("daemon/")))
300
328
  return;
301
329
  if (info.precheckstartstop !== "yes")
@@ -309,14 +337,16 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
309
337
  await tar.x({ file: join(dir, "package.tgz"), cwd: unpacked, filter: path => path.replace(/^\.\//, "") === "daemon/config.json" });
310
338
  try {
311
339
  const config = JSON.parse(readFileSync(join(unpacked, "daemon/config.json"), "utf8"));
312
- if (config.package !== info.package || Object.keys(config).some(key => !["package", "exec", "args", "env", "timeouts", "uninstall_args"].includes(key)))
340
+ if (config.package !== info.package || Object.keys(config).some(key => !["package", "exec", "args", "env", "timeouts", "uninstall_args", ...(combined ? ["network"] : [])].includes(key)))
313
341
  throw new Error("package/config mismatch");
314
342
  const daemon = readDaemon({ exec: config.exec, args: config.args, uninstall_args: config.uninstall_args, env: config.env, timeouts: config.timeouts, targets: { x86_64: "payload" } }, dir, ["x86_64"], () => { });
315
343
  if (!daemon)
316
344
  throw new Error("invalid config");
345
+ if (combined && (daemon.exec !== "_sso/gateway" || JSON.stringify(daemon.args) !== '["run"]' || daemon.uninstall_args))
346
+ throw new Error("compose gateway must run without a child");
317
347
  if (payload.find(p => p.path === `daemon/payload/${daemon.exec}`)?.mode !== "0755")
318
348
  throw new Error("missing executable");
319
- const manifest = { package: info.package, daemon, capabilities: { ...(executor ? { rootOps: true } : {}), ...(background ? { jobs: { worker: 'sha256-v1' } } : {}) } };
349
+ const manifest = { package: info.package, daemon, ...combined, capabilities: { ...(executor ? { rootOps: true } : {}), ...(background ? { jobs: { worker: 'sha256-v1' } } : {}) } };
320
350
  // A config from before timeouts.uninstall pairs with a control that rejects the key.
321
351
  const legacy = typeof config.timeouts === "object" && config.timeouts !== null && !("uninstall" in config.timeouts);
322
352
  const { uninstall: _, ...legacyTimeouts } = daemon.timeouts;
@@ -333,7 +363,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
333
363
  findings.push("daemon/config.json or the payload does not match the fixed daemon contract");
334
364
  }
335
365
  }
336
- function readInfo(path, findings, kind) {
366
+ function readInfo(path, findings, kind, compose = false) {
337
367
  if (!existsSync(path)) {
338
368
  findings.push("INFO is missing");
339
369
  return {};
@@ -354,7 +384,7 @@ function readInfo(path, findings, kind) {
354
384
  findings.push("INFO must have thirdparty=\"yes\" and dsmuidir=\"ui\" for DSM to serve /webman/3rdparty/<package>/");
355
385
  }
356
386
  // A container needs Container Manager (and Web Station for a portal); a Node App the Node.js it runs on.
357
- const allowed = kind === "container" ? /^(WebStation|ContainerManager)([><=].*)?$/ : kind === "native-node" ? /^Node\.js_v[0-9]{2}([><=].*)?$/ : undefined;
387
+ const allowed = compose ? /^ContainerManager$/ : kind === "container" ? /^(WebStation|ContainerManager)([><=].*)?$/ : kind === "native-node" ? /^Node\.js_v[0-9]{2}([><=].*)?$/ : undefined;
358
388
  if (info.install_dep_packages && !(allowed && info.install_dep_packages.split(":").every(dep => allowed.test(dep)))) {
359
389
  findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
360
390
  }
@@ -379,10 +409,10 @@ async function checkContainerExposure(dir, packageTgz, findings) {
379
409
  function readOptional(path) {
380
410
  return existsSync(path) ? readFileSync(path, "utf8") : undefined;
381
411
  }
382
- function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal) {
412
+ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose) {
383
413
  const confDir = join(dir, "conf");
384
414
  const entries = existsSync(confDir) ? readdirSync(confDir) : [];
385
- const nativeResource = kind !== "container" ? renderStandaloneResource({ package: pkg ?? "", capabilities: background ? { jobs: { worker: "sha256-v1" } } : {}, ...(sharedFolder ? { sharedFolder } : {}) }, portal) : undefined;
415
+ const nativeResource = kind !== "container" ? renderStandaloneResource({ package: pkg ?? "", ...(compose ? { compose } : {}), capabilities: background ? { jobs: { worker: "sha256-v1" } } : {}, ...(sharedFolder ? { sharedFolder } : {}) }, portal) : undefined;
386
416
  if (nativeResource !== undefined && readOptional(join(confDir, "resource")) !== nativeResource) {
387
417
  findings.push(`conf/resource must be ${nativeResource} (only the official data-share, systemd-user-unit and web-config, and consistent with the install.sharedFolder and portal declarations)`);
388
418
  }
@@ -1,3 +1,4 @@
1
+ import { composeDaemon, renderComposeInstall } from "./compose.js";
1
2
  import { renderBackgroundControl, renderBackgroundWorkerStart, renderBackgroundWorkerStop, renderUserManagerRecovery, backgroundUnitName } from "./background.js";
2
3
  import { renderPackagePostuninst } from "../build-spk/native-lifecycle.js";
3
4
  import { renderSharedFolderScript } from "./shared-folder.js";
@@ -9,6 +10,11 @@ import { composeAppScript } from "./app-scripts.js";
9
10
  const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_uninstall_hook\n';
10
11
  /** Package-account lifecycle hooks for CGI, daemon and background packages. */
11
12
  export function renderStandaloneScript(name, manifest) {
13
+ if (manifest.compose) {
14
+ const native = { ...manifest, compose: undefined, daemon: composeDaemon(manifest) };
15
+ const body = name === "preinst" ? renderComposeInstall(manifest.package) : renderStandaloneScript(name, { ...native, appScripts: undefined });
16
+ return manifest.appScripts?.some(app => app === name) ? composeAppScript(name, body) : body;
17
+ }
12
18
  const portal = nativePortal(manifest);
13
19
  const script = renderCapabilityScript(name, manifest);
14
20
  const sdk = portal ? script.replace("#!/bin/sh\n", () => `#!/bin/sh\n${renderPortalLifecycle(name, portal)}`) : script;
@@ -7,7 +7,9 @@ import { type Catalogs, type InfoTranslations } from "./i18n.js";
7
7
  export type { InfoText } from "./i18n.js";
8
8
  import { type RootOp } from "./root-ops.js";
9
9
  import type { RootSettings } from "../runtime/root-programs.js";
10
+ import { type ComposeApp } from "./compose.js";
10
11
  import { type Daemon } from "./daemon.js";
12
+ import { type IdentityGateway } from "./identity-gateway.js";
11
13
  import { type Uninstall } from "./uninstall.js";
12
14
  import { type AppScriptName } from "./app-scripts.js";
13
15
  import { type Background } from "./background.js";
@@ -76,6 +78,9 @@ export interface StandaloneManifest {
76
78
  tasks: ExecutorTask[];
77
79
  };
78
80
  daemon?: Daemon;
81
+ /** `identity_gateway`: signs DSM users in to the daemon's own web UI. */
82
+ identityGateway?: IdentityGateway;
83
+ compose?: ComposeApp;
79
84
  /** `uninstall`: the App's own purge hook and the state the wizard may delete. */
80
85
  uninstall?: Uninstall;
81
86
  /** `install.sharedFolder`: the Shared Folder the install wizard selects or `data-share` creates. */
@@ -7,7 +7,9 @@ import { parse } from "yaml";
7
7
  import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
8
8
  import { checkGlossary, loadCatalogs, readInfoTranslations } from "./i18n.js";
9
9
  import { loadRootOps, readRootSettings, rootBinaryProblem, ROOT_OPS_DIR } from "./root-ops.js";
10
+ import { readComposeApp } from "./compose.js";
10
11
  import { readDaemon } from "./daemon.js";
12
+ import { readIdentityGateway } from "./identity-gateway.js";
11
13
  import { readUninstall } from "./uninstall.js";
12
14
  import { readAppScripts } from "./app-scripts.js";
13
15
  import { readBackground } from "./background.js";
@@ -31,7 +33,7 @@ import { BRIDGE_RUNTIMES, isBridgeBackend, overlaps, readBridgeBackend } from ".
31
33
  export const STANDALONE_MANIFEST = "dsm-package.yaml";
32
34
  export const KNOWN_FIELDS = new Set([
33
35
  "schema", "package", "version", "build", "displayname", "description",
34
- "metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "uninstall", "entry", "portal", "icon", "reproducible", "root", "login_style",
36
+ "metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "identity_gateway", "compose", "install", "uninstall", "entry", "portal", "icon", "reproducible", "root", "login_style",
35
37
  ]);
36
38
  const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
37
39
  const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
@@ -83,7 +85,7 @@ export function loadStandaloneManifest(root) {
83
85
  return { ok: false, errors: [{ code: "STANDALONE_MANIFEST_YAML", message: String(error) }] };
84
86
  }
85
87
  const c = new Collector(root);
86
- if (existsSync(join(root, "compose.yaml")))
88
+ if (existsSync(join(root, "compose.yaml")) && !(isRecord(raw) && isRecord(raw.compose) && raw.compose.file === "compose.yaml"))
87
89
  c.fail("PROJECT_RUNTIME_EXCLUSIVE", "compose.yaml and dsm-package.yaml are mutually exclusive; keep only one.");
88
90
  if (!isRecord(raw)) {
89
91
  c.fail("STANDALONE_MANIFEST_YAML", `${STANDALONE_MANIFEST} must be a YAML object.`);
@@ -170,6 +172,12 @@ function readManifest(raw, c) {
170
172
  }
171
173
  }
172
174
  const daemon = arches && readDaemon(raw.daemon, c.root, arches, e => c.fail(e.code, e.message));
175
+ const compose = arches && readComposeApp(raw.compose, c.root, arches, e => c.fail(e.code, e.message));
176
+ if (raw.compose !== undefined && (raw.daemon !== undefined || raw.identity_gateway === undefined || Object.keys(capabilities).length || !osMinVer || !isDsmAtLeast(osMinVer, "7.2.1-69057")))
177
+ c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose requires identity_gateway, DSM >= 7.2.1, no daemon and no capabilities.");
178
+ const identityGateway = readIdentityGateway(raw.identity_gateway, daemon || (compose ? { targets: compose.targets } : undefined), e => c.fail(e.code, e.message));
179
+ if (compose && identityGateway && (!Object.values(compose.services).some(s => s.port === identityGateway.upstream) || Object.values(compose.services).some(s => Object.values(identityGateway.listen).includes(s.port) || s.port === identityGateway.oidc?.issuer_port)))
180
+ c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose service ports must contain upstream and not overlap gateway or issuer ports.");
173
181
  if (capabilities.jobs && raw.daemon !== undefined && capabilities.delegate?.lifetime !== 'until-revoked') {
174
182
  c.fail("CAPABILITY_COMBINATION_INVALID", "daemon can only coexist with an explicit until-revoked delegate fixed worker; plain capabilities.jobs stays mutually exclusive.");
175
183
  }
@@ -261,6 +269,8 @@ function readManifest(raw, c) {
261
269
  ...(rootOps ? { rootOps } : {}),
262
270
  ...(rootPrograms ? { rootPrograms } : {}),
263
271
  ...(daemon ? { daemon } : {}),
272
+ ...(identityGateway ? { identityGateway } : {}),
273
+ ...(compose ? { compose } : {}),
264
274
  ...(uninstall ? { uninstall } : {}),
265
275
  ...(sharedFolder ? { sharedFolder } : {}),
266
276
  ...(entry ? { entry } : {}),
@@ -1,3 +1,4 @@
1
+ import type { ComposeApp } from "./compose.js";
1
2
  import type { Capabilities } from "./manifest.js";
2
3
  import { type SharedFolder } from "./shared-folder.js";
3
4
  import { type PortalPackage } from "../portal/package.js";
@@ -9,5 +10,6 @@ import { type PortalPackage } from "../portal/package.js";
9
10
  export declare function renderStandaloneResource(m: {
10
11
  package: string;
11
12
  capabilities: Capabilities;
13
+ compose?: ComposeApp;
12
14
  sharedFolder?: SharedFolder;
13
15
  }, portal?: PortalPackage): string | undefined;
@@ -1,3 +1,4 @@
1
+ import { renderDockerProjectResource } from "../build-spk/resource-worker.js";
1
2
  import { sharedFolderResource } from "./shared-folder.js";
2
3
  import { portalResource } from "../portal/package.js";
3
4
  /**
@@ -8,6 +9,7 @@ import { portalResource } from "../portal/package.js";
8
9
  export function renderStandaloneResource(m, portal) {
9
10
  const dataShare = m.sharedFolder && sharedFolderResource(m.package, m.sharedFolder);
10
11
  const resource = {
12
+ ...(m.compose ? renderDockerProjectResource(m.package, "compose", false) : {}),
11
13
  ...(dataShare ? { "data-share": dataShare } : {}),
12
14
  ...(m.capabilities.jobs ? { "systemd-user-unit": {} } : {}),
13
15
  ...(portal ? portalResource() : {}),
@@ -15,6 +15,8 @@ export interface AssembleOptions {
15
15
  outputPath: string;
16
16
  executor?: ExecutorArtifact;
17
17
  daemonControl?: string;
18
+ /** The SDK identity gateway, required exactly when the manifest declares identity_gateway. */
19
+ identityGateway?: string;
18
20
  /** The compiled background worker, required exactly when capabilities.jobs is enabled. */
19
21
  backgroundWorker?: string;
20
22
  /** A Python, Node or Rust App's finished directory, installed as target/app. */
@@ -22,7 +22,9 @@ import { copyStandaloneNotices } from "../build-spk/notices.js";
22
22
  import { copyAppTree } from "./bridge-app.js";
23
23
  import { isBridgeBackend } from "./bridge-backend.js";
24
24
  import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
25
+ import { composeSource } from "./compose.js";
25
26
  import { copyDaemonPayload } from "./daemon.js";
27
+ import { GATEWAY_CGI_PATH, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig } from "./identity-gateway.js";
26
28
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS } from "./background.js";
27
29
  import { renderStandaloneResource } from "./resource.js";
28
30
  import { nativePortal } from "./portal.js";
@@ -79,11 +81,22 @@ export async function assembleStandaloneSpk(options) {
79
81
  mkdirSync(dirname(cgiTarget), { recursive: true });
80
82
  copyFileSync(options.cgiPath, cgiTarget);
81
83
  chmodSync(cgiTarget, EXEC_MODE);
82
- if (manifest.daemon) {
84
+ if (manifest.daemon || manifest.compose) {
83
85
  if (!options.daemonControl)
84
86
  throw new StandaloneBuildError("STANDALONE_DAEMON_ARTIFACT_REQUIRED", "daemon controller artifact required.");
85
87
  copyDaemonPayload(manifest, options.arch, join(staging, "package", "daemon"), options.daemonControl);
86
88
  }
89
+ if (manifest.identityGateway) {
90
+ if (!options.identityGateway)
91
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_ARTIFACT_REQUIRED", "identity gateway artifact required.");
92
+ for (const path of [GATEWAY_CGI_PATH, `daemon/payload/${GATEWAY_EXEC}`]) {
93
+ const target = join(staging, "package", path);
94
+ mkdirSync(dirname(target), { recursive: true });
95
+ copyFileSync(options.identityGateway, target);
96
+ chmodSync(target, EXEC_MODE);
97
+ }
98
+ writeFile(join(staging, "package", GATEWAY_CONFIG_PATH), identityGatewayConfig(manifest));
99
+ }
87
100
  if (manifest.capabilities.jobs) {
88
101
  if (!options.backgroundWorker)
89
102
  throw new StandaloneBuildError("STANDALONE_BACKGROUND_ARTIFACT_REQUIRED", "background worker artifact required.");
@@ -127,11 +140,13 @@ export async function assembleStandaloneSpk(options) {
127
140
  const hook = manifest.uninstall && uninstallExecProblem(join(staging, "package"), manifest.uninstall.purge);
128
141
  if (hook)
129
142
  throw new StandaloneBuildError(hook.code, hook.message);
143
+ if (manifest.compose)
144
+ writeFile(join(staging, "package/compose/docker-compose.yml"), composeSource(manifest.compose));
130
145
  const packageTgz = join(staging, "package.tgz");
131
146
  await createReproducibleTar(join(staging, "package"), packageTgz, { gzip: true });
132
147
  rmSync(join(staging, "package"), { recursive: true, force: true });
133
148
  const checksum = createHash("md5").update(readFileSync(packageTgz)).digest("hex");
134
- writeFile(join(staging, "sdk-package.json"), JSON.stringify({ schema: 2, backend: manifest.backend.kind, capabilities: Object.keys(manifest.capabilities).sort(), ...(manifest.entry ? { entry: manifest.entry } : {}), ...(manifest.uninstall ? { uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs) } : {}) }));
149
+ writeFile(join(staging, "sdk-package.json"), JSON.stringify({ schema: 2, backend: manifest.backend.kind, capabilities: Object.keys(manifest.capabilities).sort(), ...(manifest.compose ? { compose: { services: manifest.compose.services } } : {}), ...(manifest.entry ? { entry: manifest.entry } : {}), ...(manifest.uninstall ? { uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs) } : {}) }));
135
150
  writeFile(join(staging, "INFO"), renderInfo(manifest, options.arch, checksum, options.executor?.executorDigest));
136
151
  mkdirSync(join(staging, "conf"));
137
152
  writeFile(join(staging, "conf", "privilege"), renderNativePrivilege());
@@ -203,12 +218,14 @@ function renderInfo(manifest, arch, checksum, executorDigest) {
203
218
  // Package Center writes INFO as root; root tasks accept only this executor.
204
219
  if (executorDigest)
205
220
  fields.push([EXECUTOR_ANCHOR, executorDigest]);
221
+ if (manifest.compose)
222
+ fields.push(["install_dep_packages", "ContainerManager"]);
206
223
  const backend = manifest.backend;
207
224
  if (isBridgeBackend(backend) && backend.node)
208
225
  fields.push(["install_dep_packages", backend.node.package]);
209
226
  fields.push(...metadataInfoFields(manifest.metadata));
210
227
  // Background packages need prestart: it runs before DSM's systemd-user-unit acquire.
211
- if (manifest.daemon || manifest.capabilities.jobs)
228
+ if (manifest.daemon || manifest.compose || manifest.capabilities.jobs)
212
229
  fields.push(["precheckstartstop", "yes"]);
213
230
  for (const locale of LOCALES) {
214
231
  const text = manifest.i18n?.info[locale];
package/dist/validate.js CHANGED
@@ -21,7 +21,7 @@ export function validateProject(project, options = {}) {
21
21
  const standalonePath = join(project.root, DSM_PACKAGE_FILENAME);
22
22
  const hasCompose = existsSync(project.composePath);
23
23
  const hasStandalone = existsSync(standalonePath);
24
- if (hasCompose && hasStandalone) {
24
+ if (hasCompose && hasStandalone && isNativeLayout(project.root)) {
25
25
  return { ok: false, errors: [{
26
26
  code: "PROJECT_RUNTIME_EXCLUSIVE",
27
27
  span: { file: DSM_PACKAGE_FILENAME, line: 1, column: 1 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "prepublishOnly": "npm run build"
26
26
  },
27
27
  "dependencies": {
28
- "@synopackageland/templates": "0.12.2",
28
+ "@synopackageland/templates": "0.13.1",
29
29
  "ajv": "^8.17.1",
30
30
  "commander": "^13.1.0",
31
31
  "tar": "^7.4.3",
package/skills/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
7
7
 
8
8
  Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
9
9
 
10
- Start with `synopkgland init -t native-go`, `synopkgland init -t binary`, or `synopkgland init -t compose`. Read reference/create.md or reference/wrap.md, then reference/deploy.md. Native CGI session checks belong to the package. Container authentication belongs to upstream; fixed service credentials govern mounted data access.
10
+ Start with `synopkgland init -t native-go`, `synopkgland init -t binary`, or `synopkgland init -t compose`. Read reference/create.md or reference/wrap.md, then reference/deploy.md. Native CGI session checks belong to the package. For wrapping upstream OSS with DSM accounts, read [reference/identity-gateway.md](reference/identity-gateway.md) first. It covers native and host-network Compose upstreams, mode selection, isolation, account deletion and verification. Fixed package credentials govern container data access.
11
11
 
12
12
  Never install, uninstall, publish or modify NAS data without task authorization. Prefer check, inspect and deploy --dry-run for reviewable artifacts.
13
13
 
@@ -39,8 +39,8 @@ rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected exec
39
39
  backend 寫 `runtime: python|node|rust`(`runtime: go` 等同 `kind: go`)。CGI 是 SDK 的 Go runtime(`bridge/cmd/runtime`),它驗證 DSM session、entry 與 `permissions.json` 後,以套件帳號(不是 root)每個請求啟動一次 App。App 放在 ui 以外的目錄,打包成 `target/app/`,build 寫入 `bridge.json`(spec/bridge/v1)並把它與 `permissions.json` 的 sha256 編進 runtime。
40
40
 
41
41
  ```yaml
42
- backend: {runtime: python, dir: app, entry: main.py, sdk: X.Y.Z}
43
- backend: {runtime: node, dir: app, entry: main.mjs, sdk: X.Y.Z, node: {package: Node.js_v20}}
42
+ backend: {runtime: python, dir: app, entry: main.py, sdk: 0.13.1}
43
+ backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.13.1, node: {package: Node.js_v20}}
44
44
  backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
45
45
  ```
46
46