@synopackageland/cli 0.30.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 CHANGED
@@ -1,14 +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
+
3
12
  ## 0.30.0 (2026-10-24)
4
13
 
5
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.
6
15
  - Pin templates 0.30.x and its SDK/App 0.30.0 defaults; keep immutable executor/v0.27.0.
7
16
 
8
- ## Unreleased
9
-
10
- - Accept and package `identity_gateway.external_access.single_origin` (boolean) for one-URL upstreams. Build requires SDK sources with `single_origin` support, and inspect accepts the packaged `single_origin: true` declaration.
11
-
12
17
  ## 0.29.0 (2026-10-24)
13
18
 
14
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.30.0 depends on `~0.30.0`: npm installation or
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;
@@ -339,7 +339,10 @@ DSM readiness; inspect lists explicit acceptance blockers.
339
339
  ## Control installed Apps
340
340
 
341
341
  Use `synopkgland ctl host add nas --url https://nas.example:5001 --username alice`
342
- to register and authenticate a NAS in OS secure storage. `ctl host list --json`
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`
343
346
  checks sessions; `ctl list --host nas --json` discovers all approved task-level
344
347
  commands and their schemas. Run the listed alias/command with typed flags,
345
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
- import { parseDocument } from 'yaml';
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 (parseDocument(text, { uniqueKeys: true }).errors.length)
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))
@@ -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 = {}) {
@@ -56,7 +56,7 @@ function fromEnv(urlOverride) {
56
56
  if (urlOverride) {
57
57
  dsmOrigin = urlOverride.startsWith("http://") || urlOverride.startsWith("https://")
58
58
  ? urlOverride
59
- : `http://${urlOverride}:5000`;
59
+ : `https://${urlOverride}:5001`;
60
60
  }
61
61
  const password = process.env.DSM_PASSWORD;
62
62
  const username = process.env.DSM_USERNAME ?? "admin";
@@ -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 args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET"];
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
- args.push("-H", `${name}: ${value}`);
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
- args.push("-F", `${name}=${value}`);
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
- args.push("-F", `${name}=@${partPath};filename=${filename}`);
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
  }
@@ -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>/');
@@ -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.30.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.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",