@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.
@@ -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";
@@ -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
- return { route: "dynamic", eligibility, errors: [] };
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 routeErrors = contract
433
- ? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile).errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
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;
@@ -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" || code === "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 function bumpBuildNumber(version: string): string;
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
- function defaultSpkVersion(contractVersion) {
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();
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
- export function bumpBuildNumber(version) {
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 `${version}-0002`;
28
+ return undefined;
30
29
  }
31
- const current = Number(match[2]);
32
- if (!Number.isSafeInteger(current) || current >= CURATED_BUILD_OFFSET) {
33
- throw new DeployPersonalBuildExhaustedError();
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
- const nextBuild = current + 1;
36
- if (nextBuild > PERSONAL_BUILD_MAX) {
37
- throw new DeployPersonalBuildExhaustedError();
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
- const next = String(nextBuild).padStart(4, "0");
40
- return `${match[1]}-${next}`;
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 base = defaultSpkVersion(String(contract.version ?? "1.0.0"));
60
- if (!(await host.isPackageInstalled(packageId))) {
61
- return base;
62
- }
63
- const installed = await host.getInstalledPackageVersion?.(packageId);
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
- let version = resolveNextSpkVersion(String(contract.version ?? "1.0.0"), packageId, manifest);
102
+ const contractVersion = String(contract.version ?? "1.0.0");
76
103
  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;
104
+ const feed = latestPersonalFeedVersion(packageId, manifest);
105
+ return resolvePersonalDeploySpkVersion(contractVersion, { installed, feed });
84
106
  }
85
107
  catch (error) {
86
- if (error instanceof PersonalBuildExhaustedError ||
87
- error.code === "PERSONAL_BUILD_EXHAUSTED") {
108
+ if (error.code === "PERSONAL_BUILD_EXHAUSTED") {
88
109
  throw new DeployPersonalBuildExhaustedError();
89
110
  }
90
111
  throw error;
@@ -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",
@@ -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 ?? "" } });
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {