@synopackageland/cli 0.32.0 → 0.33.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 (58) hide show
  1. package/CHANGELOG.md +7 -5
  2. package/README.md +4 -4
  3. package/dist/cli.js +33 -12
  4. package/dist/ctl/cli.d.ts +1 -0
  5. package/dist/ctl/cli.js +61 -19
  6. package/dist/ctl/dsm-auth.js +2 -2
  7. package/dist/ctl/errors.js +1 -1
  8. package/dist/ctl/input.js +2 -2
  9. package/dist/ctl/service.js +8 -6
  10. package/dist/ctl/store.d.ts +4 -0
  11. package/dist/ctl/store.js +33 -5
  12. package/dist/ctl/tls.d.ts +22 -0
  13. package/dist/ctl/tls.js +118 -0
  14. package/dist/ctl/transfers.d.ts +2 -0
  15. package/dist/ctl/transfers.js +5 -4
  16. package/dist/ctl/transport.d.ts +3 -1
  17. package/dist/ctl/transport.js +38 -8
  18. package/dist/error-registry.js +9 -11
  19. package/dist/host/catalog-install.d.ts +0 -10
  20. package/dist/host/catalog-install.js +24 -140
  21. package/dist/host/connection.d.ts +9 -0
  22. package/dist/host/connection.js +49 -0
  23. package/dist/host/credentials.d.ts +3 -1
  24. package/dist/host/credentials.js +27 -62
  25. package/dist/host/dsm-client.d.ts +4 -7
  26. package/dist/host/dsm-client.js +47 -92
  27. package/dist/host/dsm-http.d.ts +2 -0
  28. package/dist/host/dsm-http.js +21 -1
  29. package/dist/host/dsm-port-profile.js +7 -2
  30. package/dist/host/install-plan.d.ts +32 -0
  31. package/dist/host/install-plan.js +157 -0
  32. package/dist/host/package-commands.d.ts +75 -0
  33. package/dist/host/package-commands.js +205 -0
  34. package/dist/host/port-info-lookup.d.ts +1 -1
  35. package/dist/host/port-info-lookup.js +5 -2
  36. package/dist/host/share-lookup.d.ts +3 -2
  37. package/dist/host/share-lookup.js +5 -91
  38. package/dist/host/share-permission-lookup.d.ts +3 -3
  39. package/dist/host/share-permission-lookup.js +18 -30
  40. package/dist/host/types.d.ts +5 -1
  41. package/dist/init-upstream.js +6 -6
  42. package/dist/package-metadata.d.ts +8 -1
  43. package/dist/package-metadata.js +28 -1
  44. package/dist/skill.js +1 -0
  45. package/dist/spk/inspect.d.ts +1 -0
  46. package/dist/spk/inspect.js +2 -1
  47. package/dist/spk/validate-spk.d.ts +1 -0
  48. package/dist/spk/validate-spk.js +1 -0
  49. package/dist/upstream-attribution.js +4 -3
  50. package/package.json +1 -1
  51. package/schema/package-metadata.schema.json +32 -3
  52. package/schema/synology-app.schema.json +32 -3
  53. package/skills/SKILL.md +3 -1
  54. package/skills/reference/compose.md +1 -1
  55. package/skills/reference/ctl.md +4 -4
  56. package/skills/reference/deploy.md +2 -0
  57. package/skills/reference/host-install.md +55 -0
  58. package/skills/reference/upstream-fork.md +2 -1
package/CHANGELOG.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.32.0 (2026-10-27)
3
+ ## 0.33.0 (2026-10-27)
4
4
 
5
- - Attribute upstream Apps to their original project/team or owner and the packaging publisher as distributor, with generic skill guidance and a release checklist
6
- - Prefill upstream-fork attribution from the upstream repository and publisher options or environment configuration; use a bounded public GitHub owner lookup and explicit TODOs offline
7
- - Warn when upstream attribution is incomplete or the maintainer equals the distributor; warnings appear in check JSON and keep exit status successful
8
- - Keep templates 0.31.x and its SDK defaults; CLI minor releases can ship independently of templates
5
+ - Share one secure NAS login across host, ctl, deploy, install, uninstall, status and logs
6
+ - Install or upgrade store packages and local SPKs with dependency plans, compatibility checks and pinned download verification
7
+ - Preview package changes with --dry-run, confirm with --yes and query installed packages without a project directory
8
+ - Trust default NAS certificates on first use, pin the leaf SHA-256 before sending credentials and reject changed certificates until explicitly trusted
9
+ - Distinguish certificate, DNS, connection refusal and timeout errors; support private CAs and expected certificate fingerprints
10
+ - Document interactive and environment-only automation login without saving passwords
package/README.md CHANGED
@@ -74,7 +74,7 @@ CLI-only releases can differ from the SDK patch. Compose import and SDK defaults
74
74
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
75
75
 
76
76
  `init` and `add` read their templates from the locally installed
77
- `@synopackageland/templates`. CLI 0.32.0 depends on `~0.31.0`: npm installation or
77
+ `@synopackageland/templates`. CLI 0.32.1 depends on `~0.31.0`: npm installation or
78
78
  update can select the latest templates patch in the 0.31 minor line. Templates
79
79
  patches need only a templates release; update the npm installation to receive them.
80
80
  The CLI never fetches templates at runtime, so offline use works after installation;
@@ -346,11 +346,11 @@ DSM readiness; inspect lists explicit acceptance blockers.
346
346
 
347
347
  ## Control installed Apps
348
348
 
349
- Use `synopkgland ctl host add nas --url https://nas.example:5001 --username alice`
349
+ Use `synopkgland host add nas --url https://nas.example:5001 --username alice`
350
350
  to register and authenticate a NAS in OS secure storage. A bare host name uses
351
351
  `https://<host>:5001`; provide a full URL for a different scheme or port.
352
352
  The CLI loads only `DSM_*` and `SYNOPKGLAND_*` variables from `.env`; set other
353
- process variables in the shell. `ctl host list --json`
353
+ process variables in the shell. `host list --json`
354
354
  checks sessions; `ctl list --host nas --json` discovers all approved task-level
355
355
  commands and their schemas. Run the listed alias/command with typed flags,
356
356
  `--help` and `--dry-run`; writes require `--yes`. There is no mutable current
@@ -382,7 +382,7 @@ not become process exit codes 4 or 5. Attempting to execute an excluded App with
382
382
  `--refresh` instead returns `ok:false`, `CTL_APP_UNKNOWN` and exit 3 because the
383
383
  App is absent from discovery results; no business operation runs.
384
384
 
385
- `ctl host list/show` can also succeed while reporting `authenticated:false` and
385
+ `host list/show` can also succeed while reporting `authenticated:false` and
386
386
  exit 0. Check discovery `apps`/`diagnostics` and session fields as well as the exit
387
387
  code when deciding whether an App is ready.
388
388
  `ctl --timeout <seconds>` applies to discovery, JSON calls, polling and transfer
package/dist/cli.js CHANGED
@@ -289,6 +289,11 @@ async function containerBuild(project, arches, options) {
289
289
  }
290
290
  /** Resolves after dispatch; commands may set process.exitCode. Rejections are failures. */
291
291
  export async function runCli(argv) {
292
+ if (argv[2] === "host") {
293
+ const { runCtl } = await import("./ctl/cli.js");
294
+ await runCtl(["host", ...argv.slice(3)]);
295
+ return;
296
+ }
292
297
  if (argv[2] === "ctl") {
293
298
  const { runCtl } = await import("./ctl/cli.js");
294
299
  await runCtl(argv.slice(3));
@@ -304,14 +309,24 @@ export async function runCli(argv) {
304
309
  .option("--project <dir>", "App project root")
305
310
  .exitOverride();
306
311
  const rootOf = (command) => resolve(process.cwd(), command.parent?.opts().project ?? ".");
307
- const hostOf = (host) => {
308
- if (!host)
309
- throw failure("HOST_REQUIRED", "--host is required.", "Set a synoagentcli profile, then use --host <name>.");
310
- const nas = createNasHost({ host });
311
- if (!nas)
312
- throw failure("NAS_CREDENTIALS_MISSING", "NAS credentials were not found.", "Set a synoagentcli profile or DSM_URL/DSM_PASSWORD.");
313
- return nas;
314
- };
312
+ const hostOf = (host) => createNasHost({ host });
313
+ program.command("host").description("Shared NAS login and host management (host --help)");
314
+ program.command("install <package-or-spk>")
315
+ .description("Install a store package or a local SPK")
316
+ .option("--host <name>", "Registered NAS host")
317
+ .option("--source <url>", "HTTPS package source")
318
+ .option("--param <NAME=value>", "Install wizard value", (v, p) => collectKv(v, p, "--param"))
319
+ .option("--yes", "Confirm installation")
320
+ .option("--dry-run", "Print the installation plan without writing to DSM")
321
+ .option("--json", "Print a single JSON object")
322
+ .action(async (spec, options) => { const { installPackage } = await import('./host/package-commands.js'); const result = await installPackage(spec, options); emit('install', result, JSON.stringify(result, null, 2)); });
323
+ program.command("uninstall <package-id>")
324
+ .description("Remove an installed package")
325
+ .option("--host <name>", "Registered NAS host")
326
+ .option("--yes", "Confirm removal")
327
+ .option("--dry-run", "Print the removal plan without writing to DSM")
328
+ .option("--json", "Print a single JSON object")
329
+ .action(async (id, options) => { const { uninstallPackage } = await import('./host/package-commands.js'); const result = await uninstallPackage(id, options); emit('uninstall', result, JSON.stringify(result, null, 2)); });
315
330
  program.command("ctl").description("Control registered NAS Apps using described AI commands (ctl --help)");
316
331
  program
317
332
  .command("check")
@@ -473,7 +488,7 @@ export async function runCli(argv) {
473
488
  const targetDir = resolve(dir);
474
489
  const upstream = options.upstreamFork ? upstreamRepository(targetDir, options.upstream) : undefined;
475
490
  if (options.upstream && !upstream)
476
- throw failure("INIT_UPSTREAM_INVALID", "Supply an http(s) repository URL without credentials");
491
+ throw failure("INIT_UPSTREAM_INVALID", "Supply an HTTPS repository URL without credentials");
477
492
  if (!options.upstreamFork && (options.upstream || options.upstreamMaintainer || options.publisher || options.publisherUrl))
478
493
  throw failure("INIT_OPTIONS_UNSUPPORTED", "Attribution options require --upstream-fork");
479
494
  initFromTemplate(options.template, targetDir, {
@@ -608,12 +623,18 @@ export async function runCli(argv) {
608
623
  emit("deploy", result, `Deployed ${result.packageId}${result.openUrl ? "\nOpen URL: " + result.openUrl : ""}`);
609
624
  });
610
625
  program
611
- .command("status")
626
+ .command("status [package-id]")
612
627
  .description("DSM package status, Open URL and capability readiness")
613
628
  .option("--json", "Print a single JSON object")
614
629
  .option("--host <name>", "NAS profile")
615
630
  .option("--dev", "Development Package Identity")
616
- .action(async (options, command) => {
631
+ .action(async (id, options, command) => {
632
+ if (id) {
633
+ const { packageStatus } = await import('./host/package-commands.js');
634
+ const result = await packageStatus(id, options.host);
635
+ emit('status', result, JSON.stringify(result, null, 2));
636
+ return;
637
+ }
617
638
  const host = hostOf(options.host);
618
639
  const root = rootOf(command);
619
640
  const standalone = standaloneRoot(root);
@@ -629,7 +650,7 @@ export async function runCli(argv) {
629
650
  program
630
651
  .command("logs")
631
652
  .description("Status diagnosis: DSM package status and masked diagnostics; does not stream application logs")
632
- .requiredOption("--host <name>", "NAS profile")
653
+ .option("--host <name>", "Registered NAS host")
633
654
  .option("--dev", "Development Package Identity")
634
655
  .action(async (options, command) => {
635
656
  const host = hostOf(options.host);
package/dist/ctl/cli.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { Descriptor } from './descriptor.js';
4
4
  export interface CtlIO {
5
5
  store?: CtlStore;
6
6
  out?: (line: string) => void;
7
+ err?: (line: string) => void;
7
8
  isTTY?: boolean;
8
9
  prompt?: (message: string, secret?: boolean) => Promise<string>;
9
10
  requestFor?: (host: Host) => JsonRequest;
package/dist/ctl/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { probeCertificate, normalizeFingerprint } from './tls.js';
1
2
  import { readBounded } from './json.js';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { createInterface } from 'node:readline/promises';
@@ -9,7 +10,7 @@ import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } fro
9
10
  import { canonicalOrigin, CTL_TIMEOUT_MS, createRequest } from './transport.js';
10
11
  import { loginDsm, logoutDsm } from './dsm-auth.js';
11
12
  import { publicOperation } from './redaction.js';
12
- const USAGE = 'synopkgland ctl host add|list|show|login|logout|remove|revoke; ctl list [--host a,b|--all-hosts] [--app pkg] [--refresh]; ctl <alias|package> <command...> [typed flags|--input file|-] [--dry-run] [--yes]';
13
+ const USAGE = 'synopkgland host add|list|show|login|logout|remove|revoke|default|trust; ctl list [--host a,b|--all-hosts] [--app pkg] [--refresh]; ctl <alias|package> <command...> [typed flags|--input file|-] [--dry-run] [--yes]';
13
14
  async function prompt(message, secret = false) {
14
15
  if (!process.stdin.isTTY)
15
16
  throw new CtlError('CTL_INTERACTION_REQUIRED', 'Interactive input required.', 'Use --username and --password-stdin for host authentication.', 2);
@@ -79,7 +80,7 @@ export async function runCtl(argv, io = {}) {
79
80
  if (args[0] === 'host') {
80
81
  const action = args[1], name = args[2];
81
82
  if (help || !action) {
82
- emit({ ok: true, data: { usage: USAGE, actions: ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke'] } });
83
+ emit({ ok: true, data: { usage: USAGE, actions: ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke', 'default', 'trust'] } });
83
84
  return;
84
85
  }
85
86
  if (action === 'list') {
@@ -88,31 +89,55 @@ export async function runCtl(argv, io = {}) {
88
89
  return;
89
90
  }
90
91
  if (!name)
91
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Host name is required.', 'run: synopkgland ctl host list');
92
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Host name is required.', 'run: synopkgland host list');
93
+ if (action === 'default') {
94
+ store.setDefault(name);
95
+ emit({ ok: true, data: { defaultHost: name } });
96
+ return;
97
+ }
92
98
  if (action === 'add' || action === 'login') {
93
99
  const remaining = args.slice(3);
94
- const url = take(remaining, '--url', true), usernameOption = take(remaining, '--username', true), passwordStdin = !!take(remaining, '--password-stdin'), tunnel = !!take(remaining, '--ssh-tunnel'), noRemember = !!take(remaining, '--no-remember-device'), ca = take(remaining, '--ca', true);
100
+ const url = take(remaining, '--url', true), usernameOption = take(remaining, '--username', true), passwordStdin = !!take(remaining, '--password-stdin'), tunnel = !!take(remaining, '--ssh-tunnel'), noRemember = !!take(remaining, '--no-remember-device'), ca = take(remaining, '--ca', true), fingerprint = take(remaining, '--fingerprint', true);
101
+ if (ca && fingerprint)
102
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --ca or --fingerprint', 'Choose one certificate trust policy');
95
103
  if (remaining.length)
96
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland ctl host --help', 3, remaining);
104
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland host --help', 3);
97
105
  if (action === 'add' && store.list().some(h => h.name === name))
98
- throw new CtlError('CTL_HOST_EXISTS', `Host ${name} already registered.`, `run: synopkgland ctl host login ${name}`);
106
+ throw new CtlError('CTL_HOST_EXISTS', `Host ${name} already registered.`, `run: synopkgland host login ${name}`);
99
107
  if (action === 'login' && (url || usernameOption || tunnel || ca))
100
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Login uses the registered URL/account; add a separately named host to change them.', `run: synopkgland ctl host show ${name}`);
108
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Login uses the registered URL/account; add a separately named host to change them.', `run: synopkgland host show ${name}`);
101
109
  const old = action === 'login' ? store.get(name) : undefined;
102
110
  if (action === 'add' && !url)
103
- throw new CtlError('CTL_URL_REQUIRED', '--url is required.', `run: synopkgland ctl host add ${name} --url https://${name}:5001`);
104
- const host = old ?? { name, origin: canonicalOrigin(url, tunnel), username: usernameOption ?? await ask('DSM account: '), approved: {}, ...(tunnel ? { tunnel: true } : {}), ...(ca ? { ca } : {}) };
111
+ throw new CtlError('CTL_URL_REQUIRED', '--url is required.', `run: synopkgland host add ${name} --url https://${name}:5001`);
112
+ const host = old ?? { name, origin: canonicalOrigin(url, tunnel), username: usernameOption ?? process.env.DSM_USERNAME ?? await ask('DSM account: '), approved: {}, ...(tunnel ? { tunnel: true } : {}), ...(ca ? { ca } : {}) };
113
+ if (fingerprint)
114
+ normalizeFingerprint(fingerprint);
115
+ if (old?.fingerprint && fingerprint && normalizeFingerprint(fingerprint) !== old.fingerprint)
116
+ throw new CtlError('HOST_CERT_CHANGED', 'Login cannot replace a certificate pin', `run: synopkgland host trust ${name}`, 6);
117
+ if (!io.requestFor) {
118
+ const checked = await probeCertificate(host.origin, { name, ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined, fingerprint: host.fingerprint ?? fingerprint, timeoutMs });
119
+ if (checked.fingerprint && !host.fingerprint) {
120
+ host.fingerprint = checked.fingerprint;
121
+ store.save(host);
122
+ if (!checked.trusted)
123
+ (io.err ?? console.error)(`Trusting the NAS certificate ${checked.fingerprint.slice(0, 16)} (self-signed)`);
124
+ }
125
+ }
126
+ else if (fingerprint)
127
+ host.fingerprint = normalizeFingerprint(fingerprint);
105
128
  const previous = old ? await store.credentials.get(store.key(host)) : null;
106
- const secret = passwordStdin ? readBounded(0, 4096).replace(/[\r\n]+$/, '') : await ask('User password: ', true);
107
- const session = await loginDsm(host.origin, host.username, secret, { remember: !noRemember, deviceId: previous?.deviceId, otp: () => ask('DSM OTP: ', true), request: io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined), ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined });
129
+ const secret = process.env.DSM_PASSWORD ?? (passwordStdin ? readBounded(0, 4096).replace(/[\r\n]+$/, '') : await ask('User password: ', true));
130
+ const session = await loginDsm(host.origin, host.username, secret, { remember: !noRemember, deviceId: previous?.deviceId, otp: () => process.env.DSM_OTP ? Promise.resolve(process.env.DSM_OTP) : ask('DSM OTP: ', true), request: io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name }), ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined });
108
131
  try {
109
- if (old)
132
+ if (old || host.fingerprint) {
110
133
  await store.credentials.set(store.key(host), session);
134
+ store.save(host);
135
+ }
111
136
  else
112
137
  await store.add(host, session);
113
138
  }
114
139
  catch (e) {
115
- await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs)).catch(() => { });
140
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name })).catch(() => { });
116
141
  throw e;
117
142
  }
118
143
  host.lastUsed = new Date().toISOString();
@@ -121,25 +146,42 @@ export async function runCtl(argv, io = {}) {
121
146
  return;
122
147
  }
123
148
  const host = store.get(name);
149
+ if (action === 'trust') {
150
+ if (host.ca)
151
+ throw new CtlError('HOST_CA_CONFIGURED', 'This host uses a configured CA', 'Update the trusted CA file instead of replacing it with a leaf pin', 3);
152
+ if (!host.origin.startsWith('https:'))
153
+ throw new CtlError('HOST_TLS_REQUIRED', 'Certificate trust requires HTTPS', 'Use an HTTPS host registration', 3);
154
+ const checked = await probeCertificate(host.origin, { fingerprint: take(args, '--fingerprint', true), name });
155
+ // Get the leaf even when system-trusted, to make an explicit re-pin stable.
156
+ const current = checked.leafFingerprint;
157
+ if (!current)
158
+ throw new CtlError('HOST_TLS_SYSTEM_TRUSTED', 'This NAS certificate is trusted by the system', 'Remove the old pin only after verifying the NAS certificate with --fingerprint', 3);
159
+ if (!yes && (!tty || !/^y(es)?$/i.test(await ask(`Trust NAS certificate SHA-256 ${current} (previous ${host.fingerprint ?? 'none'})? [y/N] `))))
160
+ throw new CtlError('HOST_TRUST_CONFIRM_REQUIRED', 'Certificate replacement requires confirmation', `Verify the certificate, then run: synopkgland host trust ${name}`, 7);
161
+ host.fingerprint = current;
162
+ store.save(host);
163
+ emit({ ok: true, data: { name, fingerprint: current } });
164
+ return;
165
+ }
124
166
  if (action === 'show') {
125
167
  emit({ ok: true, data: { ...host, authenticated: await client(host).valid() } });
126
168
  return;
127
169
  }
128
170
  if (action === 'revoke') {
129
171
  if (!args[3])
130
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Package ID is required.', `run: synopkgland ctl host show ${name}`);
172
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Package ID is required.', `run: synopkgland host show ${name}`);
131
173
  emit({ ok: true, data: store.revoke(name, args[3]) });
132
174
  return;
133
175
  }
134
176
  if (action === 'logout' || action === 'remove') {
135
177
  if (action === 'remove' && !yes && (!tty || !/^y(es)?$/i.test(await ask(`Remove all local data for ${name}? [y/N] `))))
136
- throw new CtlError('CTL_CONFIRM_REQUIRED', 'Host removal requires --yes.', `run: synopkgland ctl host remove ${name} --yes`, 7);
178
+ throw new CtlError('CTL_CONFIRM_REQUIRED', 'Host removal requires --yes.', `run: synopkgland host remove ${name} --yes`, 7);
137
179
  const session = await store.credentials.get(store.key(host));
138
180
  let loggedOut = false;
139
181
  let logoutError;
140
182
  if (session?.cookie) {
141
183
  try {
142
- await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined));
184
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name }));
143
185
  loggedOut = true;
144
186
  }
145
187
  catch (error) {
@@ -151,15 +193,15 @@ export async function runCtl(argv, io = {}) {
151
193
  return;
152
194
  }
153
195
  if (logoutError)
154
- throw new CtlError('CTL_LOGOUT_FAILED', 'DSM logout was not confirmed; local session retained for retry.', `run: synopkgland ctl host logout ${name}`, 6);
196
+ throw new CtlError('CTL_LOGOUT_FAILED', 'DSM logout was not confirmed; local session retained for retry.', `run: synopkgland host logout ${name}`, 6);
155
197
  if (session)
156
198
  await store.credentials.set(store.key(host), { ...session, cookie: '', token: '' });
157
199
  emit({ ok: true, data: { name, loggedOut, registrationRetained: true } });
158
200
  return;
159
201
  }
160
- throw new CtlError('CTL_COMMAND_UNKNOWN', `Unknown host action ${action}.`, 'run: synopkgland ctl host --help', 3, ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke']);
202
+ throw new CtlError('CTL_COMMAND_UNKNOWN', `Unknown host action ${action}.`, 'run: synopkgland host --help', 3, ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke', 'default', 'trust']);
161
203
  }
162
- const names = selectHosts(store.list().map(h => h.name), hostOption, allHosts);
204
+ const names = selectHosts(store.list().map(h => h.name), hostOption ?? (allHosts ? undefined : store.defaultName()), allHosts);
163
205
  const inputAt = args.indexOf('--input');
164
206
  let commonInput;
165
207
  if (inputAt >= 0 && args[inputAt + 1] === '-') {
@@ -11,12 +11,12 @@ export async function loginDsm(origin, username, password, options) {
11
11
  if (result?.error?.code === 403) {
12
12
  const otp = await options.otp();
13
13
  if (!/^\d{6}$/.test(otp))
14
- throw new CtlError('CTL_OTP_INVALID', 'OTP must be six digits.', 'run: synopkgland ctl host login <name>', 2);
14
+ throw new CtlError('CTL_OTP_INVALID', 'OTP must be six digits.', 'run: synopkgland host login <name>', 2);
15
15
  form.set('otp_code', otp);
16
16
  result = await request('/webapi/entry.cgi', form);
17
17
  }
18
18
  if (result?.success !== true || typeof result.data?.sid !== 'string' || !result.data.sid || typeof result.data.synotoken !== 'string' || !result.data.synotoken)
19
- throw new CtlError('CTL_AUTH_FAILED', `DSM login failed (${Number(result?.error?.code) || 'invalid response'}).`, 'run: synopkgland ctl host login <name>', 2);
19
+ throw new CtlError('CTL_AUTH_FAILED', `DSM login failed (${Number(result?.error?.code) || 'invalid response'}).`, 'run: synopkgland host login <name>', 2);
20
20
  if (/[;\r\n\x00]/.test(result.data.sid) || /[\r\n\x00]/.test(result.data.synotoken) || result.data.sid.length > 4096 || result.data.synotoken.length > 4096)
21
21
  throw new CtlError('CTL_AUTH_FAILED', 'DSM returned invalid session carriers.', 'Check the DSM endpoint.', 2);
22
22
  const returnedDevice = result.data.device_id || result.data.did || options.deviceId;
@@ -28,5 +28,5 @@ export function choose(value, allowed, what) {
28
28
  row = next;
29
29
  } return row[b.length]; };
30
30
  const nearest = [...allowed].sort((a, b) => distance(value.toLowerCase(), a.toLowerCase()) - distance(value.toLowerCase(), b.toLowerCase()))[0];
31
- throw new CtlError(`CTL_${what.toUpperCase()}_UNKNOWN`, `Unknown ${what}: ${value}.${nearest ? ` Did you mean ${nearest}?` : ''}`, nearest ? `run: synopkgland ctl ${what === 'host' ? `host show ${nearest}` : 'list --json'}` : 'run: synopkgland ctl host add <name> --url https://<nas>:5001', 3, allowed);
31
+ throw new CtlError(`CTL_${what.toUpperCase()}_UNKNOWN`, `Unknown ${what}: ${value}.${nearest ? ` Did you mean ${nearest}?` : ''}`, nearest ? `run: synopkgland ctl ${what === 'host' ? `host show ${nearest}` : 'list --json'}` : 'run: synopkgland host add <name> --url https://<nas>:5001', 3, allowed);
32
32
  }
package/dist/ctl/input.js CHANGED
@@ -4,9 +4,9 @@ import { CtlError, choose } from './errors.js';
4
4
  import { redactValue } from './redaction.js';
5
5
  export function selectHosts(names, selected, all = false) {
6
6
  if (selected && all)
7
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --host or --all-hosts, not both.', 'run: synopkgland ctl host list');
7
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --host or --all-hosts, not both.', 'run: synopkgland host list');
8
8
  if (!names.length)
9
- throw new CtlError('CTL_NOT_AUTHENTICATED', 'No hosts registered.', 'run: synopkgland ctl host add <name> --url https://<nas>:5001', 2);
9
+ throw new CtlError('CTL_NOT_AUTHENTICATED', 'No hosts registered.', 'run: synopkgland host add <name> --url https://<nas>:5001', 2);
10
10
  if (all)
11
11
  return names;
12
12
  if (!selected) {
@@ -17,7 +17,7 @@ export class CtlClient {
17
17
  constructor(store, host, request, timeoutMs = CTL_TIMEOUT_MS) {
18
18
  this.store = store;
19
19
  this.host = host;
20
- this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), host.ca ? readFileSync(host.ca, 'utf8') : undefined);
20
+ this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name });
21
21
  }
22
22
  until;
23
23
  remaining(fallback) { return this.until === undefined ? fallback : Math.max(0, this.until - performance.now()); }
@@ -25,14 +25,16 @@ export class CtlClient {
25
25
  await this.inventory(await this.store.session(this.host));
26
26
  return true;
27
27
  }
28
- catch {
28
+ catch (error) {
29
+ if (error instanceof CtlError && error.code.startsWith('HOST_'))
30
+ throw error;
29
31
  return false;
30
32
  } }
31
33
  async inventory(session) {
32
34
  const response = await this.request('/webapi/entry.cgi', new URLSearchParams({ api: 'SYNO.Core.Package', version: '2', method: 'list', additional: '["status"]', SynoToken: session.token }), session);
33
35
  if (response?.success !== true) {
34
36
  const code = Number(response?.error?.code);
35
- throw new CtlError([106, 107, 119].includes(code) ? 'CTL_NOT_AUTHENTICATED' : 'CTL_INVENTORY_DENIED', `DSM Package list failed (${code}).`, `run: synopkgland ctl host login ${this.host.name}`, [106, 107, 119].includes(code) ? 2 : 4);
37
+ throw new CtlError([106, 107, 119].includes(code) ? 'CTL_NOT_AUTHENTICATED' : 'CTL_INVENTORY_DENIED', `DSM Package list failed (${code}).`, `run: synopkgland host login ${this.host.name}`, [106, 107, 119].includes(code) ? 2 : 4);
36
38
  }
37
39
  const entries = response.data?.packages;
38
40
  if (!Array.isArray(entries) || response.data.total !== entries.length || entries.length > 4096)
@@ -134,7 +136,7 @@ export class CtlClient {
134
136
  const boot = await this.request(app.payload.transport.path, { method: 'identity.session', params: { bootstrap: true } }, session);
135
137
  // The transport adapter adds only validated, path-scoped App cookies for this call.
136
138
  if (boot?.success !== true)
137
- throw new CtlError('CTL_APP_SESSION', 'App session bootstrap failed; HTTPS is required.', `run: synopkgland ctl host show ${this.host.name}`, 5);
139
+ throw new CtlError('CTL_APP_SESSION', 'App session bootstrap failed; HTTPS is required.', `run: synopkgland host show ${this.host.name}`, 5);
138
140
  appSession = this.appCookieSession(session, boot);
139
141
  }
140
142
  try {
@@ -150,8 +152,8 @@ export class CtlClient {
150
152
  };
151
153
  const ca = this.host.ca ? readFileSync(this.host.ca, 'utf8') : undefined;
152
154
  const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS),
153
- ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS) }),
154
- upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS), file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
155
+ ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, trust: { fingerprint: this.host.fingerprint, name: this.host.name }, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS) }),
156
+ upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, trust: { fingerprint: this.host.fingerprint, name: this.host.name }, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS), file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
155
157
  if (operation.resultSchema && !ajv.validate(operation.resultSchema, result))
156
158
  throw new CtlError('CTL_RESPONSE_INVALID', 'Result does not match command schema.', 'Check the App version.', 5);
157
159
  return output.clean(result, operation.resultSchema);
@@ -7,6 +7,7 @@ export interface Host {
7
7
  name: string;
8
8
  origin: string;
9
9
  username: string;
10
+ fingerprint?: string;
10
11
  tunnel?: boolean;
11
12
  ca?: string;
12
13
  lastUsed?: string;
@@ -30,6 +31,9 @@ export declare class CtlStore {
30
31
  private safe;
31
32
  private file;
32
33
  key(host: Host): string;
34
+ defaultName(): string | undefined;
35
+ setDefault(name: string): void;
36
+ select(name?: string): Host;
33
37
  list(): Host[];
34
38
  get(name: string): Host;
35
39
  save(host: Host): void;
package/dist/ctl/store.js CHANGED
@@ -3,6 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID, createHash } from 'node:crypto';
5
5
  import { CtlError, choose } from './errors.js';
6
+ import { loginDsm } from './dsm-auth.js';
7
+ import { createRequest } from './transport.js';
6
8
  import { publicDescriptor } from './redaction.js';
7
9
  export class OsCredentialStore {
8
10
  async entry(key) { try {
@@ -45,22 +47,47 @@ export class CtlStore {
45
47
  throw new CtlError('CTL_HOST_INVALID', 'Invalid host name.', 'Use letters, digits, dots, dashes or underscores.'); return name; }
46
48
  file(name) { return join(this.root, 'hosts', this.safe(name) + '.json'); }
47
49
  key(host) { return createHash('sha256').update(host.name + '\0' + host.origin + '\0' + host.username).digest('hex'); }
50
+ defaultName() { try {
51
+ return JSON.parse(readFileSync(join(this.root, 'default.json'), 'utf8')).name;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ } }
56
+ setDefault(name) { this.get(name); mkdirSync(this.root, { recursive: true, mode: 0o700 }); const file = join(this.root, 'default.json'), temp = file + '.' + randomUUID(); writeFileSync(temp, JSON.stringify({ name }), { mode: 0o600 }); renameSync(temp, file); }
57
+ select(name) { if (name)
58
+ return this.get(name); const hosts = this.list(); const selected = this.defaultName(); if (selected)
59
+ return this.get(selected); if (hosts.length === 1)
60
+ return hosts[0]; throw new CtlError('CTL_HOST_REQUIRED', hosts.length ? 'Choose a NAS with --host.' : 'No NAS hosts registered.', hosts.length ? 'run: synopkgland host default <name>' : 'run: synopkgland host add <name> --url https://<nas>:5001', 2); }
48
61
  list() { const dir = join(this.root, 'hosts'); if (!existsSync(dir))
49
62
  return []; return readdirSync(dir).filter(n => n.endsWith('.json')).map(n => JSON.parse(readFileSync(join(dir, n), 'utf8'))).sort((a, b) => a.name.localeCompare(b.name)); }
50
63
  get(name) { this.safe(name); const host = this.list().find(h => h.name === name); if (!host)
51
64
  return choose(name, this.list().map(h => h.name), 'host'); return host; }
52
65
  save(host) { const file = this.file(host.name); mkdirSync(join(this.root, 'hosts'), { recursive: true, mode: 0o700 }); const temp = file + '.' + randomUUID(); writeFileSync(temp, JSON.stringify(host), { mode: 0o600 }); renameSync(temp, file); chmodSync(file, 0o600); }
53
66
  async add(host, session) { if (this.list().some(h => h.name === host.name))
54
- throw new CtlError('CTL_HOST_EXISTS', 'Host name already registered.', `run: synopkgland ctl host login ${host.name}`); await this.credentials.set(this.key(host), session); try {
67
+ throw new CtlError('CTL_HOST_EXISTS', 'Host name already registered.', `run: synopkgland host login ${host.name}`); await this.credentials.set(this.key(host), session); try {
55
68
  this.save(host);
56
69
  }
57
70
  catch (e) {
58
71
  await this.credentials.delete(this.key(host));
59
72
  throw e;
60
73
  } }
61
- async session(host) { const session = await this.credentials.get(this.key(host)); if (!session?.cookie || !session.token)
62
- throw new CtlError('CTL_NOT_AUTHENTICATED', `Host ${host.name} is not logged in.`, `run: synopkgland ctl host login ${host.name}`, 2); if (session.origin !== host.origin || session.username !== host.username)
63
- throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored credentials do not match host.', 'Run ctl host login again.', 2); return session; }
74
+ async session(host) {
75
+ let session;
76
+ if (process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
77
+ if (process.env.DSM_USERNAME !== host.username)
78
+ throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Automation account differs from the registered host.', 'Use the registered account or a separately named host.', 2);
79
+ session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, 30000, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name }) });
80
+ }
81
+ else
82
+ session = await this.credentials.get(this.key(host));
83
+ if (!session?.cookie || !session.token)
84
+ throw new CtlError('CTL_NOT_AUTHENTICATED', `Host ${host.name} is not logged in.`, `run: synopkgland host login ${host.name}`, 2);
85
+ if (/[\r\n\0]/.test(session.cookie + session.token))
86
+ throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored session carriers are invalid', `run: synopkgland host login ${host.name}`, 2);
87
+ if (session.origin !== host.origin || session.username !== host.username)
88
+ throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored credentials do not match host.', 'Run synopkgland host login again.', 2);
89
+ return session;
90
+ }
64
91
  cacheRoot(host) { return join(this.root, 'cache', this.key(host)); }
65
92
  cacheFile(host, pkg, version) { return join(this.cacheRoot(host), createHash('sha256').update(pkg + '\0' + version).digest('hex') + '.json'); }
66
93
  cache(host, pkg, version) {
@@ -89,5 +116,6 @@ export class CtlStore {
89
116
  }
90
117
  putFailure(host, pkg, version, failure) { mkdirSync(this.cacheRoot(host), { recursive: true, mode: 0o700 }); const file = this.cacheFile(host, pkg, version), tmp = file + '.' + randomUUID(); writeFileSync(tmp, JSON.stringify({ failure }), { mode: 0o600 }); renameSync(tmp, file); }
91
118
  revoke(name, pkg) { const host = this.get(name); delete host.approved[pkg]; this.save(host); rmSync(this.cacheRoot(host), { recursive: true, force: true }); return { revoked: pkg, cacheCleared: true }; }
92
- async remove(name) { const host = this.get(name); await this.credentials.delete(this.key(host)); rmSync(this.cacheRoot(host), { recursive: true, force: true }); rmSync(this.file(name)); return { removed: name, deleted: ['session', 'SynoToken', 'device token', 'App approvals', 'hash pins', 'list cache', 'host registration'] }; }
119
+ async remove(name) { const host = this.get(name); await this.credentials.delete(this.key(host)); rmSync(this.cacheRoot(host), { recursive: true, force: true }); rmSync(this.file(name)); if (this.defaultName() === name)
120
+ rmSync(join(this.root, 'default.json'), { force: true }); return { removed: name, deleted: ['session', 'SynoToken', 'device token', 'App approvals', 'hash pins', 'list cache', 'host registration'] }; }
93
121
  }
@@ -0,0 +1,22 @@
1
+ import https from "node:https";
2
+ import { CtlError } from "./errors.js";
3
+ export interface HostTls {
4
+ fingerprint?: string;
5
+ name?: string;
6
+ }
7
+ export declare function normalizeFingerprint(value: string): string;
8
+ export declare function networkError(error: unknown): CtlError;
9
+ export declare function timeoutError(): CtlError;
10
+ /** TLS-only handshake. No HTTP headers, password or session is sent during discovery. */
11
+ export declare function probeCertificate(origin: string, options?: {
12
+ ca?: string;
13
+ fingerprint?: string;
14
+ name?: string;
15
+ timeoutMs?: number;
16
+ }): Promise<{
17
+ fingerprint?: string;
18
+ leafFingerprint?: string;
19
+ trusted: boolean;
20
+ }>;
21
+ /** Release the socket to HTTPS only after matching the leaf pin on this exact connection. */
22
+ export declare function pinnedAgent(origin: string, trust: HostTls, timeoutMs: number): https.Agent;
@@ -0,0 +1,118 @@
1
+ import tls from "node:tls";
2
+ import https from "node:https";
3
+ import { isIP } from "node:net";
4
+ import { CtlError } from "./errors.js";
5
+ export function normalizeFingerprint(value) {
6
+ const normalized = value.replaceAll(":", "").toLowerCase();
7
+ if (!/^[a-f0-9]{64}$/.test(normalized))
8
+ throw new CtlError("HOST_FINGERPRINT_INVALID", "A SHA-256 fingerprint must contain 64 hexadecimal digits", "Use the SHA-256 leaf certificate fingerprint", 3);
9
+ return normalized;
10
+ }
11
+ export function networkError(error) {
12
+ if (error instanceof CtlError)
13
+ return error;
14
+ const code = error?.code;
15
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN")
16
+ return new CtlError("HOST_DNS_FAILED", "NAS hostname could not be resolved", "Check the NAS hostname and DNS", 6);
17
+ if (code === "ECONNREFUSED")
18
+ return new CtlError("HOST_CONNECTION_REFUSED", "NAS refused the connection", "Check the DSM port and NAS availability", 6);
19
+ if (code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT")
20
+ return new CtlError("HOST_TIMEOUT", "NAS connection timed out", "Check the NAS connection and firewall", 6);
21
+ if (code &&
22
+ /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(code))
23
+ return new CtlError("HOST_TLS_UNTRUSTED", "NAS TLS certificate is not trusted", "Use synopkgland host login <name>, --ca, or an expected --fingerprint", 6);
24
+ return new CtlError("CTL_NETWORK", "NAS connection failed", "Check the NAS connection; state-changing requests are not replayed", 6);
25
+ }
26
+ export function timeoutError() {
27
+ return new CtlError("HOST_TIMEOUT", "NAS connection timed out", "Check the NAS connection and firewall", 6);
28
+ }
29
+ function changed(old, current, name) {
30
+ return new CtlError("HOST_CERT_CHANGED", `NAS certificate changed: expected SHA-256 ${old}; received ${current}`, `Verify the new certificate, then run: synopkgland host trust ${name ?? "<name>"}`, 6);
31
+ }
32
+ function socketOptions(origin, ca) {
33
+ const url = new URL(origin), host = url.hostname.replace(/^\[|\]$/g, "");
34
+ return {
35
+ host,
36
+ port: Number(url.port || 443),
37
+ ...(isIP(host) ? {} : { servername: host }),
38
+ ...(ca ? { ca } : {}),
39
+ };
40
+ }
41
+ /** TLS-only handshake. No HTTP headers, password or session is sent during discovery. */
42
+ export function probeCertificate(origin, options = {}) {
43
+ if (new URL(origin).protocol !== "https:")
44
+ return Promise.resolve({ trusted: true });
45
+ return new Promise((resolve, reject) => {
46
+ const socket = tls.connect({
47
+ ...socketOptions(origin, options.ca),
48
+ rejectUnauthorized: false,
49
+ });
50
+ const timer = setTimeout(() => socket.destroy(timeoutError()), options.timeoutMs ?? 30000);
51
+ socket.once("error", (e) => reject(networkError(e)));
52
+ socket.once("close", () => clearTimeout(timer));
53
+ socket.once("secureConnect", () => {
54
+ try {
55
+ const raw = socket.getPeerCertificate().fingerprint256;
56
+ if (!raw)
57
+ throw new CtlError("HOST_TLS_UNTRUSTED", "NAS did not provide a leaf certificate", "Check the NAS HTTPS endpoint", 6);
58
+ const fingerprint = normalizeFingerprint(raw);
59
+ if (options.fingerprint &&
60
+ normalizeFingerprint(options.fingerprint) !== fingerprint)
61
+ throw changed(normalizeFingerprint(options.fingerprint), fingerprint, options.name);
62
+ // A caller-provided CA is an explicit trust policy, never a TOFU fallback.
63
+ if (options.ca && !socket.authorized)
64
+ throw new CtlError("HOST_TLS_UNTRUSTED", "NAS certificate was not verified by the supplied CA", "Check the CA file and NAS hostname", 6);
65
+ resolve({
66
+ trusted: socket.authorized,
67
+ leafFingerprint: fingerprint,
68
+ fingerprint: options.fingerprint || !socket.authorized ? fingerprint : undefined,
69
+ });
70
+ }
71
+ catch (e) {
72
+ reject(e);
73
+ }
74
+ finally {
75
+ socket.destroy();
76
+ }
77
+ });
78
+ });
79
+ }
80
+ /** Release the socket to HTTPS only after matching the leaf pin on this exact connection. */
81
+ export function pinnedAgent(origin, trust, timeoutMs) {
82
+ const agent = new https.Agent({ keepAlive: false });
83
+ agent.createConnection = (_options, callback) => {
84
+ let done = false;
85
+ const socket = tls.connect({
86
+ ...socketOptions(origin),
87
+ rejectUnauthorized: false,
88
+ });
89
+ const timer = setTimeout(() => socket.destroy(timeoutError()), timeoutMs);
90
+ const finish = (error) => {
91
+ if (done)
92
+ return;
93
+ done = true;
94
+ clearTimeout(timer);
95
+ if (error) {
96
+ socket.destroy();
97
+ callback?.(error, null);
98
+ }
99
+ else
100
+ callback?.(null, socket);
101
+ };
102
+ socket.once("error", (e) => finish(networkError(e)));
103
+ socket.once("secureConnect", () => {
104
+ try {
105
+ const current = normalizeFingerprint(socket.getPeerCertificate().fingerprint256 ?? "");
106
+ const old = normalizeFingerprint(trust.fingerprint);
107
+ if (old !== current)
108
+ throw changed(old, current, trust.name);
109
+ finish();
110
+ }
111
+ catch (e) {
112
+ finish(e);
113
+ }
114
+ });
115
+ return undefined;
116
+ };
117
+ return agent;
118
+ }