@synopackageland/cli 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attestation/app-package.js +8 -1
- package/dist/attestation/platform-service.d.ts +70 -0
- package/dist/attestation/platform-service.js +250 -0
- package/dist/bin.js +0 -0
- package/dist/build-spk/generator.js +202 -10
- package/dist/build-spk/resource-worker.d.ts +14 -0
- package/dist/build-spk/resource-worker.js +140 -0
- package/dist/cli.js +69 -10
- package/dist/compose/analyze.d.ts +41 -1
- package/dist/compose/analyze.js +458 -18
- package/dist/contract/load.d.ts +1 -0
- package/dist/contract/load.js +1 -1
- package/dist/contract/posix-all-shares.d.ts +16 -0
- package/dist/contract/posix-all-shares.js +63 -0
- package/dist/contract/schema-validate.js +26 -1
- package/dist/contract/validate.js +57 -0
- package/dist/contract/web-launch.d.ts +6 -0
- package/dist/contract/web-launch.js +25 -0
- package/dist/deploy/deploy.d.ts +1 -0
- package/dist/deploy/deploy.js +37 -2
- package/dist/deploy/homes-values.d.ts +19 -0
- package/dist/deploy/homes-values.js +29 -0
- package/dist/deploy/param-values.d.ts +21 -0
- package/dist/deploy/param-values.js +51 -0
- package/dist/deploy/spk-version.d.ts +5 -0
- package/dist/deploy/spk-version.js +50 -18
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +594 -0
- package/dist/error-collector.d.ts +2 -0
- package/dist/error-collector.js +46 -0
- package/dist/error-registry.d.ts +7 -0
- package/dist/error-registry.js +827 -0
- package/dist/host/broker-upgrade-preflight.d.ts +61 -0
- package/dist/host/broker-upgrade-preflight.js +156 -0
- package/dist/host/catalog-install.d.ts +12 -0
- package/dist/host/catalog-install.js +83 -17
- package/dist/host/credentials.js +35 -5
- package/dist/host/dsm-client.d.ts +12 -0
- package/dist/host/dsm-client.js +56 -1
- package/dist/host/dsm-compound.d.ts +37 -0
- package/dist/host/dsm-compound.js +120 -0
- package/dist/host/fake-nas.d.ts +1 -0
- package/dist/host/fake-nas.js +3 -0
- package/dist/host/types.d.ts +4 -0
- package/dist/publish/curated-release.js +3 -3
- package/dist/publish/platform-service-release.d.ts +60 -0
- package/dist/publish/platform-service-release.js +333 -0
- package/dist/publish/preflight.js +14 -1
- package/dist/release-bin.js +5 -1
- package/dist/templates/hello-files/compose.yaml +2 -0
- package/dist/templates/hello-web/compose.yaml +14 -3
- package/dist/templates/hello-web/synology-app.yaml +1 -1
- package/dist/templates/hello-web-page/compose.yaml +14 -3
- package/dist/templates/hello-web-page/synology-app.yaml +1 -1
- package/dist/test-host.d.ts +2 -0
- package/dist/test-host.js +15 -2
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +14 -3
- package/package.json +2 -2
- package/schema/doctor.schema.json +38 -0
- package/schema/synology-app.schema.json +1 -1
- package/skills/reference/create.md +19 -0
- package/skills/reference/diagnose.md +35 -2
- package/skills/reference/files.md +10 -0
- package/templates/hello-files/compose.yaml +2 -0
- package/templates/hello-web/compose.yaml +14 -3
- package/templates/hello-web/synology-app.yaml +1 -1
- package/templates/hello-web-page/compose.yaml +14 -3
- package/templates/hello-web-page/synology-app.yaml +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { composeSpan, makeError } from "../errors.js";
|
|
2
|
+
/** Install type for a deliberate, install-time snapshot of every DSM Share. */
|
|
3
|
+
export const POSIX_ALL_SHARES_TYPE = "posix_all_shares";
|
|
4
|
+
/** Reserved names keep an undeclared all-shares marker diagnosable. */
|
|
5
|
+
const RESERVED_MARKER_NAMES = new Set(["ALL_SHARES", "POSIX_ALL_SHARES"]);
|
|
6
|
+
const INTERPOLATED_SOURCE = /^\$\{([A-Z0-9_]+)\}(.*)$/;
|
|
7
|
+
export function isPosixAllSharesType(type) {
|
|
8
|
+
return type === POSIX_ALL_SHARES_TYPE;
|
|
9
|
+
}
|
|
10
|
+
export function collectPosixAllSharesParameters(installParams) {
|
|
11
|
+
return new Set(installParams.filter(({ param }) => isPosixAllSharesType(param.type)).map(({ name }) => name));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Validate the extra semantics of the all-shares marker without changing the
|
|
15
|
+
* shared host-bind analyzer. The latter is intentionally left as the single
|
|
16
|
+
* authority for absolute paths, suffix syntax, and named volumes.
|
|
17
|
+
*/
|
|
18
|
+
export function validatePosixAllSharesSources(compose, composeFile, declaredParameters) {
|
|
19
|
+
const errors = [];
|
|
20
|
+
if (!compose.services || typeof compose.services !== "object") {
|
|
21
|
+
return errors;
|
|
22
|
+
}
|
|
23
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
24
|
+
const entries = Array.isArray(service?.volumes)
|
|
25
|
+
? service.volumes
|
|
26
|
+
: service?.volumes === undefined
|
|
27
|
+
? []
|
|
28
|
+
: [service.volumes];
|
|
29
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
30
|
+
const source = volumeSource(entries[index]);
|
|
31
|
+
if (!source) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const match = source.match(INTERPOLATED_SOURCE);
|
|
35
|
+
if (!match) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const [, name, suffix] = match;
|
|
39
|
+
const path = `services.${serviceName}.volumes.${index}`;
|
|
40
|
+
if (declaredParameters.has(name)) {
|
|
41
|
+
if (suffix !== "") {
|
|
42
|
+
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN", composeSpan(composeFile, 1, 1, path), `POSIX all-shares 參數「${name}」只能作為完整 volume source,不能附加子路徑。`, `請將 ${source} 改成 \${${name}},並保留 Compose 的 container target;Share 名稱由 Broker 在安裝時展開。`));
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (RESERVED_MARKER_NAMES.has(name)) {
|
|
47
|
+
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_UNDECLARED", composeSpan(composeFile, 1, 1, path), `Compose 使用了未宣告的 POSIX all-shares marker「${name}」。`, `請在 synology-app.yaml 宣告 install.${name}.type: ${POSIX_ALL_SHARES_TYPE},並讓 Broker 以 DSM API 列出安裝當下的 Share。`));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
function volumeSource(entry) {
|
|
54
|
+
if (typeof entry === "string") {
|
|
55
|
+
const colon = entry.indexOf(":");
|
|
56
|
+
return colon > 0 ? entry.slice(0, colon).trim() : undefined;
|
|
57
|
+
}
|
|
58
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const source = entry.source;
|
|
62
|
+
return typeof source === "string" && source.trim() ? source.trim() : undefined;
|
|
63
|
+
}
|
|
@@ -30,7 +30,7 @@ function mapSchemaError(error, contractFile, parsed) {
|
|
|
30
30
|
}
|
|
31
31
|
export function validateContractSchema(parsed, contractFile) {
|
|
32
32
|
const validate = getValidator();
|
|
33
|
-
const data = parsed.doc.toJSON();
|
|
33
|
+
const data = schemaCompatibleContract(parsed.doc.toJSON());
|
|
34
34
|
const valid = validate(data);
|
|
35
35
|
if (valid) {
|
|
36
36
|
return [];
|
|
@@ -48,3 +48,28 @@ export function validateContractSchema(parsed, contractFile) {
|
|
|
48
48
|
}
|
|
49
49
|
return result;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Keep the published v1 schema's historical enum stable for older tooling,
|
|
53
|
+
* while allowing the P4 contract extension to be validated by the same v1
|
|
54
|
+
* object shape. The semantic validator and Broker retain the raw type.
|
|
55
|
+
*/
|
|
56
|
+
function schemaCompatibleContract(value) {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
const contract = value;
|
|
61
|
+
const install = contract.install;
|
|
62
|
+
if (!install || typeof install !== "object" || Array.isArray(install)) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
for (const parameter of Object.values(install)) {
|
|
66
|
+
if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const record = parameter;
|
|
70
|
+
if (record.type === "posix_all_shares") {
|
|
71
|
+
record.type = "homes";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
1
3
|
import { contractSpan, makeError } from "../errors.js";
|
|
2
4
|
import { collectInstallParameters, getValuePosition, stringViolations, } from "./load.js";
|
|
3
5
|
import { validateContractSchema } from "./schema-validate.js";
|
|
4
6
|
import { isWebLaunchMode, readWebIntegration } from "./web-launch.js";
|
|
7
|
+
import { loadCompose } from "../compose/analyze.js";
|
|
8
|
+
import { collectPosixAllSharesParameters, isPosixAllSharesType, validatePosixAllSharesSources, } from "./posix-all-shares.js";
|
|
5
9
|
/** DSM 7.4 Package Center rejects a longer package identity outright. */
|
|
6
10
|
const DSM_PACKAGE_IDENTITY_MAX = 32;
|
|
7
11
|
const METADATA_STRING_FIELDS = [
|
|
@@ -38,9 +42,36 @@ export function validateContract(parsed, contractFile) {
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
errors.push(...validateInstallParameters(parsed, contractFile));
|
|
45
|
+
errors.push(...validatePosixAllShares(parsed, contractFile));
|
|
41
46
|
errors.push(...validateWebLaunch(parsed, contractFile));
|
|
47
|
+
// The P4 worker cannot edit the shared host-bind analyzer. Its existing
|
|
48
|
+
// platform-resolved `homes` slot has the same analyzer semantics (declared
|
|
49
|
+
// interpolation is an allowed source and must be used). This normalization
|
|
50
|
+
// is local to the validation object; Broker and SPK generation read the raw
|
|
51
|
+
// YAML again and retain the actual posix_all_shares type.
|
|
52
|
+
for (const parameter of Object.values(contract.install ?? {})) {
|
|
53
|
+
if (parameter && isPosixAllSharesType(parameter.type)) {
|
|
54
|
+
parameter.type = "homes";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
42
57
|
return errors;
|
|
43
58
|
}
|
|
59
|
+
function validatePosixAllShares(parsed, contractFile) {
|
|
60
|
+
const contractPath = parsed.contractPath;
|
|
61
|
+
if (!contractPath) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const composePath = join(dirname(contractPath), "compose.yaml");
|
|
65
|
+
if (!existsSync(composePath)) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const parsedCompose = loadCompose(composePath);
|
|
69
|
+
if (!parsedCompose) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const parameters = collectPosixAllSharesParameters(collectInstallParameters(parsed.contract));
|
|
73
|
+
return validatePosixAllSharesSources(parsedCompose.compose, contractFile, parameters);
|
|
74
|
+
}
|
|
44
75
|
function validateWebLaunch(parsed, contractFile) {
|
|
45
76
|
const errors = [];
|
|
46
77
|
const { contract, doc } = parsed;
|
|
@@ -85,6 +116,32 @@ function validateInstallParameters(parsed, contractFile) {
|
|
|
85
116
|
errors.push(makeError("INSTALL_STRING_INVALID", contractSpan(contractFile, pos.line, pos.column, `${basePath}.default`), `Install Parameter「${name}」的字串值不合法。`, "請移除控制字元與 ${ 插值語法。"));
|
|
86
117
|
}
|
|
87
118
|
}
|
|
119
|
+
if (type === "homes") {
|
|
120
|
+
const configuredPath = Object.entries(param).find(([key, value]) => key !== "type" &&
|
|
121
|
+
(key === "default" || (typeof value === "string" && isPhysicalHostPath(value))));
|
|
122
|
+
if (!configuredPath) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const [field] = configuredPath;
|
|
126
|
+
const pos = getValuePosition(doc, ["install", name, field]) ??
|
|
127
|
+
getValuePosition(doc, ["install", name]) ??
|
|
128
|
+
{ line: 1, column: 1 };
|
|
129
|
+
errors.push(makeError("CONTRACT_HOMES_PATH_FORBIDDEN", contractSpan(contractFile, pos.line, pos.column, `${basePath}.${field}`), `homes Install Parameter「${name}」不得在契約欄位「${field}」指定 NAS 實體路徑或預設值。`, `請保留 install.${name}.type: homes,Compose 只引用 \${${name}};由 glue/Broker 在安裝時依這台 NAS 的 User Home 設定解析路徑。`));
|
|
130
|
+
}
|
|
131
|
+
if (isPosixAllSharesType(type)) {
|
|
132
|
+
const configuredValue = Object.entries(param).find(([key, value]) => key !== "type" && (key === "default" || value !== undefined));
|
|
133
|
+
if (configuredValue) {
|
|
134
|
+
const [field] = configuredValue;
|
|
135
|
+
const pos = getValuePosition(doc, ["install", name, field]) ??
|
|
136
|
+
getValuePosition(doc, ["install", name]) ??
|
|
137
|
+
{ line: 1, column: 1 };
|
|
138
|
+
errors.push(makeError("CONTRACT_POSIX_ALL_SHARES_VALUE_FORBIDDEN", contractSpan(contractFile, pos.line, pos.column, `${basePath}.${field}`), `POSIX all-shares Install Parameter「${name}」不得指定 default 或 NAS 實體路徑;Share 清單只在安裝時由 Broker 透過 DSM API snapshot。`, `請只保留 install.${name}.type: posix_all_shares,Compose 使用 \${${name}} 作為完整 volume source。`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
88
141
|
}
|
|
89
142
|
return errors;
|
|
90
143
|
}
|
|
144
|
+
function isPhysicalHostPath(value) {
|
|
145
|
+
const trimmed = value.trim();
|
|
146
|
+
return trimmed.startsWith("/") || /(?:^|\/)volume(?:USB|SATA)?\d+/i.test(trimmed);
|
|
147
|
+
}
|
|
@@ -13,6 +13,12 @@ export declare function isWebLaunchMode(value: unknown): value is WebLaunchMode;
|
|
|
13
13
|
export declare function resolvedWebLaunch(contract: AppContract): WebLaunchMode | undefined;
|
|
14
14
|
export declare function webStationAlias(packageId: string): string;
|
|
15
15
|
export declare function trustedAppEntryPath(contract: AppContract): string;
|
|
16
|
+
export declare const DSM_RESERVED_COOKIES: readonly ["id", "did"];
|
|
17
|
+
export declare function extractContractCookieNames(contract: AppContract): string[];
|
|
18
|
+
export declare function cookieStripHeaders(contract: AppContract): Array<{
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
}>;
|
|
16
22
|
export declare function webStationServiceName(alias: string): string;
|
|
17
23
|
export declare function webStationServiceRoot(alias: string): string;
|
|
18
24
|
export declare function renderWebStationResource(contract: AppContract): string;
|
|
@@ -46,6 +46,31 @@ export function trustedAppEntryPath(contract) {
|
|
|
46
46
|
}
|
|
47
47
|
return `/${alias}${extra}`;
|
|
48
48
|
}
|
|
49
|
+
export const DSM_RESERVED_COOKIES = ["id", "did"];
|
|
50
|
+
export function extractContractCookieNames(contract) {
|
|
51
|
+
const web = readWebIntegration(contract);
|
|
52
|
+
if (!web || !Array.isArray(web.cookies)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const reserved = new Set(DSM_RESERVED_COOKIES);
|
|
56
|
+
const result = [];
|
|
57
|
+
for (const item of web.cookies) {
|
|
58
|
+
if (typeof item === "string") {
|
|
59
|
+
const name = item.trim();
|
|
60
|
+
if (name && !reserved.has(name) && !result.includes(name)) {
|
|
61
|
+
result.push(name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
export function cookieStripHeaders(contract) {
|
|
68
|
+
const names = extractContractCookieNames(contract);
|
|
69
|
+
const value = names.length > 0
|
|
70
|
+
? names.map((name) => `${name}=$cookie_${name}`).join("; ")
|
|
71
|
+
: "_synopkgland=";
|
|
72
|
+
return [{ name: "Cookie", value }];
|
|
73
|
+
}
|
|
49
74
|
export function webStationServiceName(alias) {
|
|
50
75
|
return `PROXY-${alias}`;
|
|
51
76
|
}
|
package/dist/deploy/deploy.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface DeployProjectOptions {
|
|
|
14
14
|
catalogPublish?: (spkPath: string) => Promise<PublishSpkResult>;
|
|
15
15
|
shareValues?: Record<string, string>;
|
|
16
16
|
runAsValues?: Record<string, string>;
|
|
17
|
+
paramValues?: Record<string, string>;
|
|
17
18
|
}
|
|
18
19
|
export interface DeployProjectResult {
|
|
19
20
|
ok: boolean;
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -9,6 +9,8 @@ import { resolveDeployIdentity } from "./identity.js";
|
|
|
9
9
|
import { resolveDeploySpkVersion } from "./spk-version.js";
|
|
10
10
|
import { resolveSharedFolderExtraValues, sharedFolderParameterNames } from "./share-values.js";
|
|
11
11
|
import { resolveDsmUserExtraValues, dsmUserParameterNames } from "./run-as-values.js";
|
|
12
|
+
import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
|
|
13
|
+
import { resolveInstallParameterExtraValues } from "./param-values.js";
|
|
12
14
|
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
13
15
|
function brokerCapabilitiesFromContract(project) {
|
|
14
16
|
const contract = loadContract(project.contractPath).contract;
|
|
@@ -26,6 +28,20 @@ export async function deployProject(options) {
|
|
|
26
28
|
}
|
|
27
29
|
const identity = resolveDeployIdentity(options.project, { dev: options.dev });
|
|
28
30
|
const contract = loadContract(options.project.contractPath).contract;
|
|
31
|
+
const paramResolved = resolveInstallParameterExtraValues({
|
|
32
|
+
contract,
|
|
33
|
+
provided: options.paramValues,
|
|
34
|
+
});
|
|
35
|
+
if (!paramResolved.ok) {
|
|
36
|
+
return { ok: false, code: paramResolved.code, message: paramResolved.message };
|
|
37
|
+
}
|
|
38
|
+
const homesResolved = await resolveHomesExtraValues({
|
|
39
|
+
requiredKeys: homesParameterNames(contract),
|
|
40
|
+
lookup: async () => options.host.resolveHomesPath?.(),
|
|
41
|
+
});
|
|
42
|
+
if (!homesResolved.ok) {
|
|
43
|
+
return { ok: false, code: homesResolved.code, message: homesResolved.message };
|
|
44
|
+
}
|
|
29
45
|
const shareResolved = await resolveSharedFolderExtraValues({
|
|
30
46
|
requiredKeys: sharedFolderParameterNames(contract),
|
|
31
47
|
provided: options.shareValues ?? {},
|
|
@@ -43,7 +59,12 @@ export async function deployProject(options) {
|
|
|
43
59
|
if (!runAsResolved.ok) {
|
|
44
60
|
return { ok: false, code: runAsResolved.code, message: runAsResolved.message };
|
|
45
61
|
}
|
|
46
|
-
const extraValues = {
|
|
62
|
+
const extraValues = {
|
|
63
|
+
...paramResolved.extraValues,
|
|
64
|
+
...homesResolved.extraValues,
|
|
65
|
+
...shareResolved.extraValues,
|
|
66
|
+
...runAsResolved.extraValues,
|
|
67
|
+
};
|
|
47
68
|
const contractDigest = digestProjectContract(options.project);
|
|
48
69
|
const bundleDigest = digestProjectBundle(options.project);
|
|
49
70
|
try {
|
|
@@ -68,7 +89,21 @@ export async function deployProject(options) {
|
|
|
68
89
|
};
|
|
69
90
|
}
|
|
70
91
|
const resolveSpkVersion = options.spkVersionResolver ?? resolveDeploySpkVersion;
|
|
71
|
-
|
|
92
|
+
let spkVersion;
|
|
93
|
+
try {
|
|
94
|
+
spkVersion = await resolveSpkVersion(options.project, options.host, identity.packageId);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const code = error.code;
|
|
98
|
+
if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" || code === "PERSONAL_BUILD_EXHAUSTED") {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
code: "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
|
|
102
|
+
message: error instanceof Error ? error.message : String(error),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
72
107
|
let archFamily = options.archFamily;
|
|
73
108
|
if (archFamily === undefined && options.project.nativePath && existsSync(options.project.nativePath)) {
|
|
74
109
|
archFamily = await options.host.getArchFamily?.();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AppContract } from "../types.js";
|
|
2
|
+
export declare const HOMES_NOT_ENABLED = "HOMES_NOT_ENABLED";
|
|
3
|
+
export declare function homesParameterNames(contract: AppContract): string[];
|
|
4
|
+
export type HomesResolveResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
extraValues: Record<string, string>;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
code: typeof HOMES_NOT_ENABLED;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the platform's user-home root at deploy time. The contract only
|
|
14
|
+
* names a logical homes slot; the host adapter owns the NAS-specific lookup.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveHomesExtraValues(input: {
|
|
17
|
+
requiredKeys: string[];
|
|
18
|
+
lookup: () => Promise<string | undefined>;
|
|
19
|
+
}): Promise<HomesResolveResult>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { collectInstallParameters } from "../contract/load.js";
|
|
2
|
+
export const HOMES_NOT_ENABLED = "HOMES_NOT_ENABLED";
|
|
3
|
+
export function homesParameterNames(contract) {
|
|
4
|
+
return collectInstallParameters(contract)
|
|
5
|
+
.filter((item) => item.param.type === "homes")
|
|
6
|
+
.map((item) => item.name);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the platform's user-home root at deploy time. The contract only
|
|
10
|
+
* names a logical homes slot; the host adapter owns the NAS-specific lookup.
|
|
11
|
+
*/
|
|
12
|
+
export async function resolveHomesExtraValues(input) {
|
|
13
|
+
if (input.requiredKeys.length === 0) {
|
|
14
|
+
return { ok: true, extraValues: {} };
|
|
15
|
+
}
|
|
16
|
+
const resolved = await input.lookup();
|
|
17
|
+
const path = resolved?.trim();
|
|
18
|
+
if (!path || !path.startsWith("/")) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
code: "HOMES_NOT_ENABLED",
|
|
22
|
+
message: "這台 NAS 未啟用 DSM User Home,或 Broker 無法解析 user home 路徑;無法安裝需要 homes 的 App。請在 DSM 啟用 User Home 後重試。",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
extraValues: Object.fromEntries(input.requiredKeys.map((key) => [key, path])),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AppContract } from "../types.js";
|
|
2
|
+
export type InstallParameterResolveResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
extraValues: Record<string, string>;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
code: string;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Validate the local, non-secret values accepted by `deploy --param`.
|
|
12
|
+
*
|
|
13
|
+
* DSM's Installation/install `extra_values` is a string map. The classic
|
|
14
|
+
* wizard's boolean `multiselect` therefore crosses that boundary as the
|
|
15
|
+
* canonical text values `true` or `false`, which is also the representation
|
|
16
|
+
* consumed by the package-only materializer when it writes `.env`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveInstallParameterExtraValues(input: {
|
|
19
|
+
contract: AppContract;
|
|
20
|
+
provided?: Record<string, string>;
|
|
21
|
+
}): InstallParameterResolveResult;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { collectInstallParameters, stringViolations } from "../contract/load.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate the local, non-secret values accepted by `deploy --param`.
|
|
4
|
+
*
|
|
5
|
+
* DSM's Installation/install `extra_values` is a string map. The classic
|
|
6
|
+
* wizard's boolean `multiselect` therefore crosses that boundary as the
|
|
7
|
+
* canonical text values `true` or `false`, which is also the representation
|
|
8
|
+
* consumed by the package-only materializer when it writes `.env`.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveInstallParameterExtraValues(input) {
|
|
11
|
+
const declared = new Map(collectInstallParameters(input.contract).map(({ name, param }) => [name, param]));
|
|
12
|
+
const extraValues = {};
|
|
13
|
+
for (const [name, raw] of Object.entries(input.provided ?? {})) {
|
|
14
|
+
const param = declared.get(name);
|
|
15
|
+
if (!param) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
code: "DEPLOY_PARAM_UNDECLARED",
|
|
19
|
+
message: `--param 提供了未宣告的 Install Parameter「${name}」。只接受 synology-app.yaml install 中宣告的 string/boolean 名稱。`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (param.type === "boolean") {
|
|
23
|
+
if (raw !== "true" && raw !== "false") {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
code: "DEPLOY_PARAM_TYPE_INVALID",
|
|
27
|
+
message: `--param ${name}=... 必須是 boolean 的 true 或 false;DSM multiselect 會以這兩個字串交給 Installation/install。`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
extraValues[name] = raw;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (param.type !== "string") {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
code: "DEPLOY_PARAM_TYPE_INVALID",
|
|
37
|
+
message: `--param ${name}=... 對應的契約型別是 ${String(param.type ?? "未指定")};deploy --param 只接受 string/boolean。`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const violations = stringViolations(raw);
|
|
41
|
+
if (raw.length === 0 || violations.hasControlChars || violations.hasInterpolation || violations.tooLong) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
code: "DEPLOY_PARAM_TYPE_INVALID",
|
|
45
|
+
message: `--param ${name}=... 不符合 string Install Parameter 約束(不可空白、控制字元、\${ 插值或超過 256 字元)。`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
extraValues[name] = raw;
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, extraValues };
|
|
51
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { ProjectRoot } from "../discovery.js";
|
|
2
2
|
import type { NasHost } from "../host/types.js";
|
|
3
|
+
export declare class DeployPersonalBuildExhaustedError extends Error {
|
|
4
|
+
readonly code = "DEPLOY_PERSONAL_BUILD_EXHAUSTED";
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
export declare function bumpBuildNumber(version: string): string;
|
|
3
8
|
export declare function resolveDeploySpkVersion(project: ProjectRoot, host: NasHost, packageId: string): Promise<string>;
|
|
4
9
|
export declare function publishDeploySpk(spkPath: string): Promise<string>;
|
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
import { loadContract } from "../contract/load.js";
|
|
2
|
-
import { comparePackageVersions } from "@synopackageland/package-source/index-manifest";
|
|
3
|
-
import { resolveNextSpkVersion } from "@synopackageland/package-source/next-version";
|
|
2
|
+
import { comparePackageVersions, CURATED_BUILD_OFFSET, PERSONAL_BUILD_MAX, } from "@synopackageland/package-source/index-manifest";
|
|
3
|
+
import { PersonalBuildExhaustedError, resolveNextSpkVersion, } from "@synopackageland/package-source/next-version";
|
|
4
4
|
import { fetchPackageSourceManifest, fetchPackageSourceManifestFromR2, resolvePublishTarget, } from "@synopackageland/package-source/publish-spk";
|
|
5
5
|
import { publishBuiltSpk } from "./catalog-publish.js";
|
|
6
6
|
import { isDevelopmentPackageIdentity } from "./identity-shared.js";
|
|
7
7
|
const LIVE_TEST_FALLBACK_ORIGIN = "https://syno-package-source-throwaway.bidacumen.workers.dev";
|
|
8
|
+
export class DeployPersonalBuildExhaustedError extends Error {
|
|
9
|
+
code = "DEPLOY_PERSONAL_BUILD_EXHAUSTED";
|
|
10
|
+
constructor() {
|
|
11
|
+
super(`Personal Package Build range exhausted at ${PERSONAL_BUILD_MAX}`);
|
|
12
|
+
this.name = "DeployPersonalBuildExhaustedError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
8
15
|
function defaultSpkVersion(contractVersion) {
|
|
9
|
-
|
|
16
|
+
const match = /-(\d+)$/.exec(contractVersion);
|
|
17
|
+
if (!match) {
|
|
18
|
+
return `${contractVersion}-0001`;
|
|
19
|
+
}
|
|
20
|
+
const build = Number(match[1]);
|
|
21
|
+
if (!Number.isSafeInteger(build) || build >= CURATED_BUILD_OFFSET) {
|
|
22
|
+
throw new DeployPersonalBuildExhaustedError();
|
|
23
|
+
}
|
|
24
|
+
return contractVersion;
|
|
10
25
|
}
|
|
11
|
-
function bumpBuildNumber(version) {
|
|
26
|
+
export function bumpBuildNumber(version) {
|
|
12
27
|
const match = /^(.+)-(\d+)$/.exec(version);
|
|
13
28
|
if (!match) {
|
|
14
29
|
return `${version}-0002`;
|
|
15
30
|
}
|
|
16
|
-
const
|
|
31
|
+
const current = Number(match[2]);
|
|
32
|
+
if (!Number.isSafeInteger(current) || current >= CURATED_BUILD_OFFSET) {
|
|
33
|
+
throw new DeployPersonalBuildExhaustedError();
|
|
34
|
+
}
|
|
35
|
+
const nextBuild = current + 1;
|
|
36
|
+
if (nextBuild > PERSONAL_BUILD_MAX) {
|
|
37
|
+
throw new DeployPersonalBuildExhaustedError();
|
|
38
|
+
}
|
|
39
|
+
const next = String(nextBuild).padStart(4, "0");
|
|
17
40
|
return `${match[1]}-${next}`;
|
|
18
41
|
}
|
|
19
42
|
async function resolvePackageSourceManifest(origin) {
|
|
@@ -41,22 +64,31 @@ async function resolveOfflineDeploySpkVersion(project, host, packageId) {
|
|
|
41
64
|
return installed ? bumpBuildNumber(installed) : base;
|
|
42
65
|
}
|
|
43
66
|
export async function resolveDeploySpkVersion(project, host, packageId) {
|
|
44
|
-
|
|
45
|
-
|
|
67
|
+
try {
|
|
68
|
+
if (host.installationId !== "live-nas" || isDevelopmentPackageIdentity(packageId)) {
|
|
69
|
+
return resolveOfflineDeploySpkVersion(project, host, packageId);
|
|
70
|
+
}
|
|
71
|
+
const contract = loadContract(project.contractPath).contract;
|
|
72
|
+
const target = resolvePublishTarget();
|
|
73
|
+
const manifest = (await fetchPackageSourceManifestFromR2(target)) ??
|
|
74
|
+
(await resolvePackageSourceManifest(target.origin));
|
|
75
|
+
let version = resolveNextSpkVersion(String(contract.version ?? "1.0.0"), packageId, manifest);
|
|
76
|
+
const installed = await host.getInstalledPackageVersion?.(packageId);
|
|
77
|
+
if (installed) {
|
|
78
|
+
const installedNext = bumpBuildNumber(installed);
|
|
79
|
+
if (comparePackageVersions(installedNext, version) > 0) {
|
|
80
|
+
version = installedNext;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return version;
|
|
46
84
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let version = resolveNextSpkVersion(String(contract.version ?? "1.0.0"), packageId, manifest);
|
|
52
|
-
const installed = await host.getInstalledPackageVersion?.(packageId);
|
|
53
|
-
if (installed) {
|
|
54
|
-
const installedNext = bumpBuildNumber(installed);
|
|
55
|
-
if (comparePackageVersions(installedNext, version) > 0) {
|
|
56
|
-
version = installedNext;
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof PersonalBuildExhaustedError ||
|
|
87
|
+
error.code === "PERSONAL_BUILD_EXHAUSTED") {
|
|
88
|
+
throw new DeployPersonalBuildExhaustedError();
|
|
57
89
|
}
|
|
90
|
+
throw error;
|
|
58
91
|
}
|
|
59
|
-
return version;
|
|
60
92
|
}
|
|
61
93
|
export async function publishDeploySpk(spkPath) {
|
|
62
94
|
const published = await publishBuiltSpk(spkPath);
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProjectRoot } from "./discovery.js";
|
|
2
|
+
import type { NasHost } from "./host/types.js";
|
|
3
|
+
export type DoctorCheckStatus = "pass" | "fail" | "skip";
|
|
4
|
+
export interface DoctorCheck {
|
|
5
|
+
id: string;
|
|
6
|
+
status: DoctorCheckStatus;
|
|
7
|
+
code?: string;
|
|
8
|
+
message: string;
|
|
9
|
+
suggestion?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DoctorReport {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
checks: DoctorCheck[];
|
|
14
|
+
}
|
|
15
|
+
export interface RunDoctorOptions {
|
|
16
|
+
project?: ProjectRoot | null;
|
|
17
|
+
host?: NasHost | null;
|
|
18
|
+
packageId?: string;
|
|
19
|
+
dev?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function runDoctor(options: RunDoctorOptions): Promise<DoctorReport>;
|
|
22
|
+
export declare function formatDoctorReport(report: DoctorReport): string;
|