@synopackageland/cli 0.13.1 → 0.15.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 (119) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +44 -1
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -850
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/app-launch.d.ts +11 -0
  14. package/dist/contract/app-launch.js +18 -0
  15. package/dist/contract/web-launch.d.ts +3 -13
  16. package/dist/contract/web-launch.js +14 -81
  17. package/dist/ctl/cli.d.ts +36 -0
  18. package/dist/ctl/cli.js +218 -0
  19. package/dist/ctl/descriptor.d.ts +81 -0
  20. package/dist/ctl/descriptor.js +93 -0
  21. package/dist/ctl/dsm-auth.d.ts +10 -0
  22. package/dist/ctl/dsm-auth.js +30 -0
  23. package/dist/ctl/errors.d.ts +17 -0
  24. package/dist/ctl/errors.js +32 -0
  25. package/dist/ctl/flows.d.ts +11 -0
  26. package/dist/ctl/flows.js +52 -0
  27. package/dist/ctl/input.d.ts +20 -0
  28. package/dist/ctl/input.js +128 -0
  29. package/dist/ctl/json.d.ts +2 -0
  30. package/dist/ctl/json.js +38 -0
  31. package/dist/ctl/redaction.d.ts +15 -0
  32. package/dist/ctl/redaction.js +198 -0
  33. package/dist/ctl/service.d.ts +38 -0
  34. package/dist/ctl/service.js +156 -0
  35. package/dist/ctl/store.d.ts +51 -0
  36. package/dist/ctl/store.js +93 -0
  37. package/dist/ctl/transfers.d.ts +14 -0
  38. package/dist/ctl/transfers.js +74 -0
  39. package/dist/ctl/transport.d.ts +11 -0
  40. package/dist/ctl/transport.js +79 -0
  41. package/dist/deploy/deploy.d.ts +4 -3
  42. package/dist/deploy/deploy.js +5 -210
  43. package/dist/deploy/plan.js +26 -7
  44. package/dist/dev.js +5 -4
  45. package/dist/error-registry.js +1020 -831
  46. package/dist/info.js +120 -28
  47. package/dist/init.js +188 -64
  48. package/dist/inspect.js +21 -6
  49. package/dist/runtime/executor-digests.json +1 -12
  50. package/dist/skill.js +51 -9
  51. package/dist/standalone/api-description-inspect.d.ts +4 -0
  52. package/dist/standalone/api-description-inspect.js +12 -0
  53. package/dist/standalone/api-description.d.ts +7 -0
  54. package/dist/standalone/api-description.js +73 -0
  55. package/dist/standalone/build.d.ts +3 -0
  56. package/dist/standalone/build.js +140 -33
  57. package/dist/standalone/compose-errors.d.ts +3 -0
  58. package/dist/standalone/compose-errors.js +243 -0
  59. package/dist/standalone/compose.d.ts +97 -10
  60. package/dist/standalone/compose.js +306 -75
  61. package/dist/standalone/daemon.js +111 -29
  62. package/dist/standalone/dsm-window.js +55 -9
  63. package/dist/standalone/identity-gateway.d.ts +9 -2
  64. package/dist/standalone/identity-gateway.js +282 -66
  65. package/dist/standalone/inspect-spk.d.ts +15 -0
  66. package/dist/standalone/inspect-spk.js +714 -128
  67. package/dist/standalone/lifecycle.js +124 -42
  68. package/dist/standalone/manifest.d.ts +10 -1
  69. package/dist/standalone/manifest.js +308 -87
  70. package/dist/standalone/resource.js +6 -2
  71. package/dist/standalone/spk.d.ts +4 -1
  72. package/dist/standalone/spk.js +139 -39
  73. package/dist/template-paths.d.ts +0 -2
  74. package/dist/template-paths.js +0 -5
  75. package/dist/test-command.d.ts +1 -1
  76. package/dist/test-command.js +1 -1
  77. package/dist/validate.d.ts +3 -3
  78. package/dist/validate.js +32 -140
  79. package/package.json +5 -3
  80. package/schema/synology-app.schema.json +1 -1
  81. package/schema/synopkgland-api-v1.schema.json +423 -0
  82. package/skills/SKILL.md +34 -1
  83. package/skills/reference/compose.md +349 -0
  84. package/skills/reference/create.md +26 -4
  85. package/skills/reference/ctl.md +41 -0
  86. package/skills/reference/deploy.md +1 -1
  87. package/skills/reference/files.md +4 -0
  88. package/skills/reference/identity-gateway.md +8 -138
  89. package/skills/reference/portal.md +31 -0
  90. package/skills/reference/ui-vue.md +31 -0
  91. package/skills/reference/wrap.md +10 -30
  92. package/assets/go/daemon/fixture_linux_test.go +0 -15
  93. package/assets/go/daemon/fixture_other_test.go +0 -7
  94. package/assets/go/daemon/go.mod +0 -5
  95. package/assets/go/daemon/main.go +0 -636
  96. package/assets/go/daemon/main_test.go +0 -315
  97. package/assets/go/daemon/network.go +0 -107
  98. package/assets/go/daemon/network_test.go +0 -86
  99. package/assets/go/daemon/process_linux.go +0 -55
  100. package/assets/go/daemon/process_linux_test.go +0 -161
  101. package/assets/go/daemon/process_other.go +0 -14
  102. package/assets/go/daemon/testdata/child.go +0 -44
  103. package/assets/go/gateway/auth.go +0 -101
  104. package/assets/go/gateway/auth_test.go +0 -216
  105. package/assets/go/gateway/cgi.go +0 -133
  106. package/assets/go/gateway/cgi_test.go +0 -156
  107. package/assets/go/gateway/csrf.go +0 -73
  108. package/assets/go/gateway/csrf_test.go +0 -155
  109. package/assets/go/gateway/directory.go +0 -108
  110. package/assets/go/gateway/directory_test.go +0 -202
  111. package/assets/go/gateway/gateway.go +0 -412
  112. package/assets/go/gateway/gateway_test.go +0 -676
  113. package/assets/go/gateway/go.mod +0 -5
  114. package/assets/go/gateway/groups.go +0 -66
  115. package/assets/go/gateway/main.go +0 -45
  116. package/dist/build-spk/gateway.d.ts +0 -31
  117. package/dist/build-spk/gateway.js +0 -60
  118. package/dist/compose/privilege.d.ts +0 -14
  119. package/dist/compose/privilege.js +0 -102
@@ -0,0 +1,198 @@
1
+ import { CtlError, ctlError } from './errors.js';
2
+ export const REDACTED = '<redacted>';
3
+ function reference(root, ref) {
4
+ if (!ref.startsWith('#'))
5
+ return undefined;
6
+ if (ref === '#')
7
+ return root;
8
+ if (ref.startsWith('#/')) {
9
+ let node = root;
10
+ for (const part of decodeURIComponent(ref.slice(2)).split('/')) {
11
+ const key = part.replaceAll('~1', '/').replaceAll('~0', '~');
12
+ if (!node || typeof node !== 'object' || !Object.hasOwn(node, key))
13
+ return undefined;
14
+ node = node[key];
15
+ }
16
+ return node;
17
+ }
18
+ const anchor = decodeURIComponent(ref.slice(1));
19
+ const find = (node) => {
20
+ if (!node || typeof node !== 'object')
21
+ return undefined;
22
+ if (node.$anchor === anchor || node.$dynamicAnchor === anchor)
23
+ return node;
24
+ for (const child of Object.values(node)) {
25
+ const found = find(child);
26
+ if (found)
27
+ return found;
28
+ }
29
+ return undefined;
30
+ };
31
+ return find(root);
32
+ }
33
+ // Privacy annotations from every possible branch apply conservatively, without
34
+ // selecting a branch using secret data or mutating the schema used for validation.
35
+ function branches(schemas, root) {
36
+ const result = [], seen = new Set();
37
+ const visit = (schema) => {
38
+ if (!schema || typeof schema !== 'object' || seen.has(schema))
39
+ return;
40
+ seen.add(schema);
41
+ result.push(schema);
42
+ for (const key of ['$ref', '$dynamicRef'])
43
+ if (typeof schema[key] === 'string')
44
+ visit(reference(root, schema[key]));
45
+ for (const key of ['allOf', 'anyOf', 'oneOf'])
46
+ for (const child of schema[key] ?? [])
47
+ visit(child);
48
+ for (const key of ['if', 'then', 'else', 'not'])
49
+ visit(schema[key]);
50
+ for (const child of Object.values(schema.dependentSchemas ?? {}))
51
+ visit(child);
52
+ };
53
+ for (const schema of schemas)
54
+ visit(schema);
55
+ return result;
56
+ }
57
+ export function redactValue(value, schema, root = schema, onSecret) {
58
+ const walk = (input, schemas) => {
59
+ const nodes = branches(schemas, root);
60
+ if (nodes.some(node => node.writeOnly === true)) {
61
+ onSecret?.(input);
62
+ return REDACTED;
63
+ }
64
+ if (Array.isArray(input))
65
+ return input.map((item, index) => walk(item, nodes.flatMap(node => {
66
+ const tuple = node.prefixItems ?? (Array.isArray(node.items) ? node.items : []);
67
+ return [tuple[index] ?? (!Array.isArray(node.items) ? node.items : node.additionalItems), node.contains, node.unevaluatedItems];
68
+ })));
69
+ if (input && typeof input === 'object')
70
+ return Object.fromEntries(Object.entries(input).map(([key, item]) => {
71
+ const children = [];
72
+ for (const node of nodes) {
73
+ let matched = false;
74
+ if (Object.hasOwn(node.properties ?? {}, key)) {
75
+ children.push(node.properties[key]);
76
+ matched = true;
77
+ }
78
+ for (const [pattern, child] of Object.entries(node.patternProperties ?? {})) {
79
+ if (new RegExp(pattern).test(key)) {
80
+ children.push(child);
81
+ matched = true;
82
+ }
83
+ }
84
+ if (!matched)
85
+ children.push(node.additionalProperties);
86
+ children.push(node.unevaluatedProperties);
87
+ }
88
+ return [key, walk(item, children)];
89
+ }));
90
+ return input;
91
+ };
92
+ return walk(value, [schema]);
93
+ }
94
+ function publicSchema(schema, root = schema) {
95
+ if (!schema || typeof schema !== 'object')
96
+ return schema;
97
+ const copy = { ...schema };
98
+ // Schema maps are distinct from schemas: a parameter named "default" or
99
+ // "examples" is not the corresponding JSON Schema annotation keyword.
100
+ for (const key of ['$defs', 'definitions', 'properties', 'patternProperties', 'dependentSchemas']) {
101
+ if (schema[key] && typeof schema[key] === 'object')
102
+ copy[key] = Object.fromEntries(Object.entries(schema[key]).map(([name, child]) => [name, publicSchema(child, root)]));
103
+ }
104
+ for (const key of ['allOf', 'anyOf', 'oneOf', 'prefixItems']) {
105
+ if (Array.isArray(schema[key]))
106
+ copy[key] = schema[key].map((child) => publicSchema(child, root));
107
+ }
108
+ for (const key of ['items', 'additionalItems', 'contains', 'additionalProperties', 'unevaluatedProperties', 'unevaluatedItems', 'propertyNames', 'if', 'then', 'else', 'not', 'contentSchema']) {
109
+ if (Object.hasOwn(schema, key))
110
+ copy[key] = Array.isArray(schema[key])
111
+ ? schema[key].map((child) => publicSchema(child, root)) : publicSchema(schema[key], root);
112
+ }
113
+ for (const key of ['default', 'const'])
114
+ if (Object.hasOwn(schema, key))
115
+ copy[key] = redactValue(schema[key], schema, root);
116
+ for (const key of ['examples', 'enum'])
117
+ if (Array.isArray(schema[key]))
118
+ copy[key] = schema[key].map((value) => redactValue(value, schema, root));
119
+ return copy;
120
+ }
121
+ export function publicOperation(operation) {
122
+ return { ...operation,
123
+ ...(operation.paramsSchema ? { paramsSchema: publicSchema(operation.paramsSchema) } : {}),
124
+ ...(operation.resultSchema ? { resultSchema: publicSchema(operation.resultSchema) } : {}),
125
+ ...(operation.examples ? { examples: operation.examples.map(value => redactValue(value, operation.paramsSchema)) } : {}), };
126
+ }
127
+ export function publicDescriptor(description) {
128
+ if (!description?.payload?.commands)
129
+ return description;
130
+ return { ...description, payload: { ...description.payload,
131
+ commands: Object.fromEntries(Object.entries(description.payload.commands).map(([id, operation]) => [id, publicOperation(operation)])),
132
+ } };
133
+ }
134
+ // Redact copies of known secret values even when an App repeats them in a result
135
+ // under another key, a job ID, or an error string. Requests keep their originals.
136
+ export class SensitiveOutput {
137
+ secrets = new Set();
138
+ constructor(params, schema) { redactValue(params, schema, schema, value => this.remember(value)); }
139
+ remember(value) {
140
+ if (typeof value === 'string') {
141
+ if (!value || value === REDACTED)
142
+ return;
143
+ this.secrets.add(value);
144
+ this.secrets.add(encodeURIComponent(value));
145
+ try {
146
+ const url = new URL(value);
147
+ if (['http:', 'https:'].includes(url.protocol)) {
148
+ for (const part of [url.username, url.password, ...url.searchParams.values()])
149
+ if (part)
150
+ this.secrets.add(part);
151
+ }
152
+ }
153
+ catch { /* Not a URL. */ }
154
+ }
155
+ else if (value && typeof value === 'object') {
156
+ for (const [key, child] of Object.entries(value)) {
157
+ if (!Array.isArray(value))
158
+ this.remember(key);
159
+ this.remember(child);
160
+ }
161
+ }
162
+ else if (value !== undefined && value !== null)
163
+ this.secrets.add(String(value));
164
+ }
165
+ clean(value, schema) {
166
+ const redacted = redactValue(value, schema, schema, secret => this.remember(secret));
167
+ const secrets = [...this.secrets].sort((a, b) => b.length - a.length);
168
+ const text = (input) => {
169
+ if (input === REDACTED)
170
+ return input;
171
+ // Keep replacements separate from remaining text. This also handles large
172
+ // upload/base64 values without compiling them into a regular expression.
173
+ let parts = [input];
174
+ for (const secret of secrets)
175
+ parts = parts.flatMap(part => {
176
+ if (part === null)
177
+ return [null];
178
+ return part.split(secret).flatMap((chunk, index) => index ? [null, chunk] : [chunk]);
179
+ });
180
+ return parts.map(part => part === null ? REDACTED : part).join('');
181
+ };
182
+ const walk = (input) => {
183
+ if (typeof input === 'string')
184
+ return text(input);
185
+ if (Array.isArray(input))
186
+ return input.map(walk);
187
+ if (input && typeof input === 'object')
188
+ return Object.fromEntries(Object.entries(input).map(([key, child]) => [this.secrets.has(key) ? REDACTED : key, walk(child)]));
189
+ return input !== null && this.secrets.has(String(input)) ? REDACTED : input;
190
+ };
191
+ return walk(redacted);
192
+ }
193
+ error(error) {
194
+ const original = ctlError(error);
195
+ const safe = this.clean({ message: original.message, hint: original.hint, allowed: original.allowed, plan: original.plan });
196
+ return new CtlError(original.code, safe.message, safe.hint, original.exitCode, safe.allowed, safe.plan);
197
+ }
198
+ }
@@ -0,0 +1,38 @@
1
+ import { type DsmSession, type JsonRequest } from './transport.js';
2
+ import { CtlStore, type Host } from './store.js';
3
+ import { type Descriptor } from './descriptor.js';
4
+ export declare function boundedMap<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
5
+ export interface DiscoverOptions {
6
+ refresh?: boolean;
7
+ app?: string;
8
+ approve?: boolean;
9
+ confirm?: (packages: {
10
+ id: string;
11
+ version: string;
12
+ }[]) => Promise<boolean>;
13
+ }
14
+ export declare class CtlClient {
15
+ store: CtlStore;
16
+ host: Host;
17
+ request: JsonRequest;
18
+ constructor(store: CtlStore, host: Host, request?: JsonRequest);
19
+ valid(): Promise<boolean>;
20
+ inventory(session: DsmSession): Promise<{
21
+ id: string;
22
+ version: string;
23
+ state?: unknown;
24
+ }[]>;
25
+ discover(options?: DiscoverOptions): Promise<{
26
+ apps: Descriptor[];
27
+ diagnostics: any[];
28
+ cacheHits: number;
29
+ }>;
30
+ execute(name: string, path: string[], args: string[], options?: DiscoverOptions & {
31
+ yes?: boolean;
32
+ dryRun?: boolean;
33
+ stdin?: string;
34
+ wait?: boolean;
35
+ timeoutMs?: number;
36
+ }): Promise<any>;
37
+ private appCookieSession;
38
+ }
@@ -0,0 +1,156 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { CtlError, errorData, choose } from './errors.js';
3
+ import { APP_COOKIE, createRequest } from './transport.js';
4
+ import { DESCRIPTION_METHOD, validateDescriptor, ajv } from './descriptor.js';
5
+ import { runFlow } from './flows.js';
6
+ import { SensitiveOutput } from './redaction.js';
7
+ import { localFile, transferRequest } from './transfers.js';
8
+ import { selectApp, selectOperation, operationInput, requestPlan, confirmOperation } from './input.js';
9
+ export async function boundedMap(items, limit, fn) { const output = new Array(items.length); let next = 0; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) {
10
+ const index = next++;
11
+ output[index] = await fn(items[index]);
12
+ } })); return output; }
13
+ export class CtlClient {
14
+ store;
15
+ host;
16
+ request;
17
+ constructor(store, host, request) {
18
+ this.store = store;
19
+ this.host = host;
20
+ this.request = request ?? createRequest(host.origin, 10000, host.ca ? readFileSync(host.ca, 'utf8') : undefined);
21
+ }
22
+ async valid() { try {
23
+ await this.inventory(await this.store.session(this.host));
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ } }
29
+ async inventory(session) {
30
+ const response = await this.request('/webapi/entry.cgi', new URLSearchParams({ api: 'SYNO.Core.Package', version: '2', method: 'list', additional: '["status"]', SynoToken: session.token }), session);
31
+ if (response?.success !== true) {
32
+ const code = Number(response?.error?.code);
33
+ 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);
34
+ }
35
+ const entries = response.data?.packages;
36
+ if (!Array.isArray(entries) || response.data.total !== entries.length || entries.length > 4096)
37
+ throw new CtlError('CTL_INVENTORY_INVALID', 'Incomplete installed-package inventory.', 'Check the DSM version and permissions.', 5);
38
+ return entries.map((p) => { if (typeof p.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(p.id) || typeof p.version !== 'string')
39
+ throw new CtlError('CTL_INVENTORY_INVALID', 'Invalid package identity.', 'Check the DSM response.', 5); return { id: p.id, version: p.version, state: p.additional?.status }; });
40
+ }
41
+ async discover(options = {}) {
42
+ const session = await this.store.session(this.host);
43
+ let packages = await this.inventory(session);
44
+ // An exact package filter narrows credential delivery. Alias selection needs the cached descriptors first.
45
+ if (options.app) {
46
+ const exact = packages.find(p => p.id.toLowerCase() === options.app.toLowerCase());
47
+ if (exact)
48
+ packages = [exact];
49
+ else {
50
+ const match = packages.find(p => this.store.cache(this.host, p.id, p.version)?.description?.payload.aliases?.some((a) => a.toLowerCase() === options.app.toLowerCase()));
51
+ if (match)
52
+ packages = [match];
53
+ else {
54
+ const known = packages.flatMap(p => { const d = this.store.cache(this.host, p.id, p.version)?.description; return d ? [p.id, ...d.payload.aliases] : []; });
55
+ choose(options.app, known, 'app');
56
+ }
57
+ }
58
+ }
59
+ const pending = packages.filter(p => this.host.approved[p.id]?.version !== p.version);
60
+ if (pending.length && (options.approve || await options.confirm?.(pending))) {
61
+ for (const p of pending)
62
+ this.host.approved[p.id] = { version: p.version };
63
+ this.store.save(this.host);
64
+ }
65
+ let cacheHits = 0;
66
+ const results = await boundedMap(packages, 4, async (pkg) => {
67
+ const approval = this.host.approved[pkg.id];
68
+ if (approval?.version !== pkg.version)
69
+ return { error: { package: pkg.id, code: 'CTL_TRUST_REQUIRED', message: 'Description endpoint not approved.', hint: `run: synopkgland ctl list --host ${this.host.name} --approve-installed` } };
70
+ const cached = options.refresh ? null : this.store.cache(this.host, pkg.id, pkg.version);
71
+ if (cached?.failure) {
72
+ cacheHits++;
73
+ return { error: { package: pkg.id, ...cached.failure, cached: true } };
74
+ }
75
+ try {
76
+ let description;
77
+ if (cached?.description && !cached.redacted) {
78
+ description = validateDescriptor(cached.description, pkg.id, pkg.version);
79
+ cacheHits++;
80
+ }
81
+ else {
82
+ const response = await this.request(`/webman/3rdparty/${pkg.id}/cgi/api.cgi`, { method: DESCRIPTION_METHOD, params: {} }, session);
83
+ if (response?.success !== true)
84
+ throw new CtlError('CTL_DESCRIPTION_UNAVAILABLE', `Description refused (${String(response?.error?.code ?? 'invalid response')}).`, 'Use --refresh after updating the App.', 5);
85
+ description = validateDescriptor(response.data, pkg.id, pkg.version);
86
+ }
87
+ if (approval.hash && approval.hash !== description.sha256 && !options.approve)
88
+ throw new CtlError('CTL_DESCRIPTION_CHANGED', 'Description hash changed; approval required.', `run: synopkgland ctl list --host ${this.host.name} --refresh --approve-installed`, 4);
89
+ approval.hash = description.sha256;
90
+ this.store.putCache(this.host, pkg.id, pkg.version, description);
91
+ return { description };
92
+ }
93
+ catch (error) {
94
+ const failure = errorData(error);
95
+ if (failure.code === 'CTL_DESCRIPTION_UNAVAILABLE')
96
+ this.store.putFailure(this.host, pkg.id, pkg.version, failure);
97
+ return { error: { package: pkg.id, ...failure } };
98
+ }
99
+ });
100
+ this.host.lastUsed = new Date().toISOString();
101
+ this.store.save(this.host);
102
+ return { apps: results.flatMap(r => r.description ? [r.description] : []), diagnostics: results.flatMap(r => r.error ? [r.error] : []), cacheHits };
103
+ }
104
+ async execute(name, path, args, options = {}) {
105
+ const listing = await this.discover(options);
106
+ const app = selectApp(listing.apps, name);
107
+ const { id, operation } = selectOperation(app, path);
108
+ const params = operationInput(operation, args, options.stdin, `run: synopkgland ctl ${name} ${operation.cli.join(' ')} --host ${this.host.name} --help`);
109
+ const output = new SensitiveOutput(params, operation.paramsSchema);
110
+ try {
111
+ const plan = requestPlan(app, id, params);
112
+ if (operation.flow.type === 'upload')
113
+ localFile(params[operation.flow.fileParam]);
114
+ confirmOperation(operation, !!options.yes, !!options.dryRun, plan);
115
+ if (options.dryRun)
116
+ return { dryRun: true, request: plan };
117
+ const session = await this.store.session(this.host);
118
+ let appSession = session;
119
+ if (app.payload.transport.auth === 'dsm-app-session') {
120
+ const boot = await this.request(app.payload.transport.path, { method: 'identity.session', params: { bootstrap: true } }, session);
121
+ // The transport adapter adds only validated, path-scoped App cookies for this call.
122
+ if (boot?.success !== true)
123
+ throw new CtlError('CTL_APP_SESSION', 'App session bootstrap failed; HTTPS is required.', `run: synopkgland ctl host show ${this.host.name}`, 5);
124
+ appSession = this.appCookieSession(session, boot);
125
+ }
126
+ try {
127
+ const invoke = async (method, input) => {
128
+ if (!app.payload.bindings[method])
129
+ throw new CtlError('CTL_METHOD_NOT_ALLOWED', 'Flow method is not allowlisted.', 'Refresh the API description.', 4);
130
+ const response = await this.request(app.payload.transport.path, { method, params: input }, appSession);
131
+ if (response?.success !== true) {
132
+ const code = String(response?.error?.code ?? 'invalid_response');
133
+ throw new CtlError(['admin_required', 'app_privilege_denied'].includes(code) ? 'CTL_PERMISSION_DENIED' : 'CTL_APP_ERROR', `App rejected operation (${code}).`, 'Check command permissions and App state.', ['admin_required', 'app_privilege_denied'].includes(code) ? 4 : 5);
134
+ }
135
+ return response.data;
136
+ };
137
+ const ca = this.host.ca ? readFileSync(this.host.ca, 'utf8') : undefined;
138
+ const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: options.timeoutMs,
139
+ ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, timeoutMs: options.timeoutMs }),
140
+ upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, timeoutMs: options.timeoutMs, file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
141
+ if (operation.resultSchema && !ajv.validate(operation.resultSchema, result))
142
+ throw new CtlError('CTL_RESPONSE_INVALID', 'Result does not match command schema.', 'Check the App version.', 5);
143
+ return output.clean(result, operation.resultSchema);
144
+ }
145
+ finally {
146
+ if (app.payload.transport.auth === 'dsm-app-session')
147
+ await this.request(app.payload.transport.path, { method: 'identity.logout', params: {} }, appSession);
148
+ }
149
+ }
150
+ catch (error) {
151
+ throw output.error(error);
152
+ }
153
+ }
154
+ appCookieSession(session, body) { if (typeof body[APP_COOKIE] !== 'string' || !body[APP_COOKIE])
155
+ throw new CtlError('CTL_APP_SESSION', 'App bootstrap returned no scoped cookie.', 'Use HTTPS and a compatible App.', 5); return { ...session, cookie: session.cookie + '; ' + body[APP_COOKIE] }; }
156
+ }
@@ -0,0 +1,51 @@
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
+ }
@@ -0,0 +1,93 @@
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
+ }
@@ -0,0 +1,14 @@
1
+ import type { DsmSession } from './transport.js';
2
+ export declare function localFile(file: unknown): {
3
+ file: string;
4
+ size: number;
5
+ };
6
+ export declare function transferRequest(session: DsmSession, path: string, method: string, params: Record<string, unknown>, options?: {
7
+ file?: {
8
+ path: string;
9
+ parent: string;
10
+ name: string;
11
+ };
12
+ ca?: string;
13
+ timeoutMs?: number;
14
+ }): Promise<any>;
@@ -0,0 +1,74 @@
1
+ import { createReadStream, statSync } from 'node:fs';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import { CtlError } from './errors.js';
5
+ export function localFile(file) { if (typeof file !== 'string' || !file)
6
+ throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
7
+ const stat = statSync(file);
8
+ if (!stat.isFile() || stat.size > 16 * 1024 ** 3)
9
+ throw Error();
10
+ return { file, size: stat.size };
11
+ }
12
+ catch {
13
+ throw new CtlError('CTL_INPUT_FILE', 'Upload must be a readable regular file of at most 16 GiB.', 'Check --file <path>.');
14
+ } }
15
+ export async function transferRequest(session, path, method, params, options = {}) {
16
+ if (!/^\/webman\/3rdparty\/[A-Za-z][A-Za-z0-9_.+-]*\/cgi\/api\.cgi$/.test(path) || !/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(method))
17
+ throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
18
+ const url = new URL(path, session.origin);
19
+ const file = options.file ? localFile(options.file.path) : undefined;
20
+ if (options.file) {
21
+ url.search = new URLSearchParams({ method, parent: options.file.parent, name: options.file.name }).toString();
22
+ }
23
+ const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
24
+ return new Promise((resolve, reject) => {
25
+ const headers = { 'content-type': file ? 'application/octet-stream' : 'application/json', cookie: session.cookie, 'X-SYNO-TOKEN': session.token, origin: session.origin, 'content-length': String(file?.size ?? raw.length) };
26
+ const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: 30000, ...(options.ca ? { ca: options.ca } : {}) }, res => {
27
+ const expected = file ? 'application/json' : 'application/x-ndjson';
28
+ if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
29
+ res.resume();
30
+ reject(new CtlError('CTL_TRANSFER_FAILED', 'Unexpected transfer response; redirects are refused.', 'Check App transfer support.', 5));
31
+ return;
32
+ }
33
+ const chunks = [];
34
+ let total = 0;
35
+ res.on('data', (chunk) => { total += chunk.length; if (total > (file ? 1048576 : 8 * 1048576)) {
36
+ req.destroy();
37
+ reject(new CtlError('CTL_FLOW_LIMIT', 'Transfer result exceeds collection limit.', 'Narrow the query.', 5));
38
+ }
39
+ else
40
+ chunks.push(chunk); });
41
+ res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer response interrupted.', 'Check state before retrying writes.', 6)));
42
+ res.on('end', () => { try {
43
+ const text = Buffer.concat(chunks).toString('utf8');
44
+ if (file) {
45
+ const body = JSON.parse(text);
46
+ if (body.success !== true)
47
+ throw Error();
48
+ resolve(body.data);
49
+ }
50
+ else {
51
+ const lines = text.split('\n').filter(l => l.trim());
52
+ if (lines.length > 10000)
53
+ throw Error();
54
+ resolve({ items: lines.map(line => JSON.parse(line)) });
55
+ }
56
+ }
57
+ catch {
58
+ reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
59
+ } });
60
+ });
61
+ const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ?? 30000);
62
+ req.once('close', () => clearTimeout(wallTimer));
63
+ req.on('timeout', () => req.destroy());
64
+ req.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer interrupted; completion may be uncertain.', 'Check App state before retrying.', 6)));
65
+ if (file) {
66
+ const source = createReadStream(file.file);
67
+ source.on('error', () => req.destroy());
68
+ req.on('close', () => source.destroy());
69
+ source.pipe(req);
70
+ }
71
+ else
72
+ req.end(raw);
73
+ });
74
+ }
@@ -0,0 +1,11 @@
1
+ export declare const APP_COOKIE: unique symbol;
2
+ export interface DsmSession {
3
+ origin: string;
4
+ username: string;
5
+ cookie: string;
6
+ token: string;
7
+ deviceId?: string;
8
+ }
9
+ export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
10
+ export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
11
+ export declare function createRequest(origin: string, timeoutMs?: number, ca?: string): JsonRequest;