@synopackageland/cli 0.41.12 → 0.42.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +27 -12
  3. package/dist/ctl/cli.d.ts +5 -0
  4. package/dist/ctl/cli.js +12 -5
  5. package/dist/ctl/descriptor.js +2 -1
  6. package/dist/ctl/input.d.ts +5 -1
  7. package/dist/ctl/input.js +28 -14
  8. package/dist/ctl/pattern.d.ts +12 -0
  9. package/dist/ctl/pattern.js +10 -0
  10. package/dist/ctl/redaction.js +21 -2
  11. package/dist/ctl/service.d.ts +17 -5
  12. package/dist/ctl/service.js +33 -22
  13. package/dist/e2e/app-open.js +5 -1
  14. package/dist/error-registry.js +2 -2
  15. package/dist/host/package-commands.d.ts +15 -0
  16. package/dist/host/package-commands.js +15 -4
  17. package/dist/runtime/sdk-source.d.ts +2 -0
  18. package/dist/runtime/sdk-source.js +2 -0
  19. package/dist/spk/archive-inventory.d.ts +9 -0
  20. package/dist/spk/archive-inventory.js +65 -0
  21. package/dist/spk/check-payload.js +9 -0
  22. package/dist/spk/rules.d.ts +5 -0
  23. package/dist/spk/rules.js +1 -0
  24. package/dist/spk/validate-spk.js +12 -0
  25. package/dist/standalone/compose.js +4 -4
  26. package/dist/standalone/daemon-ingress.d.ts +3 -2
  27. package/dist/standalone/daemon-ingress.js +12 -9
  28. package/dist/standalone/daemon-restart.d.ts +1 -1
  29. package/dist/standalone/daemon-restart.js +3 -3
  30. package/dist/standalone/daemon.d.ts +2 -2
  31. package/dist/standalone/daemon.js +5 -5
  32. package/dist/standalone/inspect-bridge.d.ts +3 -1
  33. package/dist/standalone/inspect-bridge.js +6 -3
  34. package/dist/standalone/inspect-spk.js +27 -7
  35. package/dist/standalone/install-settings.js +2 -2
  36. package/dist/standalone/manifest.js +1 -1
  37. package/dist/standalone/native-layout.js +18 -1
  38. package/dist/template-paths.d.ts +0 -4
  39. package/dist/template-paths.js +0 -11
  40. package/docs/ctl-external.md +16 -0
  41. package/package.json +3 -2
  42. package/skills/SKILL.md +1 -1
  43. package/skills/reference/ctl.md +24 -0
  44. package/skills/reference/daemon-mode.md +5 -0
  45. package/skills/reference/install.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.42.0 (2026-11-01)
6
+
7
+ - Require templates ~0.37.0 so `init` and `add` scaffold with UI 0.19.1 and
8
+ SDK/App 0.41.1 (executor/v0.39.0). CLI 0.41.x stays on the templates 0.35
9
+ minor line.
10
+ - Pin CLI-owned runtime defaults (daemon controllers, Compose planning and
11
+ installer context) to SDK 0.41.1, matching the templates. Explicit App SDK
12
+ declarations still take precedence.
13
+ - Skip installation when the requested package version is already installed,
14
+ including DSM build suffixes; report `action: "none"` in plans and avoid
15
+ downloads, uploads and reinstalling on confirmed runs.
16
+ - Accept repeated ctl flags for arrays of scalar values, validate their item
17
+ schemas and expose repeatable flags in command help and examples. Preserve
18
+ sensitive-value redaction and JSON input for complex parameters.
19
+ - Return `CTL_APP_NEEDS_APPROVAL` (exit 4) with an App-scoped
20
+ `ctl list --app <package> --approve-installed` hint when an installed App,
21
+ called by package ID or a cached alias, awaits first, new-version or
22
+ changed-description approval. Cached aliases identify upgraded packages but
23
+ never authorize execution; `CTL_APP_UNKNOWN` suggestions apply only to names
24
+ matching no installed package or known alias.
25
+
26
+ ## 0.41.13
27
+
28
+ - Pin CLI-owned runtime defaults to SDK 0.41.0 independently of templates
29
+ - Check daemon restart support through the policy decoder and normalizer
30
+ - Bind daemon ingress to the exact, case-sensitive `/synopkgland/<package>/`
31
+ namespace across manifest validation, generated proxies, daemon environment,
32
+ SPK inspection and App smoke entry selection.
33
+ - Use bounded RE2 patterns for ctl schema validation and sensitive-value
34
+ redaction; reject unsupported validation patterns and redact conservatively
35
+ when an unvalidated privacy pattern cannot be compiled.
36
+ - Restrict native layout systemd units to package-process directives; reject
37
+ manager actions, activation dependencies and unknown directives or sections.
38
+ - Inspect bridge access against the declared package entry and validate its
39
+ permissions policy even when its embedded digest matches.
40
+ - Require executable native lifecycle scripts and the exact package privilege
41
+ declaration.
42
+ - Inventory both archives before SDK package extraction; flag duplicate names,
43
+ case or Unicode collisions, noncanonical paths and unexpected entry types,
44
+ including type flags otherwise ignored by the tar reader.
45
+
3
46
  ## 0.41.12 (2026-10-31)
4
47
 
5
48
  - Remove the delegated WebAPI profile API-name and method-name word lists so
package/README.md CHANGED
@@ -47,7 +47,8 @@ See the [upstream scaffold and release checklist](skills/reference/upstream-fork
47
47
  | `compose` | Unchanged upstream Compose, generated override and SDK planner | `dsm-package.yaml` |
48
48
 
49
49
  Existing native products with their own payload layout use `backend: {kind: native}` in
50
- `dsm-package.yaml`.
50
+ `dsm-package.yaml`. Their systemd units accept only the package-process settings
51
+ listed in [native systemd settings](docs/native-layout-systemd.md).
51
52
 
52
53
  Declare language-independent DSM installation requirements with
53
54
  [`dependencies.packages`](docs/manifest.md). They merge with inferred Node
@@ -88,8 +89,9 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
88
89
  packages. Private mirrors can use explicitly configured Go authentication and
89
90
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
90
91
 
91
- CLI 0.41.12 uses templates ~0.35.5, whose default SDK pin is 0.38.2. Compose import and SDK defaults
92
- read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
92
+ The CLI pins SDK 0.41.1 for its daemon controllers, Compose planning and installer
93
+ context. Explicit App SDK declarations take precedence. Templates use ~0.37.0
94
+ and retain their own scaffold SDK pins; none declare daemon ingress.
93
95
  Every App method must be declared in the permissions table. Dynamic names and
94
96
  custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
95
97
  `api.HandleHTTP` prefixes for authorized raw HTTP and `ipc.ServeUnix` for private
@@ -99,8 +101,8 @@ unimported development commands are excluded. Use
99
101
  authentication. See [App entry and registration](docs/app-entry.md).
100
102
 
101
103
  `init` and `add` read their templates from the locally installed
102
- `@synopackageland/templates`. CLI 0.41.12 depends on `~0.35.5`: npm installation or
103
- update can select the latest templates patch in the 0.35 minor line. Templates
104
+ `@synopackageland/templates`. CLI 0.42.0 depends on `~0.37.0`: npm installation or
105
+ update can select the latest templates patch in the 0.37 minor line. Templates
104
106
  patches need only a templates release; update the npm installation to receive them.
105
107
  The CLI never fetches templates at runtime, so offline use works after installation;
106
108
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -109,8 +111,8 @@ exact SDK Go module sum anchors the build manifest; npm integrity anchors the
109
111
  identical manifest in `@synopackageland/sdk/executor-manifest.json`. No manifest
110
112
  is fetched at NAS runtime. Missing, malformed or unsupported new manifests fail
111
113
  closed. SDK 0.26.0 and 0.27.0 retain the CLI's frozen legacy inventory.
112
- SDK defaults continue to come from the actual installed templates. An SDK minor
113
- release no longer requires a CLI release; new CLI capabilities or a new manifest
114
+ CLI runtime defaults use its pinned SDK independently of the templates. An SDK minor
115
+ release does not require a templates release; new CLI capabilities or a new manifest
114
116
  format can still require one.
115
117
 
116
118
  For development against local checkouts, set `SYNOPKGLAND_TEMPLATES_DIR` to a
@@ -535,7 +537,7 @@ for `ctl` calls; App command/job/transfer deadlines retain their existing defaul
535
537
  | 1 | Unexpected internal error (`CTL_INTERNAL`), or an uncaught CLI exception. |
536
538
  | 2 | Authentication/session failure: `CTL_NOT_AUTHENTICATED`, `CTL_AUTH_FAILED`, `CTL_CREDENTIAL_MISMATCH`, or unavailable OS credential storage. |
537
539
  | 3 | Invalid parameters, schema validation or unknown names: `CTL_PARAMS_INVALID`, `CTL_ARGUMENT_INVALID`, `CTL_APP_UNKNOWN`, `CTL_COMMAND_UNKNOWN`. |
538
- | 4 | Permission refusal that fails the command: `CTL_PERMISSION_DENIED`, `CTL_INVENTORY_DENIED`. |
540
+ | 4 | Approval/trust or permission refusal: `CTL_APP_NEEDS_APPROVAL`, `CTL_PERMISSION_DENIED`, `CTL_INVENTORY_DENIED`. |
539
541
  | 5 | App/contract failure that fails the command: `CTL_APP_ERROR`, `CTL_RESPONSE_INVALID`, `CTL_JOB_FAILED`. |
540
542
  | 6 | Network/HTTP failure or uncertain completion: `CTL_NETWORK`, `CTL_HTTP_ERROR`, `CTL_JOB_TIMEOUT`, `CTL_LOGOUT_FAILED`. |
541
543
  | 7 | Confirmation required (`CTL_CONFIRM_REQUIRED`): a write/destructive command or host removal without confirmation; no business operation runs. |
@@ -545,13 +547,26 @@ For example, `synopkgland ctl studio drafts create --host nas --json` without
545
547
  `--yes` returns `ok:false` with `CTL_CONFIRM_REQUIRED` and exits 7 once discovery
546
548
  and parameter validation succeed. Missing sessions fail earlier with exit 2.
547
549
  Discovery catches per-App description errors and excludes those Apps from its
548
- results. For example, a changed pinned description (`CTL_DESCRIPTION_CHANGED`)
550
+ results. For example, a changed pinned description (`CTL_APP_NEEDS_APPROVAL`)
549
551
  or an invalid descriptor (`CTL_DESCRIPTOR_INVALID`) appears in
550
552
  `ctl list --host nas --refresh --json` under `data.diagnostics`; the listing still
551
553
  returns `ok:true` and exits 0, even if no Apps remain. These diagnostic codes do
552
- not become process exit codes 4 or 5. Attempting to execute an excluded App with
553
- `--refresh` instead returns `ok:false`, `CTL_APP_UNKNOWN` and exit 3 because the
554
- App is absent from discovery results; no business operation runs.
554
+ not become process exit codes 4 or 5 for a listing. Executing or requesting help
555
+ for an installed App by package ID or a known cached alias instead returns its
556
+ specific error; no business operation runs. A first approval, new version, or
557
+ changed description returns `CTL_APP_NEEDS_APPROVAL` and exit 4, for example
558
+ `SynoReclaim 1.2.0 needs approval`, with the exact App-scoped hint:
559
+
560
+ ```sh
561
+ synopkgland ctl list --app SynoReclaim --approve-installed --host <profile>
562
+ ```
563
+
564
+ Obtain the user's authorization before approving. Previously approved aliases
565
+ identify upgraded installed packages without granting permission to execute.
566
+ Use the package ID for first approval when no alias has been discovered yet.
567
+ Only a name matching no installed package ID or known alias returns
568
+ `CTL_APP_UNKNOWN` with “Did you mean …” suggestions. Other description failures,
569
+ such as `CTL_DESCRIPTOR_INVALID`, remain specific errors (exit 5).
555
570
 
556
571
  `host list/show` can also succeed while reporting `authenticated:false` and
557
572
  exit 0. Check discovery `apps`/`diagnostics` and session fields as well as the exit
package/dist/ctl/cli.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface CtlIO {
12
12
  export declare function listView(host: string, apps: Descriptor[]): {
13
13
  commands: {
14
14
  parameters: string | Record<string, any>;
15
+ flags: {
16
+ flag: string;
17
+ valueType: string;
18
+ repeatable: boolean;
19
+ }[];
15
20
  examples: string[];
16
21
  summary: string;
17
22
  cli: string[];
package/dist/ctl/cli.js CHANGED
@@ -6,7 +6,7 @@ import { Writable } from 'node:stream';
6
6
  import { CtlError, errorData, ctlError } from './errors.js';
7
7
  import { CtlStore } from './store.js';
8
8
  import { CtlClient, boundedMap } from './service.js';
9
- import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } from './input.js';
9
+ import { selectHosts, selectOperation, flagName, flagValueType, RESERVED_FLAGS } from './input.js';
10
10
  import { canonicalOrigin, CTL_TIMEOUT_MS, createRequest } from './transport.js';
11
11
  import { loginDsm, logoutDsm } from './dsm-auth.js';
12
12
  import { publicOperation } from './redaction.js';
@@ -51,16 +51,23 @@ function take(args, flag, value = false) {
51
51
  return answer;
52
52
  }
53
53
  function q(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
54
+ function typedFlags(op) {
55
+ const fields = Object.entries(op.paramsSchema?.properties ?? {}), names = fields.map(([key]) => flagName(key));
56
+ if (names.some(name => RESERVED_FLAGS.has(name)) || new Set(names).size !== names.length)
57
+ return [];
58
+ return fields.flatMap(([key, schema]) => /^[A-Za-z][A-Za-z0-9_]*$/.test(key) && flagValueType(schema) ? [{ flag: '--' + flagName(key), valueType: flagValueType(schema), repeatable: schema.type === 'array' }] : []);
59
+ }
54
60
  function example(host, app, op) {
55
61
  const base = `synopkgland ctl ${app.payload.aliases[0] ?? app.payload.package.id} ${op.cli.join(' ')} --host ${host}`;
62
+ const flags = typedFlags(op);
56
63
  return (op.examples?.length ? op.examples : [{}]).map(params => {
57
- 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]);
64
+ const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => { const flag = flags.find(f => f.flag === '--' + flagName(key)); return flag && (flag.repeatable ? Array.isArray(value) && value.length > 0 && value.every(v => ['string', 'number', 'boolean'].includes(typeof v)) : ['string', 'number', 'boolean'].includes(typeof value)); });
58
65
  if (simple)
59
- return base + Object.entries(params).map(([key, value]) => ' --' + flagName(key) + ' ' + q(String(value))).join('') + (op.effect === 'read' ? '' : ' --yes');
66
+ return base + Object.entries(params).flatMap(([key, value]) => (Array.isArray(value) ? value : [value]).map(item => ' --' + flagName(key) + ' ' + q(String(item)))).join('') + (op.effect === 'read' ? '' : ' --yes');
60
67
  return `printf '%s' ${q(JSON.stringify(params))} | ${base} --input -` + (op.effect === 'read' ? '' : ' --yes');
61
68
  });
62
69
  }
63
- export function listView(host, apps) { return apps.map(app => ({ host, package: app.payload.package, aliases: app.payload.aliases, protocolVersion: app.version, developedBy: app.payload.provenance.developedBy, provenance: 'self-declared', hash: app.sha256, ...(app.payload.external ? { external: app.payload.external } : {}), commands: Object.entries(app.payload.commands).map(([id, original]) => { const op = publicOperation(original); return { id, ...op, parameters: op.paramsSchema ?? 'Undescribed parameters; use --input', examples: example(host, app, op) }; }) })); }
70
+ export function listView(host, apps) { return apps.map(app => ({ host, package: app.payload.package, aliases: app.payload.aliases, protocolVersion: app.version, developedBy: app.payload.provenance.developedBy, provenance: 'self-declared', hash: app.sha256, ...(app.payload.external ? { external: app.payload.external } : {}), commands: Object.entries(app.payload.commands).map(([id, original]) => { const op = publicOperation(original); return { id, ...op, parameters: op.paramsSchema ?? 'Undescribed parameters; use --input', flags: typedFlags(op), examples: example(host, app, op) }; }) })); }
64
71
  export async function runCtl(argv, io = {}) {
65
72
  if (argv[0] === 'external')
66
73
  return runExternal(argv.slice(1), io);
@@ -237,7 +244,7 @@ async function runCtlWithin(argv, io = {}) {
237
244
  if (args[0] === 'help' || help) {
238
245
  const nameArg = args[0] === 'help' ? args[1] : args[0];
239
246
  const data = await ctl.discover(discoverOptions);
240
- const app = selectApp(data.apps, nameArg ?? '');
247
+ const app = ctl.selectApp(data, nameArg ?? '');
241
248
  const path = args[0] === 'help' ? args.slice(2) : args.slice(1);
242
249
  const view = listView(name, [app])[0];
243
250
  return { host: name, ok: true, data: path.length ? { ...view, commands: [view.commands.find(c => c.id === selectOperation(app, path).id)] } : view };
@@ -3,9 +3,10 @@ import { createHash } from 'node:crypto';
3
3
  import { canonicalize } from 'json-canonicalize';
4
4
  import Ajv2020 from 'ajv/dist/2020.js';
5
5
  import { CtlError } from './errors.js';
6
+ import { schemaRegExp } from './pattern.js';
6
7
  export const DESCRIPTION_METHOD = 'synopkgland.describe';
7
8
  export const DESCRIPTION_FILE = 'synopkgland-api.json';
8
- export const ajv = new Ajv2020.default({ allErrors: true, strict: false, validateFormats: false, addUsedSchema: false });
9
+ export const ajv = new Ajv2020.default({ allErrors: true, strict: false, validateFormats: false, addUsedSchema: false, code: { regExp: schemaRegExp } });
9
10
  const descriptorSchema = ajv.compile(JSON.parse(readFileSync(new URL('../../schema/synopkgland-api-v1.schema.json', import.meta.url), 'utf8')));
10
11
  export function makeDescriptor(payload) { const value = { format: 'synopkgland.api', version: 1, payload }; return { ...value, sha256: createHash('sha256').update(canonicalize(value)).digest('hex') }; }
11
12
  export function validateDescriptor(value, id, version) {
@@ -1,12 +1,16 @@
1
1
  import { type Descriptor, type Operation } from './descriptor.js';
2
2
  export declare function selectHosts(names: string[], selected?: string, all?: boolean): string[];
3
- export declare function selectApp(apps: Descriptor[], name: string): Descriptor;
3
+ export declare function selectApp<T extends {
4
+ id: string;
5
+ aliases: string[];
6
+ }>(apps: T[], name: string): T;
4
7
  export declare function selectOperation(app: Descriptor, path: string[]): {
5
8
  id: string;
6
9
  operation: Operation;
7
10
  };
8
11
  export declare const RESERVED_FLAGS: Set<string>;
9
12
  export declare function flagName(key: string): string;
13
+ export declare function flagValueType(schema: any): string | undefined;
10
14
  export declare function operationInput(op: Operation, args: string[], stdin?: string, helpHint?: string): Record<string, unknown>;
11
15
  export declare function requestPlan(app: Descriptor, id: string, params: Record<string, unknown>): {
12
16
  package: string;
package/dist/ctl/input.js CHANGED
@@ -21,12 +21,15 @@ export function selectHosts(names, selected, all = false) {
21
21
  return [...new Set(hosts)];
22
22
  }
23
23
  export function selectApp(apps, name) {
24
- const found = apps.filter(a => [a.payload.package.id, ...a.payload.aliases].some(s => s.toLowerCase() === name.toLowerCase()));
24
+ const exact = apps.find(app => app.id.toLowerCase() === name.toLowerCase());
25
+ if (exact)
26
+ return exact;
27
+ const found = apps.filter(app => app.aliases.some(alias => alias.toLowerCase() === name.toLowerCase()));
25
28
  if (found.length > 1)
26
- throw new CtlError('CTL_APP_AMBIGUOUS', 'Alias matches multiple packages.', 'Use an exact DSM package ID.', 3, found.map(a => a.payload.package.id));
29
+ throw new CtlError('CTL_APP_AMBIGUOUS', 'Alias matches multiple packages.', 'Use an exact DSM package ID.', 3, found.map(app => app.id));
27
30
  if (found.length === 1)
28
31
  return found[0];
29
- return choose(name, apps.flatMap(a => [a.payload.package.id, ...a.payload.aliases]), 'app');
32
+ return choose(name, apps.flatMap(app => [app.id, ...app.aliases]), 'app');
30
33
  }
31
34
  export function selectOperation(app, path) {
32
35
  const found = Object.entries(app.payload.commands).find(([, op]) => op.cli.join(' ') === path.join(' '));
@@ -36,6 +39,10 @@ export function selectOperation(app, path) {
36
39
  }
37
40
  export const RESERVED_FLAGS = new Set(['ca', 'strict-tls', 'host', 'all-hosts', 'json', 'app', 'refresh', 'yes', 'dry-run', 'approve-installed', 'help', 'input', 'wait', 'no-wait', 'timeout']);
38
41
  export function flagName(key) { return key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()).replaceAll('_', '-'); }
42
+ export function flagValueType(schema) {
43
+ const type = schema?.type === 'array' ? schema.items?.type : schema?.type;
44
+ return ['string', 'integer', 'number', 'boolean'].includes(type) ? type : undefined;
45
+ }
39
46
  export function operationInput(op, args, stdin, helpHint) {
40
47
  const fail = (message, allowed) => { throw new CtlError('CTL_PARAMS_INVALID', message, helpHint ?? ('run: synopkgland ctl <package> ' + op.cli.join(' ') + ' --help'), 3, allowed); };
41
48
  const inputIndex = args.indexOf('--input');
@@ -78,35 +85,42 @@ export function operationInput(op, args, stdin, helpHint) {
78
85
  const field = fields.get(flag);
79
86
  if (!field)
80
87
  return fail('Unknown parameter flag.', [...fields.keys()]);
81
- const [key, schema] = field;
82
- if (Object.hasOwn(params, key))
88
+ const [key, schema] = field, repeatable = schema.type === 'array', type = flagValueType(schema);
89
+ if (Object.hasOwn(params, key) && !repeatable)
83
90
  return fail(`Duplicate parameter flag ${flag}.`);
84
- if (!['string', 'integer', 'number', 'boolean'].includes(schema.type))
91
+ if (!type)
85
92
  return fail(`Complex parameter ${flag}: use --input.`);
86
93
  let value = split > 0 ? args[i].slice(split + 1) : undefined;
87
- if (value === undefined && schema.type === 'boolean' && (!args[i + 1] || args[i + 1].startsWith('--')))
94
+ if (value === undefined && type === 'boolean' && (!args[i + 1] || args[i + 1].startsWith('--')))
88
95
  value = 'true';
96
+ else if (value === undefined && (!args[i + 1] || args[i + 1].startsWith('--')))
97
+ return fail(`Missing parameter value for ${flag}.`);
89
98
  else if (value === undefined)
90
99
  value = args[++i];
91
100
  if (value === undefined)
92
101
  return fail(`Missing parameter value for ${flag}.`);
93
- if (schema.type === 'integer') {
102
+ let parsed = value;
103
+ if (type === 'integer') {
94
104
  if (!/^-?\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
95
105
  return fail(`Parameter ${flag} must be a safe integer.`);
96
- params[key] = Number(value);
106
+ parsed = Number(value);
97
107
  }
98
- else if (schema.type === 'number') {
108
+ else if (type === 'number') {
99
109
  if (!value.trim() || !Number.isFinite(Number(value)))
100
110
  return fail(`Parameter ${flag} must be a number.`);
101
- params[key] = Number(value);
111
+ parsed = Number(value);
102
112
  }
103
- else if (schema.type === 'boolean') {
113
+ else if (type === 'boolean') {
104
114
  if (!['true', 'false'].includes(value))
105
115
  return fail(`Parameter ${flag} must be true or false.`);
106
- params[key] = value === 'true';
116
+ parsed = value === 'true';
117
+ }
118
+ if (repeatable) {
119
+ const values = (params[key] ??= []);
120
+ values.push(parsed);
107
121
  }
108
122
  else
109
- params[key] = value;
123
+ params[key] = parsed;
110
124
  }
111
125
  }
112
126
  if (!params || Array.isArray(params) || typeof params !== 'object')
@@ -0,0 +1,12 @@
1
+ /** Package-supplied schema patterns never run in the backtracking JS engine. */
2
+ export declare function schemaPattern(pattern: string): {
3
+ test(value: string): boolean;
4
+ toString(): string;
5
+ };
6
+ /** Ajv's pattern and patternProperties keywords must use the same engine. */
7
+ export declare const schemaRegExp: ((pattern: string, _flags: string) => {
8
+ test(value: string): boolean;
9
+ toString(): string;
10
+ }) & {
11
+ code: string;
12
+ };
@@ -0,0 +1,10 @@
1
+ import { RE2JS } from 're2js';
2
+ /** Package-supplied schema patterns never run in the backtracking JS engine. */
3
+ export function schemaPattern(pattern) {
4
+ if (pattern.length > 4096)
5
+ throw new Error('schema pattern exceeds 4096 characters');
6
+ const compiled = RE2JS.compile(RE2JS.translateRegExp(pattern));
7
+ return { test: value => compiled.test(value), toString: () => pattern };
8
+ }
9
+ /** Ajv's pattern and patternProperties keywords must use the same engine. */
10
+ export const schemaRegExp = Object.assign((pattern, _flags) => schemaPattern(pattern), { code: 'schemaRegExp' });
@@ -1,4 +1,5 @@
1
1
  import { CtlError, ctlError } from './errors.js';
2
+ import { schemaPattern } from './pattern.js';
2
3
  export const REDACTED = '<redacted>';
3
4
  function reference(root, ref) {
4
5
  if (!ref.startsWith('#'))
@@ -55,6 +56,22 @@ function branches(schemas, root) {
55
56
  return result;
56
57
  }
57
58
  export function redactValue(value, schema, root = schema, onSecret) {
59
+ const patterns = new Map();
60
+ const matches = (pattern, key) => {
61
+ let compiled = patterns.get(pattern);
62
+ if (!compiled) {
63
+ // Cached descriptors and direct callers can bypass descriptor validation.
64
+ // Unsupported syntax must conservatively apply its privacy annotations.
65
+ try {
66
+ compiled = schemaPattern(pattern);
67
+ }
68
+ catch {
69
+ compiled = { test: () => undefined };
70
+ }
71
+ patterns.set(pattern, compiled);
72
+ }
73
+ return compiled.test(key);
74
+ };
58
75
  const walk = (input, schemas) => {
59
76
  const nodes = branches(schemas, root);
60
77
  if (nodes.some(node => node.writeOnly === true)) {
@@ -76,10 +93,12 @@ export function redactValue(value, schema, root = schema, onSecret) {
76
93
  matched = true;
77
94
  }
78
95
  for (const [pattern, child] of Object.entries(node.patternProperties ?? {})) {
79
- if (new RegExp(pattern).test(key)) {
96
+ const match = matches(pattern, key);
97
+ if (match !== false)
80
98
  children.push(child);
99
+ // An unknown match must not suppress additionalProperties privacy.
100
+ if (match === true)
81
101
  matched = true;
82
- }
83
102
  }
84
103
  if (!matched)
85
104
  children.push(node.additionalProperties);
@@ -1,3 +1,4 @@
1
+ import { CtlError } from './errors.js';
1
2
  import { type DsmSession, type JsonRequest } from './transport.js';
2
3
  import { CtlStore, type Host } from './store.js';
3
4
  import { type Descriptor } from './descriptor.js';
@@ -11,6 +12,18 @@ export interface DiscoverOptions {
11
12
  version: string;
12
13
  }[]) => Promise<boolean>;
13
14
  }
15
+ interface InstalledApp {
16
+ id: string;
17
+ version: string;
18
+ aliases: string[];
19
+ error?: CtlError;
20
+ }
21
+ interface Discovery {
22
+ apps: Descriptor[];
23
+ installed: InstalledApp[];
24
+ diagnostics: any[];
25
+ cacheHits: number;
26
+ }
14
27
  export declare class CtlClient {
15
28
  store: CtlStore;
16
29
  host: Host;
@@ -24,11 +37,9 @@ export declare class CtlClient {
24
37
  version: string;
25
38
  state?: unknown;
26
39
  }[]>;
27
- discover(options?: DiscoverOptions): Promise<{
28
- apps: Descriptor[];
29
- diagnostics: any[];
30
- cacheHits: number;
31
- }>;
40
+ private needsApproval;
41
+ selectApp(listing: Discovery, name: string): Descriptor;
42
+ discover(options?: DiscoverOptions): Promise<Discovery>;
32
43
  execute(name: string, path: string[], args: string[], options?: DiscoverOptions & {
33
44
  yes?: boolean;
34
45
  dryRun?: boolean;
@@ -39,3 +50,4 @@ export declare class CtlClient {
39
50
  private executeWithin;
40
51
  private appCookieSession;
41
52
  }
53
+ export {};
@@ -1,4 +1,4 @@
1
- import { CtlError, errorData, choose } from './errors.js';
1
+ import { CtlError, ctlError, errorData } from './errors.js';
2
2
  import { APP_COOKIE, CTL_TIMEOUT_MS, createRequest } from './transport.js';
3
3
  import { DESCRIPTION_METHOD, validateDescriptor, ajv } from './descriptor.js';
4
4
  import { runFlow } from './flows.js';
@@ -41,24 +41,26 @@ export class CtlClient {
41
41
  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))
42
42
  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 }; });
43
43
  }
44
+ needsApproval(pkg) {
45
+ return new CtlError('CTL_APP_NEEDS_APPROVAL', `${pkg.id} ${pkg.version} needs approval`, `run: synopkgland ctl list --app ${pkg.id} --approve-installed --host ${this.host.name}`, 4);
46
+ }
47
+ selectApp(listing, name) {
48
+ const pkg = selectApp(listing.installed, name);
49
+ if (pkg.error)
50
+ throw pkg.error;
51
+ return listing.apps.find(app => app.payload.package.id === pkg.id);
52
+ }
44
53
  async discover(options = {}) {
45
54
  const session = await this.store.session(this.host);
46
- let packages = await this.inventory(session);
55
+ let packages = (await this.inventory(session)).map(pkg => {
56
+ const approved = this.host.approved[pkg.id];
57
+ // Previous-version metadata identifies aliases only; it never authorizes a call.
58
+ const description = this.store.cache(this.host, pkg.id, pkg.version)?.description ?? (approved ? this.store.cache(this.host, pkg.id, approved.version)?.description : undefined);
59
+ return { ...pkg, aliases: description?.payload.aliases ?? [] };
60
+ });
47
61
  // An exact package filter narrows credential delivery. Alias selection needs the cached descriptors first.
48
- if (options.app) {
49
- const exact = packages.find(p => p.id.toLowerCase() === options.app.toLowerCase());
50
- if (exact)
51
- packages = [exact];
52
- else {
53
- const match = packages.find(p => this.store.cache(this.host, p.id, p.version)?.description?.payload.aliases?.some((a) => a.toLowerCase() === options.app.toLowerCase()));
54
- if (match)
55
- packages = [match];
56
- else {
57
- const known = packages.flatMap(p => { const d = this.store.cache(this.host, p.id, p.version)?.description; return d ? [p.id, ...d.payload.aliases] : []; });
58
- choose(options.app, known, 'app');
59
- }
60
- }
61
- }
62
+ if (options.app)
63
+ packages = [selectApp(packages, options.app)];
62
64
  const pending = packages.filter(p => this.host.approved[p.id]?.version !== p.version);
63
65
  if (pending.length && (options.approve || await options.confirm?.(pending))) {
64
66
  for (const p of pending)
@@ -68,11 +70,14 @@ export class CtlClient {
68
70
  let cacheHits = 0;
69
71
  const results = await boundedMap(packages, 4, async (pkg) => {
70
72
  const approval = this.host.approved[pkg.id];
71
- if (approval?.version !== pkg.version)
72
- return { error: { package: pkg.id, code: 'CTL_TRUST_REQUIRED', message: 'Description endpoint not approved.', hint: `run: synopkgland ctl list --host ${this.host.name} --approve-installed` } };
73
+ if (approval?.version !== pkg.version) {
74
+ pkg.error = this.needsApproval(pkg);
75
+ return { error: { package: pkg.id, ...errorData(pkg.error) } };
76
+ }
73
77
  const cached = options.refresh ? null : this.store.cache(this.host, pkg.id, pkg.version);
74
78
  if (cached?.failure) {
75
79
  cacheHits++;
80
+ pkg.error = new CtlError(cached.failure.code, cached.failure.message, cached.failure.hint, 5);
76
81
  return { error: { package: pkg.id, ...cached.failure, cached: true } };
77
82
  }
78
83
  try {
@@ -87,13 +92,19 @@ export class CtlClient {
87
92
  throw new CtlError('CTL_DESCRIPTION_UNAVAILABLE', `Description refused (${String(response?.error?.code ?? 'invalid response')}).`, 'Use --refresh after updating the App.', 5);
88
93
  description = validateDescriptor(response.data, pkg.id, pkg.version);
89
94
  }
90
- if (approval.hash && approval.hash !== description.sha256 && !options.approve)
91
- throw new CtlError('CTL_DESCRIPTION_CHANGED', 'Description hash changed; approval required.', `run: synopkgland ctl list --host ${this.host.name} --refresh --approve-installed`, 4);
95
+ if (approval.hash && approval.hash !== description.sha256 && !options.approve) {
96
+ // Remember the observed change so a later cache hit cannot revive the old approval.
97
+ this.store.putCache(this.host, pkg.id, pkg.version, description);
98
+ pkg.aliases = [...new Set([...pkg.aliases, ...description.payload.aliases])];
99
+ throw this.needsApproval(pkg);
100
+ }
101
+ pkg.aliases = description.payload.aliases;
92
102
  approval.hash = description.sha256;
93
103
  this.store.putCache(this.host, pkg.id, pkg.version, description);
94
104
  return { description };
95
105
  }
96
106
  catch (error) {
107
+ pkg.error = ctlError(error);
97
108
  const failure = errorData(error);
98
109
  if (failure.code === 'CTL_DESCRIPTION_UNAVAILABLE')
99
110
  this.store.putFailure(this.host, pkg.id, pkg.version, failure);
@@ -102,7 +113,7 @@ export class CtlClient {
102
113
  });
103
114
  this.host.lastUsed = new Date().toISOString();
104
115
  this.store.save(this.host);
105
- return { apps: results.flatMap(r => r.description ? [r.description] : []), diagnostics: results.flatMap(r => r.error ? [r.error] : []), cacheHits };
116
+ return { apps: results.flatMap(r => r.description ? [r.description] : []), installed: packages, diagnostics: results.flatMap(r => r.error ? [r.error] : []), cacheHits };
106
117
  }
107
118
  async execute(name, path, args, options = {}) {
108
119
  try {
@@ -116,7 +127,7 @@ export class CtlClient {
116
127
  const started = performance.now();
117
128
  this.until = started + (options.timeoutMs ?? CTL_TIMEOUT_MS);
118
129
  const listing = await this.discover(options);
119
- const app = selectApp(listing.apps, name);
130
+ const app = this.selectApp(listing, name);
120
131
  const { id, operation } = selectOperation(app, path);
121
132
  const params = operationInput(operation, args, options.stdin, `run: synopkgland ctl ${name} ${operation.cli.join(' ')} --host ${this.host.name} --help`);
122
133
  const output = new SensitiveOutput(params, operation.paramsSchema);
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { parse } from 'yaml';
3
3
  import { resolveAppLaunch } from '../contract/app-launch.js';
4
4
  import { trustedAppEntryPath } from '../contract/web-launch.js';
5
+ import { readDaemonIngress } from '../standalone/daemon-ingress.js';
5
6
  function safeEntry(entry) {
6
7
  const url = new URL(entry, 'https://dsm.example');
7
8
  if ((!entry.startsWith('/') && !/^https?:\/\//.test(entry)) || entry.startsWith('//') ||
@@ -25,7 +26,10 @@ export function deriveSmokeEntry(raw, entry) {
25
26
  if (!launch)
26
27
  throw new Error('Invalid app launch mode in manifest.');
27
28
  const window = launch === 'dsm-window' && !raw.console && raw.ui !== false && !raw.compose && !raw.daemon?.ingress;
28
- const derived = raw.daemon?.ingress?.path ?? (gateway
29
+ const ingress = readDaemonIngress(raw.daemon?.ingress, raw.package, () => { });
30
+ if (raw.daemon?.ingress !== undefined && !ingress)
31
+ throw new Error('Invalid daemon ingress: use /synopkgland/<package>/ with the exact package ID');
32
+ const derived = ingress?.path ?? (gateway
29
33
  ? `/webman/3rdparty/${raw.package}/sso/open.cgi`
30
34
  : native ? `/?launchApp=${encodeURIComponent(appId)}`
31
35
  : trustedAppEntryPath(raw));
@@ -336,7 +336,7 @@ export const ERROR_REGISTRY = {
336
336
  STANDALONE_DAEMON_RESTART_SDK_REQUIRED: {
337
337
  code: "STANDALONE_DAEMON_RESTART_SDK_REQUIRED",
338
338
  description: "The selected controller SDK version or source does not support daemon.restart.",
339
- suggestion: "Upgrade the selected controller SDK to 0.26.0 or later and use matching sources, including a local SDK override; update the App SDK requirement, identity_gateway.sdk or the templates that supply the default.",
339
+ suggestion: "Upgrade the selected controller SDK to 0.26.0 or later and use matching sources, including a local SDK override; update the App SDK requirement, identity_gateway.sdk or the CLI that supplies the default.",
340
340
  },
341
341
  UNINSTALL_INVALID: {
342
342
  code: "UNINSTALL_INVALID",
@@ -386,7 +386,7 @@ export const ERROR_REGISTRY = {
386
386
  STANDALONE_DAEMON_INGRESS_INVALID: {
387
387
  code: "STANDALONE_DAEMON_INGRESS_INVALID",
388
388
  description: "daemon.ingress does not declare one same-origin path and a bounded proxy timeout.",
389
- suggestion: "Set daemon.ingress.path to /<name>/ with lowercase letters, digits and -, not a DSM path, and timeout (optional) to 1–3600 seconds.",
389
+ suggestion: "Set daemon.ingress.path to exactly /synopkgland/<package>/ using the case-sensitive manifest package ID, and timeout (optional) to 1–3600 seconds",
390
390
  },
391
391
  STANDALONE_DAEMON_INGRESS_MIN_DSM: {
392
392
  code: "STANDALONE_DAEMON_INGRESS_MIN_DSM",
@@ -12,6 +12,20 @@ export interface InstallOptions {
12
12
  dryRun?: boolean;
13
13
  }
14
14
  export declare function installPackage(spec: string, options?: InstallOptions, connection?: NasConnection): Promise<{
15
+ message?: string | undefined;
16
+ host: string;
17
+ target: NasTarget;
18
+ source: string;
19
+ packages: {
20
+ signed?: boolean | undefined;
21
+ package: string;
22
+ version: string;
23
+ size: number;
24
+ action: string;
25
+ }[];
26
+ parameters: string[];
27
+ } | {
28
+ message?: string | undefined;
15
29
  host: string;
16
30
  target: NasTarget;
17
31
  source: string;
@@ -26,6 +40,7 @@ export declare function installPackage(spec: string, options?: InstallOptions, c
26
40
  dryRun: boolean;
27
41
  } | {
28
42
  status: import("./catalog-install.js").InstalledPackageState | undefined;
43
+ message?: string | undefined;
29
44
  host: string;
30
45
  target: NasTarget;
31
46
  source: string;