@synopackageland/cli 0.3.2 → 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.
- package/dist/build-spk/generator.js +1 -3
- package/dist/compose/analyze.d.ts +2 -1
- package/dist/compose/analyze.js +27 -3
- package/dist/deploy/deploy.js +59 -2
- package/dist/deploy/spk-version.d.ts +9 -1
- package/dist/deploy/spk-version.js +57 -36
- package/dist/error-registry.js +10 -0
- package/dist/host/catalog-install.d.ts +8 -0
- package/dist/host/catalog-install.js +60 -0
- package/dist/host/dsm-client.js +14 -1
- package/dist/host/types.d.ts +2 -0
- package/dist/publish/curated-release.js +5 -1
- package/dist/publish/preflight.js +10 -1
- package/package.json +1 -1
|
@@ -138,9 +138,7 @@ export async function buildSpk(options) {
|
|
|
138
138
|
return { ok: false, errors: route.errors.map((error) => error.code) };
|
|
139
139
|
}
|
|
140
140
|
const rwEligibility = route?.eligibility;
|
|
141
|
-
const isDynamicRoute = route?.route === "dynamic"
|
|
142
|
-
rwEligibility?.adminport !== undefined &&
|
|
143
|
-
collectInstallParameters(contract).length > 0;
|
|
141
|
+
const isDynamicRoute = route?.route === "dynamic";
|
|
144
142
|
const isResourceWorkerRoute = route?.route === "static-zero-config" ||
|
|
145
143
|
isDynamicRoute;
|
|
146
144
|
const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
|
|
@@ -27,7 +27,7 @@ export interface ResourceWorkerEligibility {
|
|
|
27
27
|
* - any App without a unique literal published adminport status probe
|
|
28
28
|
*/
|
|
29
29
|
export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
|
|
30
|
-
export type ComposeRoute = "static-zero-config" | "dynamic" | "reject";
|
|
30
|
+
export type ComposeRoute = "static-zero-config" | "dynamic" | "broker-installer" | "reject";
|
|
31
31
|
export interface ComposeRouteClassification {
|
|
32
32
|
route: ComposeRoute;
|
|
33
33
|
eligibility: ResourceWorkerEligibility;
|
|
@@ -41,6 +41,7 @@ export interface ComposeRouteClassification {
|
|
|
41
41
|
* placed in the project .env.
|
|
42
42
|
*/
|
|
43
43
|
export declare function classifyComposeRoute(contract: AppContract, parsed: ParsedCompose, composeFile?: string): ComposeRouteClassification;
|
|
44
|
+
export declare function brokerInstallerRouteWarning(compose: ComposeDocument, composeFile?: string): ValidationError;
|
|
44
45
|
export declare function collectComposeInterpolations(compose: ComposeDocument): Set<string>;
|
|
45
46
|
export declare function validateComposeSecurity(compose: ComposeDocument, composeFile: string, options: {
|
|
46
47
|
profile: "personal" | "listing";
|
package/dist/compose/analyze.js
CHANGED
|
@@ -204,7 +204,25 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
|
|
|
204
204
|
if (eligibility.eligible) {
|
|
205
205
|
return { route: "static-zero-config", eligibility, errors: [] };
|
|
206
206
|
}
|
|
207
|
-
|
|
207
|
+
// Keep the route decision in one place. build-spk must not independently
|
|
208
|
+
// reinterpret an ineligible Compose as D1: only a route with a resolved
|
|
209
|
+
// published adminport and at least one declared install parameter has a
|
|
210
|
+
// package-only materializer. Every other buildable fallback is Broker.
|
|
211
|
+
if (eligibility.adminport !== undefined && collectInstallParameters(contract).length > 0) {
|
|
212
|
+
return { route: "dynamic", eligibility, errors: [] };
|
|
213
|
+
}
|
|
214
|
+
return { route: "broker-installer", eligibility, errors: [] };
|
|
215
|
+
}
|
|
216
|
+
export function brokerInstallerRouteWarning(compose, composeFile = "compose.yaml") {
|
|
217
|
+
const path = findDynamicComposePath(compose) ?? "services";
|
|
218
|
+
return makeError("CURATED_BROKER_ROUTE_FORBIDDEN", composeSpanForPath(compose, composeFile, path), "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,無法作為 Curated production runtime。", "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。", "warning");
|
|
219
|
+
}
|
|
220
|
+
function hasImmutableServiceImages(compose) {
|
|
221
|
+
const services = compose.services;
|
|
222
|
+
if (!services || Object.keys(services).length === 0) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return Object.values(services).every((service) => typeof service.image === "string" && /@sha256:[a-f0-9]{64}$/.test(service.image.trim()));
|
|
208
226
|
}
|
|
209
227
|
function collectRouteRejectErrors(contract, parsed, composeFile) {
|
|
210
228
|
const { compose } = parsed;
|
|
@@ -429,10 +447,16 @@ function walkForInterpolation(value, names) {
|
|
|
429
447
|
export function validateComposeSecurity(compose, composeFile, options) {
|
|
430
448
|
const errors = [];
|
|
431
449
|
const contract = composeContracts.get(compose);
|
|
432
|
-
const
|
|
433
|
-
? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
|
|
450
|
+
const route = contract
|
|
451
|
+
? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
|
|
452
|
+
: undefined;
|
|
453
|
+
const routeErrors = route
|
|
454
|
+
? route.errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
|
|
434
455
|
: [];
|
|
435
456
|
errors.push(...routeErrors);
|
|
457
|
+
if (route?.route === "broker-installer" && hasImmutableServiceImages(compose)) {
|
|
458
|
+
errors.push(brokerInstallerRouteWarning(compose, composeFile));
|
|
459
|
+
}
|
|
436
460
|
const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
|
|
437
461
|
if (!compose.services) {
|
|
438
462
|
return errors;
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -11,6 +11,7 @@ import { resolveSharedFolderExtraValues, sharedFolderParameterNames } from "./sh
|
|
|
11
11
|
import { resolveDsmUserExtraValues, dsmUserParameterNames } from "./run-as-values.js";
|
|
12
12
|
import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
|
|
13
13
|
import { resolveInstallParameterExtraValues } from "./param-values.js";
|
|
14
|
+
import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
|
|
14
15
|
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
15
16
|
function brokerCapabilitiesFromContract(project) {
|
|
16
17
|
const contract = loadContract(project.contractPath).contract;
|
|
@@ -21,12 +22,43 @@ function brokerCapabilitiesFromContract(project) {
|
|
|
21
22
|
}
|
|
22
23
|
return capabilities;
|
|
23
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
|
+
}
|
|
24
49
|
export async function deployProject(options) {
|
|
25
50
|
const validation = validateProject(options.project);
|
|
26
51
|
if (!validation.ok) {
|
|
27
52
|
return { ok: false, code: "VALIDATION_FAILED", message: validation.errors.map((e) => e.code).join(", ") };
|
|
28
53
|
}
|
|
29
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
|
+
}
|
|
30
62
|
const contract = loadContract(options.project.contractPath).contract;
|
|
31
63
|
const paramResolved = resolveInstallParameterExtraValues({
|
|
32
64
|
contract,
|
|
@@ -67,6 +99,29 @@ export async function deployProject(options) {
|
|
|
67
99
|
};
|
|
68
100
|
const contractDigest = digestProjectContract(options.project);
|
|
69
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
|
+
}
|
|
70
125
|
try {
|
|
71
126
|
await options.host.requestAttestation({
|
|
72
127
|
packageIdentity: identity.packageId,
|
|
@@ -95,10 +150,12 @@ export async function deployProject(options) {
|
|
|
95
150
|
}
|
|
96
151
|
catch (error) {
|
|
97
152
|
const code = error.code;
|
|
98
|
-
if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" ||
|
|
153
|
+
if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" ||
|
|
154
|
+
code === "PERSONAL_BUILD_EXHAUSTED" ||
|
|
155
|
+
code === "DEPLOY_VERSION_NOT_ASCENDING") {
|
|
99
156
|
return {
|
|
100
157
|
ok: false,
|
|
101
|
-
code: "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
|
|
158
|
+
code: code === "DEPLOY_VERSION_NOT_ASCENDING" ? code : "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
|
|
102
159
|
message: error instanceof Error ? error.message : String(error),
|
|
103
160
|
};
|
|
104
161
|
}
|
|
@@ -4,6 +4,14 @@ export declare class DeployPersonalBuildExhaustedError extends Error {
|
|
|
4
4
|
readonly code = "DEPLOY_PERSONAL_BUILD_EXHAUSTED";
|
|
5
5
|
constructor();
|
|
6
6
|
}
|
|
7
|
-
export declare
|
|
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;
|
|
8
16
|
export declare function resolveDeploySpkVersion(project: ProjectRoot, host: NasHost, packageId: string): Promise<string>;
|
|
9
17
|
export declare function publishDeploySpk(spkPath: string): Promise<string>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { loadContract } from "../contract/load.js";
|
|
2
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
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";
|
|
@@ -12,32 +11,61 @@ export class DeployPersonalBuildExhaustedError extends Error {
|
|
|
12
11
|
this.name = "DeployPersonalBuildExhaustedError";
|
|
13
12
|
}
|
|
14
13
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const build = Number(match[1]);
|
|
21
|
-
if (!Number.isSafeInteger(build) || build >= CURATED_BUILD_OFFSET) {
|
|
22
|
-
throw new DeployPersonalBuildExhaustedError();
|
|
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";
|
|
23
19
|
}
|
|
24
|
-
return contractVersion;
|
|
25
20
|
}
|
|
26
|
-
|
|
21
|
+
function baseVersion(version) {
|
|
27
22
|
const match = /^(.+)-(\d+)$/.exec(version);
|
|
23
|
+
return match ? match[1] : version;
|
|
24
|
+
}
|
|
25
|
+
function buildNumber(version) {
|
|
26
|
+
const match = /^.+-(\d+)$/.exec(version);
|
|
28
27
|
if (!match) {
|
|
29
|
-
return
|
|
28
|
+
return undefined;
|
|
30
29
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
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`;
|
|
34
44
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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")}`;
|
|
55
|
+
}
|
|
56
|
+
if (candidates.installed &&
|
|
57
|
+
comparePackageVersions(proposedVersion, candidates.installed) <= 0) {
|
|
58
|
+
throw new DeployVersionNotAscendingError(contractVersion, proposedVersion, candidates.installed);
|
|
38
59
|
}
|
|
39
|
-
|
|
40
|
-
|
|
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);
|
|
41
69
|
}
|
|
42
70
|
async function resolvePackageSourceManifest(origin) {
|
|
43
71
|
try {
|
|
@@ -56,12 +84,11 @@ async function resolvePackageSourceManifest(origin) {
|
|
|
56
84
|
}
|
|
57
85
|
async function resolveOfflineDeploySpkVersion(project, host, packageId) {
|
|
58
86
|
const contract = loadContract(project.contractPath).contract;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
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 });
|
|
65
92
|
}
|
|
66
93
|
export async function resolveDeploySpkVersion(project, host, packageId) {
|
|
67
94
|
try {
|
|
@@ -72,19 +99,13 @@ export async function resolveDeploySpkVersion(project, host, packageId) {
|
|
|
72
99
|
const target = resolvePublishTarget();
|
|
73
100
|
const manifest = (await fetchPackageSourceManifestFromR2(target)) ??
|
|
74
101
|
(await resolvePackageSourceManifest(target.origin));
|
|
75
|
-
|
|
102
|
+
const contractVersion = String(contract.version ?? "1.0.0");
|
|
76
103
|
const installed = await host.getInstalledPackageVersion?.(packageId);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (comparePackageVersions(installedNext, version) > 0) {
|
|
80
|
-
version = installedNext;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return version;
|
|
104
|
+
const feed = latestPersonalFeedVersion(packageId, manifest);
|
|
105
|
+
return resolvePersonalDeploySpkVersion(contractVersion, { installed, feed });
|
|
84
106
|
}
|
|
85
107
|
catch (error) {
|
|
86
|
-
if (error
|
|
87
|
-
error.code === "PERSONAL_BUILD_EXHAUSTED") {
|
|
108
|
+
if (error.code === "PERSONAL_BUILD_EXHAUSTED") {
|
|
88
109
|
throw new DeployPersonalBuildExhaustedError();
|
|
89
110
|
}
|
|
90
111
|
throw error;
|
package/dist/error-registry.js
CHANGED
|
@@ -454,6 +454,11 @@ export const ERROR_REGISTRY = {
|
|
|
454
454
|
description: "Personal App 的 Package Build 已用盡 0001–9999 命名範圍。",
|
|
455
455
|
suggestion: "請改用新的 App Version,或先清理並確認不會與 Curated 版本命名空間混用。",
|
|
456
456
|
},
|
|
457
|
+
DEPLOY_VERSION_NOT_ASCENDING: {
|
|
458
|
+
code: "DEPLOY_VERSION_NOT_ASCENDING",
|
|
459
|
+
description: "部署產生的 Package Version 不高於既有版本。",
|
|
460
|
+
suggestion: "請提高 synology-app.yaml 的 version,或確認目標 NAS 與 Package Source 上的既有版本。",
|
|
461
|
+
},
|
|
457
462
|
SHARE_VALUE_REQUIRED: {
|
|
458
463
|
code: "SHARE_VALUE_REQUIRED",
|
|
459
464
|
description: "部署時缺少合約中宣告的 shared_folder 實際路徑映射。",
|
|
@@ -755,6 +760,11 @@ export const ERROR_REGISTRY = {
|
|
|
755
760
|
description: "compose.yaml 缺少 services 區塊或服務為空。",
|
|
756
761
|
suggestion: "請在 compose.yaml 至少定義一個容器服務。",
|
|
757
762
|
},
|
|
763
|
+
CURATED_BROKER_ROUTE_FORBIDDEN: {
|
|
764
|
+
code: "CURATED_BROKER_ROUTE_FORBIDDEN",
|
|
765
|
+
description: "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,Curated listing 拒絕。",
|
|
766
|
+
suggestion: "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。",
|
|
767
|
+
},
|
|
758
768
|
CURATED_BUNDLE_FILE_MISSING: {
|
|
759
769
|
code: "CURATED_BUNDLE_FILE_MISSING",
|
|
760
770
|
description: "打包 Curated Release 時缺少必要的 Bundle 檔案。",
|
|
@@ -48,6 +48,14 @@ export declare function waitForInstalledVersion(readState: () => InstalledPackag
|
|
|
48
48
|
deadlineMs?: number;
|
|
49
49
|
pollMs?: number;
|
|
50
50
|
}): Promise<void>;
|
|
51
|
+
export interface UninstallPackageOptions {
|
|
52
|
+
dsmOrigin: string;
|
|
53
|
+
cookieHeader: string;
|
|
54
|
+
packageId: string;
|
|
55
|
+
deadlineMs?: number;
|
|
56
|
+
pollMs?: number;
|
|
57
|
+
}
|
|
58
|
+
export declare function uninstallPackageFromDsm(options: UninstallPackageOptions): Promise<void>;
|
|
51
59
|
export declare function catalogInstallationRequests(entry: Pick<CatalogPackageEntry, "package" | "version" | "size" | "beta" | "link" | "md5" | "qinst" | "deppkgs" | "breakpkgs" | "replacepkgs">, installed: boolean, sid: string, volumePath?: string): {
|
|
52
60
|
check: Record<string, string>;
|
|
53
61
|
install: Record<string, string>;
|
|
@@ -224,6 +224,66 @@ export async function waitForInstalledVersion(readState, options) {
|
|
|
224
224
|
? `Timed out waiting for ${options.packageId} to reach version ${options.expectedVersion}`
|
|
225
225
|
: `Timed out waiting for ${options.packageId} to install from catalog`);
|
|
226
226
|
}
|
|
227
|
+
function packageStatusIsStopped(state) {
|
|
228
|
+
const status = state?.status?.trim().toLowerCase();
|
|
229
|
+
return state?.installed === true && (status === "stop" || status === "stopped" || status === "manually-stopped");
|
|
230
|
+
}
|
|
231
|
+
async function waitForPackageStopped(readState, options) {
|
|
232
|
+
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
233
|
+
const pollMs = options.pollMs ?? 1000;
|
|
234
|
+
while (Date.now() < deadline) {
|
|
235
|
+
const state = await readState();
|
|
236
|
+
if (packageStatusIsStopped(state)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
240
|
+
}
|
|
241
|
+
throw new Error(`Timed out waiting for ${options.packageId} to stop before uninstall`);
|
|
242
|
+
}
|
|
243
|
+
async function waitForPackageUninstalled(readState, options) {
|
|
244
|
+
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
245
|
+
const pollMs = options.pollMs ?? 1000;
|
|
246
|
+
while (Date.now() < deadline) {
|
|
247
|
+
const state = await readState();
|
|
248
|
+
if (state?.installed === false) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`Timed out waiting for ${options.packageId} to be uninstalled`);
|
|
254
|
+
}
|
|
255
|
+
function uninstallFailure(packageId, error) {
|
|
256
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
257
|
+
const failure = new Error(`Failed to uninstall ${packageId}: ${detail}`);
|
|
258
|
+
failure.code = "CATALOG_INSTALL_FAILED";
|
|
259
|
+
return failure;
|
|
260
|
+
}
|
|
261
|
+
export async function uninstallPackageFromDsm(options) {
|
|
262
|
+
try {
|
|
263
|
+
const sid = extractSid(options.cookieHeader);
|
|
264
|
+
const readState = () => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
|
|
265
|
+
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
266
|
+
api: "SYNO.Core.Package.Control",
|
|
267
|
+
version: "1",
|
|
268
|
+
method: "stop",
|
|
269
|
+
id: jsonApiParam(options.packageId),
|
|
270
|
+
_sid: sid,
|
|
271
|
+
});
|
|
272
|
+
await waitForPackageStopped(readState, options);
|
|
273
|
+
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
274
|
+
api: "SYNO.Core.Package.Uninstallation",
|
|
275
|
+
version: "1",
|
|
276
|
+
method: "uninstall",
|
|
277
|
+
id: jsonApiParam(options.packageId),
|
|
278
|
+
extra_values: jsonApiParam("{}"),
|
|
279
|
+
_sid: sid,
|
|
280
|
+
});
|
|
281
|
+
await waitForPackageUninstalled(readState, options);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
throw uninstallFailure(options.packageId, error);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
227
287
|
export function catalogInstallationRequests(entry, installed, sid, volumePath) {
|
|
228
288
|
const install = {
|
|
229
289
|
api: "SYNO.Core.Package.Installation",
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -6,7 +6,7 @@ import { IDENTITY_CGI_PATH } from "./broker-paths.js";
|
|
|
6
6
|
import { packageSourceOrigin } from "../deploy/catalog-publish.js";
|
|
7
7
|
import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
8
8
|
import { dsmFetch } from "./dsm-http.js";
|
|
9
|
-
import { installPackageFromCatalog, installPackageFromLocalSpk, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
9
|
+
import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
10
10
|
import { resolveHostCredentials } from "./credentials.js";
|
|
11
11
|
export function createNasHost(options = {}) {
|
|
12
12
|
const credentials = options.credentials ?? resolveHostCredentials(options.host);
|
|
@@ -200,6 +200,19 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
200
200
|
projectEnvCache.delete(input.packageId);
|
|
201
201
|
}
|
|
202
202
|
},
|
|
203
|
+
async uninstallPackage(packageIdentity) {
|
|
204
|
+
try {
|
|
205
|
+
await ensureLogin();
|
|
206
|
+
await uninstallPackageFromDsm({
|
|
207
|
+
dsmOrigin,
|
|
208
|
+
cookieHeader: cookieHeader ?? "",
|
|
209
|
+
packageId: packageIdentity,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
projectEnvCache.delete(packageIdentity);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
203
216
|
async getArchFamily() {
|
|
204
217
|
await ensureLogin();
|
|
205
218
|
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?api=SYNO.Core.System&version=1&method=info`, { headers: { cookie: cookieHeader ?? "" } });
|
package/dist/host/types.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface NasHost {
|
|
|
44
44
|
spkPath: string;
|
|
45
45
|
extraValues?: Record<string, string>;
|
|
46
46
|
}): Promise<void>;
|
|
47
|
+
/** Stop and normally uninstall a formal package while preserving App Data. */
|
|
48
|
+
uninstallPackage?(packageIdentity: string): Promise<void>;
|
|
47
49
|
/**
|
|
48
50
|
* CPU family of this NAS, for picking a native App's target. Returns
|
|
49
51
|
* undefined when the DSM answer does not map to a family we ship, so the
|
|
@@ -9,7 +9,7 @@ import { createR2CuratedReleasePublisher, publishCuratedRelease, } from "@synopa
|
|
|
9
9
|
import { digestAppPackageClaims, digestCuratedReleaseClaims, normalizeCapabilities, signCuratedAppPackageAttestationToken, signCuratedReleaseAttestationToken, } from "../attestation/app-package.js";
|
|
10
10
|
import { CuratedTrustRootError, loadCuratedTrustRoot, resolveCuratedSigningTrust, } from "../attestation/keyring.js";
|
|
11
11
|
import { loadContract } from "../contract/load.js";
|
|
12
|
-
import { loadCompose } from "../compose/analyze.js";
|
|
12
|
+
import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
|
|
13
13
|
import { loadNative } from "../native/load.js";
|
|
14
14
|
import { infoArchValue, isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
15
15
|
import { EMBEDDED_ATTESTATION_RELATIVE_PATH, buildSpk, extractSpk, } from "../build-spk/generator.js";
|
|
@@ -524,6 +524,10 @@ function prepareRelease(project, build) {
|
|
|
524
524
|
if (!parsedCompose) {
|
|
525
525
|
throw new CuratedReleaseError("CURATED_COMPOSE_MISSING");
|
|
526
526
|
}
|
|
527
|
+
const route = classifyComposeRoute(contract, parsedCompose);
|
|
528
|
+
if (route.route === "broker-installer" && contract.adminport !== undefined) {
|
|
529
|
+
throw new CuratedReleaseError("CURATED_BROKER_ROUTE_FORBIDDEN");
|
|
530
|
+
}
|
|
527
531
|
const composeGate = validateCuratedCompose(parsedCompose.compose);
|
|
528
532
|
if (!composeGate.ok) {
|
|
529
533
|
throw new CuratedReleaseError("CURATED_COMPOSE_GATE_FAILED", composeGate.errors.join(","));
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { CURATED_BUILD_OFFSET, isDevelopmentPackageIdentity, } from "@synopackageland/package-source/index-manifest";
|
|
5
5
|
import { buildSpk } from "../build-spk/generator.js";
|
|
6
|
-
import { loadCompose } from "../compose/analyze.js";
|
|
6
|
+
import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
|
|
7
7
|
import { loadContract } from "../contract/load.js";
|
|
8
8
|
import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
9
9
|
import { loadNative } from "../native/load.js";
|
|
@@ -89,6 +89,15 @@ export async function runPublicReleasePreflight(project) {
|
|
|
89
89
|
ok: gate.ok,
|
|
90
90
|
detail: gate.ok ? undefined : gate.errors.join(", "),
|
|
91
91
|
});
|
|
92
|
+
const route = parsedCompose
|
|
93
|
+
? classifyComposeRoute(contract, parsedCompose)
|
|
94
|
+
: undefined;
|
|
95
|
+
add({
|
|
96
|
+
id: "curated-route",
|
|
97
|
+
title: "Compose 不使用 Broker installer 路線",
|
|
98
|
+
ok: route?.route !== "broker-installer",
|
|
99
|
+
detail: route?.route === "broker-installer" ? "CURATED_BROKER_ROUTE_FORBIDDEN" : undefined,
|
|
100
|
+
});
|
|
92
101
|
}
|
|
93
102
|
// The strongest local gate: actually produce the artifact. For native this
|
|
94
103
|
// fetches each pinned asset and refuses it unless the bytes hash to the
|