@synopackageland/cli 0.9.6 → 0.10.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.
- package/CHANGELOG.md +144 -5
- package/README.md +5 -0
- package/dist/add.js +7 -8
- package/dist/build-spk/dsm-arch.js +1 -1
- package/dist/build-spk/dsm-icons.d.ts +8 -1
- package/dist/build-spk/dsm-icons.js +26 -12
- package/dist/build-spk/dsm-version.d.ts +1 -1
- package/dist/build-spk/dsm-version.js +4 -4
- package/dist/build-spk/gateway.js +2 -2
- package/dist/build-spk/generator.js +1 -1
- package/dist/build-spk/png.js +16 -16
- package/dist/capabilities.js +1 -1
- package/dist/capability-overlays.d.ts +16 -5
- package/dist/capability-overlays.js +30 -16
- package/dist/cli.js +107 -107
- package/dist/compose/analyze.js +23 -23
- package/dist/compose/privilege.js +12 -12
- package/dist/contract/i18n.js +3 -3
- package/dist/contract/icon.js +2 -2
- package/dist/contract/posix-all-shares.js +2 -2
- package/dist/contract/schema-validate.js +3 -3
- package/dist/contract/validate.js +16 -16
- package/dist/contract/web-launch.d.ts +2 -1
- package/dist/contract/web-launch.js +3 -2
- package/dist/deploy/deploy.js +8 -8
- package/dist/deploy/homes-values.js +1 -1
- package/dist/deploy/identity-shared.js +1 -1
- package/dist/deploy/native-install-values.js +1 -1
- package/dist/deploy/param-values.js +4 -4
- package/dist/deploy/plan.d.ts +1 -1
- package/dist/deploy/port-preflight.js +3 -3
- package/dist/deploy/run-as-values.js +2 -2
- package/dist/deploy/share-permission-preflight.js +6 -6
- package/dist/deploy/share-values.js +3 -3
- package/dist/error-registry.js +369 -346
- package/dist/errors.js +1 -1
- package/dist/host/dsm-port-profile.d.ts +1 -1
- package/dist/host/dsm-port-profile.js +1 -1
- package/dist/info.js +11 -11
- package/dist/init.d.ts +2 -0
- package/dist/init.js +85 -38
- package/dist/inspect.js +5 -5
- package/dist/portal/alias.d.ts +1 -1
- package/dist/portal/alias.js +4 -4
- package/dist/runtime/executor-build.js +12 -1
- package/dist/runtime/executor-digests.json +8 -85
- package/dist/runtime/executor-inspect.js +11 -4
- package/dist/runtime/executor-policy.d.ts +9 -2
- package/dist/runtime/executor-policy.js +27 -8
- package/dist/runtime/root-programs.d.ts +47 -0
- package/dist/runtime/root-programs.js +153 -0
- package/dist/runtime/sdk-source.d.ts +5 -4
- package/dist/runtime/sdk-source.js +8 -4
- package/dist/skill.js +14 -14
- package/dist/spk/check-info.js +17 -17
- package/dist/spk/check-metadata.js +18 -18
- package/dist/spk/check-payload.js +10 -10
- package/dist/spk/rules.d.ts +57 -57
- package/dist/spk/rules.js +50 -50
- package/dist/spk/validate-spk.js +5 -5
- package/dist/standalone/api-permissions.js +1 -1
- package/dist/standalone/background.d.ts +3 -1
- package/dist/standalone/background.js +8 -4
- package/dist/standalone/binary.js +7 -7
- package/dist/standalone/bridge-app.d.ts +23 -0
- package/dist/standalone/bridge-app.js +141 -0
- package/dist/standalone/bridge-backend.d.ts +40 -0
- package/dist/standalone/bridge-backend.js +95 -0
- package/dist/standalone/bridge-build.d.ts +21 -0
- package/dist/standalone/bridge-build.js +62 -0
- package/dist/standalone/bridge-json.d.ts +16 -0
- package/dist/standalone/bridge-json.js +59 -0
- package/dist/standalone/bridge-runtime.d.ts +16 -0
- package/dist/standalone/bridge-runtime.js +46 -0
- package/dist/standalone/bridge-rust.d.ts +16 -0
- package/dist/standalone/bridge-rust.js +89 -0
- package/dist/standalone/build.js +18 -14
- package/dist/standalone/daemon.js +6 -6
- package/dist/standalone/dsm-window.d.ts +10 -5
- package/dist/standalone/dsm-window.js +64 -16
- package/dist/standalone/i18n.js +18 -18
- package/dist/standalone/inspect-bridge.d.ts +31 -0
- package/dist/standalone/inspect-bridge.js +111 -0
- package/dist/standalone/inspect-spk.d.ts +1 -1
- package/dist/standalone/inspect-spk.js +77 -69
- package/dist/standalone/lifecycle.js +22 -3
- package/dist/standalone/manifest.d.ts +17 -9
- package/dist/standalone/manifest.js +143 -65
- package/dist/standalone/portal-window.js +14 -7
- package/dist/standalone/request-timeout.d.ts +9 -0
- package/dist/standalone/request-timeout.js +13 -0
- package/dist/standalone/root-ops.d.ts +23 -2
- package/dist/standalone/root-ops.js +118 -13
- package/dist/standalone/sdk-check.js +4 -4
- package/dist/standalone/shared-folder.js +7 -7
- package/dist/standalone/spk.d.ts +2 -0
- package/dist/standalone/spk.js +30 -11
- package/dist/standalone/uninstall-hook.js +22 -4
- package/dist/standalone/uninstall.js +7 -7
- package/dist/standalone/window-size.d.ts +17 -0
- package/dist/standalone/window-size.js +31 -0
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +13 -13
- package/package.json +2 -2
- package/schema/bridge-manifest.v1.schema.json +86 -0
- package/skills/reference/create.md +76 -2
- package/skills/reference/ui-json.md +2 -1
- package/skills/reference/ui-vue.md +9 -3
- package/skills/reference/wrap.md +1 -1
- package/dist/standalone/rust-build.d.ts +0 -35
- package/dist/standalone/rust-build.js +0 -127
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { MOVE_FROM_STAGING, DELEGATE_EXTENSION_OPERATIONS } from './delegate-contract.js';
|
|
3
3
|
import { portalByExecutor } from '../build-spk/dsm-version.js';
|
|
4
4
|
import { portalEntryProblem } from '../portal/alias.js';
|
|
5
|
+
import { ROOT_JOB_OPERATIONS, ROOT_PROGRAM_NAME, rootEntryProblem, rootOperations } from './root-programs.js';
|
|
5
6
|
export const FILE_OPERATIONS = ['files.list', 'files.info', 'files.createFolder', 'files.checkPermission', 'files.write', 'files.read', 'files.rename', 'files.copy', 'files.move', 'files.delete'];
|
|
6
7
|
/** Started File Station tasks are followed with these; they are implied, never declared. */
|
|
7
8
|
export const FILE_TASK_OPERATIONS = ['files.taskStatus', 'files.taskCancel'];
|
|
@@ -25,7 +26,7 @@ export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
|
25
26
|
/** The exact policy file bytes whose sha256 is the policy pin. */
|
|
26
27
|
export const executorPolicyText = (policy) => JSON.stringify(policy) + '\n';
|
|
27
28
|
/** Version-independent, sorted policy. Only §2.4 operation IDs are admitted. */
|
|
28
|
-
export function executorPolicy(m) {
|
|
29
|
+
export function executorPolicy(m, arch) {
|
|
29
30
|
if (m.capabilities.delegate && !validDelegate(m.capabilities.delegate))
|
|
30
31
|
throw new Error('[EXECUTOR_POLICY] Invalid delegate operations or scope.');
|
|
31
32
|
const portal = executorPortal(m);
|
|
@@ -38,16 +39,28 @@ export function executorPolicy(m) {
|
|
|
38
39
|
operations.push(...DELEGATE_EXTENSION_OPERATIONS);
|
|
39
40
|
}
|
|
40
41
|
const root = {};
|
|
42
|
+
const settings = m.capabilities.rootOps ? m.rootPrograms : undefined;
|
|
41
43
|
if (m.capabilities.rootOps) {
|
|
42
|
-
operations.push('root.run');
|
|
43
44
|
for (const op of [...m.rootOps ?? []].sort((a, b) => a.name.localeCompare(b.name, 'en')))
|
|
44
|
-
root[op.name] =
|
|
45
|
+
root[op.name] = rootEntry(op, settings?.programs?.[op.name], arch);
|
|
46
|
+
operations.push(...rootOperations(root));
|
|
45
47
|
}
|
|
46
48
|
const d = m.capabilities.delegate;
|
|
47
49
|
const purge = m.capabilities.rootOps ? m.uninstall?.purge.root : undefined;
|
|
48
50
|
return { v: 1, package: m.package, app: `SYNO.SDS.${m.package}.Instance`, ...(m.entry ? { entry: m.entry } : {}), ...(d ? { scope: d.scope } : {}),
|
|
49
51
|
...(d?.lifetime ? { destinations: [...d.destinations].sort(), lifetime: d.lifetime } : {}),
|
|
50
|
-
operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), ...(purge ? { purge } : {}),
|
|
52
|
+
operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), ...(purge ? { purge } : {}), ...(settings?.stop ? { stop: settings.stop } : {}),
|
|
53
|
+
root, ...(settings?.state ? { root_state: true } : {}) };
|
|
54
|
+
}
|
|
55
|
+
/** A script without settings keeps the bare digest; a binary pins the bytes built for this architecture. */
|
|
56
|
+
function rootEntry(op, settings, arch) {
|
|
57
|
+
const digest = op.body !== undefined ? sha256(op.body) : arch && op.binary?.[arch]?.digest;
|
|
58
|
+
if (!digest)
|
|
59
|
+
throw new Error(`[EXECUTOR_POLICY] Root program ${op.name} has no binary for ${arch ?? 'an unnamed architecture'}.`);
|
|
60
|
+
if (!settings)
|
|
61
|
+
return digest;
|
|
62
|
+
return { digest, kind: op.body !== undefined ? 'script' : 'binary', ...(settings.schema ? { schema: settings.schema } : {}),
|
|
63
|
+
...(settings.run ? { run: settings.run } : {}), ...(settings.job ? { job: settings.job } : {}) };
|
|
51
64
|
}
|
|
52
65
|
/** The entry the executor registers: a portal App whose os_min_ver is below DSM 7.2. */
|
|
53
66
|
function executorPortal(m) {
|
|
@@ -64,14 +77,20 @@ export function validExecutorPolicy(value, pkg) {
|
|
|
64
77
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
65
78
|
return false;
|
|
66
79
|
const p = value;
|
|
67
|
-
if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'root'].includes(key))
|
|
80
|
+
if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'stop', 'root', 'root_state'].includes(key))
|
|
68
81
|
|| p.v !== 1 || p.package !== pkg || p.app !== `SYNO.SDS.${pkg}.Instance`
|
|
69
82
|
|| 'entry' in p && p.entry !== 'admin-only' && p.entry !== 'app-privilege'
|
|
70
83
|
|| !Array.isArray(p.operations) || !p.operations.includes('status') || new Set(p.operations).size !== p.operations.length
|
|
71
84
|
|| !p.root || typeof p.root !== 'object' || Array.isArray(p.root)
|
|
72
|
-
|| 'purge' in p && (typeof p.purge !== 'string' || !Object.hasOwn(p.root, p.purge))
|
|
85
|
+
|| 'purge' in p && (typeof p.purge !== 'string' || !Object.hasOwn(p.root, p.purge))
|
|
86
|
+
|| 'stop' in p && (typeof p.stop !== 'string' || !Object.hasOwn(p.root, p.stop))
|
|
87
|
+
|| 'root_state' in p && p.root_state !== true
|
|
88
|
+
|| Object.entries(p.root).some(([name, entry]) => !ROOT_PROGRAM_NAME.test(name) || rootEntryProblem(entry) !== undefined))
|
|
89
|
+
return false;
|
|
90
|
+
const rootOps = rootOperations(p.root);
|
|
91
|
+
if (![...ROOT_JOB_OPERATIONS, 'root.run'].every(op => p.operations.includes(op) === rootOps.includes(op)))
|
|
73
92
|
return false;
|
|
74
|
-
const base = ['status', 'root.run', 'delegate.create', 'delegate.list', 'delegate.revoke'];
|
|
93
|
+
const base = ['status', 'root.run', ...ROOT_JOB_OPERATIONS, 'delegate.create', 'delegate.list', 'delegate.revoke'];
|
|
75
94
|
if (p.operations.some(op => ![...base, ...FILE_OPERATIONS, ...FILE_TASK_OPERATIONS, ...DELEGATE_EXTENSION_OPERATIONS, ...PORTAL_OPERATIONS].includes(op)))
|
|
76
95
|
return false;
|
|
77
96
|
if (!PORTAL_OPERATIONS.every(op => p.operations.includes(op) === (p.portal !== undefined)) || p.portal !== undefined && !validPortalEntry(p.portal, pkg))
|
|
@@ -83,7 +102,7 @@ export function validExecutorPolicy(value, pkg) {
|
|
|
83
102
|
...('lifetime' in p ? { lifetime: p.lifetime } : {}), ...('destinations' in p ? { destinations: p.destinations } : {}) };
|
|
84
103
|
if (!validDelegate(capability))
|
|
85
104
|
return false;
|
|
86
|
-
const expected = executorPolicy({ package: pkg, capabilities: { delegate: capability
|
|
105
|
+
const expected = [...executorPolicy({ package: pkg, capabilities: { delegate: capability } }).operations, ...rootOps];
|
|
87
106
|
const declared = p.operations.filter(op => !PORTAL_OPERATIONS.includes(op));
|
|
88
107
|
return expected.length === declared.length && expected.every(op => declared.includes(op));
|
|
89
108
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExecutorPolicy } from './executor-policy.js';
|
|
2
|
+
export interface RootRunLimits {
|
|
3
|
+
timeout_s?: number;
|
|
4
|
+
max_output?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface RootJobLimits {
|
|
7
|
+
timeout_s?: number;
|
|
8
|
+
grace_s?: number;
|
|
9
|
+
max_concurrent?: number;
|
|
10
|
+
nice?: number;
|
|
11
|
+
}
|
|
12
|
+
/** A `root.programs.<name>` setting in dsm-package.yaml. */
|
|
13
|
+
export interface RootProgramSettings {
|
|
14
|
+
kind?: 'script' | 'binary';
|
|
15
|
+
schema?: Record<string, unknown>;
|
|
16
|
+
run?: RootRunLimits;
|
|
17
|
+
job?: RootJobLimits;
|
|
18
|
+
}
|
|
19
|
+
/** dsm-package.yaml `root`: settings of root/ programs, the stop step and the root state directory. */
|
|
20
|
+
export interface RootSettings {
|
|
21
|
+
programs?: Record<string, RootProgramSettings>;
|
|
22
|
+
stop?: string;
|
|
23
|
+
state?: true;
|
|
24
|
+
}
|
|
25
|
+
/** A policy root entry: a bare digest is a script root.run may run with the defaults. */
|
|
26
|
+
export type RootPolicyEntry = string | {
|
|
27
|
+
digest: string;
|
|
28
|
+
kind: 'script' | 'binary';
|
|
29
|
+
schema?: Record<string, unknown>;
|
|
30
|
+
run?: RootRunLimits;
|
|
31
|
+
job?: RootJobLimits;
|
|
32
|
+
};
|
|
33
|
+
export declare const ROOT_JOB_OPERATIONS: readonly ["root.start", "root.jobs.list", "root.jobs.get", "root.jobs.stop"];
|
|
34
|
+
export declare const ROOT_PROGRAM_NAME: RegExp;
|
|
35
|
+
/** The operations a policy's root entries need: root.run for any it may run, the job ops for any with job. */
|
|
36
|
+
export declare function rootOperations(root: Record<string, RootPolicyEntry>): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Why the App's SDK executor cannot run this policy: one from before root
|
|
39
|
+
* program entries refuses a policy with them, and the package would not start.
|
|
40
|
+
*/
|
|
41
|
+
export declare function rootProgramsUnsupported(policy: ExecutorPolicy, executorDir: string): string | undefined;
|
|
42
|
+
/** Why the run or job limits are outside the executor's bounds; undefined when they are within. */
|
|
43
|
+
export declare function rootLimitsProblem(value: unknown, which: 'run' | 'job'): string | undefined;
|
|
44
|
+
/** Why a params schema is outside the executor's closed JSON Schema subset; undefined when it is inside. */
|
|
45
|
+
export declare function rootSchemaProblem(schema: unknown, depth?: number): string | undefined;
|
|
46
|
+
/** Why a policy root entry would make the executor refuse the policy; undefined when it would not. */
|
|
47
|
+
export declare function rootEntryProblem(entry: unknown): string | undefined;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root programs as the executor's policy declares them (protocol: Root
|
|
3
|
+
* programs, Root jobs, Package stop step, Root state directory). The bounds
|
|
4
|
+
* and the params schema subset are the executor's; a policy outside them
|
|
5
|
+
* makes the executor refuse to start.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
export const ROOT_JOB_OPERATIONS = ['root.start', 'root.jobs.list', 'root.jobs.get', 'root.jobs.stop'];
|
|
10
|
+
export const ROOT_PROGRAM_NAME = /^[a-z][a-z0-9_]{0,31}$/;
|
|
11
|
+
const RUN_BOUNDS = { timeout_s: [1, 120], max_output: [1, 64 * 1024 * 1024] };
|
|
12
|
+
const JOB_BOUNDS = { timeout_s: [1, 604800], grace_s: [1, 60], max_concurrent: [1, 4], nice: [0, 19] };
|
|
13
|
+
const SCHEMA_TYPES = ['object', 'array', 'string', 'integer', 'number', 'boolean'];
|
|
14
|
+
const STRING_KEYWORDS = ['pattern', 'minLength', 'maxLength'];
|
|
15
|
+
const NUMBER_KEYWORDS = ['minimum', 'maximum'];
|
|
16
|
+
const ARRAY_KEYWORDS = ['items', 'minItems', 'maxItems'];
|
|
17
|
+
const OBJECT_KEYWORDS = ['properties', 'required', 'additionalProperties'];
|
|
18
|
+
const PARAM_KEY = /^[a-z0-9_]{1,64}$/;
|
|
19
|
+
/** RE2, which the executor compiles patterns with, has no lookaround and no backreferences. */
|
|
20
|
+
const NOT_RE2 = /\(\?<?[=!]|\\[1-9]|\\k</;
|
|
21
|
+
const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
+
const integer = (value) => typeof value === 'number' && Number.isInteger(value);
|
|
23
|
+
/** The operations a policy's root entries need: root.run for any it may run, the job ops for any with job. */
|
|
24
|
+
export function rootOperations(root) {
|
|
25
|
+
const entries = Object.values(root);
|
|
26
|
+
return [
|
|
27
|
+
...(entries.some(entry => typeof entry === 'string' || entry.run) ? ['root.run'] : []),
|
|
28
|
+
...(entries.some(entry => typeof entry !== 'string' && entry.job) ? ROOT_JOB_OPERATIONS : []),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
/** A file only executors that read root program entries, stop and root_state have (SDK 0.10). */
|
|
32
|
+
const ROOT_PROGRAMS_SOURCE = 'stop_step.go';
|
|
33
|
+
/**
|
|
34
|
+
* Why the App's SDK executor cannot run this policy: one from before root
|
|
35
|
+
* program entries refuses a policy with them, and the package would not start.
|
|
36
|
+
*/
|
|
37
|
+
export function rootProgramsUnsupported(policy, executorDir) {
|
|
38
|
+
const uses = Object.values(policy.root).some(entry => typeof entry !== 'string') || policy.stop !== undefined || policy.root_state !== undefined;
|
|
39
|
+
if (!uses || existsSync(join(executorDir, ROOT_PROGRAMS_SOURCE)))
|
|
40
|
+
return undefined;
|
|
41
|
+
return 'root.programs, root.stop and root.state need an SDK whose executor supports root program settings (root programs beyond root.run, stop and root_state); this SDK\'s executor does not. Use an SDK with this feature, or remove the root settings from dsm-package.yaml.';
|
|
42
|
+
}
|
|
43
|
+
/** Why the run or job limits are outside the executor's bounds; undefined when they are within. */
|
|
44
|
+
export function rootLimitsProblem(value, which) {
|
|
45
|
+
const bounds = which === 'run' ? RUN_BOUNDS : JOB_BOUNDS;
|
|
46
|
+
if (!record(value))
|
|
47
|
+
return `${which} must be an object`;
|
|
48
|
+
for (const [key, limit] of Object.entries(value)) {
|
|
49
|
+
const range = bounds[key];
|
|
50
|
+
if (!range)
|
|
51
|
+
return `${which}.${key} is not one of ${Object.keys(bounds).join(', ')}`;
|
|
52
|
+
if (!integer(limit) || limit < range[0] || limit > range[1])
|
|
53
|
+
return `${which}.${key} must be an integer from ${range[0]} to ${range[1]}`;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Why a params schema is outside the executor's closed JSON Schema subset; undefined when it is inside. */
|
|
58
|
+
export function rootSchemaProblem(schema, depth = 0) {
|
|
59
|
+
if (!record(schema) || depth > 8)
|
|
60
|
+
return 'a schema is an object nested at most 8 levels';
|
|
61
|
+
const type = schema.type;
|
|
62
|
+
if (typeof type !== 'string' || !SCHEMA_TYPES.includes(type))
|
|
63
|
+
return `type must be one of ${SCHEMA_TYPES.join(', ')}`;
|
|
64
|
+
const allowed = ['type', 'enum', ...{ string: STRING_KEYWORDS, integer: NUMBER_KEYWORDS, number: NUMBER_KEYWORDS, boolean: [], array: ARRAY_KEYWORDS, object: OBJECT_KEYWORDS }[type]];
|
|
65
|
+
const other = Object.keys(schema).find(key => !allowed.includes(key));
|
|
66
|
+
if (other)
|
|
67
|
+
return `${type} schema cannot have ${other}`;
|
|
68
|
+
for (const key of ['minLength', 'maxLength', 'minItems', 'maxItems'])
|
|
69
|
+
if (key in schema && !integer(schema[key]))
|
|
70
|
+
return `${key} must be an integer`;
|
|
71
|
+
for (const key of NUMBER_KEYWORDS)
|
|
72
|
+
if (key in schema && typeof schema[key] !== 'number')
|
|
73
|
+
return `${key} must be a number`;
|
|
74
|
+
if ('pattern' in schema) {
|
|
75
|
+
const pattern = schema.pattern;
|
|
76
|
+
if (typeof pattern !== 'string' || NOT_RE2.test(pattern))
|
|
77
|
+
return 'pattern must be an RE2 regular expression (no lookaround or backreferences)';
|
|
78
|
+
try {
|
|
79
|
+
new RegExp(pattern, 'u');
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return `pattern ${pattern} does not compile`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (type === 'array') {
|
|
86
|
+
if (!('items' in schema))
|
|
87
|
+
return 'array schema needs items';
|
|
88
|
+
const items = rootSchemaProblem(schema.items, depth + 1);
|
|
89
|
+
if (items)
|
|
90
|
+
return `items: ${items}`;
|
|
91
|
+
}
|
|
92
|
+
if (type === 'object') {
|
|
93
|
+
if (schema.additionalProperties !== false)
|
|
94
|
+
return 'every object schema sets additionalProperties: false';
|
|
95
|
+
if ('enum' in schema)
|
|
96
|
+
return 'object schema cannot have enum';
|
|
97
|
+
const properties = schema.properties ?? {};
|
|
98
|
+
if (!record(properties))
|
|
99
|
+
return 'properties must be an object';
|
|
100
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
101
|
+
if (!PARAM_KEY.test(key))
|
|
102
|
+
return `property ${key} must match [a-z0-9_]{1,64}`;
|
|
103
|
+
const problem = rootSchemaProblem(property, depth + 1);
|
|
104
|
+
if (problem)
|
|
105
|
+
return `${key}: ${problem}`;
|
|
106
|
+
}
|
|
107
|
+
const required = schema.required ?? [];
|
|
108
|
+
if (!Array.isArray(required) || required.some(key => typeof key !== 'string' || !Object.hasOwn(properties, key)))
|
|
109
|
+
return 'required must name properties';
|
|
110
|
+
}
|
|
111
|
+
if ('enum' in schema && (!Array.isArray(schema.enum) || schema.enum.some(value => !matchesType(value, type))))
|
|
112
|
+
return `enum values must be ${type}s`;
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
function matchesType(value, type) {
|
|
116
|
+
switch (type) {
|
|
117
|
+
case 'string': return typeof value === 'string';
|
|
118
|
+
case 'integer': return integer(value);
|
|
119
|
+
case 'number': return typeof value === 'number';
|
|
120
|
+
case 'boolean': return typeof value === 'boolean';
|
|
121
|
+
case 'array': return Array.isArray(value);
|
|
122
|
+
default: return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Why a policy root entry would make the executor refuse the policy; undefined when it would not. */
|
|
126
|
+
export function rootEntryProblem(entry) {
|
|
127
|
+
if (typeof entry === 'string')
|
|
128
|
+
return /^[a-f0-9]{64}$/.test(entry) ? undefined : 'digest must be 64 lowercase hex digits';
|
|
129
|
+
if (!record(entry))
|
|
130
|
+
return 'an entry is a digest or an object';
|
|
131
|
+
const other = Object.keys(entry).find(key => !['digest', 'kind', 'schema', 'run', 'job'].includes(key));
|
|
132
|
+
if (other)
|
|
133
|
+
return `an entry cannot have ${other}`;
|
|
134
|
+
if (typeof entry.digest !== 'string' || !/^[a-f0-9]{64}$/.test(entry.digest))
|
|
135
|
+
return 'digest must be 64 lowercase hex digits';
|
|
136
|
+
if (entry.kind !== 'script' && entry.kind !== 'binary')
|
|
137
|
+
return 'kind is script or binary';
|
|
138
|
+
if ('schema' in entry) {
|
|
139
|
+
if (!record(entry.schema) || entry.schema.type !== 'object')
|
|
140
|
+
return 'schema must be an object schema';
|
|
141
|
+
const problem = rootSchemaProblem(entry.schema);
|
|
142
|
+
if (problem)
|
|
143
|
+
return `schema: ${problem}`;
|
|
144
|
+
}
|
|
145
|
+
for (const which of ['run', 'job']) {
|
|
146
|
+
if (which in entry) {
|
|
147
|
+
const problem = rootLimitsProblem(entry[which], which);
|
|
148
|
+
if (problem)
|
|
149
|
+
return problem;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export declare const SDK_PACKAGE = "@synopackageland/sdk";
|
|
2
2
|
export declare const SDK_MODULE = "github.com/synopackageland/syno-pkg-land-sdk";
|
|
3
3
|
export declare const EXECUTOR_MODULE = "github.com/synopackageland/syno-pkg-land-sdk/executor";
|
|
4
|
-
/** The Rust
|
|
5
|
-
export declare const SDK_CRATE = "syno-pkg-land-
|
|
4
|
+
/** The thin Rust App crate (spec/bridge/v1), a git dependency on a release tag of the SDK repository. */
|
|
5
|
+
export declare const SDK_CRATE = "syno-pkg-land-app";
|
|
6
6
|
export declare const SDK_GIT_URL = "https://github.com/synopackageland/syno-pkg-land-sdk";
|
|
7
7
|
/** Where one SDK version's Go client and executor sources are read from. */
|
|
8
8
|
export interface SdkSource {
|
|
@@ -13,10 +13,11 @@ export interface SdkSource {
|
|
|
13
13
|
}
|
|
14
14
|
/** How each backend declares the SDK. */
|
|
15
15
|
export declare const SDK_DEPENDENCY: Record<SdkBackend['kind'], string>;
|
|
16
|
-
/** The App backend whose dependency files name its SDK version. */
|
|
16
|
+
/** The App backend whose dependency files, or for python and node whose `sdk`, name its SDK version. */
|
|
17
17
|
export interface SdkBackend {
|
|
18
|
-
kind: 'go' | 'rust';
|
|
18
|
+
kind: 'go' | 'rust' | 'python' | 'node';
|
|
19
19
|
dir: string;
|
|
20
|
+
sdk?: string;
|
|
20
21
|
}
|
|
21
22
|
/** The SDK version an App requires, read from its backend's dependency files. */
|
|
22
23
|
export declare function appSdkVersion(backend: SdkBackend): string | undefined;
|
|
@@ -5,16 +5,20 @@ import { join } from 'node:path';
|
|
|
5
5
|
export const SDK_PACKAGE = '@synopackageland/sdk';
|
|
6
6
|
export const SDK_MODULE = 'github.com/synopackageland/syno-pkg-land-sdk';
|
|
7
7
|
export const EXECUTOR_MODULE = `${SDK_MODULE}/executor`;
|
|
8
|
-
/** The Rust
|
|
9
|
-
export const SDK_CRATE = 'syno-pkg-land-
|
|
8
|
+
/** The thin Rust App crate (spec/bridge/v1), a git dependency on a release tag of the SDK repository. */
|
|
9
|
+
export const SDK_CRATE = 'syno-pkg-land-app';
|
|
10
10
|
export const SDK_GIT_URL = `https://${SDK_MODULE}`;
|
|
11
11
|
/** How each backend declares the SDK. */
|
|
12
12
|
export const SDK_DEPENDENCY = {
|
|
13
13
|
go: `add \`require ${SDK_MODULE} v<version>\` to backend/go.mod.`,
|
|
14
|
-
rust: `add \`${SDK_CRATE} = { git = "${SDK_GIT_URL}", tag = "v<version>" }\` to
|
|
14
|
+
rust: `add \`${SDK_CRATE} = { git = "${SDK_GIT_URL}", tag = "v<version>" }\` to the App's Cargo.toml and commit Cargo.lock.`,
|
|
15
|
+
python: 'set backend.sdk: <version> in dsm-package.yaml.',
|
|
16
|
+
node: 'set backend.sdk: <version> in dsm-package.yaml.',
|
|
15
17
|
};
|
|
16
18
|
/** The SDK version an App requires, read from its backend's dependency files. */
|
|
17
19
|
export function appSdkVersion(backend) {
|
|
20
|
+
if (backend.kind === 'python' || backend.kind === 'node')
|
|
21
|
+
return backend.sdk;
|
|
18
22
|
return backend.kind === 'go' ? sdkRequirement(backend.dir) : cargoSdkRequirement(backend.dir);
|
|
19
23
|
}
|
|
20
24
|
/**
|
|
@@ -37,7 +41,7 @@ function cargoSdkRequirement(backendDir) {
|
|
|
37
41
|
const tag = new RegExp(`^source = "git\\+${SDK_GIT_URL.replaceAll('.', '\\.')}\\?tag=v(\\d+\\.\\d+\\.\\d+)#[0-9a-f]{40}"$`, 'm');
|
|
38
42
|
const version = sdk.length === 1 ? tag.exec(sdk[0])?.[1] : undefined;
|
|
39
43
|
if (!version)
|
|
40
|
-
throw new Error(`[SDK_TAG_REQUIRED]
|
|
44
|
+
throw new Error(`[SDK_TAG_REQUIRED] Cargo.lock must pin one ${SDK_CRATE} from a release tag: { git = "${SDK_GIT_URL}", tag = "vX.Y.Z" } in Cargo.toml, then cargo update -p ${SDK_CRATE}.`);
|
|
41
45
|
return version;
|
|
42
46
|
}
|
|
43
47
|
/** The SDK version a Go backend requires: `require github.com/synopackageland/syno-pkg-land-sdk vX.Y.Z` in backend/go.mod, its only version source. */
|
package/dist/skill.js
CHANGED
|
@@ -2,16 +2,16 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
const SKILL_CATALOG = [
|
|
5
|
-
{ id: "orchestrator", file: "SKILL.md", when: "
|
|
6
|
-
{ id: "create", file: "reference/create.md", when: "
|
|
7
|
-
{ id: "wrap", file: "reference/wrap.md", when: "
|
|
8
|
-
{ id: "identity", file: "reference/identity.md", when: "
|
|
9
|
-
{ id: "files", file: "reference/files.md", when: "
|
|
10
|
-
{ id: "portal", file: "reference/portal.md", when: "
|
|
11
|
-
{ id: "ui-vue", file: "reference/ui-vue.md", when: "
|
|
12
|
-
{ id: "ui-json", file: "reference/ui-json.md", when: "
|
|
13
|
-
{ id: "deploy", file: "reference/deploy.md", when: "
|
|
14
|
-
{ id: "diagnose", file: "reference/diagnose.md", when: "check
|
|
5
|
+
{ id: "orchestrator", file: "SKILL.md", when: "Read first" },
|
|
6
|
+
{ id: "create", file: "reference/create.md", when: "Start from nothing or a one-liner" },
|
|
7
|
+
{ id: "wrap", file: "reference/wrap.md", when: "Existing Compose, or adding a contract" },
|
|
8
|
+
{ id: "identity", file: "reference/identity.md", when: "Needs a DSM sign-in" },
|
|
9
|
+
{ id: "files", file: "reference/files.md", when: "Needs NAS files" },
|
|
10
|
+
{ id: "portal", file: "reference/portal.md", when: "Needs a DSM Application Portal alias link" },
|
|
11
|
+
{ id: "ui-vue", file: "reference/ui-vue.md", when: "Write or check a Vue UI" },
|
|
12
|
+
{ id: "ui-json", file: "reference/ui-json.md", when: "Generate or check a JSON UI" },
|
|
13
|
+
{ id: "deploy", file: "reference/deploy.md", when: "Deploy to a NAS" },
|
|
14
|
+
{ id: "diagnose", file: "reference/diagnose.md", when: "check/deploy/Open failed" },
|
|
15
15
|
];
|
|
16
16
|
export function resolveSkillsRoot() {
|
|
17
17
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -24,7 +24,7 @@ export function resolveSkillsRoot() {
|
|
|
24
24
|
return candidate;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
throw new Error("
|
|
27
|
+
throw new Error("Skill Pack not found. Reinstall @synopackageland/cli.");
|
|
28
28
|
}
|
|
29
29
|
export function listSkills() {
|
|
30
30
|
return [...SKILL_CATALOG];
|
|
@@ -32,11 +32,11 @@ export function listSkills() {
|
|
|
32
32
|
export function skillPath(id) {
|
|
33
33
|
const entry = SKILL_CATALOG.find((item) => item.id === id);
|
|
34
34
|
if (!entry) {
|
|
35
|
-
throw new Error(
|
|
35
|
+
throw new Error(`Unknown skill: ${id}`);
|
|
36
36
|
}
|
|
37
37
|
const path = join(resolveSkillsRoot(), entry.file);
|
|
38
38
|
if (!existsSync(path)) {
|
|
39
|
-
throw new Error(`Skill
|
|
39
|
+
throw new Error(`Skill file does not exist: ${entry.file}`);
|
|
40
40
|
}
|
|
41
41
|
return path;
|
|
42
42
|
}
|
|
@@ -45,7 +45,7 @@ export function readSkill(id) {
|
|
|
45
45
|
}
|
|
46
46
|
export function formatSkillList() {
|
|
47
47
|
const root = resolveSkillsRoot();
|
|
48
|
-
const lines = [`Skill Pack
|
|
48
|
+
const lines = [`Skill Pack: ${root}`, ""];
|
|
49
49
|
for (const entry of SKILL_CATALOG) {
|
|
50
50
|
lines.push(`${entry.id}\t${entry.when}\t${entry.file}`);
|
|
51
51
|
}
|
package/dist/spk/check-info.js
CHANGED
|
@@ -15,7 +15,7 @@ export function parseInfo(text, findings) {
|
|
|
15
15
|
return;
|
|
16
16
|
const match = /^([A-Za-z0-9_]+)=(.*)$/.exec(line);
|
|
17
17
|
if (!match) {
|
|
18
|
-
findings?.add("SPK_INFO_LINE_INVALID", `INFO
|
|
18
|
+
findings?.add("SPK_INFO_LINE_INVALID", `INFO line ${index + 1} is not key="value": ${line.slice(0, 60)}`, "INFO");
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
21
|
const [, key, raw] = match;
|
|
@@ -23,13 +23,13 @@ export function parseInfo(text, findings) {
|
|
|
23
23
|
if (raw.startsWith('"')) {
|
|
24
24
|
const quoted = /^"((?:[^"\\]|\\.)*)"\s*$/.exec(raw);
|
|
25
25
|
if (!quoted) {
|
|
26
|
-
findings?.add("SPK_INFO_VALUE_UNTERMINATED", `INFO
|
|
26
|
+
findings?.add("SPK_INFO_VALUE_UNTERMINATED", `INFO line ${index + 1} has an unterminated value quote for ${key}.`, "INFO");
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
value = quoted[1].replace(/\\(.)/g, "$1");
|
|
30
30
|
}
|
|
31
31
|
if (fields.has(key))
|
|
32
|
-
findings?.add("SPK_INFO_FIELD_DUPLICATE", `INFO
|
|
32
|
+
findings?.add("SPK_INFO_FIELD_DUPLICATE", `INFO sets ${key} twice; only one value takes effect.`, "INFO");
|
|
33
33
|
fields.set(key, value);
|
|
34
34
|
});
|
|
35
35
|
return fields;
|
|
@@ -45,19 +45,19 @@ function compareOsVer(a, b) {
|
|
|
45
45
|
function checkVersion(version, findings) {
|
|
46
46
|
const parts = version.split(/[._-]/);
|
|
47
47
|
if (parts.some((part) => !/^\d+$/.test(part) || Number(part) > MAX_VERSION_PART)) {
|
|
48
|
-
findings.add("SPK_INFO_VERSION_INVALID", `INFO version
|
|
48
|
+
findings.add("SPK_INFO_VERSION_INVALID", `INFO version "${version}" must be numbers separated by . - _ (each segment 0-2147483647), for example 1.2.3-0001.`, "INFO");
|
|
49
49
|
}
|
|
50
50
|
else if (!version.includes("-")) {
|
|
51
|
-
findings.add("SPK_INFO_VERSION_NO_BUILD", `INFO version
|
|
51
|
+
findings.add("SPK_INFO_VERSION_NO_BUILD", `INFO version "${version}" has no -<build> number; the guide format is [feature number]-[build number], for example ${version}-0001.`, "INFO");
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
function checkArch(field, value, findings) {
|
|
55
55
|
const tokens = value.split(/\s+/).filter(Boolean);
|
|
56
56
|
for (const token of tokens.filter((t) => !ARCH_ELF_MACHINE.has(t))) {
|
|
57
|
-
findings.add("SPK_INFO_ARCH_UNKNOWN", `INFO ${field}
|
|
57
|
+
findings.add("SPK_INFO_ARCH_UNKNOWN", `INFO ${field} has a value that is in neither the guide's Appendix A nor the known DSM 7 platforms: "${token}".`, "INFO");
|
|
58
58
|
}
|
|
59
59
|
if (field === "arch" && tokens.includes("noarch") && tokens.length > 1) {
|
|
60
|
-
findings.add("SPK_INFO_ARCH_NOARCH_MIXED", `INFO arch
|
|
60
|
+
findings.add("SPK_INFO_ARCH_NOARCH_MIXED", `INFO arch "${value}" has noarch together with other values; noarch already covers every platform.`, "INFO");
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
function checkTranslations(key, findings) {
|
|
@@ -65,7 +65,7 @@ function checkTranslations(key, findings) {
|
|
|
65
65
|
if (!match)
|
|
66
66
|
return false;
|
|
67
67
|
if (!LANGUAGES.has(match[2])) {
|
|
68
|
-
findings.add("SPK_INFO_LANG_UNKNOWN", `INFO ${key}
|
|
68
|
+
findings.add("SPK_INFO_LANG_UNKNOWN", `The language code of INFO ${key} is "${match[2]}", not in the guide list (${INFO_LANGUAGES.join(", ")}).`, "INFO");
|
|
69
69
|
}
|
|
70
70
|
return true;
|
|
71
71
|
}
|
|
@@ -73,14 +73,14 @@ function checkTranslations(key, findings) {
|
|
|
73
73
|
export function checkInfoFields(fields, payloadPath, findings) {
|
|
74
74
|
for (const key of REQUIRED_INFO_FIELDS) {
|
|
75
75
|
if (!fields.get(key))
|
|
76
|
-
findings.add("SPK_INFO_FIELD_REQUIRED", `INFO
|
|
76
|
+
findings.add("SPK_INFO_FIELD_REQUIRED", `INFO is missing required field ${key}.`, "INFO");
|
|
77
77
|
}
|
|
78
78
|
if (!fields.get("displayname")) {
|
|
79
|
-
findings.add("SPK_INFO_DISPLAYNAME_MISSING", "INFO
|
|
79
|
+
findings.add("SPK_INFO_DISPLAYNAME_MISSING", "INFO has no displayname; Package Center shows the package value.", "INFO");
|
|
80
80
|
}
|
|
81
81
|
const pkg = fields.get("package");
|
|
82
82
|
if (pkg && /[:/><|=]/.test(pkg)) {
|
|
83
|
-
findings.add("SPK_INFO_PACKAGE_INVALID", `INFO package
|
|
83
|
+
findings.add("SPK_INFO_PACKAGE_INVALID", `INFO package "${pkg}" cannot contain : / > < | =.`, "INFO");
|
|
84
84
|
}
|
|
85
85
|
const version = fields.get("version");
|
|
86
86
|
if (version)
|
|
@@ -90,10 +90,10 @@ export function checkInfoFields(fields, payloadPath, findings) {
|
|
|
90
90
|
if (!value)
|
|
91
91
|
continue;
|
|
92
92
|
if (!OS_VER.test(value)) {
|
|
93
|
-
findings.add("SPK_INFO_OS_VER_INVALID", `INFO ${key}
|
|
93
|
+
findings.add("SPK_INFO_OS_VER_INVALID", `INFO ${key} "${value}" is not <major>.<minor>-<build> (for example 7.0-40000).`, "INFO");
|
|
94
94
|
}
|
|
95
95
|
else if (key === "os_min_ver" && compareOsVer(value, DSM7_MIN_VER) < 0) {
|
|
96
|
-
findings.add("SPK_INFO_OS_MIN_VER_LOW", `INFO os_min_ver
|
|
96
|
+
findings.add("SPK_INFO_OS_MIN_VER_LOW", `INFO os_min_ver "${value}" is below ${DSM7_MIN_VER}; a DSM 7 package should be at least ${DSM7_MIN_VER}.`, "INFO");
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
for (const key of ["arch", "exclude_arch"]) {
|
|
@@ -103,21 +103,21 @@ export function checkInfoFields(fields, payloadPath, findings) {
|
|
|
103
103
|
}
|
|
104
104
|
for (const [key, value] of fields) {
|
|
105
105
|
if (BOOLEAN_FIELDS.has(key) && value !== "yes" && value !== "no") {
|
|
106
|
-
findings.add("SPK_INFO_BOOLEAN_INVALID", `INFO ${key}
|
|
106
|
+
findings.add("SPK_INFO_BOOLEAN_INVALID", `INFO ${key} "${value}" can only be "yes" or "no".`, "INFO");
|
|
107
107
|
}
|
|
108
108
|
if (!checkTranslations(key, findings) && !KNOWN_FIELDS.has(key)) {
|
|
109
|
-
findings.add("SPK_INFO_FIELD_UNKNOWN", `INFO ${key}
|
|
109
|
+
findings.add("SPK_INFO_FIELD_UNKNOWN", `INFO ${key} is not a field the guide lists; DSM does not use it.`, "INFO");
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
const adminport = fields.get("adminport");
|
|
113
113
|
if (adminport !== undefined && (!/^\d+$/.test(adminport) || Number(adminport) > 65536)) {
|
|
114
|
-
findings.add("SPK_INFO_ADMINPORT_INVALID", `INFO adminport
|
|
114
|
+
findings.add("SPK_INFO_ADMINPORT_INVALID", `INFO adminport "${adminport}" must be an integer from 0 to 65536.`, "INFO");
|
|
115
115
|
}
|
|
116
116
|
const checksum = fields.get("checksum");
|
|
117
117
|
if (checksum && payloadPath) {
|
|
118
118
|
const actual = createHash("md5").update(readFileSync(payloadPath)).digest("hex");
|
|
119
119
|
if (checksum.toLowerCase() !== actual) {
|
|
120
|
-
findings.add("SPK_INFO_CHECKSUM_MISMATCH", `INFO checksum
|
|
120
|
+
findings.add("SPK_INFO_CHECKSUM_MISMATCH", `INFO checksum "${checksum}" is not the MD5 of package.tgz (${actual}).`, "INFO");
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
}
|
|
@@ -22,22 +22,22 @@ function checkScripts({ files, modes }, findings) {
|
|
|
22
22
|
const body = files.get(path);
|
|
23
23
|
if (!body) {
|
|
24
24
|
if (name === "start-stop-status")
|
|
25
|
-
findings.add("SPK_SCRIPT_MISSING",
|
|
25
|
+
findings.add("SPK_SCRIPT_MISSING", `${path} is missing; DSM uses it to start, stop and query the package status.`, path);
|
|
26
26
|
else if (LIFECYCLE_SCRIPTS.includes(name))
|
|
27
|
-
findings.add("SPK_SCRIPT_HOOK_MISSING",
|
|
27
|
+
findings.add("SPK_SCRIPT_HOOK_MISSING", `${path} is missing; the guide lists it as required, so write #!/bin/sh with exit 0 when there is nothing to do.`, path);
|
|
28
28
|
continue;
|
|
29
29
|
}
|
|
30
30
|
if (!executable(modes.get(path)))
|
|
31
|
-
findings.add("SPK_SCRIPT_NOT_EXECUTABLE", `${path}
|
|
31
|
+
findings.add("SPK_SCRIPT_NOT_EXECUTABLE", `${path} is not executable (mode ${(modes.get(path) ?? 0).toString(8)}); chmod 755 it before packaging.`, path);
|
|
32
32
|
const head = body.subarray(0, 4).toString("latin1");
|
|
33
33
|
if (!head.startsWith("#!") && head !== "\x7fELF")
|
|
34
|
-
findings.add("SPK_SCRIPT_NO_INTERPRETER", `${path}
|
|
34
|
+
findings.add("SPK_SCRIPT_NO_INTERPRETER", `${path} does not start with #! (for example #!/bin/sh).`, path);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
function checkPrivilege(files, findings) {
|
|
38
38
|
const path = "conf/privilege";
|
|
39
39
|
if (!files.has(path)) {
|
|
40
|
-
findings.add("SPK_PRIVILEGE_MISSING", "
|
|
40
|
+
findings.add("SPK_PRIVILEGE_MISSING", "conf/privilege is missing; from DSM 7 every package must declare its run identity explicitly.", path);
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
const privilege = parseJson(files.get(path));
|
|
@@ -47,11 +47,11 @@ function checkPrivilege(files, findings) {
|
|
|
47
47
|
: [];
|
|
48
48
|
const values = isRecord(privilege) ? [runAs(privilege.defaults), ...listed] : [];
|
|
49
49
|
if (!isRecord(privilege) || values.some((value) => typeof value !== "string" || !RUN_AS.includes(value))) {
|
|
50
|
-
findings.add("SPK_PRIVILEGE_INVALID", 'conf/privilege
|
|
50
|
+
findings.add("SPK_PRIVILEGE_INVALID", 'conf/privilege must be a JSON object with defaults.run-as, and every run-as must be "package" or "root".', path);
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
if (values.includes("root"))
|
|
54
|
-
findings.add("SPK_PRIVILEGE_ROOT", "conf/privilege
|
|
54
|
+
findings.add("SPK_PRIVILEGE_ROOT", "conf/privilege requires running as root; a DSM 7 third-party package should run as the package account.", path);
|
|
55
55
|
}
|
|
56
56
|
function checkConf({ files, modes }, findings) {
|
|
57
57
|
checkPrivilege(files, findings);
|
|
@@ -59,27 +59,27 @@ function checkConf({ files, modes }, findings) {
|
|
|
59
59
|
if (files.has(resourcePath)) {
|
|
60
60
|
const resource = parseJson(files.get(resourcePath));
|
|
61
61
|
if (!isRecord(resource))
|
|
62
|
-
findings.add("SPK_RESOURCE_INVALID", "conf/resource
|
|
62
|
+
findings.add("SPK_RESOURCE_INVALID", "conf/resource is not a JSON object.", resourcePath);
|
|
63
63
|
else
|
|
64
64
|
for (const id of Object.keys(resource).filter((key) => !WORKERS.has(key))) {
|
|
65
|
-
findings.add("SPK_RESOURCE_UNKNOWN", `conf/resource
|
|
65
|
+
findings.add("SPK_RESOURCE_UNKNOWN", `conf/resource "${id}" is not a resource worker the guide lists.`, resourcePath);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
const names = new Set([...modes.keys()].filter((path) => path.startsWith("conf/")).map((path) => path.split("/")[1]).filter(Boolean));
|
|
69
69
|
for (const name of [...names].filter((n) => !CONF.has(n)))
|
|
70
|
-
findings.add("SPK_CONF_UNKNOWN", `conf/${name}
|
|
70
|
+
findings.add("SPK_CONF_UNKNOWN", `conf/${name} is not a config file DSM reads.`, `conf/${name}`);
|
|
71
71
|
}
|
|
72
72
|
function checkIcons(files, findings) {
|
|
73
73
|
for (const [name, size] of PACKAGE_ICONS) {
|
|
74
74
|
const png = files.get(name);
|
|
75
75
|
if (!png) {
|
|
76
|
-
findings.add("SPK_ICON_MISSING",
|
|
76
|
+
findings.add("SPK_ICON_MISSING", `${name} is missing (a ${size}x${size} PNG).`, name);
|
|
77
77
|
}
|
|
78
78
|
else if (png.length < 24 || !png.subarray(0, 8).equals(PNG_SIGNATURE) || png.toString("latin1", 12, 16) !== "IHDR") {
|
|
79
|
-
findings.add("SPK_ICON_INVALID", `${name}
|
|
79
|
+
findings.add("SPK_ICON_INVALID", `${name} is not a PNG.`, name);
|
|
80
80
|
}
|
|
81
81
|
else if (png.readUInt32BE(16) !== size || png.readUInt32BE(20) !== size) {
|
|
82
|
-
findings.add("SPK_ICON_SIZE", `${name}
|
|
82
|
+
findings.add("SPK_ICON_SIZE", `${name} is ${png.readUInt32BE(16)}x${png.readUInt32BE(20)}; the guide requires ${size}x${size}.`, name);
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
}
|
|
@@ -87,14 +87,14 @@ function checkWizards({ files, modes }, findings) {
|
|
|
87
87
|
for (const path of [...modes.keys()].filter((p) => p.startsWith("WIZARD_UIFILES/") && files.has(p))) {
|
|
88
88
|
const name = path.slice("WIZARD_UIFILES/".length);
|
|
89
89
|
if (!WIZARD_NAME.test(name)) {
|
|
90
|
-
findings.add("SPK_WIZARD_UNKNOWN", `${path}
|
|
90
|
+
findings.add("SPK_WIZARD_UNKNOWN", `${path} is not install/upgrade/uninstall_uifile[_<lang>][.sh]; DSM does not read it.`, path);
|
|
91
91
|
}
|
|
92
92
|
else if (name.endsWith(".sh")) {
|
|
93
93
|
if (!executable(modes.get(path)))
|
|
94
|
-
findings.add("SPK_WIZARD_SCRIPT_NOT_EXECUTABLE", `${path}
|
|
94
|
+
findings.add("SPK_WIZARD_SCRIPT_NOT_EXECUTABLE", `${path} is not executable; DSM runs it to generate the wizard.`, path);
|
|
95
95
|
}
|
|
96
96
|
else if (!Array.isArray(parseJson(files.get(path)))) {
|
|
97
|
-
findings.add("SPK_WIZARD_INVALID", `${path}
|
|
97
|
+
findings.add("SPK_WIZARD_INVALID", `${path} is not a JSON array (each element is a wizard step).`, path);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
}
|
|
@@ -105,9 +105,9 @@ export function checkMetadata(outer, findings) {
|
|
|
105
105
|
checkWizards(outer, findings);
|
|
106
106
|
const license = outer.files.get("LICENSE");
|
|
107
107
|
if (license && license.length >= MAX_LICENSE_BYTES)
|
|
108
|
-
findings.add("SPK_LICENSE_TOO_LARGE", `LICENSE
|
|
108
|
+
findings.add("SPK_LICENSE_TOO_LARGE", `LICENSE is ${license.length} bytes; the guide requires less than 1 MB.`, "LICENSE");
|
|
109
109
|
const signature = outer.files.get("syno_signature.asc");
|
|
110
110
|
if (signature && !signature.toString("latin1").includes("-----BEGIN PGP SIGNATURE-----")) {
|
|
111
|
-
findings.add("SPK_SIGNATURE_INVALID", "syno_signature.asc
|
|
111
|
+
findings.add("SPK_SIGNATURE_INVALID", "syno_signature.asc is not an ASCII-armored PGP signature.", "syno_signature.asc");
|
|
112
112
|
}
|
|
113
113
|
}
|