@synopackageland/cli 0.3.1 → 0.3.3

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 (69) hide show
  1. package/dist/attestation/app-package.js +8 -1
  2. package/dist/attestation/platform-service.d.ts +70 -0
  3. package/dist/attestation/platform-service.js +250 -0
  4. package/dist/bin.js +0 -0
  5. package/dist/build-spk/generator.js +200 -10
  6. package/dist/build-spk/resource-worker.d.ts +14 -0
  7. package/dist/build-spk/resource-worker.js +140 -0
  8. package/dist/cli.js +69 -10
  9. package/dist/compose/analyze.d.ts +42 -1
  10. package/dist/compose/analyze.js +482 -18
  11. package/dist/contract/load.d.ts +1 -0
  12. package/dist/contract/load.js +1 -1
  13. package/dist/contract/posix-all-shares.d.ts +16 -0
  14. package/dist/contract/posix-all-shares.js +63 -0
  15. package/dist/contract/schema-validate.js +26 -1
  16. package/dist/contract/validate.js +57 -0
  17. package/dist/contract/web-launch.d.ts +6 -0
  18. package/dist/contract/web-launch.js +25 -0
  19. package/dist/deploy/deploy.d.ts +1 -0
  20. package/dist/deploy/deploy.js +94 -2
  21. package/dist/deploy/homes-values.d.ts +19 -0
  22. package/dist/deploy/homes-values.js +29 -0
  23. package/dist/deploy/param-values.d.ts +21 -0
  24. package/dist/deploy/param-values.js +51 -0
  25. package/dist/deploy/spk-version.d.ts +13 -0
  26. package/dist/deploy/spk-version.js +80 -27
  27. package/dist/doctor.d.ts +22 -0
  28. package/dist/doctor.js +594 -0
  29. package/dist/error-collector.d.ts +2 -0
  30. package/dist/error-collector.js +46 -0
  31. package/dist/error-registry.d.ts +7 -0
  32. package/dist/error-registry.js +837 -0
  33. package/dist/host/broker-upgrade-preflight.d.ts +61 -0
  34. package/dist/host/broker-upgrade-preflight.js +156 -0
  35. package/dist/host/catalog-install.d.ts +20 -0
  36. package/dist/host/catalog-install.js +143 -17
  37. package/dist/host/credentials.js +35 -5
  38. package/dist/host/dsm-client.d.ts +12 -0
  39. package/dist/host/dsm-client.js +69 -1
  40. package/dist/host/dsm-compound.d.ts +37 -0
  41. package/dist/host/dsm-compound.js +120 -0
  42. package/dist/host/fake-nas.d.ts +1 -0
  43. package/dist/host/fake-nas.js +3 -0
  44. package/dist/host/types.d.ts +6 -0
  45. package/dist/publish/curated-release.js +8 -4
  46. package/dist/publish/platform-service-release.d.ts +60 -0
  47. package/dist/publish/platform-service-release.js +333 -0
  48. package/dist/publish/preflight.js +24 -2
  49. package/dist/release-bin.js +5 -1
  50. package/dist/templates/hello-files/compose.yaml +2 -0
  51. package/dist/templates/hello-web/compose.yaml +14 -3
  52. package/dist/templates/hello-web/synology-app.yaml +1 -1
  53. package/dist/templates/hello-web-page/compose.yaml +14 -3
  54. package/dist/templates/hello-web-page/synology-app.yaml +1 -1
  55. package/dist/test-host.d.ts +2 -0
  56. package/dist/test-host.js +15 -2
  57. package/dist/types.d.ts +1 -1
  58. package/dist/validate.js +14 -3
  59. package/package.json +2 -2
  60. package/schema/doctor.schema.json +38 -0
  61. package/schema/synology-app.schema.json +1 -1
  62. package/skills/reference/create.md +19 -0
  63. package/skills/reference/diagnose.md +35 -2
  64. package/skills/reference/files.md +10 -0
  65. package/templates/hello-files/compose.yaml +2 -0
  66. package/templates/hello-web/compose.yaml +14 -3
  67. package/templates/hello-web/synology-app.yaml +1 -1
  68. package/templates/hello-web-page/compose.yaml +14 -3
  69. 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
  }
@@ -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;
@@ -9,6 +9,9 @@ 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";
14
+ import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
12
15
  const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
13
16
  function brokerCapabilitiesFromContract(project) {
14
17
  const contract = loadContract(project.contractPath).contract;
@@ -19,13 +22,58 @@ function brokerCapabilitiesFromContract(project) {
19
22
  }
20
23
  return capabilities;
21
24
  }
25
+ function versionBuildIsCurated(version) {
26
+ if (!version) {
27
+ return false;
28
+ }
29
+ const match = /-(\d+)$/.exec(version);
30
+ if (!match) {
31
+ return false;
32
+ }
33
+ const build = Number(match[1]);
34
+ return Number.isSafeInteger(build) && build >= CURATED_BUILD_OFFSET;
35
+ }
36
+ async function detectCuratedInstall(host, packageId) {
37
+ let brokerRecord = false;
38
+ try {
39
+ brokerRecord = (await host.getAttestationInfo(packageId)).attestationKind === "app-package";
40
+ }
41
+ catch {
42
+ // requestAttestation below remains the authoritative error path when the Broker info probe is unavailable.
43
+ }
44
+ return {
45
+ brokerRecord,
46
+ installedVersion: await host.getInstalledPackageVersion?.(packageId),
47
+ };
48
+ }
22
49
  export async function deployProject(options) {
23
50
  const validation = validateProject(options.project);
24
51
  if (!validation.ok) {
25
52
  return { ok: false, code: "VALIDATION_FAILED", message: validation.errors.map((e) => e.code).join(", ") };
26
53
  }
27
54
  const identity = resolveDeployIdentity(options.project, { dev: options.dev });
55
+ const curatedInstall = identity.developmentInstance
56
+ ? undefined
57
+ : await detectCuratedInstall(options.host, identity.packageId);
58
+ const hasCuratedInstall = curatedInstall?.brokerRecord === true || versionBuildIsCurated(curatedInstall?.installedVersion);
59
+ if (hasCuratedInstall && !options.replaceCurated) {
60
+ return { ok: false, code: "CURATED_DEPLOY_REFUSED", message: CURATED_DEPLOY_REFUSED_MESSAGE };
61
+ }
28
62
  const contract = loadContract(options.project.contractPath).contract;
63
+ const paramResolved = resolveInstallParameterExtraValues({
64
+ contract,
65
+ provided: options.paramValues,
66
+ });
67
+ if (!paramResolved.ok) {
68
+ return { ok: false, code: paramResolved.code, message: paramResolved.message };
69
+ }
70
+ const homesResolved = await resolveHomesExtraValues({
71
+ requiredKeys: homesParameterNames(contract),
72
+ lookup: async () => options.host.resolveHomesPath?.(),
73
+ });
74
+ if (!homesResolved.ok) {
75
+ return { ok: false, code: homesResolved.code, message: homesResolved.message };
76
+ }
29
77
  const shareResolved = await resolveSharedFolderExtraValues({
30
78
  requiredKeys: sharedFolderParameterNames(contract),
31
79
  provided: options.shareValues ?? {},
@@ -43,9 +91,37 @@ export async function deployProject(options) {
43
91
  if (!runAsResolved.ok) {
44
92
  return { ok: false, code: runAsResolved.code, message: runAsResolved.message };
45
93
  }
46
- const extraValues = { ...shareResolved.extraValues, ...runAsResolved.extraValues };
94
+ const extraValues = {
95
+ ...paramResolved.extraValues,
96
+ ...homesResolved.extraValues,
97
+ ...shareResolved.extraValues,
98
+ ...runAsResolved.extraValues,
99
+ };
47
100
  const contractDigest = digestProjectContract(options.project);
48
101
  const bundleDigest = digestProjectBundle(options.project);
102
+ if (hasCuratedInstall && options.replaceCurated) {
103
+ const installed = await options.host.isPackageInstalled(identity.packageId);
104
+ if (installed) {
105
+ console.warn("警告:--replace-curated 會移除 Curated 安裝,但會保留 App Data。");
106
+ if (!options.host.uninstallPackage) {
107
+ return {
108
+ ok: false,
109
+ code: "CATALOG_INSTALL_FAILED",
110
+ message: "此 NAS Host 不支援保留 App Data 的 Curated replacement uninstall。",
111
+ };
112
+ }
113
+ try {
114
+ await options.host.uninstallPackage(identity.packageId);
115
+ }
116
+ catch (error) {
117
+ return {
118
+ ok: false,
119
+ code: "CATALOG_INSTALL_FAILED",
120
+ message: error instanceof Error ? error.message : String(error),
121
+ };
122
+ }
123
+ }
124
+ }
49
125
  try {
50
126
  await options.host.requestAttestation({
51
127
  packageIdentity: identity.packageId,
@@ -68,7 +144,23 @@ export async function deployProject(options) {
68
144
  };
69
145
  }
70
146
  const resolveSpkVersion = options.spkVersionResolver ?? resolveDeploySpkVersion;
71
- const spkVersion = await resolveSpkVersion(options.project, options.host, identity.packageId);
147
+ let spkVersion;
148
+ try {
149
+ spkVersion = await resolveSpkVersion(options.project, options.host, identity.packageId);
150
+ }
151
+ catch (error) {
152
+ const code = error.code;
153
+ if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" ||
154
+ code === "PERSONAL_BUILD_EXHAUSTED" ||
155
+ code === "DEPLOY_VERSION_NOT_ASCENDING") {
156
+ return {
157
+ ok: false,
158
+ code: code === "DEPLOY_VERSION_NOT_ASCENDING" ? code : "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
159
+ message: error instanceof Error ? error.message : String(error),
160
+ };
161
+ }
162
+ throw error;
163
+ }
72
164
  let archFamily = options.archFamily;
73
165
  if (archFamily === undefined && options.project.nativePath && existsSync(options.project.nativePath)) {
74
166
  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,17 @@
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 class DeployVersionNotAscendingError extends Error {
8
+ readonly code = "DEPLOY_VERSION_NOT_ASCENDING";
9
+ constructor(contractVersion: string, proposedVersion: string, existingVersion: string);
10
+ }
11
+ export interface DeployVersionCandidates {
12
+ installed?: string;
13
+ feed?: string;
14
+ }
15
+ export declare function resolvePersonalDeploySpkVersion(contractVersion: string, candidates?: DeployVersionCandidates): string;
3
16
  export declare function resolveDeploySpkVersion(project: ProjectRoot, host: NasHost, packageId: string): Promise<string>;
4
17
  export declare function publishDeploySpk(spkPath: string): Promise<string>;
@@ -1,20 +1,71 @@
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";
4
3
  import { fetchPackageSourceManifest, fetchPackageSourceManifestFromR2, resolvePublishTarget, } from "@synopackageland/package-source/publish-spk";
5
4
  import { publishBuiltSpk } from "./catalog-publish.js";
6
5
  import { isDevelopmentPackageIdentity } from "./identity-shared.js";
7
6
  const LIVE_TEST_FALLBACK_ORIGIN = "https://syno-package-source-throwaway.bidacumen.workers.dev";
8
- function defaultSpkVersion(contractVersion) {
9
- return /-\d+$/.test(contractVersion) ? contractVersion : `${contractVersion}-0001`;
7
+ export class DeployPersonalBuildExhaustedError extends Error {
8
+ code = "DEPLOY_PERSONAL_BUILD_EXHAUSTED";
9
+ constructor() {
10
+ super(`Personal Package Build range exhausted at ${PERSONAL_BUILD_MAX}`);
11
+ this.name = "DeployPersonalBuildExhaustedError";
12
+ }
13
+ }
14
+ export class DeployVersionNotAscendingError extends Error {
15
+ code = "DEPLOY_VERSION_NOT_ASCENDING";
16
+ constructor(contractVersion, proposedVersion, existingVersion) {
17
+ super(`Contract version ${contractVersion} would produce ${proposedVersion}, which is not higher than installed package version ${existingVersion}.`);
18
+ this.name = "DeployVersionNotAscendingError";
19
+ }
10
20
  }
11
- function bumpBuildNumber(version) {
21
+ function baseVersion(version) {
12
22
  const match = /^(.+)-(\d+)$/.exec(version);
23
+ return match ? match[1] : version;
24
+ }
25
+ function buildNumber(version) {
26
+ const match = /^.+-(\d+)$/.exec(version);
13
27
  if (!match) {
14
- return `${version}-0002`;
28
+ return undefined;
29
+ }
30
+ const build = Number(match[1]);
31
+ return Number.isSafeInteger(build) ? build : undefined;
32
+ }
33
+ function personalBuildNumber(version) {
34
+ const build = buildNumber(version);
35
+ return build !== undefined && build >= 1 && build <= PERSONAL_BUILD_MAX ? build : undefined;
36
+ }
37
+ export function resolvePersonalDeploySpkVersion(contractVersion, candidates = {}) {
38
+ const base = baseVersion(contractVersion);
39
+ const existingVersions = [candidates.installed, candidates.feed].filter((version) => typeof version === "string" && version.length > 0);
40
+ const latestExisting = existingVersions.sort(comparePackageVersions).at(-1);
41
+ let proposedVersion;
42
+ if (!latestExisting || baseVersion(latestExisting) !== base) {
43
+ proposedVersion = `${base}-0001`;
44
+ }
45
+ else {
46
+ const latestBuild = buildNumber(latestExisting);
47
+ if (latestBuild === undefined || latestBuild >= CURATED_BUILD_OFFSET) {
48
+ throw new DeployPersonalBuildExhaustedError();
49
+ }
50
+ const nextBuild = latestBuild + 1;
51
+ if (nextBuild > PERSONAL_BUILD_MAX) {
52
+ throw new DeployPersonalBuildExhaustedError();
53
+ }
54
+ proposedVersion = `${base}-${String(nextBuild).padStart(4, "0")}`;
15
55
  }
16
- const next = String(Number(match[2]) + 1).padStart(4, "0");
17
- return `${match[1]}-${next}`;
56
+ if (candidates.installed &&
57
+ comparePackageVersions(proposedVersion, candidates.installed) <= 0) {
58
+ throw new DeployVersionNotAscendingError(contractVersion, proposedVersion, candidates.installed);
59
+ }
60
+ return proposedVersion;
61
+ }
62
+ function latestPersonalFeedVersion(packageId, manifest) {
63
+ return (manifest?.packages ?? [])
64
+ .filter((entry) => entry.package === packageId)
65
+ .map((entry) => entry.version)
66
+ .filter((version) => personalBuildNumber(version) !== undefined)
67
+ .sort(comparePackageVersions)
68
+ .at(-1);
18
69
  }
19
70
  async function resolvePackageSourceManifest(origin) {
20
71
  try {
@@ -33,30 +84,32 @@ async function resolvePackageSourceManifest(origin) {
33
84
  }
34
85
  async function resolveOfflineDeploySpkVersion(project, host, packageId) {
35
86
  const contract = loadContract(project.contractPath).contract;
36
- const base = defaultSpkVersion(String(contract.version ?? "1.0.0"));
37
- if (!(await host.isPackageInstalled(packageId))) {
38
- return base;
39
- }
40
- const installed = await host.getInstalledPackageVersion?.(packageId);
41
- return installed ? bumpBuildNumber(installed) : base;
87
+ const contractVersion = String(contract.version ?? "1.0.0");
88
+ const installed = (await host.isPackageInstalled(packageId))
89
+ ? await host.getInstalledPackageVersion?.(packageId)
90
+ : undefined;
91
+ return resolvePersonalDeploySpkVersion(contractVersion, { installed });
42
92
  }
43
93
  export async function resolveDeploySpkVersion(project, host, packageId) {
44
- if (host.installationId !== "live-nas" || isDevelopmentPackageIdentity(packageId)) {
45
- return resolveOfflineDeploySpkVersion(project, host, packageId);
94
+ try {
95
+ if (host.installationId !== "live-nas" || isDevelopmentPackageIdentity(packageId)) {
96
+ return resolveOfflineDeploySpkVersion(project, host, packageId);
97
+ }
98
+ const contract = loadContract(project.contractPath).contract;
99
+ const target = resolvePublishTarget();
100
+ const manifest = (await fetchPackageSourceManifestFromR2(target)) ??
101
+ (await resolvePackageSourceManifest(target.origin));
102
+ const contractVersion = String(contract.version ?? "1.0.0");
103
+ const installed = await host.getInstalledPackageVersion?.(packageId);
104
+ const feed = latestPersonalFeedVersion(packageId, manifest);
105
+ return resolvePersonalDeploySpkVersion(contractVersion, { installed, feed });
46
106
  }
47
- const contract = loadContract(project.contractPath).contract;
48
- const target = resolvePublishTarget();
49
- const manifest = (await fetchPackageSourceManifestFromR2(target)) ??
50
- (await resolvePackageSourceManifest(target.origin));
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;
107
+ catch (error) {
108
+ if (error.code === "PERSONAL_BUILD_EXHAUSTED") {
109
+ throw new DeployPersonalBuildExhaustedError();
57
110
  }
111
+ throw error;
58
112
  }
59
- return version;
60
113
  }
61
114
  export async function publishDeploySpk(spkPath) {
62
115
  const published = await publishBuiltSpk(spkPath);