@synopackageland/cli 0.33.0 → 0.33.2

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 (60) hide show
  1. package/CHANGELOG.md +3 -7
  2. package/README.md +14 -2
  3. package/dist/cli.js +7 -0
  4. package/dist/ctl/cli.js +27 -17
  5. package/dist/ctl/dsm-auth.d.ts +1 -10
  6. package/dist/ctl/dsm-auth.js +2 -30
  7. package/dist/ctl/input.js +1 -1
  8. package/dist/ctl/service.js +3 -5
  9. package/dist/ctl/store.d.ts +1 -55
  10. package/dist/ctl/store.js +2 -121
  11. package/dist/ctl/tls.d.ts +1 -22
  12. package/dist/ctl/tls.js +2 -118
  13. package/dist/ctl/transfers.d.ts +1 -16
  14. package/dist/ctl/transfers.js +2 -78
  15. package/dist/ctl/transport.d.ts +1 -14
  16. package/dist/ctl/transport.js +2 -113
  17. package/dist/e2e/dsm.d.ts +2 -2
  18. package/dist/e2e/dsm.js +33 -17
  19. package/dist/error-registry.js +4 -4
  20. package/dist/host/connection.d.ts +1 -9
  21. package/dist/host/connection.js +2 -49
  22. package/dist/host/credentials.d.ts +1 -4
  23. package/dist/host/credentials.js +2 -32
  24. package/dist/host/dsm-client.js +4 -27
  25. package/dist/host/dsm-http.d.ts +1 -3
  26. package/dist/host/dsm-http.js +2 -144
  27. package/dist/host/package-commands.js +3 -3
  28. package/dist/host/types.d.ts +1 -0
  29. package/dist/nas/auth.d.ts +10 -0
  30. package/dist/nas/auth.js +30 -0
  31. package/dist/nas/connection.d.ts +9 -0
  32. package/dist/nas/connection.js +46 -0
  33. package/dist/nas/credentials.d.ts +6 -0
  34. package/dist/nas/credentials.js +22 -0
  35. package/dist/nas/http.d.ts +5 -0
  36. package/dist/nas/http.js +25 -0
  37. package/dist/nas/options.d.ts +13 -0
  38. package/dist/nas/options.js +33 -0
  39. package/dist/nas/session.d.ts +2 -0
  40. package/dist/nas/session.js +6 -0
  41. package/dist/nas/store.d.ts +61 -0
  42. package/dist/nas/store.js +144 -0
  43. package/dist/nas/tls.d.ts +25 -0
  44. package/dist/nas/tls.js +129 -0
  45. package/dist/nas/transfers.d.ts +16 -0
  46. package/dist/nas/transfers.js +76 -0
  47. package/dist/nas/transport.d.ts +17 -0
  48. package/dist/nas/transport.js +143 -0
  49. package/dist/package-source.d.ts +2 -0
  50. package/dist/package-source.js +4 -0
  51. package/dist/standalone/dsm-window.d.ts +2 -0
  52. package/dist/standalone/dsm-window.js +30 -3
  53. package/package.json +1 -1
  54. package/skills/SKILL.md +4 -2
  55. package/skills/reference/create.md +1 -1
  56. package/skills/reference/ctl.md +2 -0
  57. package/skills/reference/deploy.md +2 -0
  58. package/skills/reference/host-install.md +21 -10
  59. package/skills/reference/portal.md +1 -1
  60. package/skills/reference/ui-vue.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,10 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.33.0 (2026-10-27)
3
+ ## 0.33.2 (2026-10-28)
4
4
 
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
5
+ - Load the installed app when a DSM window opens after a package upgrade without a page reload; the loader previously reused the app module the previous build left in the page
6
+ - Give DSM windows and full pages `ctx.uiUpdate.check()`, which reports when the installed package has replaced the UI the page loaded, so the SDK's AppShell can offer a reload after an upgrade
package/README.md CHANGED
@@ -282,8 +282,10 @@ for every other frontend. Plain/binary scaffolds explicitly choose new-page; Vue
282
282
  native `ui.launch` resolves to `dsm-window`; the CLI does not guess a frontend's
283
283
  framework from its build command. `new-page` registers DSM `type: "standalone"`,
284
284
  so the main menu and Package Center open a full page on the DSM origin.
285
- `ctx.fullPage`, `ctx.appIcon`, session/SynoToken, launch parameters and entry
286
- permissions use the same wrapper in both modes. The wrapper sets the standalone
285
+ `ctx.fullPage`, `ctx.appIcon`, `ctx.uiUpdate`, session/SynoToken, launch parameters and entry
286
+ permissions use the same wrapper in both modes. `ctx.uiUpdate.check()` reports when
287
+ a package upgrade replaced the UI the page loaded; SDK 0.31.5+ AppShell then shows
288
+ a one-line reload notice. The wrapper sets the standalone
287
289
  page title, favicon and device-width viewport. It retains ExtJS; `pureEnv` is
288
290
  not appropriate, and alternate Portal ports are not needed (`allowAltPort` is omitted).
289
291
 
@@ -356,6 +358,16 @@ commands and their schemas. Run the listed alias/command with typed flags,
356
358
  `--help` and `--dry-run`; writes require `--yes`. There is no mutable current
357
359
  context. Use `--host a,b` or `--all-hosts` for bounded parallel operations.
358
360
 
361
+ NAS HTTPS accepts self-signed, untrusted CA, wrong-name and expired certificates
362
+ by default on every CLI path. The CLI records the leaf SHA-256 for display; a
363
+ changed leaf warns once on stderr, updates that value and continues.
364
+ `host add --strict-tls` stores verification in the profile. `--strict-tls` or
365
+ `--ca <file>` on any NAS command opts into CA, hostname, validity and recorded
366
+ certificate checks. Strict failures use `HOST_TLS_UNTRUSTED` or `HOST_CERT_CHANGED`;
367
+ `host trust <name>` can explicitly update the recorded fingerprint. Passwords
368
+ are sent only over a connection whose certificate was inspected. Public package
369
+ feeds and SPK downloads retain normal HTTPS verification.
370
+
359
371
  `ctl` preserves these process exit codes, including with `--json`:
360
372
 
361
373
  | Exit code | Meaning and examples |
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,10 @@ 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) {
292
297
  if (argv[2] === "host") {
293
298
  const { runCtl } = await import("./ctl/cli.js");
294
299
  await runCtl(["host", ...argv.slice(3)]);
@@ -307,6 +312,8 @@ export async function runCli(argv) {
307
312
  .name("synopkgland")
308
313
  .description("Syno Package Land Developer Kit CLI")
309
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")
310
317
  .exitOverride();
311
318
  const rootOf = (command) => resolve(process.cwd(), command.parent?.opts().project ?? ".");
312
319
  const hostOf = (host) => createNasHost({ host });
package/dist/ctl/cli.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { nasFlags, withNasOptions, nasOptions } from '../nas/options.js';
1
2
  import { probeCertificate, normalizeFingerprint } from './tls.js';
2
3
  import { readBounded } from './json.js';
3
- import { readFileSync } from 'node:fs';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { Writable } from 'node:stream';
6
6
  import { CtlError, errorData, ctlError } from './errors.js';
@@ -10,7 +10,7 @@ import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } fro
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';
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
+ 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';
14
14
  async function prompt(message, secret = false) {
15
15
  if (!process.stdin.isTTY)
16
16
  throw new CtlError('CTL_INTERACTION_REQUIRED', 'Interactive input required.', 'Use --username and --password-stdin for host authentication.', 2);
@@ -61,6 +61,16 @@ function example(host, app, op) {
61
61
  }
62
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) }; }) })); }
63
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 = {}) {
64
74
  const args = [...argv], store = io.store ?? new CtlStore(), out = io.out ?? console.log, tty = io.isTTY ?? !!process.stdout.isTTY, ask = io.prompt ?? prompt;
65
75
  let json = argv.includes('--json') || !tty;
66
76
  const emit = (value) => out(json ? JSON.stringify(value) : JSON.stringify(value, null, 2));
@@ -98,7 +108,7 @@ export async function runCtl(argv, io = {}) {
98
108
  if (action === 'add' || action === 'login') {
99
109
  const remaining = args.slice(3);
100
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);
101
- if (ca && fingerprint)
111
+ if ((ca || nasOptions().ca) && fingerprint)
102
112
  throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --ca or --fingerprint', 'Choose one certificate trust policy');
103
113
  if (remaining.length)
104
114
  throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland host --help', 3);
@@ -109,25 +119,24 @@ export async function runCtl(argv, io = {}) {
109
119
  const old = action === 'login' ? store.get(name) : undefined;
110
120
  if (action === 'add' && !url)
111
121
  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 } : {}) };
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 } : {}) };
113
123
  if (fingerprint)
114
124
  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
125
  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;
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;
121
130
  store.save(host);
122
- if (!checked.trusted)
123
- (io.err ?? console.error)(`Trusting the NAS certificate ${checked.fingerprint.slice(0, 16)} (self-signed)`);
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)`);
124
133
  }
125
134
  }
126
135
  else if (fingerprint)
127
136
  host.fingerprint = normalizeFingerprint(fingerprint);
128
137
  const previous = old ? await store.credentials.get(store.key(host)) : null;
129
138
  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 });
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 }) });
131
140
  try {
132
141
  if (old || host.fingerprint) {
133
142
  await store.credentials.set(store.key(host), session);
@@ -137,7 +146,7 @@ export async function runCtl(argv, io = {}) {
137
146
  await store.add(host, session);
138
147
  }
139
148
  catch (e) {
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(() => { });
149
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, undefined, store.trust(host))).catch(() => { });
141
150
  throw e;
142
151
  }
143
152
  host.lastUsed = new Date().toISOString();
@@ -151,7 +160,7 @@ export async function runCtl(argv, io = {}) {
151
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);
152
161
  if (!host.origin.startsWith('https:'))
153
162
  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 });
163
+ const checked = await probeCertificate(host.origin, { expectedFingerprint: take(args, '--fingerprint', true), name });
155
164
  // Get the leaf even when system-trusted, to make an explicit re-pin stable.
156
165
  const current = checked.leafFingerprint;
157
166
  if (!current)
@@ -181,7 +190,7 @@ export async function runCtl(argv, io = {}) {
181
190
  let logoutError;
182
191
  if (session?.cookie) {
183
192
  try {
184
- await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name }));
193
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, undefined, store.trust(host)));
185
194
  loggedOut = true;
186
195
  }
187
196
  catch (error) {
@@ -201,7 +210,8 @@ export async function runCtl(argv, io = {}) {
201
210
  }
202
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']);
203
212
  }
204
- const names = selectHosts(store.list().map(h => h.name), hostOption ?? (allHosts ? undefined : store.defaultName()), allHosts);
213
+ const targets = store.targets();
214
+ const names = selectHosts(targets.map(h => h.name), hostOption ?? (allHosts ? undefined : store.defaultName()), allHosts);
205
215
  const inputAt = args.indexOf('--input');
206
216
  let commonInput;
207
217
  if (inputAt >= 0 && args[inputAt + 1] === '-') {
@@ -214,7 +224,7 @@ export async function runCtl(argv, io = {}) {
214
224
  }
215
225
  const results = await boundedMap(names, 4, async (name) => {
216
226
  try {
217
- const host = store.get(name), ctl = client(host);
227
+ const host = targets.find(host => host.name === name), ctl = client(host);
218
228
  if (args[0] === 'list') {
219
229
  if (args.length !== 1)
220
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 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 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";
package/dist/ctl/input.js CHANGED
@@ -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, { fingerprint: host.fingerprint, name: host.name });
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()); }
@@ -150,10 +149,9 @@ export class CtlClient {
150
149
  }
151
150
  return response.data;
152
151
  };
153
- const ca = this.host.ca ? readFileSync(this.host.ca, 'utf8') : undefined;
154
152
  const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS),
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]) } }) });
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]) } }) });
157
155
  if (operation.resultSchema && !ajv.validate(operation.resultSchema, result))
158
156
  throw new CtlError('CTL_RESPONSE_INVALID', 'Result does not match command schema.', 'Check the App version.', 5);
159
157
  return output.clean(result, operation.resultSchema);
@@ -1,55 +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
- fingerprint?: string;
11
- tunnel?: boolean;
12
- ca?: string;
13
- lastUsed?: string;
14
- approved: Record<string, Approval>;
15
- }
16
- export interface CredentialStore {
17
- get(key: string): Promise<DsmSession | null>;
18
- set(key: string, value: DsmSession): Promise<void>;
19
- delete(key: string): Promise<void>;
20
- }
21
- export declare class OsCredentialStore implements CredentialStore {
22
- private entry;
23
- get(key: string): Promise<DsmSession | null>;
24
- set(key: string, value: DsmSession): Promise<void>;
25
- delete(key: string): Promise<void>;
26
- }
27
- export declare class CtlStore {
28
- root: string;
29
- credentials: CredentialStore;
30
- constructor(root?: string, credentials?: CredentialStore);
31
- private safe;
32
- private file;
33
- key(host: Host): string;
34
- defaultName(): string | undefined;
35
- setDefault(name: string): void;
36
- select(name?: string): Host;
37
- list(): Host[];
38
- get(name: string): Host;
39
- save(host: Host): void;
40
- add(host: Host, session: DsmSession): Promise<void>;
41
- session(host: Host): Promise<DsmSession>;
42
- cacheRoot(host: Host): string;
43
- cacheFile(host: Host, pkg: string, version: string): string;
44
- cache(host: Host, pkg: string, version: string): any | null;
45
- putCache(host: Host, pkg: string, version: string, description: any): void;
46
- putFailure(host: Host, pkg: string, version: string, failure: unknown): void;
47
- revoke(name: string, pkg: string): {
48
- revoked: string;
49
- cacheCleared: boolean;
50
- };
51
- remove(name: string): Promise<{
52
- removed: string;
53
- deleted: string[];
54
- }>;
55
- }
1
+ export * from "../nas/store.js";
package/dist/ctl/store.js CHANGED
@@ -1,121 +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 { loginDsm } from './dsm-auth.js';
7
- import { createRequest } from './transport.js';
8
- import { publicDescriptor } from './redaction.js';
9
- export class OsCredentialStore {
10
- async entry(key) { try {
11
- const { AsyncEntry } = await import('@napi-rs/keyring');
12
- return new AsyncEntry('synopkgland.ctl', key, { linux: { store: 'secret-service' } });
13
- }
14
- catch {
15
- throw new CtlError('CTL_CREDENTIAL_STORE', 'OS secure credential store unavailable.', 'Unlock the OS credential store and retry.', 2);
16
- } }
17
- async get(key) { try {
18
- const raw = await (await this.entry(key)).getPassword();
19
- return raw ? JSON.parse(raw) : null;
20
- }
21
- catch (e) {
22
- if (e instanceof CtlError)
23
- throw e;
24
- throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot read OS credentials.', 'Unlock the OS credential store and retry.', 2);
25
- } }
26
- async set(key, value) { try {
27
- await (await this.entry(key)).setPassword(JSON.stringify(value));
28
- }
29
- catch {
30
- throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot save session in OS secure storage.', 'Unlock the OS credential store and retry.', 2);
31
- } }
32
- async delete(key) { try {
33
- await (await this.entry(key)).deleteCredential();
34
- }
35
- catch {
36
- throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot remove OS credentials.', 'Unlock the OS credential store and retry removal.', 2);
37
- } }
38
- }
39
- export class CtlStore {
40
- root;
41
- credentials;
42
- constructor(root = process.env.SYNOPKGLAND_CTL_DIR ?? join(homedir(), '.synopkgland', 'ctl'), credentials = new OsCredentialStore()) {
43
- this.root = root;
44
- this.credentials = credentials;
45
- }
46
- safe(name) { if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name))
47
- throw new CtlError('CTL_HOST_INVALID', 'Invalid host name.', 'Use letters, digits, dots, dashes or underscores.'); return name; }
48
- file(name) { return join(this.root, 'hosts', this.safe(name) + '.json'); }
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); }
61
- list() { const dir = join(this.root, 'hosts'); if (!existsSync(dir))
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)); }
63
- get(name) { this.safe(name); const host = this.list().find(h => h.name === name); if (!host)
64
- return choose(name, this.list().map(h => h.name), 'host'); return host; }
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); }
66
- async add(host, session) { if (this.list().some(h => h.name === host.name))
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 {
68
- this.save(host);
69
- }
70
- catch (e) {
71
- await this.credentials.delete(this.key(host));
72
- throw e;
73
- } }
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
- }
91
- cacheRoot(host) { return join(this.root, 'cache', this.key(host)); }
92
- cacheFile(host, pkg, version) { return join(this.cacheRoot(host), createHash('sha256').update(pkg + '\0' + version).digest('hex') + '.json'); }
93
- cache(host, pkg, version) {
94
- try {
95
- const entry = JSON.parse(readFileSync(this.cacheFile(host, pkg, version), 'utf8'));
96
- if (entry.description && !entry.redacted) {
97
- const safe = publicDescriptor(entry.description);
98
- if (JSON.stringify(safe) !== JSON.stringify(entry.description)) {
99
- this.putCache(host, pkg, version, entry.description);
100
- return { ...entry, description: safe, redacted: true };
101
- }
102
- }
103
- return entry;
104
- }
105
- catch {
106
- return null;
107
- }
108
- }
109
- putCache(host, pkg, version, description) {
110
- const safe = publicDescriptor(description);
111
- const redacted = JSON.stringify(safe) !== JSON.stringify(description);
112
- mkdirSync(this.cacheRoot(host), { recursive: true, mode: 0o700 });
113
- const file = this.cacheFile(host, pkg, version), tmp = file + '.' + randomUUID();
114
- writeFileSync(tmp, JSON.stringify({ hash: description.sha256, description: safe, ...(redacted ? { redacted: true } : {}) }), { mode: 0o600 });
115
- renameSync(tmp, file);
116
- }
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); }
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 }; }
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'] }; }
121
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/store.js";
package/dist/ctl/tls.d.ts CHANGED
@@ -1,22 +1 @@
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;
1
+ export * from "../nas/tls.js";
package/dist/ctl/tls.js CHANGED
@@ -1,118 +1,2 @@
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
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/tls.js";
@@ -1,16 +1 @@
1
- import { type HostTls } from './tls.js';
2
- import { type DsmSession } from './transport.js';
3
- export declare function localFile(file: unknown): {
4
- file: string;
5
- size: number;
6
- };
7
- export declare function transferRequest(session: DsmSession, path: string, method: string, params: Record<string, unknown>, options?: {
8
- file?: {
9
- path: string;
10
- parent: string;
11
- name: string;
12
- };
13
- ca?: string;
14
- timeoutMs?: number;
15
- trust?: HostTls;
16
- }): Promise<any>;
1
+ export * from "../nas/transfers.js";