@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +144 -5
  2. package/README.md +5 -0
  3. package/dist/add.js +7 -8
  4. package/dist/build-spk/dsm-arch.js +1 -1
  5. package/dist/build-spk/dsm-icons.d.ts +8 -1
  6. package/dist/build-spk/dsm-icons.js +26 -12
  7. package/dist/build-spk/dsm-version.d.ts +1 -1
  8. package/dist/build-spk/dsm-version.js +4 -4
  9. package/dist/build-spk/gateway.js +2 -2
  10. package/dist/build-spk/generator.js +1 -1
  11. package/dist/build-spk/png.js +16 -16
  12. package/dist/capabilities.js +1 -1
  13. package/dist/capability-overlays.d.ts +16 -5
  14. package/dist/capability-overlays.js +30 -16
  15. package/dist/cli.js +107 -107
  16. package/dist/compose/analyze.js +23 -23
  17. package/dist/compose/privilege.js +12 -12
  18. package/dist/contract/i18n.js +3 -3
  19. package/dist/contract/icon.js +2 -2
  20. package/dist/contract/posix-all-shares.js +2 -2
  21. package/dist/contract/schema-validate.js +3 -3
  22. package/dist/contract/validate.js +16 -16
  23. package/dist/contract/web-launch.d.ts +2 -1
  24. package/dist/contract/web-launch.js +3 -2
  25. package/dist/deploy/deploy.js +8 -8
  26. package/dist/deploy/homes-values.js +1 -1
  27. package/dist/deploy/identity-shared.js +1 -1
  28. package/dist/deploy/native-install-values.js +1 -1
  29. package/dist/deploy/param-values.js +4 -4
  30. package/dist/deploy/plan.d.ts +1 -1
  31. package/dist/deploy/port-preflight.js +3 -3
  32. package/dist/deploy/run-as-values.js +2 -2
  33. package/dist/deploy/share-permission-preflight.js +6 -6
  34. package/dist/deploy/share-values.js +3 -3
  35. package/dist/error-registry.js +369 -346
  36. package/dist/errors.js +1 -1
  37. package/dist/host/dsm-port-profile.d.ts +1 -1
  38. package/dist/host/dsm-port-profile.js +1 -1
  39. package/dist/info.js +11 -11
  40. package/dist/init.d.ts +2 -0
  41. package/dist/init.js +85 -38
  42. package/dist/inspect.js +5 -5
  43. package/dist/portal/alias.d.ts +1 -1
  44. package/dist/portal/alias.js +4 -4
  45. package/dist/runtime/executor-build.js +12 -1
  46. package/dist/runtime/executor-digests.json +8 -85
  47. package/dist/runtime/executor-inspect.js +11 -4
  48. package/dist/runtime/executor-policy.d.ts +9 -2
  49. package/dist/runtime/executor-policy.js +27 -8
  50. package/dist/runtime/root-programs.d.ts +47 -0
  51. package/dist/runtime/root-programs.js +153 -0
  52. package/dist/runtime/sdk-source.d.ts +5 -4
  53. package/dist/runtime/sdk-source.js +8 -4
  54. package/dist/skill.js +14 -14
  55. package/dist/spk/check-info.js +17 -17
  56. package/dist/spk/check-metadata.js +18 -18
  57. package/dist/spk/check-payload.js +10 -10
  58. package/dist/spk/rules.d.ts +57 -57
  59. package/dist/spk/rules.js +50 -50
  60. package/dist/spk/validate-spk.js +5 -5
  61. package/dist/standalone/api-permissions.js +1 -1
  62. package/dist/standalone/background.d.ts +3 -1
  63. package/dist/standalone/background.js +8 -4
  64. package/dist/standalone/binary.js +7 -7
  65. package/dist/standalone/bridge-app.d.ts +23 -0
  66. package/dist/standalone/bridge-app.js +141 -0
  67. package/dist/standalone/bridge-backend.d.ts +40 -0
  68. package/dist/standalone/bridge-backend.js +95 -0
  69. package/dist/standalone/bridge-build.d.ts +21 -0
  70. package/dist/standalone/bridge-build.js +62 -0
  71. package/dist/standalone/bridge-json.d.ts +16 -0
  72. package/dist/standalone/bridge-json.js +59 -0
  73. package/dist/standalone/bridge-runtime.d.ts +16 -0
  74. package/dist/standalone/bridge-runtime.js +46 -0
  75. package/dist/standalone/bridge-rust.d.ts +16 -0
  76. package/dist/standalone/bridge-rust.js +89 -0
  77. package/dist/standalone/build.js +18 -14
  78. package/dist/standalone/daemon.js +6 -6
  79. package/dist/standalone/dsm-window.d.ts +10 -5
  80. package/dist/standalone/dsm-window.js +64 -16
  81. package/dist/standalone/i18n.js +18 -18
  82. package/dist/standalone/inspect-bridge.d.ts +31 -0
  83. package/dist/standalone/inspect-bridge.js +111 -0
  84. package/dist/standalone/inspect-spk.d.ts +1 -1
  85. package/dist/standalone/inspect-spk.js +77 -69
  86. package/dist/standalone/lifecycle.js +22 -3
  87. package/dist/standalone/manifest.d.ts +17 -9
  88. package/dist/standalone/manifest.js +143 -65
  89. package/dist/standalone/portal-window.js +14 -7
  90. package/dist/standalone/request-timeout.d.ts +9 -0
  91. package/dist/standalone/request-timeout.js +13 -0
  92. package/dist/standalone/root-ops.d.ts +23 -2
  93. package/dist/standalone/root-ops.js +118 -13
  94. package/dist/standalone/sdk-check.js +4 -4
  95. package/dist/standalone/shared-folder.js +7 -7
  96. package/dist/standalone/spk.d.ts +2 -0
  97. package/dist/standalone/spk.js +30 -11
  98. package/dist/standalone/uninstall-hook.js +22 -4
  99. package/dist/standalone/uninstall.js +7 -7
  100. package/dist/standalone/window-size.d.ts +17 -0
  101. package/dist/standalone/window-size.js +31 -0
  102. package/dist/types.d.ts +1 -1
  103. package/dist/validate.js +13 -13
  104. package/package.json +2 -2
  105. package/schema/bridge-manifest.v1.schema.json +86 -0
  106. package/skills/reference/create.md +76 -2
  107. package/skills/reference/ui-json.md +2 -1
  108. package/skills/reference/ui-vue.md +9 -3
  109. package/skills/reference/wrap.md +1 -1
  110. package/dist/standalone/rust-build.d.ts +0 -35
  111. 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] = sha256(op.body);
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 } : {}), root };
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, ...(p.operations.includes('root.run') ? { rootOps: true } : {}) } }).operations;
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 SDK crate, a git dependency on a release tag of the SDK repository. */
5
- export declare const SDK_CRATE = "syno-pkg-land-sdk";
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 SDK crate, a git dependency on a release tag of the SDK repository. */
9
- export const SDK_CRATE = 'syno-pkg-land-sdk';
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 backend/Cargo.toml and commit Cargo.lock.`,
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] backend/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}.`);
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: "已有 Compose 或要補契約" },
8
- { id: "identity", file: "reference/identity.md", when: "需要 DSM 登入者" },
9
- { id: "files", file: "reference/files.md", when: "需要 NAS 檔案" },
10
- { id: "portal", file: "reference/portal.md", when: "需要 DSM Application Portal 別名連結" },
11
- { id: "ui-vue", file: "reference/ui-vue.md", when: "編寫/檢查 Vue UI" },
12
- { id: "ui-json", file: "reference/ui-json.md", when: "產生/檢查 JSON UI" },
13
- { id: "deploy", file: "reference/deploy.md", when: "要放到 NAS" },
14
- { id: "diagnose", file: "reference/diagnose.md", when: "check/deploy/Open 失敗" },
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("找不到 Skill Pack。請重新安裝 @synopackageland/cli。");
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(`未知 skill:${id}`);
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 檔案不存在:${entry.file}`);
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:${root}`, ""];
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
  }
@@ -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 第 ${index + 1} 行不是 key="value":${line.slice(0, 60)}`, "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 第 ${index + 1} 行 ${key} 的引號沒有結束。`, "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 重複設定 ${key};只有一個值會生效。`, "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「${version}」只能是以 .、-、_ 分隔的數字(每段 0–2147483647),例如 1.2.3-0001。`, "INFO");
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「${version}」沒有 -<build> 編號;指南格式為 [feature number]-[build number],例如 ${version}-0001。`, "INFO");
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} 含有指南 Appendix A 與已知 DSM 7 平台都沒有的值「${token}」。`, "INFO");
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「${value}」同時有 noarch 與其他值;noarch 已代表所有平台。`, "INFO");
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} 的語言碼「${match[2]}」不在指南的清單(${INFO_LANGUAGES.join("、")})。`, "INFO");
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 缺少必填欄位 ${key}。`, "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 沒有 displayname;Package Center 會顯示 package 值。", "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「${pkg}」不能含有 : / > < | =。`, "INFO");
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}「${value}」不是 <major>.<minor>-<build>(例如 7.0-40000)。`, "INFO");
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「${value}」低於 ${DSM7_MIN_VER};DSM 7 套件應至少為 ${DSM7_MIN_VER}。`, "INFO");
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}「${value}」只能是 "yes" 或 "no"。`, "INFO");
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} 不是指南列出的欄位;DSM 不會使用它。`, "INFO");
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「${adminport}」必須是 0–65536 的整數。`, "INFO");
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「${checksum}」不是 package.tgz 的 MD5(${actual})。`, "INFO");
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", `缺少 ${path};DSM 用它啟動、停止與查詢套件狀態。`, path);
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", `缺少 ${path};指南把它列為必備,沒有事要做時寫 #!/bin/sh 與 exit 0。`, path);
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} 沒有執行權限(mode ${(modes.get(path) ?? 0).toString(8)});打包前 chmod 755。`, 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} 開頭沒有 #!(例如 #!/bin/sh)。`, 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", "缺少 conf/privilege;DSM 7 起每個套件都必須明確宣告執行身分。", path);
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 必須是 JSON 物件,有 defaults.run-as,且每個 run-as 是 "package" 或 "root"。', path);
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 要求以 root 執行;DSM 7 第三方套件應以 package 帳號執行。", path);
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 不是 JSON 物件。", resourcePath);
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 的「${id}」不是指南列出的 resource worker。`, resourcePath);
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} 不是 DSM 讀取的設定檔。`, `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", `缺少 ${name}(${size}×${size} PNG)。`, name);
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} 不是 PNG。`, 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} 是 ${png.readUInt32BE(16)}×${png.readUInt32BE(20)},指南要求 ${size}×${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} 不是 install/upgrade/uninstall_uifile[_<lang>][.sh];DSM 不會讀它。`, 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} 沒有執行權限;DSM 執行它來產生精靈。`, 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} 不是 JSON 陣列(每個元素是一個精靈步驟)。`, 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 有 ${license.length} bytes;指南要求小於 1 MB。`, "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 不是 ASCII-armored PGP 簽章。", "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
  }