@synopackageland/cli 0.32.1 → 0.33.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +6 -6
  2. package/README.md +13 -3
  3. package/dist/cli.js +39 -11
  4. package/dist/ctl/cli.d.ts +1 -0
  5. package/dist/ctl/cli.js +73 -21
  6. package/dist/ctl/dsm-auth.d.ts +1 -10
  7. package/dist/ctl/dsm-auth.js +2 -30
  8. package/dist/ctl/errors.js +1 -1
  9. package/dist/ctl/input.js +3 -3
  10. package/dist/ctl/service.js +8 -8
  11. package/dist/ctl/store.d.ts +1 -51
  12. package/dist/ctl/store.js +2 -93
  13. package/dist/ctl/tls.d.ts +1 -0
  14. package/dist/ctl/tls.js +2 -0
  15. package/dist/ctl/transfers.d.ts +1 -14
  16. package/dist/ctl/transfers.js +2 -77
  17. package/dist/ctl/transport.d.ts +1 -12
  18. package/dist/ctl/transport.js +2 -83
  19. package/dist/e2e/dsm.d.ts +2 -2
  20. package/dist/e2e/dsm.js +33 -17
  21. package/dist/error-registry.js +10 -12
  22. package/dist/host/catalog-install.d.ts +0 -10
  23. package/dist/host/catalog-install.js +24 -140
  24. package/dist/host/connection.d.ts +1 -0
  25. package/dist/host/connection.js +2 -0
  26. package/dist/host/credentials.d.ts +1 -2
  27. package/dist/host/credentials.js +2 -67
  28. package/dist/host/dsm-client.d.ts +4 -7
  29. package/dist/host/dsm-client.js +44 -112
  30. package/dist/host/dsm-http.d.ts +1 -1
  31. package/dist/host/dsm-http.js +2 -124
  32. package/dist/host/dsm-port-profile.js +7 -2
  33. package/dist/host/install-plan.d.ts +32 -0
  34. package/dist/host/install-plan.js +157 -0
  35. package/dist/host/package-commands.d.ts +75 -0
  36. package/dist/host/package-commands.js +205 -0
  37. package/dist/host/port-info-lookup.d.ts +1 -1
  38. package/dist/host/port-info-lookup.js +5 -2
  39. package/dist/host/share-lookup.d.ts +3 -2
  40. package/dist/host/share-lookup.js +5 -91
  41. package/dist/host/share-permission-lookup.d.ts +3 -3
  42. package/dist/host/share-permission-lookup.js +18 -30
  43. package/dist/host/types.d.ts +6 -1
  44. package/dist/nas/auth.d.ts +10 -0
  45. package/dist/nas/auth.js +30 -0
  46. package/dist/nas/connection.d.ts +9 -0
  47. package/dist/nas/connection.js +46 -0
  48. package/dist/nas/credentials.d.ts +6 -0
  49. package/dist/nas/credentials.js +22 -0
  50. package/dist/nas/http.d.ts +5 -0
  51. package/dist/nas/http.js +25 -0
  52. package/dist/nas/options.d.ts +13 -0
  53. package/dist/nas/options.js +33 -0
  54. package/dist/nas/session.d.ts +2 -0
  55. package/dist/nas/session.js +6 -0
  56. package/dist/nas/store.d.ts +61 -0
  57. package/dist/nas/store.js +144 -0
  58. package/dist/nas/tls.d.ts +25 -0
  59. package/dist/nas/tls.js +129 -0
  60. package/dist/nas/transfers.d.ts +16 -0
  61. package/dist/nas/transfers.js +76 -0
  62. package/dist/nas/transport.d.ts +17 -0
  63. package/dist/nas/transport.js +143 -0
  64. package/dist/package-source.d.ts +2 -0
  65. package/dist/package-source.js +4 -0
  66. package/dist/skill.js +1 -0
  67. package/dist/spk/inspect.d.ts +1 -0
  68. package/dist/spk/inspect.js +2 -1
  69. package/dist/spk/validate-spk.d.ts +1 -0
  70. package/dist/spk/validate-spk.js +1 -0
  71. package/package.json +2 -2
  72. package/skills/SKILL.md +5 -1
  73. package/skills/reference/ctl.md +6 -4
  74. package/skills/reference/deploy.md +4 -0
  75. package/skills/reference/host-install.md +66 -0
package/CHANGELOG.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.32.1 (2026-10-27)
3
+ ## 0.33.1 (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
9
- - Write upstream provenance as the catalog-compatible `{name, url, license?}` object; normalize HTTPS URL shorthand when building SPKs
5
+ - Use one NAS connection path for host login, secure session storage, TLS and every DSM request, including package commands, deployment, ctl transfers and e2e login helpers
6
+ - Accept self-signed, untrusted CA, hostname-mismatched and expired NAS certificates by default
7
+ - Record each leaf SHA-256 for display; warn once and update it when a certificate changes, then continue
8
+ - Add opt-in --strict-tls for host profiles and individual commands; --ca also verifies CA trust, hostname, validity and recorded certificates
9
+ - Keep passwords on the exact connection whose certificate was inspected, without changing global TLS settings
package/README.md CHANGED
@@ -346,16 +346,26 @@ 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
357
357
  context. Use `--host a,b` or `--all-hosts` for bounded parallel operations.
358
358
 
359
+ NAS HTTPS accepts self-signed, untrusted CA, wrong-name and expired certificates
360
+ by default on every CLI path. The CLI records the leaf SHA-256 for display; a
361
+ changed leaf warns once on stderr, updates that value and continues.
362
+ `host add --strict-tls` stores verification in the profile. `--strict-tls` or
363
+ `--ca <file>` on any NAS command opts into CA, hostname, validity and recorded
364
+ certificate checks. Strict failures use `HOST_TLS_UNTRUSTED` or `HOST_CERT_CHANGED`;
365
+ `host trust <name>` can explicitly update the recorded fingerprint. Passwords
366
+ are sent only over a connection whose certificate was inspected. Public package
367
+ feeds and SPK downloads retain normal HTTPS verification.
368
+
359
369
  `ctl` preserves these process exit codes, including with `--json`:
360
370
 
361
371
  | Exit code | Meaning and examples |
@@ -382,7 +392,7 @@ not become process exit codes 4 or 5. Attempting to execute an excluded App with
382
392
  `--refresh` instead returns `ok:false`, `CTL_APP_UNKNOWN` and exit 3 because the
383
393
  App is absent from discovery results; no business operation runs.
384
394
 
385
- `ctl host list/show` can also succeed while reporting `authenticated:false` and
395
+ `host list/show` can also succeed while reporting `authenticated:false` and
386
396
  exit 0. Check discovery `apps`/`diagnostics` and session fields as well as the exit
387
397
  code when deciding whether an App is ready.
388
398
  `ctl --timeout <seconds>` applies to discovery, JSON calls, polling and transfer
package/dist/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { nasFlags, withNasOptions } from './nas/options.js';
1
2
  import { upstreamRepository, upstreamMaintainer } from './upstream-attribution.js';
2
3
  import { Command, CommanderError } from "commander";
3
4
  import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
@@ -289,6 +290,15 @@ async function containerBuild(project, arches, options) {
289
290
  }
290
291
  /** Resolves after dispatch; commands may set process.exitCode. Rejections are failures. */
291
292
  export async function runCli(argv) {
293
+ const { args, options } = nasFlags(argv, argv[2] !== "ctl");
294
+ return withNasOptions(options, () => runCliWithin(args));
295
+ }
296
+ async function runCliWithin(argv) {
297
+ if (argv[2] === "host") {
298
+ const { runCtl } = await import("./ctl/cli.js");
299
+ await runCtl(["host", ...argv.slice(3)]);
300
+ return;
301
+ }
292
302
  if (argv[2] === "ctl") {
293
303
  const { runCtl } = await import("./ctl/cli.js");
294
304
  await runCtl(argv.slice(3));
@@ -302,16 +312,28 @@ export async function runCli(argv) {
302
312
  .name("synopkgland")
303
313
  .description("Syno Package Land Developer Kit CLI")
304
314
  .option("--project <dir>", "App project root")
315
+ .option("--strict-tls", "Verify NAS CA, hostname, validity and recorded certificate")
316
+ .option("--ca <file>", "Verify NAS HTTPS with a PEM CA file")
305
317
  .exitOverride();
306
318
  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
- };
319
+ const hostOf = (host) => createNasHost({ host });
320
+ program.command("host").description("Shared NAS login and host management (host --help)");
321
+ program.command("install <package-or-spk>")
322
+ .description("Install a store package or a local SPK")
323
+ .option("--host <name>", "Registered NAS host")
324
+ .option("--source <url>", "HTTPS package source")
325
+ .option("--param <NAME=value>", "Install wizard value", (v, p) => collectKv(v, p, "--param"))
326
+ .option("--yes", "Confirm installation")
327
+ .option("--dry-run", "Print the installation plan without writing to DSM")
328
+ .option("--json", "Print a single JSON object")
329
+ .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)); });
330
+ program.command("uninstall <package-id>")
331
+ .description("Remove an installed package")
332
+ .option("--host <name>", "Registered NAS host")
333
+ .option("--yes", "Confirm removal")
334
+ .option("--dry-run", "Print the removal plan without writing to DSM")
335
+ .option("--json", "Print a single JSON object")
336
+ .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
337
  program.command("ctl").description("Control registered NAS Apps using described AI commands (ctl --help)");
316
338
  program
317
339
  .command("check")
@@ -608,12 +630,18 @@ export async function runCli(argv) {
608
630
  emit("deploy", result, `Deployed ${result.packageId}${result.openUrl ? "\nOpen URL: " + result.openUrl : ""}`);
609
631
  });
610
632
  program
611
- .command("status")
633
+ .command("status [package-id]")
612
634
  .description("DSM package status, Open URL and capability readiness")
613
635
  .option("--json", "Print a single JSON object")
614
636
  .option("--host <name>", "NAS profile")
615
637
  .option("--dev", "Development Package Identity")
616
- .action(async (options, command) => {
638
+ .action(async (id, options, command) => {
639
+ if (id) {
640
+ const { packageStatus } = await import('./host/package-commands.js');
641
+ const result = await packageStatus(id, options.host);
642
+ emit('status', result, JSON.stringify(result, null, 2));
643
+ return;
644
+ }
617
645
  const host = hostOf(options.host);
618
646
  const root = rootOf(command);
619
647
  const standalone = standaloneRoot(root);
@@ -629,7 +657,7 @@ export async function runCli(argv) {
629
657
  program
630
658
  .command("logs")
631
659
  .description("Status diagnosis: DSM package status and masked diagnostics; does not stream application logs")
632
- .requiredOption("--host <name>", "NAS profile")
660
+ .option("--host <name>", "Registered NAS host")
633
661
  .option("--dev", "Development Package Identity")
634
662
  .action(async (options, command) => {
635
663
  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,5 +1,6 @@
1
+ import { nasFlags, withNasOptions, nasOptions } from '../nas/options.js';
2
+ import { probeCertificate, normalizeFingerprint } from './tls.js';
1
3
  import { readBounded } from './json.js';
2
- import { readFileSync } from 'node:fs';
3
4
  import { createInterface } from 'node:readline/promises';
4
5
  import { Writable } from 'node:stream';
5
6
  import { CtlError, errorData, ctlError } from './errors.js';
@@ -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]; NAS HTTPS accepts any certificate by default; --strict-tls or --ca <file> opts into verification';
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);
@@ -60,6 +61,16 @@ function example(host, app, op) {
60
61
  }
61
62
  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, 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) }; }) })); }
62
63
  export async function runCtl(argv, io = {}) {
64
+ try {
65
+ const { args, options } = nasFlags(argv);
66
+ return await withNasOptions({ ...options, warn: io.err ?? console.error }, () => runCtlWithin(args, io));
67
+ }
68
+ catch (error) {
69
+ (io.out ?? console.log)(JSON.stringify({ ok: false, error: errorData(error) }));
70
+ process.exitCode = ctlError(error).exitCode;
71
+ }
72
+ }
73
+ async function runCtlWithin(argv, io = {}) {
63
74
  const args = [...argv], store = io.store ?? new CtlStore(), out = io.out ?? console.log, tty = io.isTTY ?? !!process.stdout.isTTY, ask = io.prompt ?? prompt;
64
75
  let json = argv.includes('--json') || !tty;
65
76
  const emit = (value) => out(json ? JSON.stringify(value) : JSON.stringify(value, null, 2));
@@ -79,7 +90,7 @@ export async function runCtl(argv, io = {}) {
79
90
  if (args[0] === 'host') {
80
91
  const action = args[1], name = args[2];
81
92
  if (help || !action) {
82
- emit({ ok: true, data: { usage: USAGE, actions: ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke'] } });
93
+ emit({ ok: true, data: { usage: USAGE, actions: ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke', 'default', 'trust'] } });
83
94
  return;
84
95
  }
85
96
  if (action === 'list') {
@@ -88,31 +99,54 @@ export async function runCtl(argv, io = {}) {
88
99
  return;
89
100
  }
90
101
  if (!name)
91
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Host name is required.', 'run: synopkgland ctl host list');
102
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Host name is required.', 'run: synopkgland host list');
103
+ if (action === 'default') {
104
+ store.setDefault(name);
105
+ emit({ ok: true, data: { defaultHost: name } });
106
+ return;
107
+ }
92
108
  if (action === 'add' || action === 'login') {
93
109
  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);
110
+ 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);
111
+ if ((ca || nasOptions().ca) && fingerprint)
112
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --ca or --fingerprint', 'Choose one certificate trust policy');
95
113
  if (remaining.length)
96
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland ctl host --help', 3, remaining);
114
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland host --help', 3);
97
115
  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}`);
116
+ throw new CtlError('CTL_HOST_EXISTS', `Host ${name} already registered.`, `run: synopkgland host login ${name}`);
99
117
  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}`);
118
+ 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
119
  const old = action === 'login' ? store.get(name) : undefined;
102
120
  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 } : {}) };
121
+ throw new CtlError('CTL_URL_REQUIRED', '--url is required.', `run: synopkgland host add ${name} --url https://${name}:5001`);
122
+ const host = old ?? { name, origin: canonicalOrigin(url, tunnel), username: usernameOption ?? process.env.DSM_USERNAME ?? await ask('DSM account: '), approved: {}, ...(tunnel ? { tunnel: true } : {}), ...(nasOptions().ca ? { ca: nasOptions().ca } : {}), ...(nasOptions().strictTls ? { strictTls: true } : {}) };
123
+ if (fingerprint)
124
+ normalizeFingerprint(fingerprint);
125
+ if (!io.requestFor) {
126
+ const prior = host.fingerprint;
127
+ const checked = await probeCertificate(host.origin, { ...store.trust(host), expectedFingerprint: fingerprint, timeoutMs });
128
+ if (checked.leafFingerprint) {
129
+ host.fingerprint = checked.leafFingerprint;
130
+ store.save(host);
131
+ if (!prior && !checked.trusted)
132
+ (io.err ?? console.error)(`Trusting the NAS certificate ${checked.leafFingerprint.slice(0, 16)} (not verified by a trusted CA)`);
133
+ }
134
+ }
135
+ else if (fingerprint)
136
+ host.fingerprint = normalizeFingerprint(fingerprint);
105
137
  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 });
138
+ const secret = process.env.DSM_PASSWORD ?? (passwordStdin ? readBounded(0, 4096).replace(/[\r\n]+$/, '') : await ask('User password: ', true));
139
+ 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, undefined, { ...store.trust(host), expectedFingerprint: fingerprint }) });
108
140
  try {
109
- if (old)
141
+ if (old || host.fingerprint) {
110
142
  await store.credentials.set(store.key(host), session);
143
+ store.save(host);
144
+ }
111
145
  else
112
146
  await store.add(host, session);
113
147
  }
114
148
  catch (e) {
115
- await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs)).catch(() => { });
149
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, undefined, store.trust(host))).catch(() => { });
116
150
  throw e;
117
151
  }
118
152
  host.lastUsed = new Date().toISOString();
@@ -121,25 +155,42 @@ export async function runCtl(argv, io = {}) {
121
155
  return;
122
156
  }
123
157
  const host = store.get(name);
158
+ if (action === 'trust') {
159
+ if (host.ca)
160
+ 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);
161
+ if (!host.origin.startsWith('https:'))
162
+ throw new CtlError('HOST_TLS_REQUIRED', 'Certificate trust requires HTTPS', 'Use an HTTPS host registration', 3);
163
+ const checked = await probeCertificate(host.origin, { expectedFingerprint: take(args, '--fingerprint', true), name });
164
+ // Get the leaf even when system-trusted, to make an explicit re-pin stable.
165
+ const current = checked.leafFingerprint;
166
+ if (!current)
167
+ 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);
168
+ if (!yes && (!tty || !/^y(es)?$/i.test(await ask(`Trust NAS certificate SHA-256 ${current} (previous ${host.fingerprint ?? 'none'})? [y/N] `))))
169
+ throw new CtlError('HOST_TRUST_CONFIRM_REQUIRED', 'Certificate replacement requires confirmation', `Verify the certificate, then run: synopkgland host trust ${name}`, 7);
170
+ host.fingerprint = current;
171
+ store.save(host);
172
+ emit({ ok: true, data: { name, fingerprint: current } });
173
+ return;
174
+ }
124
175
  if (action === 'show') {
125
176
  emit({ ok: true, data: { ...host, authenticated: await client(host).valid() } });
126
177
  return;
127
178
  }
128
179
  if (action === 'revoke') {
129
180
  if (!args[3])
130
- throw new CtlError('CTL_ARGUMENT_INVALID', 'Package ID is required.', `run: synopkgland ctl host show ${name}`);
181
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Package ID is required.', `run: synopkgland host show ${name}`);
131
182
  emit({ ok: true, data: store.revoke(name, args[3]) });
132
183
  return;
133
184
  }
134
185
  if (action === 'logout' || action === 'remove') {
135
186
  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);
187
+ throw new CtlError('CTL_CONFIRM_REQUIRED', 'Host removal requires --yes.', `run: synopkgland host remove ${name} --yes`, 7);
137
188
  const session = await store.credentials.get(store.key(host));
138
189
  let loggedOut = false;
139
190
  let logoutError;
140
191
  if (session?.cookie) {
141
192
  try {
142
- await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined));
193
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, undefined, store.trust(host)));
143
194
  loggedOut = true;
144
195
  }
145
196
  catch (error) {
@@ -151,15 +202,16 @@ export async function runCtl(argv, io = {}) {
151
202
  return;
152
203
  }
153
204
  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);
205
+ throw new CtlError('CTL_LOGOUT_FAILED', 'DSM logout was not confirmed; local session retained for retry.', `run: synopkgland host logout ${name}`, 6);
155
206
  if (session)
156
207
  await store.credentials.set(store.key(host), { ...session, cookie: '', token: '' });
157
208
  emit({ ok: true, data: { name, loggedOut, registrationRetained: true } });
158
209
  return;
159
210
  }
160
- throw new CtlError('CTL_COMMAND_UNKNOWN', `Unknown host action ${action}.`, 'run: synopkgland ctl host --help', 3, ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke']);
211
+ 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
212
  }
162
- const names = selectHosts(store.list().map(h => h.name), hostOption, allHosts);
213
+ const targets = store.targets();
214
+ const names = selectHosts(targets.map(h => h.name), hostOption ?? (allHosts ? undefined : store.defaultName()), allHosts);
163
215
  const inputAt = args.indexOf('--input');
164
216
  let commonInput;
165
217
  if (inputAt >= 0 && args[inputAt + 1] === '-') {
@@ -172,7 +224,7 @@ export async function runCtl(argv, io = {}) {
172
224
  }
173
225
  const results = await boundedMap(names, 4, async (name) => {
174
226
  try {
175
- const host = store.get(name), ctl = client(host);
227
+ const host = targets.find(host => host.name === name), ctl = client(host);
176
228
  if (args[0] === 'list') {
177
229
  if (args.length !== 1)
178
230
  throw new CtlError('CTL_ARGUMENT_INVALID', 'Unexpected list arguments.', `run: synopkgland ctl list --host ${name}`);
@@ -1,10 +1 @@
1
- import { type DsmSession, type JsonRequest } from './transport.js';
2
- export interface LoginOptions {
3
- remember?: boolean;
4
- deviceId?: string;
5
- otp: () => Promise<string>;
6
- request?: JsonRequest;
7
- ca?: string;
8
- }
9
- export declare function loginDsm(origin: string, username: string, password: string, options: LoginOptions): Promise<DsmSession>;
10
- export declare function logoutDsm(session: DsmSession, request?: JsonRequest): Promise<void>;
1
+ export * from "../nas/auth.js";
@@ -1,30 +1,2 @@
1
- import { CtlError } from './errors.js';
2
- import { createRequest } from './transport.js';
3
- export async function loginDsm(origin, username, password, options) {
4
- const request = options.request ?? createRequest(origin, 30000, options.ca);
5
- const form = new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'login', account: username, passwd: password, session: 'DSM', format: 'cookie', enable_syno_token: 'yes', enable_device_token: options.remember === false ? 'no' : 'yes', device_name: 'synopkgland', device_id: options.deviceId ?? '' });
6
- let result = await request('/webapi/entry.cgi', form);
7
- if (result?.error?.code === 402) {
8
- form.delete('session');
9
- result = await request('/webapi/entry.cgi', form);
10
- }
11
- if (result?.error?.code === 403) {
12
- const otp = await options.otp();
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);
15
- form.set('otp_code', otp);
16
- result = await request('/webapi/entry.cgi', form);
17
- }
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);
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
- throw new CtlError('CTL_AUTH_FAILED', 'DSM returned invalid session carriers.', 'Check the DSM endpoint.', 2);
22
- const returnedDevice = result.data.device_id || result.data.did || options.deviceId;
23
- const deviceId = options.remember === false ? undefined : typeof returnedDevice === 'string' && returnedDevice.length <= 4096 && !/[\r\n\x00]/.test(returnedDevice) ? returnedDevice : undefined;
24
- return { origin, username, cookie: `id=${result.data.sid}`, token: result.data.synotoken, ...(deviceId ? { deviceId } : {}) };
25
- }
26
- export async function logoutDsm(session, request = createRequest(session.origin)) {
27
- const body = await request('/webapi/entry.cgi', new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'logout', SynoToken: session.token }), session);
28
- if (body?.success !== true)
29
- throw new CtlError('CTL_LOGOUT_FAILED', 'DSM did not confirm logout.', 'Retry logout or revoke the NAS session.', 2);
30
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/auth.js";
@@ -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) {
@@ -34,7 +34,7 @@ export function selectOperation(app, path) {
34
34
  return { id: found[0], operation: found[1] };
35
35
  return choose(path.join(' '), Object.values(app.payload.commands).map(op => op.cli.join(' ')), 'command');
36
36
  }
37
- export const RESERVED_FLAGS = new Set(['host', 'all-hosts', 'json', 'app', 'refresh', 'yes', 'dry-run', 'approve-installed', 'help', 'input', 'wait', 'no-wait', 'timeout']);
37
+ 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
38
  export function flagName(key) { return key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()).replaceAll('_', '-'); }
39
39
  export function operationInput(op, args, stdin, helpHint) {
40
40
  const fail = (message, allowed) => { throw new CtlError('CTL_PARAMS_INVALID', message, helpHint ?? ('run: synopkgland ctl <package> ' + op.cli.join(' ') + ' --help'), 3, allowed); };
@@ -1,4 +1,3 @@
1
- import { readFileSync } from 'node:fs';
2
1
  import { CtlError, errorData, choose } from './errors.js';
3
2
  import { APP_COOKIE, CTL_TIMEOUT_MS, createRequest } from './transport.js';
4
3
  import { DESCRIPTION_METHOD, validateDescriptor, ajv } from './descriptor.js';
@@ -17,7 +16,7 @@ export class CtlClient {
17
16
  constructor(store, host, request, timeoutMs = CTL_TIMEOUT_MS) {
18
17
  this.store = store;
19
18
  this.host = host;
20
- this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), host.ca ? readFileSync(host.ca, 'utf8') : undefined);
19
+ this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), undefined, store.trust(host));
21
20
  }
22
21
  until;
23
22
  remaining(fallback) { return this.until === undefined ? fallback : Math.max(0, this.until - performance.now()); }
@@ -25,14 +24,16 @@ export class CtlClient {
25
24
  await this.inventory(await this.store.session(this.host));
26
25
  return true;
27
26
  }
28
- catch {
27
+ catch (error) {
28
+ if (error instanceof CtlError && error.code.startsWith('HOST_'))
29
+ throw error;
29
30
  return false;
30
31
  } }
31
32
  async inventory(session) {
32
33
  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
34
  if (response?.success !== true) {
34
35
  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);
36
+ 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
37
  }
37
38
  const entries = response.data?.packages;
38
39
  if (!Array.isArray(entries) || response.data.total !== entries.length || entries.length > 4096)
@@ -134,7 +135,7 @@ export class CtlClient {
134
135
  const boot = await this.request(app.payload.transport.path, { method: 'identity.session', params: { bootstrap: true } }, session);
135
136
  // The transport adapter adds only validated, path-scoped App cookies for this call.
136
137
  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);
138
+ throw new CtlError('CTL_APP_SESSION', 'App session bootstrap failed; HTTPS is required.', `run: synopkgland host show ${this.host.name}`, 5);
138
139
  appSession = this.appCookieSession(session, boot);
139
140
  }
140
141
  try {
@@ -148,10 +149,9 @@ export class CtlClient {
148
149
  }
149
150
  return response.data;
150
151
  };
151
- const ca = this.host.ca ? readFileSync(this.host.ca, 'utf8') : undefined;
152
152
  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]) } }) });
153
+ ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { trust: this.store.trust(this.host), timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS) }),
154
+ upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { trust: this.store.trust(this.host), 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
155
  if (operation.resultSchema && !ajv.validate(operation.resultSchema, result))
156
156
  throw new CtlError('CTL_RESPONSE_INVALID', 'Result does not match command schema.', 'Check the App version.', 5);
157
157
  return output.clean(result, operation.resultSchema);
@@ -1,51 +1 @@
1
- import type { DsmSession } from './transport.js';
2
- export interface Approval {
3
- version: string;
4
- hash?: string;
5
- }
6
- export interface Host {
7
- name: string;
8
- origin: string;
9
- username: string;
10
- tunnel?: boolean;
11
- ca?: string;
12
- lastUsed?: string;
13
- approved: Record<string, Approval>;
14
- }
15
- export interface CredentialStore {
16
- get(key: string): Promise<DsmSession | null>;
17
- set(key: string, value: DsmSession): Promise<void>;
18
- delete(key: string): Promise<void>;
19
- }
20
- export declare class OsCredentialStore implements CredentialStore {
21
- private entry;
22
- get(key: string): Promise<DsmSession | null>;
23
- set(key: string, value: DsmSession): Promise<void>;
24
- delete(key: string): Promise<void>;
25
- }
26
- export declare class CtlStore {
27
- root: string;
28
- credentials: CredentialStore;
29
- constructor(root?: string, credentials?: CredentialStore);
30
- private safe;
31
- private file;
32
- key(host: Host): string;
33
- list(): Host[];
34
- get(name: string): Host;
35
- save(host: Host): void;
36
- add(host: Host, session: DsmSession): Promise<void>;
37
- session(host: Host): Promise<DsmSession>;
38
- cacheRoot(host: Host): string;
39
- cacheFile(host: Host, pkg: string, version: string): string;
40
- cache(host: Host, pkg: string, version: string): any | null;
41
- putCache(host: Host, pkg: string, version: string, description: any): void;
42
- putFailure(host: Host, pkg: string, version: string, failure: unknown): void;
43
- revoke(name: string, pkg: string): {
44
- revoked: string;
45
- cacheCleared: boolean;
46
- };
47
- remove(name: string): Promise<{
48
- removed: string;
49
- deleted: string[];
50
- }>;
51
- }
1
+ export * from "../nas/store.js";
package/dist/ctl/store.js CHANGED
@@ -1,93 +1,2 @@
1
- import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, rmSync, existsSync, chmodSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { randomUUID, createHash } from 'node:crypto';
5
- import { CtlError, choose } from './errors.js';
6
- import { publicDescriptor } from './redaction.js';
7
- export class OsCredentialStore {
8
- async entry(key) { try {
9
- const { AsyncEntry } = await import('@napi-rs/keyring');
10
- return new AsyncEntry('synopkgland.ctl', key, { linux: { store: 'secret-service' } });
11
- }
12
- catch {
13
- throw new CtlError('CTL_CREDENTIAL_STORE', 'OS secure credential store unavailable.', 'Unlock the OS credential store and retry.', 2);
14
- } }
15
- async get(key) { try {
16
- const raw = await (await this.entry(key)).getPassword();
17
- return raw ? JSON.parse(raw) : null;
18
- }
19
- catch (e) {
20
- if (e instanceof CtlError)
21
- throw e;
22
- throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot read OS credentials.', 'Unlock the OS credential store and retry.', 2);
23
- } }
24
- async set(key, value) { try {
25
- await (await this.entry(key)).setPassword(JSON.stringify(value));
26
- }
27
- catch {
28
- throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot save session in OS secure storage.', 'Unlock the OS credential store and retry.', 2);
29
- } }
30
- async delete(key) { try {
31
- await (await this.entry(key)).deleteCredential();
32
- }
33
- catch {
34
- throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot remove OS credentials.', 'Unlock the OS credential store and retry removal.', 2);
35
- } }
36
- }
37
- export class CtlStore {
38
- root;
39
- credentials;
40
- constructor(root = process.env.SYNOPKGLAND_CTL_DIR ?? join(homedir(), '.synopkgland', 'ctl'), credentials = new OsCredentialStore()) {
41
- this.root = root;
42
- this.credentials = credentials;
43
- }
44
- safe(name) { if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name))
45
- throw new CtlError('CTL_HOST_INVALID', 'Invalid host name.', 'Use letters, digits, dots, dashes or underscores.'); return name; }
46
- file(name) { return join(this.root, 'hosts', this.safe(name) + '.json'); }
47
- key(host) { return createHash('sha256').update(host.name + '\0' + host.origin + '\0' + host.username).digest('hex'); }
48
- list() { const dir = join(this.root, 'hosts'); if (!existsSync(dir))
49
- 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
- get(name) { this.safe(name); const host = this.list().find(h => h.name === name); if (!host)
51
- return choose(name, this.list().map(h => h.name), 'host'); return host; }
52
- 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
- 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 {
55
- this.save(host);
56
- }
57
- catch (e) {
58
- await this.credentials.delete(this.key(host));
59
- throw e;
60
- } }
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; }
64
- cacheRoot(host) { return join(this.root, 'cache', this.key(host)); }
65
- cacheFile(host, pkg, version) { return join(this.cacheRoot(host), createHash('sha256').update(pkg + '\0' + version).digest('hex') + '.json'); }
66
- cache(host, pkg, version) {
67
- try {
68
- const entry = JSON.parse(readFileSync(this.cacheFile(host, pkg, version), 'utf8'));
69
- if (entry.description && !entry.redacted) {
70
- const safe = publicDescriptor(entry.description);
71
- if (JSON.stringify(safe) !== JSON.stringify(entry.description)) {
72
- this.putCache(host, pkg, version, entry.description);
73
- return { ...entry, description: safe, redacted: true };
74
- }
75
- }
76
- return entry;
77
- }
78
- catch {
79
- return null;
80
- }
81
- }
82
- putCache(host, pkg, version, description) {
83
- const safe = publicDescriptor(description);
84
- const redacted = JSON.stringify(safe) !== JSON.stringify(description);
85
- mkdirSync(this.cacheRoot(host), { recursive: true, mode: 0o700 });
86
- const file = this.cacheFile(host, pkg, version), tmp = file + '.' + randomUUID();
87
- writeFileSync(tmp, JSON.stringify({ hash: description.sha256, description: safe, ...(redacted ? { redacted: true } : {}) }), { mode: 0o600 });
88
- renameSync(tmp, file);
89
- }
90
- 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
- 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'] }; }
93
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/store.js";