@synopackageland/cli 0.29.0 → 0.31.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 +14 -0
- package/README.md +7 -3
- package/dist/bin.js +2 -1
- package/dist/cli.js +2 -1
- package/dist/ctl/cli.js +1 -1
- package/dist/ctl/json.js +36 -2
- package/dist/ctl/service.js +1 -1
- package/dist/error-registry.js +1 -1
- package/dist/host/credentials.js +1 -1
- package/dist/host/dsm-http.js +19 -4
- package/dist/load-env.js +4 -1
- package/dist/standalone/identity-gateway.d.ts +2 -0
- package/dist/standalone/identity-gateway.js +9 -4
- package/dist/standalone/inspect-spk.js +7 -1
- package/dist/standalone/spk.js +9 -4
- package/dist/terminal-text.d.ts +1 -0
- package/dist/terminal-text.js +5 -0
- package/package.json +2 -2
- package/skills/reference/identity-gateway.md +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.31.0 (2026-10-25)
|
|
4
|
+
|
|
5
|
+
- Pin templates 0.31.x and SDK/App 0.31.0 defaults, including the hardened executor payloads. Rebuild Apps and obtain administrator approval for changed system-access payloads.
|
|
6
|
+
|
|
7
|
+
- A bare host name now resolves to `https://host:5001` instead of `http://host:5000`; pass a full URL to keep plain HTTP.
|
|
8
|
+
- JSON input duplicate-key detection is now linear-time, so large inputs no longer stall the parser.
|
|
9
|
+
- The CLI's own `.env` loading now sets only `DSM_*` and `SYNOPKGLAND_*` variables; set other variables (for example `GOCACHE` or `PATH`) in the shell.
|
|
10
|
+
- Harden the DSM curl fallback (headers and form fields go through a private curl config; form text is sent literally), human-readable output (control characters are escaped), `ctl` examples and package versions, the UI directory (must resolve inside the project), and standalone inspect (INFO `package` must be an SDK package id).
|
|
11
|
+
|
|
12
|
+
## 0.30.0 (2026-10-24)
|
|
13
|
+
|
|
14
|
+
- Package and forward `identity_gateway.external_access.single_origin`; use SDK 0.30.0 for one-URL Apps, provider-aware DSM menu launches and launch probes.
|
|
15
|
+
- Pin templates 0.30.x and its SDK/App 0.30.0 defaults; keep immutable executor/v0.27.0.
|
|
16
|
+
|
|
3
17
|
## 0.29.0 (2026-10-24)
|
|
4
18
|
|
|
5
19
|
- Depend on templates `~0.29.0` with SDK/App 0.29.0 and UI 0.14.10. SDK 0.29.0 reuses executor/v0.27.0 through executor manifest format 1; no executor digests change.
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ CLI-only patch releases can differ from the SDK patch. Compose import and SDK de
|
|
|
66
66
|
read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
|
|
67
67
|
|
|
68
68
|
`init` and `add` read their templates from the locally installed
|
|
69
|
-
`@synopackageland/templates`. CLI 0.
|
|
69
|
+
`@synopackageland/templates`. CLI 0.31.0 depends on `~0.31.0`: npm installation or
|
|
70
70
|
update can select the latest templates patch in the 0.29 minor line. Templates
|
|
71
71
|
patches need only a templates release; update the npm installation to receive them.
|
|
72
72
|
The CLI never fetches templates at runtime, so offline use works after installation;
|
|
@@ -308,7 +308,8 @@ and default to `external_access: {connect: optional, origin_change: none}`.
|
|
|
308
308
|
Declare `identity_gateway.external_access.connect: none` to ignore Connect's
|
|
309
309
|
public URL projection; manual URLs remain available without Connect. Use
|
|
310
310
|
`origin_change: confirm` for Apps that need confirmation when their public
|
|
311
|
-
origin changes
|
|
311
|
+
origin changes, and `single_origin: true` (SDK 0.30.0+) for an upstream that
|
|
312
|
+
works on one URL only. `connect: required` is rejected, and declaring `external_access`
|
|
312
313
|
with SDK older than 0.29.0 is rejected. Compose and gateway SDK pins must match.
|
|
313
314
|
See [external access configuration](skills/reference/identity-gateway.md#external-access)
|
|
314
315
|
for the manifest, settings entry, URL rules and cold-login handoff.
|
|
@@ -338,7 +339,10 @@ DSM readiness; inspect lists explicit acceptance blockers.
|
|
|
338
339
|
## Control installed Apps
|
|
339
340
|
|
|
340
341
|
Use `synopkgland ctl host add nas --url https://nas.example:5001 --username alice`
|
|
341
|
-
to register and authenticate a NAS in OS secure storage.
|
|
342
|
+
to register and authenticate a NAS in OS secure storage. A bare host name uses
|
|
343
|
+
`https://<host>:5001`; provide a full URL for a different scheme or port.
|
|
344
|
+
The CLI loads only `DSM_*` and `SYNOPKGLAND_*` variables from `.env`; set other
|
|
345
|
+
process variables in the shell. `ctl host list --json`
|
|
342
346
|
checks sessions; `ctl list --host nas --json` discovers all approved task-level
|
|
343
347
|
commands and their schemas. Run the listed alias/command with typed flags,
|
|
344
348
|
`--help` and `--dry-run`; writes require `--yes`. There is no mutable current
|
package/dist/bin.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { loadDotEnv } from "./load-env.js";
|
|
3
3
|
import { runCli } from "./cli.js";
|
|
4
|
+
import { terminalSafe } from "./terminal-text.js";
|
|
4
5
|
loadDotEnv();
|
|
5
6
|
// Package deployment is explicit; this CLI does not publish releases.
|
|
6
7
|
// Successful completion leaves Node's default 0 or the command's exitCode intact.
|
|
7
8
|
runCli(process.argv).catch((error) => {
|
|
8
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
9
|
+
console.error(terminalSafe(error instanceof Error ? error.message : String(error)));
|
|
9
10
|
process.exit(1);
|
|
10
11
|
});
|
package/dist/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ import { checkJobs, commitOutputs, forEachArch, } from "./standalone/arch-jobs.j
|
|
|
36
36
|
import { checkReproducible } from "./reproducible.js";
|
|
37
37
|
import { spkApprovalDiff, formatApprovalDiff } from "./runtime/approval-diff.js";
|
|
38
38
|
import { inspectAnySpk } from "./spk/inspect.js";
|
|
39
|
+
import { terminalSafe } from "./terminal-text.js";
|
|
39
40
|
function failure(code, message, hint = "Fix the configuration and retry; use --help to see command usage.") {
|
|
40
41
|
return Object.assign(new Error(codedMessage(code, message)), { code, hint });
|
|
41
42
|
}
|
|
@@ -295,7 +296,7 @@ export async function runCli(argv) {
|
|
|
295
296
|
const json = argv.includes("--json");
|
|
296
297
|
const stdout = console.log.bind(console);
|
|
297
298
|
const progress = console.log;
|
|
298
|
-
const emit = (command, data, text) => json ? stdout(JSON.stringify({ ok: true, command, data })) : stdout(text);
|
|
299
|
+
const emit = (command, data, text) => json ? stdout(JSON.stringify({ ok: true, command, data })) : stdout(terminalSafe(text));
|
|
299
300
|
const program = new Command()
|
|
300
301
|
.name("synopkgland")
|
|
301
302
|
.description("Syno Package Land Developer Kit CLI")
|
package/dist/ctl/cli.js
CHANGED
|
@@ -52,7 +52,7 @@ function q(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
|
|
|
52
52
|
function example(host, app, op) {
|
|
53
53
|
const base = `synopkgland ctl ${app.payload.aliases[0] ?? app.payload.package.id} ${op.cli.join(' ')} --host ${host}`;
|
|
54
54
|
return (op.examples?.length ? op.examples : [{}]).map(params => {
|
|
55
|
-
const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => ['string', 'number', 'boolean'].includes(typeof value) && !RESERVED_FLAGS.has(flagName(key)) && op.paramsSchema?.properties?.[key]);
|
|
55
|
+
const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => ['string', 'number', 'boolean'].includes(typeof value) && /^[A-Za-z][A-Za-z0-9_]*$/.test(key) && !RESERVED_FLAGS.has(flagName(key)) && op.paramsSchema?.properties?.[key]);
|
|
56
56
|
if (simple)
|
|
57
57
|
return base + Object.entries(params).map(([key, value]) => ' --' + flagName(key) + ' ' + q(String(value))).join('') + (op.effect === 'read' ? '' : ' --yes');
|
|
58
58
|
return `printf '%s' ${q(JSON.stringify(params))} | ${base} --input -` + (op.effect === 'read' ? '' : ' --yes');
|
package/dist/ctl/json.js
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
import { openSync, closeSync, readSync } from 'node:fs';
|
|
2
|
-
|
|
2
|
+
/** Linear duplicate-key scan over text that JSON.parse already accepted (yaml's uniqueKeys check is quadratic). */
|
|
3
|
+
function hasDuplicateKeys(text) {
|
|
4
|
+
const stack = [];
|
|
5
|
+
let expectKey = false;
|
|
6
|
+
for (let i = 0; i < text.length; i++) {
|
|
7
|
+
const c = text[i];
|
|
8
|
+
if (c === '"') {
|
|
9
|
+
let j = i + 1;
|
|
10
|
+
while (text[j] !== '"')
|
|
11
|
+
j += text[j] === '\\' ? 2 : 1;
|
|
12
|
+
const top = stack[stack.length - 1];
|
|
13
|
+
if (top && expectKey) {
|
|
14
|
+
const key = JSON.parse(text.slice(i, j + 1));
|
|
15
|
+
if (top.has(key))
|
|
16
|
+
return true;
|
|
17
|
+
top.add(key);
|
|
18
|
+
expectKey = false;
|
|
19
|
+
}
|
|
20
|
+
i = j;
|
|
21
|
+
}
|
|
22
|
+
else if (c === '{') {
|
|
23
|
+
stack.push(new Set());
|
|
24
|
+
expectKey = true;
|
|
25
|
+
}
|
|
26
|
+
else if (c === '[') {
|
|
27
|
+
stack.push(null);
|
|
28
|
+
}
|
|
29
|
+
else if (c === '}' || c === ']') {
|
|
30
|
+
stack.pop();
|
|
31
|
+
}
|
|
32
|
+
else if (c === ',' && stack[stack.length - 1])
|
|
33
|
+
expectKey = true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
3
37
|
export function strictJson(raw) {
|
|
4
38
|
const text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw);
|
|
5
39
|
const value = JSON.parse(text);
|
|
6
|
-
if (
|
|
40
|
+
if (hasDuplicateKeys(text))
|
|
7
41
|
throw Error('Duplicate JSON properties');
|
|
8
42
|
const check = (v, depth) => { if (depth > 32)
|
|
9
43
|
throw Error('JSON too deep'); if (typeof v === 'number' && !Number.isFinite(v))
|
package/dist/ctl/service.js
CHANGED
|
@@ -37,7 +37,7 @@ export class CtlClient {
|
|
|
37
37
|
const entries = response.data?.packages;
|
|
38
38
|
if (!Array.isArray(entries) || response.data.total !== entries.length || entries.length > 4096)
|
|
39
39
|
throw new CtlError('CTL_INVENTORY_INVALID', 'Incomplete installed-package inventory.', 'Check the DSM version and permissions.', 5);
|
|
40
|
-
return entries.map((p) => { if (typeof p.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(p.id) || typeof p.version !== 'string')
|
|
40
|
+
return entries.map((p) => { if (typeof p.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(p.id) || typeof p.version !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._+~-]{0,63}$/.test(p.version))
|
|
41
41
|
throw new CtlError('CTL_INVENTORY_INVALID', 'Invalid package identity.', 'Check the DSM response.', 5); return { id: p.id, version: p.version, state: p.additional?.status }; });
|
|
42
42
|
}
|
|
43
43
|
async discover(options = {}) {
|
package/dist/error-registry.js
CHANGED
|
@@ -365,7 +365,7 @@ export const ERROR_REGISTRY = {
|
|
|
365
365
|
STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID: {
|
|
366
366
|
code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID",
|
|
367
367
|
description: "The external access declaration does not match the gateway contract.",
|
|
368
|
-
suggestion: "Set identity_gateway.external_access.connect to optional or none,
|
|
368
|
+
suggestion: "Set identity_gateway.external_access.connect to optional or none, origin_change to none or confirm, and single_origin to a boolean; required is not supported.",
|
|
369
369
|
},
|
|
370
370
|
STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED: {
|
|
371
371
|
code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED",
|
package/dist/host/credentials.js
CHANGED
|
@@ -56,7 +56,7 @@ function fromEnv(urlOverride) {
|
|
|
56
56
|
if (urlOverride) {
|
|
57
57
|
dsmOrigin = urlOverride.startsWith("http://") || urlOverride.startsWith("https://")
|
|
58
58
|
? urlOverride
|
|
59
|
-
: `
|
|
59
|
+
: `https://${urlOverride}:5001`;
|
|
60
60
|
}
|
|
61
61
|
const password = process.env.DSM_PASSWORD;
|
|
62
62
|
const username = process.env.DSM_USERNAME ?? "admin";
|
package/dist/host/dsm-http.js
CHANGED
|
@@ -52,30 +52,44 @@ export async function dsmFetch(url, init = {}) {
|
|
|
52
52
|
return curlFetch(url, init);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
function curlConfigString(value) {
|
|
56
|
+
const escapes = { "\\": "\\\\", '"': '\\"', "\t": "\\t", "\n": "\\n", "\r": "\\r", "\v": "\\v" };
|
|
57
|
+
return `"${value.replace(/[\\"\t\n\r\v]/g, (c) => escapes[c])}"`;
|
|
58
|
+
}
|
|
55
59
|
async function curlFetch(url, init) {
|
|
56
60
|
const staging = mkdtempSync(join(tmpdir(), "synopkgland-dsm-http-"));
|
|
57
61
|
const headerFile = join(staging, "headers");
|
|
58
62
|
const bodyFile = join(staging, "body");
|
|
59
63
|
try {
|
|
60
|
-
const
|
|
64
|
+
const configFile = join(staging, "config");
|
|
65
|
+
const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET", "-K", configFile];
|
|
66
|
+
// Headers and form text go through a private config file, not argv, and
|
|
67
|
+
// form text is sent literally (curl -F would read "@file" and "<file").
|
|
68
|
+
const config = [];
|
|
61
69
|
const headers = headerRecord(init.headers);
|
|
62
70
|
for (const [name, value] of Object.entries(headers)) {
|
|
63
71
|
if (name.toLowerCase() === "content-type" && init.body instanceof FormData) {
|
|
64
72
|
continue;
|
|
65
73
|
}
|
|
66
|
-
|
|
74
|
+
if (/[\r\n\0]/.test(name + value)) {
|
|
75
|
+
throw new Error(`Invalid header value: ${name}`);
|
|
76
|
+
}
|
|
77
|
+
config.push(`header = ${curlConfigString(`${name}: ${value}`)}`);
|
|
67
78
|
}
|
|
68
79
|
let stdin;
|
|
69
80
|
if (init.body instanceof FormData) {
|
|
70
81
|
for (const [name, value] of init.body.entries()) {
|
|
71
82
|
if (typeof value === "string") {
|
|
72
|
-
|
|
83
|
+
if (/[=\0]/.test(name) || value.includes("\0")) {
|
|
84
|
+
throw new Error(`Invalid form field: ${name}`);
|
|
85
|
+
}
|
|
86
|
+
config.push(`form-string = ${curlConfigString(`${name}=${value}`)}`);
|
|
73
87
|
continue;
|
|
74
88
|
}
|
|
75
89
|
const filename = "name" in value && typeof value.name === "string" ? value.name : "upload.bin";
|
|
76
90
|
const partPath = join(staging, filename);
|
|
77
91
|
writeFileSync(partPath, Buffer.from(await value.arrayBuffer()));
|
|
78
|
-
|
|
92
|
+
config.push(`form = ${curlConfigString(`${name}=@${partPath};filename=${filename}`)}`);
|
|
79
93
|
}
|
|
80
94
|
}
|
|
81
95
|
else if (typeof init.body === "string") {
|
|
@@ -86,6 +100,7 @@ async function curlFetch(url, init) {
|
|
|
86
100
|
stdin = Buffer.from(await new Response(init.body).arrayBuffer());
|
|
87
101
|
args.push("--data-binary", "@-");
|
|
88
102
|
}
|
|
103
|
+
writeFileSync(configFile, config.map((line) => line + "\n").join(""), { mode: 0o600 });
|
|
89
104
|
args.push(url);
|
|
90
105
|
const result = spawnSync("curl", args, { encoding: "utf8", input: stdin });
|
|
91
106
|
if (result.status !== 0) {
|
package/dist/load-env.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
// A project .env may only supply the CLI's own settings, never variables that
|
|
4
|
+
// change how child tools (go, docker, curl, tar) run.
|
|
5
|
+
const DOTENV_KEY = /^(?:DSM|SYNOPKGLAND)_[A-Z0-9_]+$/;
|
|
3
6
|
export function loadDotEnv(fromDir = process.cwd()) {
|
|
4
7
|
let current = resolve(fromDir);
|
|
5
8
|
while (true) {
|
|
@@ -16,7 +19,7 @@ export function loadDotEnv(fromDir = process.cwd()) {
|
|
|
16
19
|
}
|
|
17
20
|
const key = trimmed.slice(0, eq).trim();
|
|
18
21
|
const value = trimmed.slice(eq + 1).trim();
|
|
19
|
-
if (process.env[key] === undefined) {
|
|
22
|
+
if (DOTENV_KEY.test(key) && process.env[key] === undefined) {
|
|
20
23
|
process.env[key] = value;
|
|
21
24
|
}
|
|
22
25
|
}
|
|
@@ -16,6 +16,8 @@ export type GatewayMode = "none" | "oidc" | "header" | "token";
|
|
|
16
16
|
export interface GatewayExternalAccess {
|
|
17
17
|
connect: "optional" | "none";
|
|
18
18
|
origin_change: "none" | "confirm";
|
|
19
|
+
/** One-URL upstream: DSM launch and local navigations go to the primary external origin. */
|
|
20
|
+
single_origin?: true;
|
|
19
21
|
}
|
|
20
22
|
export declare const EXTERNAL_ACCESS_MIN_SDK = "0.29.0";
|
|
21
23
|
/** SDK 0.28 has the diagnostic ping only; older gateways reject external JSON. */
|
|
@@ -167,13 +167,14 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
167
167
|
let externalAccess;
|
|
168
168
|
if (v.external_access !== undefined) {
|
|
169
169
|
const external = v.external_access;
|
|
170
|
-
if (!record(external) || !only(external, ["connect", "origin_change"]) ||
|
|
170
|
+
if (!record(external) || !only(external, ["connect", "origin_change", "single_origin"]) ||
|
|
171
171
|
(external.connect !== undefined && external.connect !== "optional" && external.connect !== "none") ||
|
|
172
|
-
(external.origin_change !== undefined && external.origin_change !== "none" && external.origin_change !== "confirm")
|
|
173
|
-
|
|
172
|
+
(external.origin_change !== undefined && external.origin_change !== "none" && external.origin_change !== "confirm") ||
|
|
173
|
+
(external.single_origin !== undefined && typeof external.single_origin !== "boolean")) {
|
|
174
|
+
fail({ code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID", message: "identity_gateway.external_access accepts only connect (optional or none), origin_change (none or confirm) and single_origin (boolean); required is not supported." });
|
|
174
175
|
return undefined;
|
|
175
176
|
}
|
|
176
|
-
externalAccess = { connect: external.connect ?? "optional", origin_change: external.origin_change ?? "none" };
|
|
177
|
+
externalAccess = { connect: external.connect ?? "optional", origin_change: external.origin_change ?? "none", ...(external.single_origin ? { single_origin: true } : {}) };
|
|
177
178
|
}
|
|
178
179
|
if (v.launch_path !== undefined) {
|
|
179
180
|
try {
|
|
@@ -531,6 +532,10 @@ export async function buildIdentityGateway(m, arch, work, run) {
|
|
|
531
532
|
if (!existsSync(config) || !/`json:"external(?:,omitempty)?"`/.test(readFileSync(config, "utf8")) ||
|
|
532
533
|
!existsSync(main) || !/["']\/?settings\.cgi["']/.test(readFileSync(main, "utf8")))
|
|
533
534
|
throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED", `The selected SDK source lacks external access or settings.cgi support; use SDK ${EXTERNAL_ACCESS_MIN_SDK} or later with matching sources.`);
|
|
535
|
+
const external = join(sdk.goDir, "idgateway/external.go");
|
|
536
|
+
if (gatewayExternalAccess(m.identityGateway)?.single_origin &&
|
|
537
|
+
(!existsSync(external) || !/`json:"single_origin(?:,omitempty)?"`/.test(readFileSync(external, "utf8"))))
|
|
538
|
+
throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED", "The selected SDK source lacks external_access.single_origin support; use a matching SDK release or an explicit development source override.");
|
|
534
539
|
}
|
|
535
540
|
if (m.identityGateway.oidc?.browser_redirect) {
|
|
536
541
|
const capability = join(sdk.goDir, "idgateway/browser_redirect.go");
|
|
@@ -1037,6 +1037,11 @@ function readInfo(path, findings, kind, compose = false, daemonNode) {
|
|
|
1037
1037
|
findings.push(`INFO is missing ${key}`);
|
|
1038
1038
|
}
|
|
1039
1039
|
}
|
|
1040
|
+
// Every SDK build writes this package grammar; inspect renders paths and
|
|
1041
|
+
// configs from it, so anything else is not an SDK-built package.
|
|
1042
|
+
if (info.package && !/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package)) {
|
|
1043
|
+
findings.push("INFO package is not a valid SDK package id");
|
|
1044
|
+
}
|
|
1040
1045
|
if (kind !== "container" &&
|
|
1041
1046
|
(info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
|
|
1042
1047
|
findings.push('INFO must have thirdparty="yes" and dsmuidir="ui" for DSM to serve /webman/3rdparty/<package>/');
|
|
@@ -1135,8 +1140,9 @@ async function inspectExternalGateway(dir, paths, payload, saved, findings) {
|
|
|
1135
1140
|
return;
|
|
1136
1141
|
const external = saved;
|
|
1137
1142
|
if (!external || typeof external !== "object" || Array.isArray(external) ||
|
|
1138
|
-
Object.keys(external).length !== 2 || (external.connect !== "optional" && external.connect !== "none") ||
|
|
1143
|
+
Object.keys(external).length !== (external.single_origin === undefined ? 2 : 3) || (external.connect !== "optional" && external.connect !== "none") ||
|
|
1139
1144
|
(external.origin_change !== "none" && external.origin_change !== "confirm") ||
|
|
1145
|
+
(external.single_origin !== undefined && external.single_origin !== true) ||
|
|
1140
1146
|
!isDeepStrictEqual(cfg.external, external) || cfg.package !== info.package)
|
|
1141
1147
|
throw new Error("external configuration differs from its package declaration");
|
|
1142
1148
|
const arch = info.arch.split(/\s+/).map(familyOfArchToken).find(Boolean) ?? "x86_64";
|
package/dist/standalone/spk.js
CHANGED
|
@@ -6,9 +6,9 @@ import { resolvedSdkLaunch } from "../contract/app-launch.js";
|
|
|
6
6
|
import { gatewayAdmissionPolicy } from "./identity-gateway.js";
|
|
7
7
|
import { metadataInfoFields } from "../package-metadata.js";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
9
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, realpathSync, } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
|
-
import { dirname, join } from "node:path";
|
|
11
|
+
import { dirname, join, sep } from "node:path";
|
|
12
12
|
import { infoArchValue } from "../build-spk/dsm-arch.js";
|
|
13
13
|
import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons, } from "../build-spk/dsm-icons.js";
|
|
14
14
|
import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
|
|
@@ -78,7 +78,7 @@ export async function assembleStandaloneSpk(options) {
|
|
|
78
78
|
const uiRoot = join(staging, "package", "ui");
|
|
79
79
|
const appDir = join(uiRoot, "app");
|
|
80
80
|
if (hasSdkWindow(manifest))
|
|
81
|
-
copyUiTree(manifest.ui.dir, appDir);
|
|
81
|
+
copyUiTree(manifest.ui.dir, appDir, manifest.root);
|
|
82
82
|
else
|
|
83
83
|
mkdirSync(appDir, { recursive: true });
|
|
84
84
|
const assetVersion = digestTree(appDir).slice(0, 12);
|
|
@@ -386,12 +386,17 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
|
|
|
386
386
|
fields.push(['startstop_restart_services', 'nginx.service']);
|
|
387
387
|
return portalInfoFields(fields, Boolean(manifest.portal) || nginx).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
|
|
388
388
|
}
|
|
389
|
-
function copyUiTree(source, target) {
|
|
389
|
+
function copyUiTree(source, target, root) {
|
|
390
390
|
for (const entry of ["app.js", "app.css"]) {
|
|
391
391
|
if (!existsSync(join(source, entry))) {
|
|
392
392
|
throw new StandaloneBuildError("STANDALONE_UI_ENTRY_MISSING", `The UI directory is missing ${entry}: ${source}`);
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
|
+
const real = realpathSync(source);
|
|
396
|
+
const realRoot = realpathSync(root);
|
|
397
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) {
|
|
398
|
+
throw new StandaloneBuildError("STANDALONE_UI_SYMLINK", `The UI directory must resolve inside the project: ${source}`);
|
|
399
|
+
}
|
|
395
400
|
let total = 0;
|
|
396
401
|
const walk = (from, to) => {
|
|
397
402
|
mkdirSync(to, { recursive: true });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function terminalSafe(text: string): string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Package-supplied strings (INFO fields, file names, DSM data) reach human
|
|
2
|
+
// output; show control characters as escapes so they cannot drive the terminal.
|
|
3
|
+
export function terminalSafe(text) {
|
|
4
|
+
return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
5
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@napi-rs/keyring": "^2.1.1",
|
|
30
|
-
"@synopackageland/templates": "~0.
|
|
30
|
+
"@synopackageland/templates": "~0.31.0",
|
|
31
31
|
"ajv": "^8.17.1",
|
|
32
32
|
"commander": "^13.1.0",
|
|
33
33
|
"json-canonicalize": "^3.0.1",
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## External access
|
|
4
4
|
|
|
5
|
-
Use SDK 0.
|
|
5
|
+
Use SDK 0.30.0 or later with matching gateway sources. Add this to the existing
|
|
6
6
|
complete gateway declaration; keep the App's upstream, listener and auth policy:
|
|
7
7
|
|
|
8
8
|
```yaml
|
|
9
9
|
identity_gateway:
|
|
10
|
-
sdk: 0.
|
|
10
|
+
sdk: 0.30.0
|
|
11
11
|
external_access:
|
|
12
12
|
connect: optional # optional | none
|
|
13
13
|
origin_change: none # none | confirm
|
|
14
|
+
single_origin: false # true for an upstream that works on one URL only
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
Omitting the block or either member uses `connect: optional` and
|
|
@@ -38,11 +39,18 @@ installation. It edits only `manual_origin` and `dsm_origin` in private
|
|
|
38
39
|
URL fields.
|
|
39
40
|
|
|
40
41
|
Public origins must use HTTPS and a DNS hostname. Port 443 is normalized;
|
|
41
|
-
other ports, IP addresses, paths, queries and user information are rejected,
|
|
42
|
+
other ports (except a single-origin App's `manual_origin`), IP addresses, paths, queries and user information are rejected,
|
|
42
43
|
as is an origin equal to the DSM origin. Manual takes precedence, then the
|
|
43
44
|
Connect projection when `connect: optional`; the first effective origin is
|
|
44
45
|
primary. `connect: none` ignores that projection. Private URLs are display-only.
|
|
45
46
|
Use `origin_change: confirm` for an App that must confirm public-origin changes.
|
|
47
|
+
Declare `single_origin: true` (with `origin_change: confirm`) for an App whose
|
|
48
|
+
upstream sign-in works on one fixed URL, such as Vaultwarden's `DOMAIN`. While
|
|
49
|
+
an external origin is set, the DSM launcher opens the primary origin and local
|
|
50
|
+
browser navigations redirect to it; the settings page says that only that
|
|
51
|
+
address can sign in. Without an external origin nothing changes. The generated
|
|
52
|
+
`external` object then carries `"single_origin":true`, and build rejects a
|
|
53
|
+
development SDK source without `single_origin` support.
|
|
46
54
|
|
|
47
55
|
Cold DSM login preserves external resume tokens
|
|
48
56
|
`<txn>:x<n>:<r1>.<r2>` in the fixed `authorize.cgi?resume=<encoded token>`
|
|
@@ -260,3 +268,5 @@ control characters are rejected. Caller query values cannot override it.
|
|
|
260
268
|
Use the upstream's supported entry route; this does not modify upstream UI,
|
|
261
269
|
produce an App session, or unlock encrypted data. HTTPS and App-specific
|
|
262
270
|
fragment/query semantics still require deployment acceptance.
|
|
271
|
+
|
|
272
|
+
Single-origin Apps require SDK 0.30.0 or later. Their `manual_origin` may include an explicit HTTPS port (for example, `https://example.ts.net:29443`). The DSM launcher resolves the destination for the provider used to open DSM, probes `/_spl/ping` for up to eight seconds, and offers **Open anyway** if the probe fails. A single-origin App always uses its configured primary origin. Other Apps and the `external-origin` install setting continue to require HTTPS DNS origins on port 443.
|