@synopackageland/cli 0.12.2 → 0.13.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 +8 -0
- package/assets/go/daemon/main.go +25 -0
- package/assets/go/daemon/network.go +107 -0
- package/assets/go/daemon/network_test.go +86 -0
- package/dist/error-registry.js +15 -0
- package/dist/runtime/executor-digests.json +1 -23
- package/dist/skill.js +1 -0
- package/dist/standalone/build.js +5 -1
- package/dist/standalone/compose.d.ts +22 -0
- package/dist/standalone/compose.js +90 -0
- package/dist/standalone/daemon.d.ts +2 -0
- package/dist/standalone/daemon.js +22 -14
- package/dist/standalone/identity-gateway.d.ts +73 -0
- package/dist/standalone/identity-gateway.js +215 -0
- package/dist/standalone/inspect-spk.js +40 -10
- package/dist/standalone/lifecycle.js +6 -0
- package/dist/standalone/manifest.d.ts +5 -0
- package/dist/standalone/manifest.js +12 -2
- package/dist/standalone/resource.d.ts +2 -0
- package/dist/standalone/resource.js +2 -0
- package/dist/standalone/spk.d.ts +2 -0
- package/dist/standalone/spk.js +20 -3
- package/dist/validate.js +1 -1
- package/package.json +2 -2
- package/skills/SKILL.md +1 -1
- package/skills/reference/create.md +2 -2
- package/skills/reference/identity-gateway.md +174 -0
- package/skills/reference/wrap.md +4 -0
|
@@ -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
|
|
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
|
|
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() : {}),
|
package/dist/standalone/spk.d.ts
CHANGED
|
@@ -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. */
|
package/dist/standalone/spk.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.13.0",
|
|
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.
|
|
28
|
+
"@synopackageland/templates": "0.13.0",
|
|
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.
|
|
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:
|
|
43
|
-
backend: {runtime: node, dir: app, entry: main.mjs, sdk:
|
|
42
|
+
backend: {runtime: python, dir: app, entry: main.py, sdk: 0.13.0}
|
|
43
|
+
backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.13.0, node: {package: Node.js_v20}}
|
|
44
44
|
backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
|
|
45
45
|
```
|
|
46
46
|
|