@synopackageland/cli 0.41.13 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,31 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.42.1 (2026-11-01)
4
+
5
+ - Add `--version` and `-V` to report the installed CLI version.
6
+ - Register the installed-App approval diagnostic as `CTL_APP_NEEDS_APPROVAL`
7
+ with the App-scoped approve command, replacing the retired error entry.
8
+
9
+ ## 0.42.0 (2026-11-01)
10
+
11
+ - Require templates ~0.37.0 so `init` and `add` scaffold with UI 0.19.1 and
12
+ SDK/App 0.41.1 (executor/v0.39.0). CLI 0.41.x stays on the templates 0.35
13
+ minor line.
14
+ - Pin CLI-owned runtime defaults (daemon controllers, Compose planning and
15
+ installer context) to SDK 0.41.1, matching the templates. Explicit App SDK
16
+ declarations still take precedence.
17
+ - Skip installation when the requested package version is already installed,
18
+ including DSM build suffixes; report `action: "none"` in plans and avoid
19
+ downloads, uploads and reinstalling on confirmed runs.
20
+ - Accept repeated ctl flags for arrays of scalar values, validate their item
21
+ schemas and expose repeatable flags in command help and examples. Preserve
22
+ sensitive-value redaction and JSON input for complex parameters.
23
+ - Return `CTL_APP_NEEDS_APPROVAL` (exit 4) with an App-scoped
24
+ `ctl list --app <package> --approve-installed` hint when an installed App,
25
+ called by package ID or a cached alias, awaits first, new-version or
26
+ changed-description approval. Cached aliases identify upgraded packages but
27
+ never authorize execution; `CTL_APP_UNKNOWN` suggestions apply only to names
28
+ matching no installed package or known alias.
4
29
 
5
30
  ## 0.41.13
6
31
 
package/README.md CHANGED
@@ -89,8 +89,8 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
89
89
  packages. Private mirrors can use explicitly configured Go authentication and
90
90
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
91
91
 
92
- The CLI pins SDK 0.41.0 for its daemon controllers, Compose planning and installer
93
- context. Explicit App SDK declarations take precedence. Templates remain ~0.35.5
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
94
  and retain their own scaffold SDK pins; none declare daemon ingress.
95
95
  Every App method must be declared in the permissions table. Dynamic names and
96
96
  custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
@@ -101,8 +101,8 @@ unimported development commands are excluded. Use
101
101
  authentication. See [App entry and registration](docs/app-entry.md).
102
102
 
103
103
  `init` and `add` read their templates from the locally installed
104
- `@synopackageland/templates`. CLI 0.41.13 depends on `~0.35.5`: npm installation or
105
- 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
106
106
  patches need only a templates release; update the npm installation to receive them.
107
107
  The CLI never fetches templates at runtime, so offline use works after installation;
108
108
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -537,7 +537,7 @@ for `ctl` calls; App command/job/transfer deadlines retain their existing defaul
537
537
  | 1 | Unexpected internal error (`CTL_INTERNAL`), or an uncaught CLI exception. |
538
538
  | 2 | Authentication/session failure: `CTL_NOT_AUTHENTICATED`, `CTL_AUTH_FAILED`, `CTL_CREDENTIAL_MISMATCH`, or unavailable OS credential storage. |
539
539
  | 3 | Invalid parameters, schema validation or unknown names: `CTL_PARAMS_INVALID`, `CTL_ARGUMENT_INVALID`, `CTL_APP_UNKNOWN`, `CTL_COMMAND_UNKNOWN`. |
540
- | 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`. |
541
541
  | 5 | App/contract failure that fails the command: `CTL_APP_ERROR`, `CTL_RESPONSE_INVALID`, `CTL_JOB_FAILED`. |
542
542
  | 6 | Network/HTTP failure or uncertain completion: `CTL_NETWORK`, `CTL_HTTP_ERROR`, `CTL_JOB_TIMEOUT`, `CTL_LOGOUT_FAILED`. |
543
543
  | 7 | Confirmation required (`CTL_CONFIRM_REQUIRED`): a write/destructive command or host removal without confirmation; no business operation runs. |
@@ -547,13 +547,26 @@ For example, `synopkgland ctl studio drafts create --host nas --json` without
547
547
  `--yes` returns `ok:false` with `CTL_CONFIRM_REQUIRED` and exits 7 once discovery
548
548
  and parameter validation succeed. Missing sessions fail earlier with exit 2.
549
549
  Discovery catches per-App description errors and excludes those Apps from its
550
- results. For example, a changed pinned description (`CTL_DESCRIPTION_CHANGED`)
550
+ results. For example, a changed pinned description (`CTL_APP_NEEDS_APPROVAL`)
551
551
  or an invalid descriptor (`CTL_DESCRIPTOR_INVALID`) appears in
552
552
  `ctl list --host nas --refresh --json` under `data.diagnostics`; the listing still
553
553
  returns `ok:true` and exits 0, even if no Apps remain. These diagnostic codes do
554
- not become process exit codes 4 or 5. Attempting to execute an excluded App with
555
- `--refresh` instead returns `ok:false`, `CTL_APP_UNKNOWN` and exit 3 because the
556
- 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).
557
570
 
558
571
  `host list/show` can also succeed while reporting `authenticated:false` and
559
572
  exit 0. Check discovery `apps`/`diagnostics` and session fields as well as the exit
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { nasFlags, withNasOptions } from './nas/options.js';
2
2
  import { upstreamRepository, upstreamMaintainer } from './upstream-attribution.js';
3
3
  import { Command, CommanderError } from "commander";
4
+ import { CLI_VERSION } from "./cli-version.js";
4
5
  import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
5
6
  import { createHash } from "node:crypto";
6
7
  import { basename, resolve, dirname, join } from "node:path";
@@ -322,6 +323,7 @@ async function runCliWithin(argv) {
322
323
  const program = new Command()
323
324
  .name("synopkgland")
324
325
  .description("Syno Package Land Developer Kit CLI")
326
+ .version(CLI_VERSION)
325
327
  .option("--project <dir>", "App project root")
326
328
  .option("--strict-tls", "Verify NAS CA, hostname, validity and recorded certificate")
327
329
  .option("--ca <file>", "Verify NAS HTTPS with a PEM CA file")
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 };
@@ -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')
@@ -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);
@@ -33,7 +33,7 @@ export const ERROR_REGISTRY = {
33
33
  INIT_HEALTH_INVALID: { code: "INIT_HEALTH_INVALID", description: "The Compose readiness selection is invalid.", suggestion: "Supply both --health-path and --health-status with the actual endpoint and expected 2xx response; redirects are not followed." },
34
34
  INIT_UPSTREAM_IMAGE_REQUIRED: { code: "INIT_UPSTREAM_IMAGE_REQUIRED", description: "The official upstream image repository was not selected.", suggestion: "Use init --upstream-fork --upstream-image <official-repository> for Compose." },
35
35
  INIT_UPSTREAM_IMAGE_INVALID: { code: "INIT_UPSTREAM_IMAGE_INVALID", description: "The upstream image repository is invalid.", suggestion: "Supply the official repository without a tag or digest; upgrade.sh selects the matching release and resolves its digest." },
36
- CTL_TRUST_REQUIRED: { "code": "CTL_TRUST_REQUIRED", "description": "App description access requires approval.", "suggestion": "Run synopkgland ctl list with explicit host and approve the candidate batch." },
36
+ CTL_APP_NEEDS_APPROVAL: { "code": "CTL_APP_NEEDS_APPROVAL", "description": "The installed App needs approval or re-approval.", "suggestion": "Run synopkgland ctl list --app <package-id> --approve-installed --host <host>." },
37
37
  COMPOSE_HEALTH_REQUIRED: { code: "COMPOSE_HEALTH_REQUIRED", description: "Compose web readiness was not selected.", suggestion: "Set compose.web.health.path and expected 2xx status for the actual App; redirects are not followed." },
38
38
  CTL_UNSUPPORTED_BACKEND: { "code": "CTL_UNSUPPORTED_BACKEND", "description": "Automatic descriptions require a native SDK backend.", "suggestion": "Disable ctl for binary/Compose wrappers or implement the public contract separately." },
39
39
  CTL_DESCRIBE_REQUIRED: { "code": "CTL_DESCRIBE_REQUIRED", "description": "The Go backend has no build-only registry exporter.", "suggestion": "Update the native template and implement --describe using api.DescribeRoutes." },
@@ -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;
@@ -122,6 +122,8 @@ export async function installPackage(spec, options = {}, connection) {
122
122
  if (installed.has(item.package) &&
123
123
  compareVersions(item.version, installed.get(item.package)) < 0)
124
124
  throw fail("PACKAGE_DOWNGRADE_REFUSED", `Refusing downgrade of ${item.package}`, "Choose the installed version or a newer release");
125
+ const changes = plan.filter((item) => !installed.has(item.package) ||
126
+ compareVersions(item.version, installed.get(item.package)) !== 0);
125
127
  const publicPlan = {
126
128
  host: nas.name,
127
129
  target,
@@ -130,21 +132,30 @@ export async function installPackage(spec, options = {}, connection) {
130
132
  package: item.package,
131
133
  version: item.version,
132
134
  size: item.size,
133
- action: installed.has(item.package) ? "upgrade" : "install",
135
+ action: !installed.has(item.package)
136
+ ? "install"
137
+ : compareVersions(item.version, installed.get(item.package)) === 0
138
+ ? "none"
139
+ : "upgrade",
134
140
  ...(item.package === root.package && report
135
141
  ? { signed: report.signed }
136
142
  : {}),
137
143
  })),
138
144
  parameters: Object.keys(options.param ?? {}),
145
+ ...(!changes.length
146
+ ? { message: `${root.package}@${root.version} is already up to date; no package changes required.` }
147
+ : {}),
139
148
  };
140
149
  if (options.dryRun)
141
150
  return { dryRun: true, ...publicPlan };
151
+ if (!changes.length)
152
+ return publicPlan;
142
153
  await requireAdministrator(nas);
143
- await confirm(`Install ${plan.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
154
+ await confirm(`Install ${changes.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
144
155
  // Download, verify and inspect the entire plan before the first NAS write.
145
156
  const files = new Map();
146
157
  const images = new Map();
147
- for (const entry of plan) {
158
+ for (const entry of changes) {
148
159
  let file = localFile;
149
160
  if (!local || entry.package !== root.package) {
150
161
  const response = await fetchPackageSource(entry.link);
@@ -163,7 +174,7 @@ export async function installPackage(spec, options = {}, connection) {
163
174
  files.set(entry.package, file);
164
175
  images.set(entry.package, containerImageReferences(checked));
165
176
  }
166
- for (const entry of plan)
177
+ for (const entry of changes)
167
178
  await installPackageFromLocalSpk({
168
179
  dsmOrigin: nas.session.origin,
169
180
  cookieHeader: nas.session.cookie,
@@ -1,7 +1,7 @@
1
1
  export { goPrivateCovers } from './go-env.js';
2
2
  import { type ExecutorManifest } from './sdk-executor-manifest.js';
3
3
  /** Pinned SDK for CLI-owned controllers and Compose planning; App pins take precedence. */
4
- export declare const DEFAULT_SDK_VERSION = "0.41.0";
4
+ export declare const DEFAULT_SDK_VERSION = "0.41.1";
5
5
  export declare const SDK_PACKAGE = "@synopackageland/sdk";
6
6
  export declare const SDK_MODULE = "github.com/synopackageland/syno-pkg-land-sdk";
7
7
  export declare const EXECUTOR_MODULE = "github.com/synopackageland/syno-pkg-land-sdk/executor";
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { readExecutorManifest, predatesExecutorManifest } from './sdk-executor-manifest.js';
7
7
  /** Pinned SDK for CLI-owned controllers and Compose planning; App pins take precedence. */
8
- export const DEFAULT_SDK_VERSION = '0.41.0';
8
+ export const DEFAULT_SDK_VERSION = '0.41.1';
9
9
  export const SDK_PACKAGE = '@synopackageland/sdk';
10
10
  export const SDK_MODULE = 'github.com/synopackageland/syno-pkg-land-sdk';
11
11
  export const EXECUTOR_MODULE = `${SDK_MODULE}/executor`;
@@ -68,6 +68,22 @@ selected live descriptor against its approved hash. Zero or multiple matching
68
68
  Apps refuse; revoke the unwanted approval or use distinct protocol IDs. A cache
69
69
  is only a recipient selector and never grants tool authority.
70
70
 
71
+ For ordinary `ctl <alias|package> …` and help requests, an installed App awaiting
72
+ first approval, a new version's approval, or approval of a changed description
73
+ returns `CTL_APP_NEEDS_APPROVAL` (exit 4). For example, the message is
74
+ `SynoReclaim 1.2.0 needs approval` with an App-scoped command:
75
+
76
+ ```sh
77
+ synopkgland ctl list --app SynoReclaim --approve-installed --host controller
78
+ ```
79
+
80
+ Obtain the user's authorization before running the hint. Cached aliases from the
81
+ previously approved version still identify an upgraded installed App, but cannot
82
+ authorize execution. Before an alias has been discovered, use the package ID for
83
+ first approval. Unknown-name suggestions appear only if no installed package ID
84
+ or known alias matches. `ctl list` includes pending approvals in its diagnostics;
85
+ external calls retain the separate protocol/pin checks described below.
86
+
71
87
  Only a named, registered host and its OS-stored session are used. Ambient
72
88
  `DSM_*` credentials/URL cannot create or replace this login. HTTPS requires the
73
89
  stored SHA-256 certificate pin and refuses rotation before sending HTTP headers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.41.13",
3
+ "version": "0.42.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@napi-rs/keyring": "^2.1.1",
31
- "@synopackageland/templates": "~0.35.5",
31
+ "@synopackageland/templates": "~0.37.0",
32
32
  "ajv": "^8.17.1",
33
33
  "commander": "^13.1.0",
34
34
  "json-canonicalize": "^3.0.1",
package/skills/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
- The CLI installs templates through `~0.35.5`. A templates patch needs only a
8
+ The CLI installs templates through `~0.37.0`. A templates patch needs only a
9
9
  templates release; npm install/update selects a patch in that minor line. The CLI
10
10
  uses the locally installed templates and reads their SDK pin for defaults; it
11
11
  never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
@@ -10,8 +10,30 @@ Use this for operating installed Apps, rather than building/deploying projects.
10
10
  4. Use the discovered alias, CLI path and typed flags with an explicit `--host nas`; inspect `--help` and `--dry-run` before execution. For example, `synopkgland ctl notes list --status pending --host nas --dry-run`. Use the real command/schema from discovery, not this illustrative name.
11
11
  5. Reads need no write confirmation. For authorized write/destructive commands, add `--yes`; without it the CLI returns a plan and confirmation error. A --yes flag does not replace the user's authorization or the App's backend checks.
12
12
 
13
+ Typed flags accept strings, integers, numbers and booleans, including arrays of
14
+ these scalar types. Repeat an array flag for each item, for example
15
+ `--targets /volume1 --targets /volume2`; one occurrence still produces an array.
16
+ Use `--flag=value` when a string item starts with `--`. Commas are literal string
17
+ content, not array separators. Items and the complete array are checked against
18
+ the command schema. Command `--help` lists each supported flag's `valueType` and
19
+ `repeatable` status and renders scalar-array examples as repeated flags.
20
+ Objects, arrays of objects, nested arrays and arrays without a scalar item type
21
+ require `--input <file|->`; use JSON input for an explicit empty array too.
22
+ Plans keep schema-marked private paths and other sensitive values redacted.
23
+
13
24
  With multiple hosts, select a default with `synopkgland host default <name>` or specify --host. `--host a,b` and --all-hosts return per-host results, with exit 8 on partial failure. Do not retry a write merely because one host failed; query its state first. --refresh rereads descriptions; unchanged package versions use the host/account/package/version/hash cache. Never assume cached metadata grants resource permission.
14
25
 
26
+ An installed App awaiting first approval, approval of a new version, or approval
27
+ of a changed description returns `CTL_APP_NEEDS_APPROVAL` (exit 4) when selected
28
+ by package ID or a known cached alias, including help requests. The message names
29
+ the installed package and version, for example `SynoReclaim 1.2.0 needs approval`.
30
+ Obtain the user's authorization, then follow the exact App-scoped hint:
31
+ `synopkgland ctl list --app SynoReclaim --approve-installed --host <profile>`.
32
+ Previous-version aliases identify the installed App only; they never authorize
33
+ execution. An alias not yet discovered requires the package ID for first approval.
34
+ `CTL_APP_UNKNOWN` and “Did you mean …” apply only when no installed package ID or
35
+ known alias matches. `ctl list` reports pending approvals in `data.diagnostics`.
36
+
15
37
  Use `host show nas` to inspect approvals/pins, `host revoke nas PackageID` to remove an App approval, `host logout nas` to retain registration, and `host remove nas --yes` to delete sessions, device token, approvals and cache. A failed NAS logout is reported independently from successful local removal.
16
38
 
17
39
  Stdout defaults to JSON without a TTY. Success is {ok:true,data}; failure is {ok:false,error:{code,message,hint,allowed?}}. Multi-host output uses {ok,results:[{host,ok,data|error}]}. Follow the concrete hint and complete allowed list; do not guess another method or bypass ctl with raw HTTP. Exit codes: 2 authentication, 3 parameters/names, 4 permissions/trust, 5 App/contract, 6 network, 7 confirmation, 8 partial failure.
@@ -1,6 +1,6 @@
1
1
  # Native installation settings
2
2
 
3
- Use SDK/App 0.38.6, CLI 0.41.12 and templates 0.35.5. Configure installation through the manifest and generated wizard; do not add App ACL writers, root hooks or independent port state.
3
+ Use SDK/App 0.41.1, CLI 0.42.0 and templates 0.37.0. Configure installation through the manifest and generated wizard; do not add App ACL writers, root hooks or independent port state.
4
4
 
5
5
  ```yaml
6
6
  install: