@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
package/dist/doctor.js ADDED
@@ -0,0 +1,594 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { validateProject } from "./validate.js";
4
+ import { resolveDeployIdentity } from "./deploy/identity.js";
5
+ import { loadContract } from "./contract/load.js";
6
+ import { getErrorCode } from "./error-registry.js";
7
+ import { dsmFetch } from "./host/dsm-http.js";
8
+ import { appOriginFromContract } from "./host/open-url.js";
9
+ const PROJECT_FOUR_FILES = ["synology-app.yaml", "README.md", "icon.png"];
10
+ function packageFailureSummary(state) {
11
+ const fields = [];
12
+ if (state.error?.code !== undefined) {
13
+ fields.push(`error.code=${state.error.code}`);
14
+ }
15
+ if (state.error?.description) {
16
+ fields.push(`description=${state.error.description}`);
17
+ }
18
+ if (state.brokenBy) {
19
+ fields.push(`broken_by=${state.brokenBy}`);
20
+ }
21
+ if (state.stage) {
22
+ fields.push(`stage=${state.stage}`);
23
+ }
24
+ if (state.lastStage) {
25
+ fields.push(`last_stage=${state.lastStage}`);
26
+ }
27
+ if (state.statusCode !== undefined) {
28
+ fields.push(`statusCode=${state.statusCode}`);
29
+ }
30
+ if (state.statusDescription) {
31
+ fields.push(`statusDescription=${state.statusDescription}`);
32
+ }
33
+ if (state.statusOrigin) {
34
+ fields.push(`statusOrigin=${state.statusOrigin}`);
35
+ }
36
+ if (state.error?.code === undefined && !state.error?.description && !state.stage) {
37
+ fields.push("event.error/stage 未由 Package API 回傳");
38
+ }
39
+ if (fields.length === 0) {
40
+ return "DSM API 未回傳可用的失敗欄位";
41
+ }
42
+ return `DSM 回報:${fields.join(";")}`;
43
+ }
44
+ function packageFailureSuggestion(state) {
45
+ const diagnosis = state.error?.code === 276
46
+ ? "DSM error 276(postinst worker acquire 失敗)常見原因是 Compose command 含純數字元素,或發布的 port 已被占用;請把數字參數併入 flag/用 sh -c 包成單一字串,並確認 host port 未被其他服務使用。"
47
+ : state.error?.code === undefined &&
48
+ state.statusCode === 150 &&
49
+ state.brokenBy === "install_corruption"
50
+ ? "DSM Package API 未回傳 event error.code;同類 install_corruption/status 150 的實機 event 曾對應 error 276。常見原因是 Compose command 含純數字元素,或發布的 port 已被占用;請把數字參數併入 flag/用 sh -c 包成單一字串,並確認 host port 未被其他服務使用。"
51
+ : "請依 DSM 回報的欄位檢查 Package Center 安裝流程與 Compose 設定,必要時重新安裝套件。";
52
+ const workerMessage = state.error?.workerMsg && state.error.workerMsg.length > 0
53
+ ? `DSM API 已提供 ${state.error.workerMsg.length} 筆 worker_msg;`
54
+ : "DSM API 的 worker_msg 實測為空或未提供;";
55
+ return `${diagnosis} ${workerMessage}worker 內部完整訊息只存在 NAS 的 /var/log/synoplugin.log(需 root),doctor 不會假裝取得。`;
56
+ }
57
+ export async function runDoctor(options) {
58
+ const checks = [];
59
+ // --- 1. Local checks (Always run) ---
60
+ if (!options.project) {
61
+ const errorEntry = getErrorCode("PROJECT_FILES_MISSING");
62
+ checks.push({
63
+ id: "project.files",
64
+ status: "fail",
65
+ code: "PROJECT_FILES_MISSING",
66
+ message: "找不到 synology-app.yaml。請在專案根目錄執行,或使用 --project 指定。",
67
+ suggestion: errorEntry?.suggestion ?? "請在 App 專案根目錄執行 doctor,或使用 synopkgland init 建立專案。",
68
+ });
69
+ checks.push({
70
+ id: "project.validate",
71
+ status: "skip",
72
+ message: "專案不存在,略過合約與 Compose 靜態驗證",
73
+ });
74
+ }
75
+ else {
76
+ const project = options.project;
77
+ // Check 1.1: Project four files
78
+ // In a Compose project: synology-app.yaml, compose.yaml
79
+ // In a Native project: synology-app.yaml, native.yaml
80
+ // Additional packaging files (README.md, icon.png) are checked as well.
81
+ const missingFiles = [];
82
+ if (!existsSync(project.contractPath)) {
83
+ missingFiles.push("synology-app.yaml");
84
+ }
85
+ const hasCompose = existsSync(project.composePath);
86
+ const hasNative = existsSync(project.nativePath ?? join(project.root, "native.yaml"));
87
+ if (!hasCompose && !hasNative) {
88
+ missingFiles.push("compose.yaml 或 native.yaml");
89
+ }
90
+ if (missingFiles.length > 0) {
91
+ const errorEntry = getErrorCode("PROJECT_FILES_MISSING");
92
+ checks.push({
93
+ id: "project.files",
94
+ status: "fail",
95
+ code: "PROJECT_FILES_MISSING",
96
+ message: `專案缺少核心定義檔案:${missingFiles.join(", ")}`,
97
+ suggestion: errorEntry?.suggestion ?? "請補齊缺失的專案核心檔案。",
98
+ });
99
+ }
100
+ else {
101
+ const packagingNotice = ["README.md", "icon.png"].filter((f) => !existsSync(join(project.root, f)));
102
+ const detail = packagingNotice.length > 0 ? `(公開發布建議補齊 ${packagingNotice.join(", ")})` : "";
103
+ checks.push({
104
+ id: "project.files",
105
+ status: "pass",
106
+ message: `專案核心定義檔案皆存在${detail}`,
107
+ });
108
+ }
109
+ // Check 1.2: Contract / Compose / Native validation (reuses analyzer)
110
+ const validation = validateProject(project, { profile: "personal" });
111
+ if (!validation.ok) {
112
+ const firstError = validation.errors[0];
113
+ const code = firstError?.code ?? "VALIDATION_FAILED";
114
+ const errorEntry = getErrorCode(code);
115
+ checks.push({
116
+ id: "project.validate",
117
+ status: "fail",
118
+ code,
119
+ message: firstError ? `${firstError.message}` : "專案合約或 Compose 驗證失敗",
120
+ suggestion: firstError?.suggestion ?? errorEntry?.suggestion ?? "請執行 synopkgland validate 修正錯誤。",
121
+ });
122
+ }
123
+ else {
124
+ checks.push({
125
+ id: "project.validate",
126
+ status: "pass",
127
+ message: "合約與 Compose / Native 靜態驗證通過",
128
+ });
129
+ }
130
+ }
131
+ // Check 1.3: CLI toolchain version drift
132
+ let cliVersion = "unknown";
133
+ try {
134
+ const pkgJsonPath = new URL("../package.json", import.meta.url);
135
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
136
+ cliVersion = pkg.version ?? "unknown";
137
+ }
138
+ catch {
139
+ // ignore
140
+ }
141
+ let toolchainVersion;
142
+ const projectRoot = options.project ? options.project.root : process.cwd();
143
+ const toolchainScriptPath = join(projectRoot, "..", "ci", "standalone-toolchain.sh");
144
+ if (existsSync(toolchainScriptPath)) {
145
+ try {
146
+ const script = readFileSync(toolchainScriptPath, "utf8");
147
+ const match = /TOOLCHAIN_VERSION="([^"]+)"/.exec(script);
148
+ if (match?.[1]) {
149
+ toolchainVersion = match[1];
150
+ }
151
+ }
152
+ catch {
153
+ // ignore
154
+ }
155
+ }
156
+ if (toolchainVersion && cliVersion !== "unknown" && cliVersion !== toolchainVersion) {
157
+ const errorEntry = getErrorCode("CLI_VERSION_DRIFT");
158
+ checks.push({
159
+ id: "cli.version",
160
+ status: "fail",
161
+ code: "CLI_VERSION_DRIFT",
162
+ message: `CLI 版本 (${cliVersion}) 與 CI TOOLCHAIN_VERSION (${toolchainVersion}) 不一致`,
163
+ suggestion: errorEntry?.suggestion ?? "請更新 CLI 或 CI 設定使版本一致。",
164
+ });
165
+ }
166
+ else {
167
+ checks.push({
168
+ id: "cli.version",
169
+ status: "pass",
170
+ message: `CLI 版本 (${cliVersion}) 正常`,
171
+ });
172
+ }
173
+ // --- 2. Host checks ---
174
+ if (!options.host) {
175
+ checks.push({
176
+ id: "host.reachability",
177
+ status: "skip",
178
+ message: "未指定 --host,略過 NAS 檢查",
179
+ });
180
+ checks.push({
181
+ id: "host.broker",
182
+ status: "skip",
183
+ message: "未指定 --host,略過 Broker 檢查",
184
+ });
185
+ checks.push({
186
+ id: "host.attestation_trust_root",
187
+ status: "skip",
188
+ message: "未指定 --host,略過 Attestation Trust Root 檢查",
189
+ });
190
+ if (options.packageId) {
191
+ checks.push({
192
+ id: "host.package_binding",
193
+ status: "skip",
194
+ message: "未指定 --host,略過 Package Binding 檢查",
195
+ });
196
+ checks.push({
197
+ id: "host.trusted_app_entry",
198
+ status: "skip",
199
+ message: "未指定 --host,略過 Trusted App Entry 檢查",
200
+ });
201
+ }
202
+ }
203
+ else {
204
+ const host = options.host;
205
+ // Check 2.1: NAS Reachability
206
+ let isReachable = false;
207
+ try {
208
+ // Use getInstalledPackageVersion probe or fetch
209
+ await host.isPackageInstalled("SynoPkgLandService");
210
+ isReachable = true;
211
+ checks.push({
212
+ id: "host.reachability",
213
+ status: "pass",
214
+ message: `成功連線至 NAS (${host.dsmOrigin})`,
215
+ });
216
+ }
217
+ catch (error) {
218
+ const errorEntry = getErrorCode("HOST_UNREACHABLE");
219
+ const errStr = error instanceof Error ? error.message : String(error);
220
+ const isNodeUnreach = errStr.includes("EHOSTUNREACH") || errStr.includes("ENETUNREACH");
221
+ const suggestion = isNodeUnreach
222
+ ? "連線失敗 (EHOSTUNREACH)。已內建 curl fallback,請確認 NAS IP/網址可達且防火牆允許連線。"
223
+ : (errorEntry?.suggestion ?? "請檢查 NAS 位址與憑證。");
224
+ checks.push({
225
+ id: "host.reachability",
226
+ status: "fail",
227
+ code: "HOST_UNREACHABLE",
228
+ message: `無法連線至 NAS (${host.dsmOrigin}):${errStr}`,
229
+ suggestion,
230
+ });
231
+ }
232
+ if (!isReachable) {
233
+ checks.push({
234
+ id: "host.broker",
235
+ status: "skip",
236
+ message: "NAS 無法連線,略過 Broker 檢查",
237
+ });
238
+ checks.push({
239
+ id: "host.attestation_trust_root",
240
+ status: "skip",
241
+ message: "NAS 無法連線,略過 Attestation Trust Root 檢查",
242
+ });
243
+ if (options.packageId) {
244
+ checks.push({
245
+ id: "host.package_binding",
246
+ status: "skip",
247
+ message: "NAS 無法連線,略過 Package Binding 檢查",
248
+ });
249
+ checks.push({
250
+ id: "host.trusted_app_entry",
251
+ status: "skip",
252
+ message: "NAS 無法連線,略過 Trusted App Entry 檢查",
253
+ });
254
+ }
255
+ }
256
+ else {
257
+ // Check 2.2: Broker installed / running
258
+ let isBrokerReady = false;
259
+ let brokerInstalled;
260
+ try {
261
+ brokerInstalled = await host.isPackageInstalled("SynoPkgLandService");
262
+ if (!brokerInstalled) {
263
+ const errorEntry = getErrorCode("BROKER_NOT_INSTALLED");
264
+ checks.push({
265
+ id: "host.broker",
266
+ status: "fail",
267
+ code: "BROKER_NOT_INSTALLED",
268
+ message: "目標 NAS 尚未安裝 SynoPkgLandService (Broker)",
269
+ suggestion: errorEntry?.suggestion ?? "請至 DSM Package Center 安裝 SynoPkgLandService 套件。",
270
+ });
271
+ }
272
+ else {
273
+ // Probe inner-loop API
274
+ try {
275
+ await host.getAttestationInfo("probe");
276
+ isBrokerReady = true;
277
+ checks.push({
278
+ id: "host.broker",
279
+ status: "pass",
280
+ message: "SynoPkgLandService (Broker) 已安裝且運作中",
281
+ });
282
+ }
283
+ catch (innerErr) {
284
+ const errCode = innerErr?.code;
285
+ if (errCode === "INNER_LOOP_API_MISSING") {
286
+ const errorEntry = getErrorCode("INNER_LOOP_API_MISSING");
287
+ checks.push({
288
+ id: "host.broker",
289
+ status: "fail",
290
+ code: "INNER_LOOP_API_MISSING",
291
+ message: "Broker inner-loop API 不可用,版本可能過舊",
292
+ suggestion: errorEntry?.suggestion ?? "請升級 SynoPkgLandService 套件至最新版本。",
293
+ });
294
+ }
295
+ else {
296
+ isBrokerReady = true;
297
+ checks.push({
298
+ id: "host.broker",
299
+ status: "pass",
300
+ message: "SynoPkgLandService (Broker) 運作中",
301
+ });
302
+ }
303
+ }
304
+ }
305
+ }
306
+ catch (err) {
307
+ const errorEntry = getErrorCode("BROKER_NOT_INSTALLED");
308
+ checks.push({
309
+ id: "host.broker",
310
+ status: "fail",
311
+ code: "BROKER_NOT_INSTALLED",
312
+ message: `Broker 狀態檢查失敗:${err instanceof Error ? err.message : String(err)}`,
313
+ suggestion: errorEntry?.suggestion ?? "請確認 SynoPkgLandService 已安裝並正常啟動。",
314
+ });
315
+ }
316
+ // Check 2.3: Attestation Trust Root
317
+ if (!isBrokerReady) {
318
+ checks.push({
319
+ id: "host.attestation_trust_root",
320
+ status: "skip",
321
+ message: "Broker 未就緒,略過 Trust Root 檢查",
322
+ });
323
+ }
324
+ else {
325
+ // Probe trust root availability
326
+ try {
327
+ const info = await host.getAttestationInfo("probe");
328
+ if (info.attestationKind === "none") {
329
+ // Not broken, trust root is fine or absent
330
+ checks.push({
331
+ id: "host.attestation_trust_root",
332
+ status: "pass",
333
+ message: "Broker Attestation API 已連通",
334
+ });
335
+ }
336
+ else {
337
+ checks.push({
338
+ id: "host.attestation_trust_root",
339
+ status: "pass",
340
+ message: "Broker Attestation API 已連通",
341
+ });
342
+ }
343
+ }
344
+ catch (attErr) {
345
+ const errCode = attErr?.code;
346
+ if (errCode === "ATTESTATION_TRUST_ROOT_MISSING") {
347
+ const errorEntry = getErrorCode("ATTESTATION_TRUST_ROOT_MISSING");
348
+ checks.push({
349
+ id: "host.attestation_trust_root",
350
+ status: "fail",
351
+ code: "ATTESTATION_TRUST_ROOT_MISSING",
352
+ message: "Broker 缺少生產環境 Attestation Trust Root 信任根憑證",
353
+ suggestion: errorEntry?.suggestion ?? "請確認 SynoPkgLandService 套件完整安裝且 trust root keyring 未被移除。",
354
+ });
355
+ }
356
+ else if (errCode === "ATTESTATION_MISSING") {
357
+ const errorEntry = getErrorCode("ATTESTATION_MISSING");
358
+ checks.push({
359
+ id: "host.attestation_trust_root",
360
+ status: "fail",
361
+ code: "ATTESTATION_MISSING",
362
+ message: "App Bundle 缺少必要的 Attestation 簽章憑證",
363
+ suggestion: errorEntry?.suggestion ?? "請先透過 synopkgland deploy 簽發 Local Attestation。",
364
+ });
365
+ }
366
+ else if (errCode === "ATTESTATION_INVALID") {
367
+ const errorEntry = getErrorCode("ATTESTATION_INVALID");
368
+ checks.push({
369
+ id: "host.attestation_trust_root",
370
+ status: "fail",
371
+ code: "ATTESTATION_INVALID",
372
+ message: "Attestation Token 驗證失敗",
373
+ suggestion: errorEntry?.suggestion ?? "請重新執行 synopkgland deploy 產生新 Attestation。",
374
+ });
375
+ }
376
+ else {
377
+ checks.push({
378
+ id: "host.attestation_trust_root",
379
+ status: "pass",
380
+ message: "Broker Attestation API 已連通",
381
+ });
382
+ }
383
+ }
384
+ }
385
+ // Check 2.4: If packageId is provided (or derived)
386
+ const targetPackageId = options.packageId ?? (options.project ? resolveDeployIdentity(options.project, { dev: options.dev }).packageId : undefined);
387
+ if (targetPackageId) {
388
+ if (brokerInstalled === false) {
389
+ checks.push({
390
+ id: "host.package_binding",
391
+ status: "skip",
392
+ message: `Broker 未安裝,無法查詢套件 ${targetPackageId} 的 Project Binding`,
393
+ });
394
+ checks.push({
395
+ id: "host.trusted_app_entry",
396
+ status: "skip",
397
+ message: `Broker 未安裝,略過套件 ${targetPackageId} 的 Trusted App Entry 探測`,
398
+ });
399
+ }
400
+ else {
401
+ try {
402
+ const isInstalled = await host.isPackageInstalled(targetPackageId);
403
+ if (!isInstalled) {
404
+ checks.push({
405
+ id: "host.package_binding",
406
+ status: "skip",
407
+ message: `套件 ${targetPackageId} 尚未部署至此 NAS`,
408
+ });
409
+ checks.push({
410
+ id: "host.trusted_app_entry",
411
+ status: "skip",
412
+ message: `套件 ${targetPackageId} 未安裝,略過 Web Entry 探測`,
413
+ });
414
+ }
415
+ else {
416
+ // Check DSM package status first
417
+ let dsmPackageState;
418
+ if (host.getInstalledPackageState) {
419
+ try {
420
+ dsmPackageState = await host.getInstalledPackageState(targetPackageId);
421
+ }
422
+ catch {
423
+ // ignore
424
+ }
425
+ }
426
+ const isBroken = dsmPackageState?.status !== undefined &&
427
+ /(broken|error|fail|installing|150)/i.test(dsmPackageState.status);
428
+ if (isBroken && dsmPackageState) {
429
+ checks.push({
430
+ id: "host.package_binding",
431
+ status: "fail",
432
+ code: "PACKAGE_BROKEN",
433
+ message: `套件 ${targetPackageId} 在 DSM 處於異常狀態(status: ${dsmPackageState.status})。${packageFailureSummary(dsmPackageState)}`,
434
+ suggestion: packageFailureSuggestion(dsmPackageState),
435
+ });
436
+ checks.push({
437
+ id: "host.trusted_app_entry",
438
+ status: "skip",
439
+ message: `套件 ${targetPackageId} 安裝異常,略過 Web Entry 探測`,
440
+ });
441
+ }
442
+ else {
443
+ const info = await host.getAttestationInfo(targetPackageId);
444
+ if (info.readiness === "NOT_INSTALLED") {
445
+ const errorEntry = getErrorCode("PROJECT_BINDING_NOT_FOUND");
446
+ checks.push({
447
+ id: "host.package_binding",
448
+ status: "fail",
449
+ code: "PROJECT_BINDING_NOT_FOUND",
450
+ message: `套件 ${targetPackageId} 在 Broker 中無有效 Project Binding`,
451
+ suggestion: errorEntry?.suggestion ?? "請執行 synopkgland deploy 重新部署套件。",
452
+ });
453
+ }
454
+ else if (info.readiness === "PROJECT_BINDING_DRIFTED") {
455
+ const errorEntry = getErrorCode("PROJECT_BINDING_DRIFTED");
456
+ checks.push({
457
+ id: "host.package_binding",
458
+ status: "fail",
459
+ code: "PROJECT_BINDING_DRIFTED",
460
+ message: `套件 ${targetPackageId} Project Binding 發生狀態漂移`,
461
+ suggestion: errorEntry?.suggestion ?? "請執行 synopkgland deploy 重新同步部署。",
462
+ });
463
+ }
464
+ else if (info.readiness === "PROJECT_BINDING_REMOVED") {
465
+ const errorEntry = getErrorCode("PROJECT_BINDING_REMOVED");
466
+ checks.push({
467
+ id: "host.package_binding",
468
+ status: "fail",
469
+ code: "PROJECT_BINDING_REMOVED",
470
+ message: `套件 ${targetPackageId} Project Binding 已被標記為移除`,
471
+ suggestion: errorEntry?.suggestion ?? "請重新安裝套件。",
472
+ });
473
+ }
474
+ else if (info.readiness === "PROJECT_BINDING_UNKNOWN") {
475
+ const errorEntry = getErrorCode("PROJECT_BINDING_UNKNOWN");
476
+ checks.push({
477
+ id: "host.package_binding",
478
+ status: "fail",
479
+ code: "PROJECT_BINDING_UNKNOWN",
480
+ message: `套件 ${targetPackageId} Project 狀態暫時無法取得`,
481
+ suggestion: errorEntry?.suggestion ?? "請至 DSM 確認 Container Manager 服務正常運作。",
482
+ });
483
+ }
484
+ else if (info.readiness === "READINESS_TIMEOUT") {
485
+ const errorEntry = getErrorCode("READINESS_TIMEOUT");
486
+ checks.push({
487
+ id: "host.package_binding",
488
+ status: "fail",
489
+ code: "READINESS_TIMEOUT",
490
+ message: `套件 ${targetPackageId} 啟動逾時`,
491
+ suggestion: errorEntry?.suggestion ?? "請使用 synopkgland log --host <nas> 檢查容器啟動日誌。",
492
+ });
493
+ }
494
+ else {
495
+ const attNotice = info.attestationKind === "none" ? "未綁定 Attestation" : `Attestation:${info.attestationKind}`;
496
+ checks.push({
497
+ id: "host.package_binding",
498
+ status: "pass",
499
+ message: `Project Binding 正常(狀態:${info.readiness ?? "RUNNING"},${attNotice})`,
500
+ });
501
+ }
502
+ // Resource-worker SPKs do not register with Broker, so their
503
+ // Broker info has no openUrl even though the generated package
504
+ // still declares a Web entry. Keep the same contract fallback
505
+ // used by `info` and bind it to the explicitly diagnosed ID.
506
+ const contract = options.project
507
+ ? loadContract(options.project.contractPath).contract
508
+ : undefined;
509
+ const contractOpenUrl = contract
510
+ ? appOriginFromContract(host.dsmOrigin, {
511
+ package: targetPackageId,
512
+ adminprotocol: contract.adminprotocol,
513
+ adminport: contract.adminport,
514
+ })
515
+ : undefined;
516
+ const openUrl = info.openUrl ?? contractOpenUrl;
517
+ const hasDeclaredWebEntry = contract?.adminport !== undefined &&
518
+ contract.adminport !== null &&
519
+ contract.adminport !== "";
520
+ // HTTP probe on Trusted App Entry if an Open URL exists.
521
+ if (openUrl) {
522
+ try {
523
+ const response = await dsmFetch(openUrl);
524
+ if (response.ok) {
525
+ checks.push({
526
+ id: "host.trusted_app_entry",
527
+ status: "pass",
528
+ message: `Trusted App Entry 存取正常 (HTTP ${response.status}):${openUrl}`,
529
+ });
530
+ }
531
+ else {
532
+ const errorEntry = getErrorCode("TRUSTED_APP_ENTRY_FAILED");
533
+ checks.push({
534
+ id: "host.trusted_app_entry",
535
+ status: "fail",
536
+ code: "TRUSTED_APP_ENTRY_FAILED",
537
+ message: `Trusted App Entry HTTP 探測回傳 ${response.status}(可能為 WEBSTATION_ENTRY_FAILED):${openUrl}`,
538
+ suggestion: errorEntry?.suggestion ?? "請至 DSM Web Station 檢查反向代理與容器連接埠。",
539
+ });
540
+ }
541
+ }
542
+ catch (httpErr) {
543
+ const errorEntry = getErrorCode("TRUSTED_APP_ENTRY_FAILED");
544
+ checks.push({
545
+ id: "host.trusted_app_entry",
546
+ status: "fail",
547
+ code: "TRUSTED_APP_ENTRY_FAILED",
548
+ message: `無法連線至 Trusted App Entry (${openUrl}):${httpErr instanceof Error ? httpErr.message : String(httpErr)}`,
549
+ suggestion: errorEntry?.suggestion ?? "請檢查容器內部 Web 服務是否正常監聽並啟動。",
550
+ });
551
+ }
552
+ }
553
+ else {
554
+ checks.push({
555
+ id: "host.trusted_app_entry",
556
+ status: "skip",
557
+ message: hasDeclaredWebEntry
558
+ ? "此套件已宣告 Web 入口,但無法取得 Trusted App Entry Open URL,略過探測"
559
+ : options.project
560
+ ? "此套件沒有 Web 入口(Headless App),略過 Trusted App Entry 探測"
561
+ : "無法取得此套件的 Trusted App Entry Open URL,略過探測",
562
+ });
563
+ }
564
+ }
565
+ }
566
+ }
567
+ catch (pkgErr) {
568
+ checks.push({
569
+ id: "host.package_binding",
570
+ status: "skip",
571
+ message: `查詢套件 ${targetPackageId} 狀態失敗:${pkgErr instanceof Error ? pkgErr.message : String(pkgErr)}`,
572
+ });
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+ const ok = checks.every((c) => c.status !== "fail");
579
+ return { ok, checks };
580
+ }
581
+ export function formatDoctorReport(report) {
582
+ const lines = [];
583
+ for (const check of report.checks) {
584
+ const symbol = check.status === "pass" ? "[✓]" : check.status === "fail" ? "[✗]" : "[-]";
585
+ const codeTag = check.code ? ` [${check.code}]` : "";
586
+ lines.push(`${symbol} ${check.id}${codeTag}: ${check.message}`);
587
+ if (check.status === "fail" && check.suggestion) {
588
+ lines.push(` 建議:${check.suggestion}`);
589
+ }
590
+ }
591
+ lines.push("");
592
+ lines.push(report.ok ? "診斷全部通過。" : "診斷發現問題,請依照上述建議修正。");
593
+ return lines.join("\n");
594
+ }
@@ -0,0 +1,2 @@
1
+ export declare function extractReferencedErrorCodes(srcDirectory: string): Set<string>;
2
+ export declare function extractAllStringLiterals(srcDirectory: string): Set<string>;
@@ -0,0 +1,46 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ERROR_REGISTRY } from "../src/error-registry.js";
4
+ function getAllTsFiles(dir, excludeDirs = []) {
5
+ const files = [];
6
+ for (const entry of readdirSync(dir)) {
7
+ const fullPath = join(dir, entry);
8
+ if (statSync(fullPath).isDirectory()) {
9
+ if (!excludeDirs.includes(entry)) {
10
+ files.push(...getAllTsFiles(fullPath, excludeDirs));
11
+ }
12
+ }
13
+ else if (fullPath.endsWith(".ts")) {
14
+ files.push(fullPath);
15
+ }
16
+ }
17
+ return files;
18
+ }
19
+ export function extractReferencedErrorCodes(srcDirectory) {
20
+ const tsFiles = getAllTsFiles(srcDirectory, ["mock-broker"]).filter((f) => !f.endsWith("error-registry.ts"));
21
+ const referenced = new Set();
22
+ for (const file of tsFiles) {
23
+ const content = readFileSync(file, "utf8");
24
+ for (const code of Object.keys(ERROR_REGISTRY)) {
25
+ // Check if `code` appears as a string literal or identifier in the file
26
+ if (content.includes(`"${code}"`) || content.includes(`'${code}'`) || content.includes(`\`${code}\``)) {
27
+ referenced.add(code);
28
+ }
29
+ }
30
+ }
31
+ return referenced;
32
+ }
33
+ export function extractAllStringLiterals(srcDirectory) {
34
+ const tsFiles = getAllTsFiles(srcDirectory, ["mock-broker"]).filter((f) => !f.endsWith("error-registry.ts"));
35
+ const literals = new Set();
36
+ const codeRegex = /["']([A-Z][A-Z0-9_]{2,})["']/g;
37
+ for (const file of tsFiles) {
38
+ const content = readFileSync(file, "utf8");
39
+ codeRegex.lastIndex = 0;
40
+ let match;
41
+ while ((match = codeRegex.exec(content)) !== null) {
42
+ literals.add(match[1]);
43
+ }
44
+ }
45
+ return literals;
46
+ }
@@ -0,0 +1,7 @@
1
+ export interface ErrorCodeEntry {
2
+ code: string;
3
+ description: string;
4
+ suggestion: string;
5
+ }
6
+ export declare const ERROR_REGISTRY: Record<string, ErrorCodeEntry>;
7
+ export declare function getErrorCode(code: string): ErrorCodeEntry | undefined;