@synopackageland/cli 0.14.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/README.md +33 -18
- package/dist/build-spk/generator.d.ts +3 -2
- package/dist/build-spk/generator.js +6 -846
- package/dist/build-spk/resource-worker.d.ts +2 -14
- package/dist/build-spk/resource-worker.js +3 -166
- package/dist/cli.js +208 -56
- package/dist/compose/analyze.d.ts +3 -61
- package/dist/compose/analyze.js +35 -776
- package/dist/compose/env.d.ts +5 -3
- package/dist/compose/env.js +17 -25
- package/dist/compose/runner.js +23 -6
- package/dist/contract/unsupported-types.js +1 -1
- package/dist/contract/validate.js +2 -2
- package/dist/contract/web-launch.d.ts +1 -12
- package/dist/contract/web-launch.js +10 -74
- package/dist/ctl/cli.d.ts +36 -0
- package/dist/ctl/cli.js +218 -0
- package/dist/ctl/descriptor.d.ts +81 -0
- package/dist/ctl/descriptor.js +93 -0
- package/dist/ctl/dsm-auth.d.ts +10 -0
- package/dist/ctl/dsm-auth.js +30 -0
- package/dist/ctl/errors.d.ts +17 -0
- package/dist/ctl/errors.js +32 -0
- package/dist/ctl/flows.d.ts +11 -0
- package/dist/ctl/flows.js +52 -0
- package/dist/ctl/input.d.ts +20 -0
- package/dist/ctl/input.js +128 -0
- package/dist/ctl/json.d.ts +2 -0
- package/dist/ctl/json.js +38 -0
- package/dist/ctl/redaction.d.ts +15 -0
- package/dist/ctl/redaction.js +198 -0
- package/dist/ctl/service.d.ts +38 -0
- package/dist/ctl/service.js +156 -0
- package/dist/ctl/store.d.ts +51 -0
- package/dist/ctl/store.js +93 -0
- package/dist/ctl/transfers.d.ts +14 -0
- package/dist/ctl/transfers.js +74 -0
- package/dist/ctl/transport.d.ts +11 -0
- package/dist/ctl/transport.js +79 -0
- package/dist/deploy/deploy.d.ts +4 -3
- package/dist/deploy/deploy.js +5 -210
- package/dist/deploy/plan.js +26 -7
- package/dist/dev.js +5 -4
- package/dist/e2e/dsm.d.ts +2 -2
- package/dist/e2e/dsm.js +10 -6
- package/dist/error-registry.js +1020 -834
- package/dist/host/share-permission-lookup.d.ts +1 -1
- package/dist/host/share-permission.d.ts +1 -1
- package/dist/host/share-permission.js +1 -1
- package/dist/info.js +120 -28
- package/dist/init.js +188 -64
- package/dist/inspect.js +21 -6
- package/dist/runtime/executor-digests.json +5 -5
- package/dist/skill.js +51 -9
- package/dist/standalone/api-description-inspect.d.ts +4 -0
- package/dist/standalone/api-description-inspect.js +12 -0
- package/dist/standalone/api-description.d.ts +7 -0
- package/dist/standalone/api-description.js +73 -0
- package/dist/standalone/build.d.ts +3 -0
- package/dist/standalone/build.js +140 -33
- package/dist/standalone/compose-errors.d.ts +3 -0
- package/dist/standalone/compose-errors.js +243 -0
- package/dist/standalone/compose.d.ts +97 -10
- package/dist/standalone/compose.js +306 -75
- package/dist/standalone/daemon.js +111 -29
- package/dist/standalone/dsm-window.js +39 -8
- package/dist/standalone/identity-gateway.d.ts +9 -2
- package/dist/standalone/identity-gateway.js +282 -66
- package/dist/standalone/inspect-spk.d.ts +12 -0
- package/dist/standalone/inspect-spk.js +666 -136
- package/dist/standalone/lifecycle.js +124 -42
- package/dist/standalone/manifest.d.ts +8 -1
- package/dist/standalone/manifest.js +303 -86
- package/dist/standalone/resource.js +6 -2
- package/dist/standalone/spk.d.ts +4 -1
- package/dist/standalone/spk.js +138 -39
- package/dist/template-paths.d.ts +0 -2
- package/dist/template-paths.js +0 -5
- package/dist/test-command.d.ts +1 -1
- package/dist/test-command.js +1 -1
- package/dist/validate.d.ts +3 -3
- package/dist/validate.js +32 -140
- package/package.json +5 -3
- package/schema/synopkgland-api-v1.schema.json +423 -0
- package/skills/SKILL.md +4 -2
- package/skills/reference/compose.md +349 -0
- package/skills/reference/create.md +9 -18
- package/skills/reference/ctl.md +41 -0
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/files.md +5 -1
- package/skills/reference/identity-gateway.md +8 -144
- package/skills/reference/wrap.md +10 -39
- package/assets/go/daemon/fixture_linux_test.go +0 -15
- package/assets/go/daemon/fixture_other_test.go +0 -7
- package/assets/go/daemon/go.mod +0 -5
- package/assets/go/daemon/main.go +0 -636
- package/assets/go/daemon/main_test.go +0 -315
- package/assets/go/daemon/network.go +0 -107
- package/assets/go/daemon/network_test.go +0 -86
- package/assets/go/daemon/process_linux.go +0 -55
- package/assets/go/daemon/process_linux_test.go +0 -161
- package/assets/go/daemon/process_other.go +0 -14
- package/assets/go/daemon/testdata/child.go +0 -44
- package/assets/go/gateway/auth.go +0 -101
- package/assets/go/gateway/auth_test.go +0 -216
- package/assets/go/gateway/cgi.go +0 -133
- package/assets/go/gateway/cgi_test.go +0 -156
- package/assets/go/gateway/csrf.go +0 -73
- package/assets/go/gateway/csrf_test.go +0 -155
- package/assets/go/gateway/directory.go +0 -108
- package/assets/go/gateway/directory_test.go +0 -202
- package/assets/go/gateway/gateway.go +0 -412
- package/assets/go/gateway/gateway_test.go +0 -676
- package/assets/go/gateway/go.mod +0 -5
- package/assets/go/gateway/groups.go +0 -66
- package/assets/go/gateway/main.go +0 -45
- package/dist/build-spk/gateway.d.ts +0 -31
- package/dist/build-spk/gateway.js +0 -60
- package/dist/compose/privilege.d.ts +0 -14
- package/dist/compose/privilege.js +0 -102
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { canonicalize } from 'json-canonicalize';
|
|
4
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
5
|
+
import { CtlError } from './errors.js';
|
|
6
|
+
export const DESCRIPTION_METHOD = 'synopkgland.describe';
|
|
7
|
+
export const DESCRIPTION_FILE = 'synopkgland-api.json';
|
|
8
|
+
export const ajv = new Ajv2020.default({ allErrors: true, strict: false, validateFormats: false, addUsedSchema: false });
|
|
9
|
+
const descriptorSchema = ajv.compile(JSON.parse(readFileSync(new URL('../../schema/synopkgland-api-v1.schema.json', import.meta.url), 'utf8')));
|
|
10
|
+
export function makeDescriptor(payload) { const value = { format: 'synopkgland.api', version: 1, payload }; return { ...value, sha256: createHash('sha256').update(canonicalize(value)).digest('hex') }; }
|
|
11
|
+
export function validateDescriptor(value, id, version) {
|
|
12
|
+
const fail = (message) => { throw new CtlError('CTL_DESCRIPTOR_INVALID', message, 'run: synopkgland ctl list --refresh', 5); };
|
|
13
|
+
if (!value || value.format !== 'synopkgland.api' || value.version !== 1 || !value.payload || !/^([0-9a-f]{64})$/.test(value.sha256))
|
|
14
|
+
return fail('Unsupported API description format.');
|
|
15
|
+
if (!descriptorSchema(value))
|
|
16
|
+
return fail('API description schema validation failed.');
|
|
17
|
+
const p = value.payload;
|
|
18
|
+
if (p.package?.id !== id || p.package?.version !== version)
|
|
19
|
+
return fail('API description does not match installed package identity/version.');
|
|
20
|
+
if (typeof p.package.displayName !== 'string' || !Array.isArray(p.aliases) || p.aliases.some((a) => typeof a !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/i.test(a)))
|
|
21
|
+
return fail('Invalid App aliases.');
|
|
22
|
+
if (!['dsm-session', 'dsm-app-session'].includes(p.transport?.auth) || p.transport?.path !== `/webman/3rdparty/${id}/cgi/api.cgi`)
|
|
23
|
+
return fail('API endpoint must be the fixed CGI of its own package.');
|
|
24
|
+
if (!['admin-only', 'app-privilege', 'policy'].includes(p.entry) || !['synopkgland', 'third-party', 'unknown'].includes(p.provenance?.developedBy) || typeof p.provenance?.generator !== 'string')
|
|
25
|
+
return fail('Invalid API metadata.');
|
|
26
|
+
if (!p.commands || Array.isArray(p.commands) || typeof p.commands !== 'object' || Object.keys(p.commands).length < 1 || Object.keys(p.commands).length > 1024)
|
|
27
|
+
return fail('Invalid command allowlist.');
|
|
28
|
+
if (!p.bindings || typeof p.bindings !== 'object')
|
|
29
|
+
return fail('Missing internal method bindings.');
|
|
30
|
+
const paths = new Set();
|
|
31
|
+
for (const [method, raw] of Object.entries(p.commands)) {
|
|
32
|
+
const op = raw;
|
|
33
|
+
if (method.length > 128 || !/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(method) || !op || typeof op.summary !== 'string' || !['read', 'write', 'destructive'].includes(op.effect) || !['public', 'authenticated', 'administrators'].includes(op.permission) || !Array.isArray(op.cli) || !op.cli.length || op.cli.some(s => typeof s !== 'string' || !/^[a-z][a-z0-9-]*$/.test(s)))
|
|
34
|
+
return fail('Invalid command metadata.');
|
|
35
|
+
try {
|
|
36
|
+
validateFlow(op, p.bindings);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return fail('Invalid command flow or method bindings.');
|
|
40
|
+
}
|
|
41
|
+
const path = op.cli.join(' ');
|
|
42
|
+
if (paths.has(path))
|
|
43
|
+
return fail('Duplicate CLI command path.');
|
|
44
|
+
paths.add(path);
|
|
45
|
+
for (const schema of [op.paramsSchema, op.resultSchema])
|
|
46
|
+
if (schema) {
|
|
47
|
+
try {
|
|
48
|
+
safeSchema(schema);
|
|
49
|
+
ajv.compile(schema);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return fail('Invalid or unsafe parameter/result schema.');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (p.transport.auth === 'dsm-app-session' && (!p.bindings?.['identity.session'] || !p.bindings?.['identity.logout']))
|
|
57
|
+
return fail('Standalone session helpers must be allowlisted.');
|
|
58
|
+
if (makeDescriptor(p).sha256 !== value.sha256)
|
|
59
|
+
return fail('API description hash mismatch.');
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
export function safeSchema(schema, depth = 0) {
|
|
63
|
+
if (depth > 32)
|
|
64
|
+
throw Error('schema too deep');
|
|
65
|
+
if (schema && typeof schema === 'object')
|
|
66
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
67
|
+
if ((key === '$ref' || key === '$dynamicRef') && (typeof value !== 'string' || !value.startsWith('#')))
|
|
68
|
+
throw Error('external schema reference');
|
|
69
|
+
if (key === '$schema' && value !== 'https://json-schema.org/draft/2020-12/schema')
|
|
70
|
+
throw Error('schema dialect');
|
|
71
|
+
safeSchema(value, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function flowMethods(flow) { return flow.type === 'job' ? [flow.start, flow.poll, ...(flow.result ? [flow.result] : [])] : [flow.method]; }
|
|
75
|
+
export function validateFlow(op, bindings) {
|
|
76
|
+
const flow = op.flow;
|
|
77
|
+
if (!flow || !['invoke', 'job', 'paginate', 'ndjson', 'upload'].includes(flow.type))
|
|
78
|
+
throw Error('unsupported flow');
|
|
79
|
+
const effects = { read: 0, write: 1, destructive: 2 };
|
|
80
|
+
for (const name of flowMethods(flow)) {
|
|
81
|
+
const b = bindings[name];
|
|
82
|
+
if (!b || !['public', 'authenticated', 'administrators'].includes(b.permission) || effects[op.effect] < effects[b.effect])
|
|
83
|
+
throw Error('binding/effect');
|
|
84
|
+
}
|
|
85
|
+
if (flow.type === 'job') {
|
|
86
|
+
if (!flow.jobId || !flow.idParam || !flow.state || !Array.isArray(flow.done) || !flow.done.length || !Array.isArray(flow.failed) || !Number.isInteger(flow.intervalMs) || flow.intervalMs < 10 || flow.intervalMs > 60000 || !Number.isInteger(flow.timeoutMs) || flow.timeoutMs < flow.intervalMs || flow.timeoutMs > 3600000 || bindings[flow.poll].effect !== 'read' || flow.result && bindings[flow.result].effect !== 'read')
|
|
87
|
+
throw Error('job contract');
|
|
88
|
+
}
|
|
89
|
+
if (flow.type === 'paginate' && (!flow.items || !flow.offsetParam || !flow.limitParam || !Number.isInteger(flow.pageSize) || flow.pageSize < 1 || flow.pageSize > 1000 || !Number.isInteger(flow.maxPages) || flow.maxPages < 1 || flow.maxPages > 1000 || op.effect !== 'read'))
|
|
90
|
+
throw Error('pagination contract');
|
|
91
|
+
if (flow.type === 'upload' && (op.effect === 'read' || ![flow.fileParam, flow.parentParam, flow.nameParam].every(Boolean)))
|
|
92
|
+
throw Error('upload contract');
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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>;
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class CtlError extends Error {
|
|
2
|
+
code: string;
|
|
3
|
+
hint: string;
|
|
4
|
+
exitCode: number;
|
|
5
|
+
allowed?: string[] | undefined;
|
|
6
|
+
plan?: unknown | undefined;
|
|
7
|
+
constructor(code: string, message: string, hint: string, exitCode?: number, allowed?: string[] | undefined, plan?: unknown | undefined);
|
|
8
|
+
}
|
|
9
|
+
export declare function ctlError(error: unknown): CtlError;
|
|
10
|
+
export declare function errorData(error: unknown): {
|
|
11
|
+
plan?: {} | undefined;
|
|
12
|
+
allowed?: string[] | undefined;
|
|
13
|
+
code: string;
|
|
14
|
+
message: string;
|
|
15
|
+
hint: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function choose(value: string, allowed: string[], what: string): never;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class CtlError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
hint;
|
|
4
|
+
exitCode;
|
|
5
|
+
allowed;
|
|
6
|
+
plan;
|
|
7
|
+
constructor(code, message, hint, exitCode = 3, allowed, plan) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.hint = hint;
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
this.allowed = allowed;
|
|
13
|
+
this.plan = plan;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function ctlError(error) {
|
|
17
|
+
return error instanceof CtlError ? error : new CtlError('CTL_INTERNAL', 'Operation failed.', 'run: synopkgland ctl --help', 1);
|
|
18
|
+
}
|
|
19
|
+
export function errorData(error) {
|
|
20
|
+
const e = ctlError(error);
|
|
21
|
+
return { code: e.code, message: e.message, hint: e.hint, ...(e.allowed ? { allowed: e.allowed } : {}), ...(e.plan ? { plan: e.plan } : {}) };
|
|
22
|
+
}
|
|
23
|
+
export function choose(value, allowed, what) {
|
|
24
|
+
const distance = (a, b) => { let row = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 1; i <= a.length; i++) {
|
|
25
|
+
const next = [i];
|
|
26
|
+
for (let j = 1; j <= b.length; j++)
|
|
27
|
+
next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
28
|
+
row = next;
|
|
29
|
+
} return row[b.length]; };
|
|
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);
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Flow } from './descriptor.js';
|
|
2
|
+
export type Invoke = (method: string, params: Record<string, unknown>) => Promise<any>;
|
|
3
|
+
export interface FlowOptions {
|
|
4
|
+
wait?: boolean;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
upload?: (method: string, params: Record<string, unknown>, flow: Extract<Flow, {
|
|
7
|
+
type: 'upload';
|
|
8
|
+
}>) => Promise<unknown>;
|
|
9
|
+
ndjson?: (method: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare function runFlow(flow: Flow, params: Record<string, unknown>, invoke: Invoke, options: FlowOptions): Promise<any>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CtlError } from './errors.js';
|
|
2
|
+
function field(value, path) { for (const key of path.split('.')) {
|
|
3
|
+
if (!value || typeof value !== 'object' || !Object.hasOwn(value, key))
|
|
4
|
+
return undefined;
|
|
5
|
+
value = value[key];
|
|
6
|
+
} return value; }
|
|
7
|
+
export async function runFlow(flow, params, invoke, options) {
|
|
8
|
+
if (flow.type === 'invoke')
|
|
9
|
+
return invoke(flow.method, params);
|
|
10
|
+
if (flow.type === 'upload') {
|
|
11
|
+
if (!options.upload)
|
|
12
|
+
throw new CtlError('CTL_FLOW_UNSUPPORTED', 'Upload transport unavailable.', 'Use a compatible CLI.', 5);
|
|
13
|
+
return options.upload(flow.method, params, flow);
|
|
14
|
+
}
|
|
15
|
+
if (flow.type === 'ndjson') {
|
|
16
|
+
if (!options.ndjson)
|
|
17
|
+
throw new CtlError('CTL_FLOW_UNSUPPORTED', 'NDJSON transport unavailable.', 'Use a compatible CLI.', 5);
|
|
18
|
+
return options.ndjson(flow.method, params);
|
|
19
|
+
}
|
|
20
|
+
if (flow.type === 'paginate') {
|
|
21
|
+
const items = [];
|
|
22
|
+
for (let page = 0; page < flow.maxPages; page++) {
|
|
23
|
+
const response = await invoke(flow.method, { ...params, [flow.offsetParam]: page * flow.pageSize, [flow.limitParam]: flow.pageSize });
|
|
24
|
+
const rows = field(response, flow.items);
|
|
25
|
+
if (!Array.isArray(rows))
|
|
26
|
+
throw new CtlError('CTL_FLOW_RESPONSE', 'Pagination result has no items array.', 'Check command metadata.', 5);
|
|
27
|
+
if (items.length + rows.length > 100000)
|
|
28
|
+
throw new CtlError('CTL_FLOW_LIMIT', 'Pagination item limit exceeded.', 'Narrow the query.', 5);
|
|
29
|
+
items.push(...rows);
|
|
30
|
+
if (rows.length < flow.pageSize)
|
|
31
|
+
return { items };
|
|
32
|
+
}
|
|
33
|
+
throw new CtlError('CTL_FLOW_LIMIT', 'Pagination page limit reached; result is incomplete.', 'Narrow the query.', 5);
|
|
34
|
+
}
|
|
35
|
+
const accepted = await invoke(flow.start, params);
|
|
36
|
+
if (options.wait === false)
|
|
37
|
+
return { accepted: true, job: accepted };
|
|
38
|
+
const id = field(accepted, flow.jobId);
|
|
39
|
+
if (!['string', 'number'].includes(typeof id))
|
|
40
|
+
throw new CtlError('CTL_FLOW_RESPONSE', 'Start response has no job ID.', 'Check command metadata; start is not retried.', 5);
|
|
41
|
+
const until = performance.now() + Math.min(options.timeoutMs ?? flow.timeoutMs, 3600000);
|
|
42
|
+
while (performance.now() < until) {
|
|
43
|
+
const poll = await invoke(flow.poll, { [flow.idParam]: id });
|
|
44
|
+
const state = field(poll, flow.state);
|
|
45
|
+
if (flow.failed.includes(state))
|
|
46
|
+
throw new CtlError('CTL_JOB_FAILED', `Job ${id} failed (${state}).`, 'Inspect the job in the App; start was not retried.', 5);
|
|
47
|
+
if (flow.done.includes(state))
|
|
48
|
+
return flow.result ? invoke(flow.result, { [flow.idParam]: id }) : poll;
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(flow.intervalMs, Math.max(0, until - performance.now()))));
|
|
50
|
+
}
|
|
51
|
+
throw new CtlError('CTL_JOB_TIMEOUT', `Wait timed out; job ${id} may still be running.`, 'Query the job status; do not repeat the start blindly.', 6);
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Descriptor, type Operation } from './descriptor.js';
|
|
2
|
+
export declare function selectHosts(names: string[], selected?: string, all?: boolean): string[];
|
|
3
|
+
export declare function selectApp(apps: Descriptor[], name: string): Descriptor;
|
|
4
|
+
export declare function selectOperation(app: Descriptor, path: string[]): {
|
|
5
|
+
id: string;
|
|
6
|
+
operation: Operation;
|
|
7
|
+
};
|
|
8
|
+
export declare const RESERVED_FLAGS: Set<string>;
|
|
9
|
+
export declare function flagName(key: string): string;
|
|
10
|
+
export declare function operationInput(op: Operation, args: string[], stdin?: string, helpHint?: string): Record<string, unknown>;
|
|
11
|
+
export declare function requestPlan(app: Descriptor, id: string, params: Record<string, unknown>): {
|
|
12
|
+
package: string;
|
|
13
|
+
version: string;
|
|
14
|
+
path: string;
|
|
15
|
+
command: string;
|
|
16
|
+
flow: import("./descriptor.js").Flow;
|
|
17
|
+
params: Record<string, unknown>;
|
|
18
|
+
effect: "read" | "write" | "destructive";
|
|
19
|
+
};
|
|
20
|
+
export declare function confirmOperation(op: Operation, yes: boolean, dryRun: boolean, plan: unknown): void;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readBounded, strictJson } from './json.js';
|
|
2
|
+
import { ajv } from './descriptor.js';
|
|
3
|
+
import { CtlError, choose } from './errors.js';
|
|
4
|
+
import { redactValue } from './redaction.js';
|
|
5
|
+
export function selectHosts(names, selected, all = false) {
|
|
6
|
+
if (selected && all)
|
|
7
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --host or --all-hosts, not both.', 'run: synopkgland ctl host list');
|
|
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);
|
|
10
|
+
if (all)
|
|
11
|
+
return names;
|
|
12
|
+
if (!selected) {
|
|
13
|
+
if (names.length === 1)
|
|
14
|
+
return names;
|
|
15
|
+
throw new CtlError('CTL_HOST_REQUIRED', 'Choose explicit hosts; no current context is stored.', `run: synopkgland ctl list --host ${names[0]}`, 3, names);
|
|
16
|
+
}
|
|
17
|
+
const hosts = selected.split(',');
|
|
18
|
+
for (const name of hosts)
|
|
19
|
+
if (!names.includes(name))
|
|
20
|
+
choose(name, names, 'host');
|
|
21
|
+
return [...new Set(hosts)];
|
|
22
|
+
}
|
|
23
|
+
export function selectApp(apps, name) {
|
|
24
|
+
const found = apps.filter(a => [a.payload.package.id, ...a.payload.aliases].some(s => s.toLowerCase() === name.toLowerCase()));
|
|
25
|
+
if (found.length > 1)
|
|
26
|
+
throw new CtlError('CTL_APP_AMBIGUOUS', 'Alias matches multiple packages.', 'Use an exact DSM package ID.', 3, found.map(a => a.payload.package.id));
|
|
27
|
+
if (found.length === 1)
|
|
28
|
+
return found[0];
|
|
29
|
+
return choose(name, apps.flatMap(a => [a.payload.package.id, ...a.payload.aliases]), 'app');
|
|
30
|
+
}
|
|
31
|
+
export function selectOperation(app, path) {
|
|
32
|
+
const found = Object.entries(app.payload.commands).find(([, op]) => op.cli.join(' ') === path.join(' '));
|
|
33
|
+
if (found)
|
|
34
|
+
return { id: found[0], operation: found[1] };
|
|
35
|
+
return choose(path.join(' '), Object.values(app.payload.commands).map(op => op.cli.join(' ')), 'command');
|
|
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']);
|
|
38
|
+
export function flagName(key) { return key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()).replaceAll('_', '-'); }
|
|
39
|
+
export function operationInput(op, args, stdin, helpHint) {
|
|
40
|
+
const fail = (message, allowed) => { throw new CtlError('CTL_PARAMS_INVALID', message, helpHint ?? ('run: synopkgland ctl <package> ' + op.cli.join(' ') + ' --help'), 3, allowed); };
|
|
41
|
+
const inputIndex = args.indexOf('--input');
|
|
42
|
+
let params = {};
|
|
43
|
+
if (inputIndex >= 0) {
|
|
44
|
+
if (args.length !== 2 || inputIndex !== 0 || !args[1])
|
|
45
|
+
return fail('Use --input <file|-> alone for complex parameters.');
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = args[1] === '-' ? (stdin ?? readBounded(0)) : readBounded(args[1]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return fail('Cannot read parameter input.');
|
|
52
|
+
}
|
|
53
|
+
if (Buffer.byteLength(raw) > 1048576)
|
|
54
|
+
return fail('Parameter input exceeds 1 MiB.');
|
|
55
|
+
try {
|
|
56
|
+
params = strictJson(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return fail('Parameter input is not valid JSON.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
if (!op.paramsSchema)
|
|
64
|
+
return fail('Undescribed parameters: use --input <file> or --input -.');
|
|
65
|
+
const properties = op.paramsSchema.properties ?? {};
|
|
66
|
+
if (Object.keys(properties).some(key => RESERVED_FLAGS.has(flagName(key))))
|
|
67
|
+
return fail('Parameter names conflict with CLI control flags: use --input.');
|
|
68
|
+
const fields = new Map();
|
|
69
|
+
for (const [key, schema] of Object.entries(properties)) {
|
|
70
|
+
const flag = '--' + flagName(key);
|
|
71
|
+
if (fields.has(flag))
|
|
72
|
+
return fail('Parameter names collide as CLI flags; use --input.');
|
|
73
|
+
fields.set(flag, [key, schema]);
|
|
74
|
+
}
|
|
75
|
+
for (let i = 0; i < args.length; i++) {
|
|
76
|
+
const split = args[i].indexOf('=');
|
|
77
|
+
const flag = split > 0 ? args[i].slice(0, split) : args[i];
|
|
78
|
+
const field = fields.get(flag);
|
|
79
|
+
if (!field)
|
|
80
|
+
return fail('Unknown parameter flag.', [...fields.keys()]);
|
|
81
|
+
const [key, schema] = field;
|
|
82
|
+
if (Object.hasOwn(params, key))
|
|
83
|
+
return fail(`Duplicate parameter flag ${flag}.`);
|
|
84
|
+
if (!['string', 'integer', 'number', 'boolean'].includes(schema.type))
|
|
85
|
+
return fail(`Complex parameter ${flag}: use --input.`);
|
|
86
|
+
let value = split > 0 ? args[i].slice(split + 1) : undefined;
|
|
87
|
+
if (value === undefined && schema.type === 'boolean' && (!args[i + 1] || args[i + 1].startsWith('--')))
|
|
88
|
+
value = 'true';
|
|
89
|
+
else if (value === undefined)
|
|
90
|
+
value = args[++i];
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return fail(`Missing parameter value for ${flag}.`);
|
|
93
|
+
if (schema.type === 'integer') {
|
|
94
|
+
if (!/^-?\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
95
|
+
return fail(`Parameter ${flag} must be a safe integer.`);
|
|
96
|
+
params[key] = Number(value);
|
|
97
|
+
}
|
|
98
|
+
else if (schema.type === 'number') {
|
|
99
|
+
if (!value.trim() || !Number.isFinite(Number(value)))
|
|
100
|
+
return fail(`Parameter ${flag} must be a number.`);
|
|
101
|
+
params[key] = Number(value);
|
|
102
|
+
}
|
|
103
|
+
else if (schema.type === 'boolean') {
|
|
104
|
+
if (!['true', 'false'].includes(value))
|
|
105
|
+
return fail(`Parameter ${flag} must be true or false.`);
|
|
106
|
+
params[key] = value === 'true';
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
params[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!params || Array.isArray(params) || typeof params !== 'object')
|
|
113
|
+
return fail('Parameters must be a JSON object.');
|
|
114
|
+
if (op.paramsSchema) {
|
|
115
|
+
for (const [key, schema] of Object.entries(op.paramsSchema.properties ?? {}))
|
|
116
|
+
if (!Object.hasOwn(params, key) && Object.hasOwn(schema, 'default'))
|
|
117
|
+
params[key] = structuredClone(schema.default);
|
|
118
|
+
const validator = ajv.compile(op.paramsSchema);
|
|
119
|
+
if (!validator(params))
|
|
120
|
+
return fail('Invalid parameters: values do not match the command schema.');
|
|
121
|
+
}
|
|
122
|
+
return params;
|
|
123
|
+
}
|
|
124
|
+
export function requestPlan(app, id, params) { return { package: app.payload.package.id, version: app.payload.package.version, path: app.payload.transport.path, command: id, flow: app.payload.commands[id].flow, params: redactValue(params, app.payload.commands[id].paramsSchema), effect: app.payload.commands[id].effect }; }
|
|
125
|
+
export function confirmOperation(op, yes, dryRun, plan) {
|
|
126
|
+
if (!dryRun && op.effect !== 'read' && !yes)
|
|
127
|
+
throw new CtlError('CTL_CONFIRM_REQUIRED', 'State-changing operation requires --yes.', 'Inspect with --dry-run, then repeat the same command with --yes.', 7, undefined, plan);
|
|
128
|
+
}
|
package/dist/ctl/json.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { openSync, closeSync, readSync } from 'node:fs';
|
|
2
|
+
import { parseDocument } from 'yaml';
|
|
3
|
+
export function strictJson(raw) {
|
|
4
|
+
const text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw);
|
|
5
|
+
const value = JSON.parse(text);
|
|
6
|
+
if (parseDocument(text, { uniqueKeys: true }).errors.length)
|
|
7
|
+
throw Error('Duplicate JSON properties');
|
|
8
|
+
const check = (v, depth) => { if (depth > 32)
|
|
9
|
+
throw Error('JSON too deep'); if (typeof v === 'number' && !Number.isFinite(v))
|
|
10
|
+
throw Error('Invalid number'); if (typeof v === 'string' && /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(v))
|
|
11
|
+
throw Error('Invalid Unicode'); if (v && typeof v === 'object')
|
|
12
|
+
for (const x of Object.values(v))
|
|
13
|
+
check(x, depth + 1); };
|
|
14
|
+
check(value, 0);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function readBounded(file, limit = 1048576) {
|
|
18
|
+
const fd = typeof file === 'number' ? file : openSync(file, 'r');
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let count = 0;
|
|
21
|
+
try {
|
|
22
|
+
while (count <= limit) {
|
|
23
|
+
const chunk = Buffer.alloc(Math.min(65536, limit + 1 - count));
|
|
24
|
+
const read = readSync(fd, chunk, 0, chunk.length, null);
|
|
25
|
+
if (!read)
|
|
26
|
+
break;
|
|
27
|
+
count += read;
|
|
28
|
+
if (count > limit)
|
|
29
|
+
throw Error('Input too large');
|
|
30
|
+
chunks.push(chunk.subarray(0, read));
|
|
31
|
+
}
|
|
32
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
if (typeof file !== 'number')
|
|
36
|
+
closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Descriptor, Operation } from './descriptor.js';
|
|
2
|
+
import { CtlError } from './errors.js';
|
|
3
|
+
type Schema = Record<string, any> | boolean | undefined;
|
|
4
|
+
export declare const REDACTED = "<redacted>";
|
|
5
|
+
export declare function redactValue<T>(value: T, schema: Schema, root?: Schema, onSecret?: (value: unknown) => void): T;
|
|
6
|
+
export declare function publicOperation(operation: Operation): Operation;
|
|
7
|
+
export declare function publicDescriptor(description: Descriptor): Descriptor;
|
|
8
|
+
export declare class SensitiveOutput {
|
|
9
|
+
private readonly secrets;
|
|
10
|
+
constructor(params: unknown, schema: Schema);
|
|
11
|
+
private remember;
|
|
12
|
+
clean<T>(value: T, schema?: Schema): T;
|
|
13
|
+
error(error: unknown): CtlError;
|
|
14
|
+
}
|
|
15
|
+
export {};
|