@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.
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 +202 -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 +41 -1
  10. package/dist/compose/analyze.js +458 -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 +37 -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 +5 -0
  26. package/dist/deploy/spk-version.js +50 -18
  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 +827 -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 +12 -0
  36. package/dist/host/catalog-install.js +83 -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 +56 -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 +4 -0
  45. package/dist/publish/curated-release.js +3 -3
  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 +14 -1
  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,14 @@
1
+ import type { AppContract } from "../types.js";
2
+ import type { ResourceWorkerEligibility } from "../compose/analyze.js";
3
+ export { classifyComposeRoute, resourceWorkerEligibility, type ComposeRoute, type ComposeRouteClassification, type ResourceWorkerEligibility, type ResourceWorkerIneligibleReason, } from "../compose/analyze.js";
4
+ export declare function composeProjectName(packageId: string): string;
5
+ export declare function renderDockerProjectResource(packageId: string, projectPath?: string): Record<string, unknown>;
6
+ export declare function renderWebserviceResource(contract: AppContract, adminport: number): Record<string, unknown>;
7
+ export declare function renderResourceConfig(contract: AppContract, eligibility: ResourceWorkerEligibility): string;
8
+ /**
9
+ * Renders the lifecycle control script for the Docker Project resource-worker
10
+ * route. ContainerManager owns the actual project start/stop operation through
11
+ * the worker timing hooks; the package script must wait for that operation to
12
+ * leave a reachable service and must not claim running unconditionally.
13
+ */
14
+ export declare function renderResourceWorkerStartStopStatus(adminport?: number): string;
@@ -0,0 +1,140 @@
1
+ import { cookieStripHeaders, resolvedWebLaunch, webStationAlias, webStationServiceName, } from "../contract/web-launch.js";
2
+ export { classifyComposeRoute, resourceWorkerEligibility, } from "../compose/analyze.js";
3
+ export function composeProjectName(packageId) {
4
+ const slug = packageId
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9_-]+/g, "-")
7
+ .replace(/^[-_]+|[-_]+$/g, "");
8
+ return slug.length > 0 ? slug : "app";
9
+ }
10
+ export function renderDockerProjectResource(packageId, projectPath = "compose") {
11
+ const projectName = composeProjectName(packageId);
12
+ return {
13
+ "docker-project": {
14
+ projects: [
15
+ {
16
+ name: projectName,
17
+ path: projectPath,
18
+ build_params: {
19
+ force_pull: true,
20
+ force_recreate: true,
21
+ build: false,
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ };
27
+ }
28
+ export function renderWebserviceResource(contract, adminport) {
29
+ const alias = webStationAlias(String(contract.package));
30
+ const launch = resolvedWebLaunch(contract);
31
+ const cookieHeaders = cookieStripHeaders(contract);
32
+ const proxyHeaderEntries = cookieHeaders.map((header) => ({
33
+ name: header.name,
34
+ value: header.value,
35
+ }));
36
+ const service = {
37
+ service: webStationServiceName(alias),
38
+ display_name: String(contract.displayname ?? contract.package),
39
+ type: "reverse_proxy",
40
+ support_alias: true,
41
+ support_server: false,
42
+ proxy_target: `http://127.0.0.1:${adminport}`,
43
+ proxy_headers: proxyHeaderEntries,
44
+ };
45
+ const portal = {
46
+ service: webStationServiceName(alias),
47
+ name: alias,
48
+ type: "alias",
49
+ alias,
50
+ };
51
+ if (launch === "dsm-window") {
52
+ portal.app = String(contract.package);
53
+ }
54
+ return {
55
+ webservice: {
56
+ services: [service],
57
+ portals: [portal],
58
+ },
59
+ };
60
+ }
61
+ export function renderResourceConfig(contract, eligibility) {
62
+ const packageId = String(contract.package);
63
+ const dockerResource = renderDockerProjectResource(packageId, "compose");
64
+ let combined = { ...dockerResource };
65
+ if (eligibility.adminport !== undefined) {
66
+ const webResource = renderWebserviceResource(contract, eligibility.adminport);
67
+ combined = {
68
+ ...combined,
69
+ ...webResource,
70
+ };
71
+ }
72
+ return `${JSON.stringify(combined, null, 2)}\n`;
73
+ }
74
+ /**
75
+ * Renders the lifecycle control script for the Docker Project resource-worker
76
+ * route. ContainerManager owns the actual project start/stop operation through
77
+ * the worker timing hooks; the package script must wait for that operation to
78
+ * leave a reachable service and must not claim running unconditionally.
79
+ */
80
+ export function renderResourceWorkerStartStopStatus(adminport) {
81
+ if (adminport === undefined) {
82
+ return `#!/bin/sh
83
+ case "$1" in
84
+ start|status)
85
+ echo "resource-worker package has no published port to probe" >&2
86
+ exit 3
87
+ ;;
88
+ stop) exit 0 ;;
89
+ *) exit 1 ;;
90
+ esac
91
+ `;
92
+ }
93
+ const url = `http://127.0.0.1:${adminport}/`;
94
+ return `#!/bin/sh
95
+ probe_service() {
96
+ if command -v curl >/dev/null 2>&1; then
97
+ curl --silent --output /dev/null --connect-timeout 2 --max-time 5 "${url}"
98
+ return $?
99
+ fi
100
+ if command -v wget >/dev/null 2>&1; then
101
+ wget --quiet --output-document=/dev/null --timeout=5 --tries=1 "${url}"
102
+ return $?
103
+ fi
104
+ return 127
105
+ }
106
+
107
+ wait_for_service() {
108
+ attempts=0
109
+ while [ "$attempts" -lt 30 ]; do
110
+ if probe_service; then
111
+ return 0
112
+ fi
113
+ attempts=$((attempts + 1))
114
+ sleep 1
115
+ done
116
+ return 1
117
+ }
118
+
119
+ case "$1" in
120
+ start)
121
+ if wait_for_service; then
122
+ exit 0
123
+ fi
124
+ exit 3
125
+ ;;
126
+ stop)
127
+ exit 0
128
+ ;;
129
+ status)
130
+ if probe_service; then
131
+ exit 0
132
+ fi
133
+ exit 3
134
+ ;;
135
+ *)
136
+ exit 1
137
+ ;;
138
+ esac
139
+ `;
140
+ }
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { findProjectRoot } from "./discovery.js";
4
+ import { loadContract } from "./contract/load.js";
4
5
  import { formatErrors } from "./errors.js";
5
6
  import { formatInspectReport, inspectProject } from "./inspect.js";
6
7
  import { initFromTemplate, isTemplateName, listTemplates } from "./init.js";
@@ -19,6 +20,7 @@ import { publishProject, resolveCommunityAppsRoot } from "./publish/publish.js";
19
20
  import { formatPreflight, runPublicReleasePreflight } from "./publish/preflight.js";
20
21
  import { createExecGitHubCli, createExecGitOps } from "./publish/github-pr.js";
21
22
  import { communityAppsGitHubRepo } from "./publish/github-repo.js";
23
+ import { resolveInstallParameterExtraValues } from "./deploy/param-values.js";
22
24
  function collectKvOption(value, previous, flag, example) {
23
25
  const separator = value.indexOf("=");
24
26
  if (separator <= 0) {
@@ -35,9 +37,15 @@ function collectShareOption(value, previous) {
35
37
  function collectRunAsOption(value, previous) {
36
38
  return collectKvOption(value, previous, "--run-as", "--run-as RUN_AS=alice");
37
39
  }
40
+ function collectParamOption(value, previous) {
41
+ return collectKvOption(value, previous, "--param", "--param APP_MODE=prod");
42
+ }
38
43
  function resolveProject(cwd, project) {
39
44
  const start = project ? resolve(cwd, project) : cwd;
40
- const found = findProjectRoot(start);
45
+ return findProjectRoot(start);
46
+ }
47
+ function requireProject(cwd, project) {
48
+ const found = resolveProject(cwd, project);
41
49
  if (!found) {
42
50
  console.error("找不到 synology-app.yaml。請在專案根目錄執行,或使用 --project 指定。");
43
51
  process.exit(1);
@@ -50,12 +58,53 @@ export async function runCli(argv) {
50
58
  .name("synopkgland")
51
59
  .description("Syno Package Land Developer Kit CLI")
52
60
  .option("--project <dir>", "App 專案根目錄");
61
+ program
62
+ .command("doctor")
63
+ .description("唯讀診斷本機合約/Compose 與 NAS 部署狀態")
64
+ .option("--host <name>", "synoagentcli profile 或 NAS 名稱")
65
+ .option("--package <id>", "指定診斷的 Package Identity(省略時由合約判定)")
66
+ .option("--dev", "診斷 Development Package Identity({package}.dev)")
67
+ .option("--json", "輸出 machine-readable JSON 診斷報告")
68
+ .action(async (options, command) => {
69
+ const global = command.parent?.opts() ?? {};
70
+ const project = resolveProject(process.cwd(), global.project);
71
+ let host = null;
72
+ if (options.host) {
73
+ host = createNasHost({ host: options.host });
74
+ if (!host) {
75
+ console.error(`[${"HOST_CREDENTIALS_MISSING"}] 找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。`);
76
+ process.exit(1);
77
+ }
78
+ }
79
+ try {
80
+ const { runDoctor, formatDoctorReport } = await import("./doctor.js");
81
+ const report = await runDoctor({
82
+ project,
83
+ host,
84
+ packageId: options.package,
85
+ dev: options.dev,
86
+ });
87
+ if (options.json) {
88
+ console.log(JSON.stringify(report, null, 2));
89
+ }
90
+ else {
91
+ console.log(formatDoctorReport(report));
92
+ }
93
+ if (!report.ok) {
94
+ process.exit(1);
95
+ }
96
+ }
97
+ catch (error) {
98
+ console.error(error instanceof Error ? error.message : String(error));
99
+ process.exit(1);
100
+ }
101
+ });
53
102
  program
54
103
  .command("validate")
55
104
  .description("驗證 App Contract 與 Compose")
56
105
  .action((options, command) => {
57
106
  const global = command.parent?.opts() ?? {};
58
- const project = resolveProject(process.cwd(), global.project);
107
+ const project = requireProject(process.cwd(), global.project);
59
108
  const result = validateProject(project, {
60
109
  profile: "personal",
61
110
  });
@@ -73,7 +122,7 @@ export async function runCli(argv) {
73
122
  .description("檢視 Compose 意圖與 Web entry 提示")
74
123
  .action((_options, command) => {
75
124
  const global = command.parent?.opts() ?? {};
76
- const project = resolveProject(process.cwd(), global.project);
125
+ const project = requireProject(process.cwd(), global.project);
77
126
  const report = inspectProject(project);
78
127
  console.log(formatInspectReport(report));
79
128
  });
@@ -112,7 +161,7 @@ export async function runCli(argv) {
112
161
  .option("--files-root <dir>", "將本機目錄對應為 NAS Logical Path")
113
162
  .action(async (options, command) => {
114
163
  const global = command.parent?.opts() ?? {};
115
- const project = resolveProject(process.cwd(), global.project);
164
+ const project = requireProject(process.cwd(), global.project);
116
165
  const validation = validateProject(project);
117
166
  if (!validation.ok) {
118
167
  console.error(formatErrors(validation.errors));
@@ -146,7 +195,7 @@ export async function runCli(argv) {
146
195
  .option("--dev", "檢查 Development Package Identity({package}.dev)")
147
196
  .action(async (options, command) => {
148
197
  const global = command.parent?.opts() ?? {};
149
- const project = resolveProject(process.cwd(), global.project);
198
+ const project = requireProject(process.cwd(), global.project);
150
199
  if (options.host) {
151
200
  const host = createNasHost({ host: options.host });
152
201
  if (!host) {
@@ -189,9 +238,18 @@ export async function runCli(argv) {
189
238
  .option("--replace-curated", "危險:將既有 Curated App Package Attestation 降級為 Local Attestation")
190
239
  .option("--share <slot=name>", "此 NAS 上的 Shared Folder 邏輯名(契約 shared_folder 槽,可重複)", collectShareOption)
191
240
  .option("--run-as <slot=name>", "此 NAS 上的 DSM 使用者邏輯名(契約 dsm_user 槽)", collectRunAsOption)
241
+ .option("--param <NAME=value>", "契約 string/boolean Install Parameter 答案(可重複)", collectParamOption)
192
242
  .action(async (options, command) => {
193
243
  const global = command.parent?.opts() ?? {};
194
- const project = resolveProject(process.cwd(), global.project);
244
+ const project = requireProject(process.cwd(), global.project);
245
+ const parameterCheck = resolveInstallParameterExtraValues({
246
+ contract: loadContract(project.contractPath).contract,
247
+ provided: options.param,
248
+ });
249
+ if (!parameterCheck.ok) {
250
+ console.error(`[${parameterCheck.code}] ${parameterCheck.message}`);
251
+ process.exit(1);
252
+ }
195
253
  const host = createNasHost({ host: options.host });
196
254
  if (!host) {
197
255
  console.error("找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。");
@@ -210,6 +268,7 @@ export async function runCli(argv) {
210
268
  replaceCurated: options.replaceCurated,
211
269
  shareValues: options.share,
212
270
  runAsValues: options.runAs,
271
+ paramValues: options.param,
213
272
  });
214
273
  if (!result.ok) {
215
274
  console.error(`[${result.code ?? "DEPLOY_FAILED"}] ${result.message ?? "部署失敗。"}`);
@@ -232,7 +291,7 @@ export async function runCli(argv) {
232
291
  .option("--dev", "查詢 Development Package Identity")
233
292
  .action(async (options, command) => {
234
293
  const global = command.parent?.opts() ?? {};
235
- const project = resolveProject(process.cwd(), global.project);
294
+ const project = requireProject(process.cwd(), global.project);
236
295
  const host = createNasHost({ host: options.host });
237
296
  if (!host) {
238
297
  console.error("找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。");
@@ -254,7 +313,7 @@ export async function runCli(argv) {
254
313
  .option("--dev", "查詢 Development Package Identity")
255
314
  .action(async (options, command) => {
256
315
  const global = command.parent?.opts() ?? {};
257
- const project = resolveProject(process.cwd(), global.project);
316
+ const project = requireProject(process.cwd(), global.project);
258
317
  const host = createNasHost({ host: options.host });
259
318
  if (!host) {
260
319
  console.error("找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。");
@@ -312,7 +371,7 @@ export async function runCli(argv) {
312
371
  .option("--arch <family>", "native App 的目標架構:x86_64 或 aarch64(Compose App 不需要)")
313
372
  .action(async (options, command) => {
314
373
  const global = command.parent?.opts() ?? {};
315
- const project = resolveProject(process.cwd(), global.project);
374
+ const project = requireProject(process.cwd(), global.project);
316
375
  if (options.arch !== undefined && !isNativeArchFamily(String(options.arch))) {
317
376
  console.error(`--arch 只接受 x86_64 或 aarch64,收到 ${String(options.arch)}。`);
318
377
  process.exit(1);
@@ -336,7 +395,7 @@ export async function runCli(argv) {
336
395
  .option("--base <branch>", "PR 目標分支", "master")
337
396
  .action(async (options, command) => {
338
397
  const global = command.parent?.opts() ?? {};
339
- const project = resolveProject(process.cwd(), global.project);
398
+ const project = requireProject(process.cwd(), global.project);
340
399
  const preflight = await runPublicReleasePreflight(project);
341
400
  if (!preflight.ok) {
342
401
  // A gate's reasons belong on stderr so CI logs and `2>` capture them.
@@ -1,20 +1,60 @@
1
- import type { ComposeDocument, ComposeService, ValidationError } from "../types.js";
1
+ import { type Document } from "yaml";
2
+ import type { AppContract, ComposeDocument, ComposeService, ValidationError } from "../types.js";
2
3
  import { type PrivilegeFlag } from "./privilege.js";
3
4
  export interface ParsedCompose {
4
5
  compose: ComposeDocument;
5
6
  source: string;
7
+ document: Document;
6
8
  }
7
9
  export declare function loadCompose(composePath: string): ParsedCompose | null;
8
10
  export declare function listServiceNames(compose: ComposeDocument): string[];
9
11
  export declare function extractPublishedTcpPorts(service: ComposeService): number[];
10
12
  export declare function collectPublishedTcpPorts(compose: ComposeDocument): Map<number, string[]>;
13
+ export type ResourceWorkerIneligibleReason = "HAS_INSTALL_PARAMETERS" | "HAS_COMPOSE_INTERPOLATION" | "HAS_COMPOSE_SECRETS_OR_CONFIGS" | "HAS_BUILD_STANZA" | "HAS_PRIVILEGED_CAPABILITY" | "WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT";
14
+ export interface ResourceWorkerEligibility {
15
+ eligible: boolean;
16
+ reasons: ResourceWorkerIneligibleReason[];
17
+ adminport?: number;
18
+ }
19
+ /**
20
+ * Checks if a Compose contract and document are eligible for the zero-config DSM resource-worker path.
21
+ * Zero-config slice rejects:
22
+ * - any install parameter
23
+ * - any ${...} interpolation anywhere in compose document
24
+ * - compose secrets or configs
25
+ * - build: stanzas
26
+ * - privileged / host network / device / root-like capabilities
27
+ * - any App without a unique literal published adminport status probe
28
+ */
29
+ export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
30
+ export type ComposeRoute = "static-zero-config" | "dynamic" | "reject";
31
+ export interface ComposeRouteClassification {
32
+ route: ComposeRoute;
33
+ eligibility: ResourceWorkerEligibility;
34
+ errors: ValidationError[];
35
+ }
36
+ /**
37
+ * Chooses the local route D0 can safely emit. The first dynamic slice is
38
+ * deliberately small: only string/boolean wizard answers interpolated as
39
+ * ordinary Compose values. Secrets/configs remain rejected because the live
40
+ * DSM worker does not load docker-compose.override.yml, and no secret may be
41
+ * placed in the project .env.
42
+ */
43
+ export declare function classifyComposeRoute(contract: AppContract, parsed: ParsedCompose, composeFile?: string): ComposeRouteClassification;
11
44
  export declare function collectComposeInterpolations(compose: ComposeDocument): Set<string>;
12
45
  export declare function validateComposeSecurity(compose: ComposeDocument, composeFile: string, options: {
13
46
  profile: "personal" | "listing";
14
47
  declaredPrivilege: ReadonlySet<PrivilegeFlag>;
15
48
  }): ValidationError[];
49
+ /**
50
+ * DSM's Docker Project resource worker can lose YAML quotes while acquiring a
51
+ * project. A quoted numeric command argument can therefore become a number;
52
+ * reject that platform-invalid shape before it reaches the NAS.
53
+ */
54
+ export declare function validateComposeNumericCommandArgs(compose: ComposeDocument, composeFile: string): ValidationError[];
16
55
  export interface HostBindContext {
17
56
  sharedFolderParams: ReadonlySet<string>;
57
+ homesParams?: ReadonlySet<string>;
18
58
  installParamNames: ReadonlySet<string>;
19
59
  }
20
60
  export declare function collectVolumeSourceInterpolations(compose: ComposeDocument): Set<string>;