@synopackageland/cli 0.3.0 → 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.
- package/dist/attestation/app-package.js +8 -1
- package/dist/attestation/platform-service.d.ts +70 -0
- package/dist/attestation/platform-service.js +250 -0
- package/dist/bin.js +0 -0
- package/dist/build-spk/generator.js +210 -14
- package/dist/build-spk/resource-worker.d.ts +14 -0
- package/dist/build-spk/resource-worker.js +140 -0
- package/dist/cli.js +69 -10
- package/dist/compose/analyze.d.ts +41 -1
- package/dist/compose/analyze.js +458 -18
- package/dist/contract/load.d.ts +1 -0
- package/dist/contract/load.js +1 -1
- package/dist/contract/posix-all-shares.d.ts +16 -0
- package/dist/contract/posix-all-shares.js +63 -0
- package/dist/contract/schema-validate.js +26 -1
- package/dist/contract/validate.js +67 -0
- package/dist/contract/web-launch.d.ts +6 -0
- package/dist/contract/web-launch.js +25 -0
- package/dist/deploy/deploy.d.ts +3 -0
- package/dist/deploy/deploy.js +38 -2
- package/dist/deploy/homes-values.d.ts +19 -0
- package/dist/deploy/homes-values.js +29 -0
- package/dist/deploy/param-values.d.ts +21 -0
- package/dist/deploy/param-values.js +51 -0
- package/dist/deploy/spk-version.d.ts +5 -0
- package/dist/deploy/spk-version.js +50 -18
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +594 -0
- package/dist/error-collector.d.ts +2 -0
- package/dist/error-collector.js +46 -0
- package/dist/error-registry.d.ts +7 -0
- package/dist/error-registry.js +827 -0
- package/dist/host/broker-upgrade-preflight.d.ts +61 -0
- package/dist/host/broker-upgrade-preflight.js +156 -0
- package/dist/host/catalog-install.d.ts +12 -0
- package/dist/host/catalog-install.js +83 -17
- package/dist/host/credentials.js +35 -5
- package/dist/host/dsm-client.d.ts +12 -0
- package/dist/host/dsm-client.js +56 -1
- package/dist/host/dsm-compound.d.ts +37 -0
- package/dist/host/dsm-compound.js +120 -0
- package/dist/host/fake-nas.d.ts +1 -0
- package/dist/host/fake-nas.js +3 -0
- package/dist/host/types.d.ts +4 -0
- package/dist/publish/curated-release.js +3 -3
- package/dist/publish/platform-service-release.d.ts +60 -0
- package/dist/publish/platform-service-release.js +333 -0
- package/dist/publish/preflight.js +14 -1
- package/dist/release-bin.js +5 -1
- package/dist/templates/hello-files/compose.yaml +2 -0
- package/dist/templates/hello-web/compose.yaml +14 -3
- package/dist/templates/hello-web/synology-app.yaml +1 -1
- package/dist/templates/hello-web-page/compose.yaml +14 -3
- package/dist/templates/hello-web-page/synology-app.yaml +1 -1
- package/dist/test-host.d.ts +2 -0
- package/dist/test-host.js +15 -2
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +14 -3
- package/package.json +2 -2
- package/schema/doctor.schema.json +38 -0
- package/schema/synology-app.schema.json +1 -1
- package/skills/reference/create.md +19 -0
- package/skills/reference/diagnose.md +35 -2
- package/skills/reference/files.md +10 -0
- package/templates/hello-files/compose.yaml +2 -0
- package/templates/hello-web/compose.yaml +14 -3
- package/templates/hello-web/synology-app.yaml +1 -1
- package/templates/hello-web-page/compose.yaml +14 -3
- package/templates/hello-web-page/synology-app.yaml +1 -1
|
@@ -5,7 +5,7 @@ import { createHash } from "node:crypto";
|
|
|
5
5
|
import * as tar from "tar";
|
|
6
6
|
import { loadContract, collectInstallParameters } from "../contract/load.js";
|
|
7
7
|
import { renderDsmWindowConfig, resolvedWebLaunch, trustedAppEntryPath, } from "../contract/web-launch.js";
|
|
8
|
-
import { loadCompose } from "../compose/analyze.js";
|
|
8
|
+
import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
|
|
9
9
|
import { scanComposePrivilege } from "../compose/privilege.js";
|
|
10
10
|
import { validateProject } from "../validate.js";
|
|
11
11
|
import { writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
|
|
@@ -16,6 +16,7 @@ import { BROKER_INSTALLER_VARS, BROKER_SOCKET_REQUIRED_SCRIPT, renderBrokerStatu
|
|
|
16
16
|
import { loadNative } from "../native/load.js";
|
|
17
17
|
import { materializeNativePayload } from "./native-payload.js";
|
|
18
18
|
import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, } from "./native-lifecycle.js";
|
|
19
|
+
import { renderResourceConfig, renderResourceWorkerStartStopStatus } from "./resource-worker.js";
|
|
19
20
|
const FILE_MODE = 0o644;
|
|
20
21
|
const SCRIPT_MODE = 0o755;
|
|
21
22
|
const INSTALL_UIFILE = "install_uifile";
|
|
@@ -129,30 +130,56 @@ export async function buildSpk(options) {
|
|
|
129
130
|
rmSync(staging, { recursive: true, force: true });
|
|
130
131
|
}
|
|
131
132
|
}
|
|
133
|
+
const parsedCompose = loadCompose(options.project.composePath);
|
|
134
|
+
const route = parsedCompose?.compose
|
|
135
|
+
? classifyComposeRoute(contract, parsedCompose)
|
|
136
|
+
: undefined;
|
|
137
|
+
if (route && route.errors.length > 0) {
|
|
138
|
+
return { ok: false, errors: route.errors.map((error) => error.code) };
|
|
139
|
+
}
|
|
140
|
+
const rwEligibility = route?.eligibility;
|
|
141
|
+
const isDynamicRoute = route?.route === "dynamic" &&
|
|
142
|
+
rwEligibility?.adminport !== undefined &&
|
|
143
|
+
collectInstallParameters(contract).length > 0;
|
|
144
|
+
const isResourceWorkerRoute = route?.route === "static-zero-config" ||
|
|
145
|
+
isDynamicRoute;
|
|
132
146
|
const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
|
|
133
147
|
try {
|
|
134
|
-
writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily), "utf8");
|
|
148
|
+
writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily, isResourceWorkerRoute), "utf8");
|
|
135
149
|
writePackageCenterIcons(staging);
|
|
136
150
|
mkdirSync(join(staging, "conf"), { recursive: true });
|
|
137
151
|
writeFileSync(join(staging, "conf", "privilege"), renderPrivilege(), "utf8");
|
|
138
152
|
writeFileSync(join(staging, "privilege"), renderPrivilege(), "utf8");
|
|
153
|
+
if (isResourceWorkerRoute) {
|
|
154
|
+
const resourceConfig = renderResourceConfig(contract, rwEligibility);
|
|
155
|
+
writeFileSync(join(staging, "conf", "resource"), resourceConfig, "utf8");
|
|
156
|
+
chmodSync(join(staging, "conf", "resource"), FILE_MODE);
|
|
157
|
+
}
|
|
139
158
|
mkdirSync(join(staging, "WIZARD_UIFILES"), { recursive: true });
|
|
140
|
-
const parsedCompose = loadCompose(options.project.composePath);
|
|
141
159
|
writeFileSync(join(staging, "WIZARD_UIFILES", INSTALL_UIFILE), JSON.stringify(renderInstallWizard(contract, parsedCompose?.compose), null, 2), "utf8");
|
|
142
160
|
writeFileSync(join(staging, "WIZARD_UIFILES", UPGRADE_UIFILE), JSON.stringify(renderUpgradeWizard(contract, parsedCompose?.compose), null, 2), "utf8");
|
|
143
161
|
writeFileSync(join(staging, "WIZARD_UIFILES", UNINSTALL_UIFILE), JSON.stringify(renderUninstallWizard(), null, 2), "utf8");
|
|
144
162
|
mkdirSync(join(staging, "scripts"), { recursive: true });
|
|
145
163
|
for (const name of SCRIPT_NAMES) {
|
|
146
164
|
const scriptPath = join(staging, "scripts", name);
|
|
147
|
-
writeFileSync(scriptPath, renderManagedScript(name, contract), "utf8");
|
|
165
|
+
writeFileSync(scriptPath, renderManagedScript(name, contract, undefined, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute), "utf8");
|
|
148
166
|
chmodSync(scriptPath, SCRIPT_MODE);
|
|
149
167
|
}
|
|
150
168
|
mkdirSync(join(staging, "package"), { recursive: true });
|
|
151
169
|
copyFileSync(options.project.composePath, join(staging, "package", "compose.yaml"));
|
|
152
170
|
copyFileSync(options.project.contractPath, join(staging, "package", "synology-app.yaml"));
|
|
171
|
+
if (isResourceWorkerRoute) {
|
|
172
|
+
const resourceProjectDir = join(staging, "package", "compose");
|
|
173
|
+
mkdirSync(resourceProjectDir, { recursive: true });
|
|
174
|
+
copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
|
|
175
|
+
chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
|
|
176
|
+
}
|
|
153
177
|
const htmlDir = join(options.project.root, "html");
|
|
154
178
|
if (existsSync(htmlDir)) {
|
|
155
179
|
cpSync(htmlDir, join(staging, "package", "html"), { recursive: true });
|
|
180
|
+
if (isResourceWorkerRoute) {
|
|
181
|
+
cpSync(htmlDir, join(staging, "package", "compose", "html"), { recursive: true });
|
|
182
|
+
}
|
|
156
183
|
}
|
|
157
184
|
if (options.embeddedAttestationToken !== undefined) {
|
|
158
185
|
const attestationPath = join(staging, "package", EMBEDDED_ATTESTATION_RELATIVE_PATH);
|
|
@@ -190,7 +217,7 @@ function quoteInfo(value) {
|
|
|
190
217
|
function spkVersion(version) {
|
|
191
218
|
return /-\d+$/.test(version) ? version : `${version}-0001`;
|
|
192
219
|
}
|
|
193
|
-
function renderInfo(contract, archFamily) {
|
|
220
|
+
function renderInfo(contract, archFamily, isResourceWorker = false) {
|
|
194
221
|
const lines = [];
|
|
195
222
|
const add = (key, value) => {
|
|
196
223
|
lines.push(`${key}=${quoteInfo(String(value))}`);
|
|
@@ -202,10 +229,22 @@ function renderInfo(contract, archFamily) {
|
|
|
202
229
|
add("maintainer", String(contract.maintainer));
|
|
203
230
|
add("arch", archFamily === undefined ? "noarch" : infoArchValue(archFamily));
|
|
204
231
|
add("os_min_ver", String(contract.os_min_ver));
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
232
|
+
if (isResourceWorker) {
|
|
233
|
+
// DSM reports Container Manager as "not ready" through the package
|
|
234
|
+
// dependency gate even while pkgctl-ContainerManager is running. The
|
|
235
|
+
// docker-project worker itself remains the authoritative live prerequisite
|
|
236
|
+
// (ContainerManager >= 1432); keep only the reliable Web Station package
|
|
237
|
+
// dependency here so a valid resource-worker SPK can reach acquisition.
|
|
238
|
+
if (contract.adminport !== undefined) {
|
|
239
|
+
add("install_dep_packages", "WebStation");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// synopkg's Container Manager dep gate reports "not ready" even when CM is
|
|
244
|
+
// running. Depend on Broker only; Broker's adapter.probe() checks CM at
|
|
245
|
+
// App install time (CM_CAPABILITY_MISSING).
|
|
246
|
+
add("install_dep_packages", "SynoPkgLandService>=0.1.0-0016");
|
|
247
|
+
}
|
|
209
248
|
add("startable", "yes");
|
|
210
249
|
const launch = resolvedWebLaunch(contract);
|
|
211
250
|
if (contract.adminprotocol) {
|
|
@@ -243,6 +282,7 @@ function renderPrivilege() {
|
|
|
243
282
|
}, null, 2)}\n`;
|
|
244
283
|
}
|
|
245
284
|
const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
|
|
285
|
+
const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
|
|
246
286
|
const DSM_USER_RUN_AS_CONSENT = "寫進 Share 的新檔在 File Station 顯示為你選的那位 DSM 使用者。點開此 App 的人共用這個磁碟身分,不是逐登入 ACL。";
|
|
247
287
|
const DSM_USER_UPGRADE_CONSENT = "升級時更換 Container Run-as 不會 chown Share 上的舊檔;只有升級後寫入的新檔才會使用新的磁碟身分。";
|
|
248
288
|
const SHARED_FOLDER_RW_UPGRADE_CONSENT = "此版本把 Share 掛載改為讀寫;容器可讀取並寫入你選的 Share,不再是唯讀。";
|
|
@@ -315,11 +355,12 @@ function volumeMounts(compose) {
|
|
|
315
355
|
}
|
|
316
356
|
return mounts;
|
|
317
357
|
}
|
|
358
|
+
// A Compose that mounts nothing reaches even less of the NAS than one that
|
|
359
|
+
// mounts only PKGVAR, so it earns the same consent line. Returning false for
|
|
360
|
+
// zero sources left the wizard step with no items at all, and DSM's Package
|
|
361
|
+
// Center rejects that SPK with "Invalid file format".
|
|
318
362
|
function composeUsesOnlyAppSpace(compose) {
|
|
319
363
|
const sources = volumeSources(compose);
|
|
320
|
-
if (sources.length === 0) {
|
|
321
|
-
return false;
|
|
322
|
-
}
|
|
323
364
|
return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
|
|
324
365
|
}
|
|
325
366
|
const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
|
|
@@ -327,6 +368,7 @@ function renderInstallWizard(contract, compose) {
|
|
|
327
368
|
const items = [];
|
|
328
369
|
const params = collectInstallParameters(contract);
|
|
329
370
|
const hasSharedFolder = params.some((item) => item.param.type === "shared_folder");
|
|
371
|
+
const hasHomes = params.some((item) => item.param.type === "homes");
|
|
330
372
|
const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
|
|
331
373
|
const capabilityText = renderFilesCapability(contract);
|
|
332
374
|
if (compose && scanComposePrivilege(compose).length > 0) {
|
|
@@ -344,6 +386,11 @@ function renderInstallWizard(contract, compose) {
|
|
|
344
386
|
desc: SHARED_FOLDER_MOUNT_CONSENT,
|
|
345
387
|
});
|
|
346
388
|
}
|
|
389
|
+
if (hasHomes) {
|
|
390
|
+
items.push({
|
|
391
|
+
desc: HOMES_MOUNT_CONSENT,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
347
394
|
if (hasDsmUser) {
|
|
348
395
|
items.push({
|
|
349
396
|
desc: DSM_USER_RUN_AS_CONSENT,
|
|
@@ -354,7 +401,10 @@ function renderInstallWizard(contract, compose) {
|
|
|
354
401
|
desc: capabilityText,
|
|
355
402
|
});
|
|
356
403
|
}
|
|
357
|
-
|
|
404
|
+
// No compose at all is the native branch: the binary gets PKGVAR and nothing
|
|
405
|
+
// else, so it earns the same line. Requiring a compose left native wizards
|
|
406
|
+
// empty, which DSM rejects as an invalid package.
|
|
407
|
+
if (!hasSharedFolder && !capabilityText && (!compose || composeUsesOnlyAppSpace(compose))) {
|
|
358
408
|
items.push({
|
|
359
409
|
desc: APP_OWN_SPACE_CONSENT,
|
|
360
410
|
});
|
|
@@ -511,7 +561,128 @@ function renderUninstallWizard() {
|
|
|
511
561
|
],
|
|
512
562
|
}];
|
|
513
563
|
}
|
|
514
|
-
function
|
|
564
|
+
function renderDynamicPreinstMaterializer(contract) {
|
|
565
|
+
const params = collectInstallParameters(contract).filter(({ param }) => param.type === "string" || param.type === "boolean");
|
|
566
|
+
const lines = [
|
|
567
|
+
"#!/bin/sh",
|
|
568
|
+
"set -eu",
|
|
569
|
+
"umask 077",
|
|
570
|
+
"# D3 package-only carrier; values are wizard answers, canonical values, or defaults.",
|
|
571
|
+
"die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
|
|
572
|
+
'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
|
|
573
|
+
'STATUS="${SYNOPKG_PKG_STATUS:?}"',
|
|
574
|
+
'PROJECT="$T/compose"',
|
|
575
|
+
'RUNTIME_ENV="$PROJECT/.env"',
|
|
576
|
+
'CANONICAL_ENV="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}/.env"',
|
|
577
|
+
'PLATFORM_PKGVAR="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
|
|
578
|
+
'PLATFORM_SECRETS="${SYNOPKG_PKGDEST_VOL:?}/@apphome/${SYNOPKG_PKGNAME:?}/secrets"',
|
|
579
|
+
'[ -d "$PROJECT" ] || die "compose directory is missing"',
|
|
580
|
+
'[ ! -L "$PROJECT" ] || die "compose directory is a symlink"',
|
|
581
|
+
'[ "$(stat -c %u "$PROJECT")" = "$(id -u)" ] || die "compose directory owner is not the package user"',
|
|
582
|
+
'[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
|
|
583
|
+
'if [ -e "$RUNTIME_ENV" ] && [ ! -f "$RUNTIME_ENV" ]; then die "runtime path is not a regular file"; fi',
|
|
584
|
+
"read_env_value() {",
|
|
585
|
+
" file=\"$1\"",
|
|
586
|
+
" key=\"$2\"",
|
|
587
|
+
" awk -F= -v key=\"$key\" '$1 == key { value=substr($0, index($0,\"=\")+1); if (value ~ /^\".*\"$/) value=substr(value,2,length(value)-2); print value; found=1; exit } END { if (!found) exit 1 }' \"$file\"",
|
|
588
|
+
"}",
|
|
589
|
+
"append_env() {",
|
|
590
|
+
" key=\"$1\"",
|
|
591
|
+
" value=\"$2\"",
|
|
592
|
+
" type=\"${3:-string}\"",
|
|
593
|
+
" if printf '%s' \"$value\" | LC_ALL=C grep -q '[[:cntrl:]]'; then",
|
|
594
|
+
" die \"control character in environment value $key\"",
|
|
595
|
+
" fi",
|
|
596
|
+
" case \"$value\" in",
|
|
597
|
+
" *[[:cntrl:]]*) die \"control character in environment value $key\" ;;",
|
|
598
|
+
" esac",
|
|
599
|
+
" if [ \"$type\" = \"boolean\" ]; then",
|
|
600
|
+
" normalized=\"$(printf '%s' \"$value\" | LC_ALL=C tr '[:upper:]' '[:lower:]')\"",
|
|
601
|
+
" case \"$normalized\" in",
|
|
602
|
+
" true|false) value=\"$normalized\" ;;",
|
|
603
|
+
" *) die \"invalid boolean value $key\" ;;",
|
|
604
|
+
" esac",
|
|
605
|
+
" fi",
|
|
606
|
+
" case \"$value\" in",
|
|
607
|
+
" *[!A-Za-z0-9_./:@-]*)",
|
|
608
|
+
" escaped=\"$(printf '%s' \"$value\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\"",
|
|
609
|
+
" printf '%s=\"%s\"\\n' \"$key\" \"$escaped\"",
|
|
610
|
+
" ;;",
|
|
611
|
+
" *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
|
|
612
|
+
" esac",
|
|
613
|
+
"}",
|
|
614
|
+
'tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"',
|
|
615
|
+
'chmod 600 "$tmp"',
|
|
616
|
+
'[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
|
|
617
|
+
'[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"',
|
|
618
|
+
"trap 'rm -f \"$tmp\"' EXIT HUP INT TERM",
|
|
619
|
+
"{",
|
|
620
|
+
];
|
|
621
|
+
const valueVars = [];
|
|
622
|
+
for (const { name, param } of params) {
|
|
623
|
+
const type = param.type;
|
|
624
|
+
const valueVar = "D3_VALUE_" + name;
|
|
625
|
+
valueVars.push({ name, valueVar, type });
|
|
626
|
+
const fallback = param.default !== undefined
|
|
627
|
+
? String(param.default)
|
|
628
|
+
: type === "boolean"
|
|
629
|
+
? "false"
|
|
630
|
+
: "";
|
|
631
|
+
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
632
|
+
`${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
|
|
633
|
+
}
|
|
634
|
+
lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
|
|
635
|
+
for (const { name, valueVar, type } of valueVars) {
|
|
636
|
+
lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
|
|
637
|
+
}
|
|
638
|
+
lines.push(" } > \"$tmp\"", " mv \"$tmp\" \"$RUNTIME_ENV\"", " trap - EXIT HUP INT TERM", " [ ! -L \"$RUNTIME_ENV\" ] || die \"runtime file became a symlink\"", " [ \"$(stat -c %a \"$RUNTIME_ENV\")\" = \"600\" ] || die \"runtime env mode is not 0600\"", "exit 0");
|
|
639
|
+
return lines.join("\n") + "\n";
|
|
640
|
+
}
|
|
641
|
+
function renderDynamicCanonicalMirror() {
|
|
642
|
+
const lines = [
|
|
643
|
+
"#!/bin/sh",
|
|
644
|
+
"set -eu",
|
|
645
|
+
"umask 077",
|
|
646
|
+
"# D3 post-worker mirror; the runtime .env is the only source of truth here.",
|
|
647
|
+
"die() { echo \"dynamic route canonical mirror failed: $1\" >&2; exit 1; }",
|
|
648
|
+
'PKGVAR="${SYNOPKG_PKGVAR:?}"',
|
|
649
|
+
'PKGHOME="${SYNOPKG_PKGHOME:?}"',
|
|
650
|
+
'PKGDEST="${SYNOPKG_PKGDEST:?}"',
|
|
651
|
+
'PROJECT="${PKGDEST}/compose"',
|
|
652
|
+
'RUNTIME_ENV="${PROJECT}/.env"',
|
|
653
|
+
'CANONICAL_ENV="${PKGVAR}/.env"',
|
|
654
|
+
'SECRETS="${PKGHOME}/secrets"',
|
|
655
|
+
'mkdir -p "$PKGVAR"',
|
|
656
|
+
'[ -d "$PKGVAR" ] || die "PKGVAR is not a directory"',
|
|
657
|
+
'if [ -L "$SECRETS" ]; then die "secret root is a symlink"; fi',
|
|
658
|
+
'mkdir -p "$SECRETS"',
|
|
659
|
+
'[ -d "$SECRETS" ] || die "secret root is not a directory"',
|
|
660
|
+
'chmod 700 "$SECRETS"',
|
|
661
|
+
'[ "$(stat -c %a "$SECRETS")" = "700" ] || die "secret root mode is not 0700"',
|
|
662
|
+
'[ -d "$PROJECT" ] || die "project directory is missing"',
|
|
663
|
+
'[ ! -L "$PROJECT" ] || die "project directory is a symlink"',
|
|
664
|
+
'[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
|
|
665
|
+
'[ -f "$RUNTIME_ENV" ] || die "runtime file is not a regular file"',
|
|
666
|
+
'[ ! -L "$CANONICAL_ENV" ] || die "canonical file is a symlink"',
|
|
667
|
+
'if [ -e "$CANONICAL_ENV" ] && [ ! -f "$CANONICAL_ENV" ]; then die "canonical path is not a regular file"; fi',
|
|
668
|
+
'tmp="$(mktemp "$PKGVAR/.env.tmp.XXXXXX")"',
|
|
669
|
+
'chmod 600 "$tmp"',
|
|
670
|
+
'[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
|
|
671
|
+
'trap \'rm -f "$tmp"\' EXIT HUP INT TERM',
|
|
672
|
+
'cp "$RUNTIME_ENV" "$tmp"',
|
|
673
|
+
'chmod 600 "$tmp"',
|
|
674
|
+
'mv "$tmp" "$CANONICAL_ENV"',
|
|
675
|
+
'trap - EXIT HUP INT TERM',
|
|
676
|
+
'[ ! -L "$CANONICAL_ENV" ] || die "canonical file became a symlink"',
|
|
677
|
+
'[ "$(stat -c %a "$CANONICAL_ENV")" = "600" ] || die "canonical env mode is not 0600"',
|
|
678
|
+
"exit 0",
|
|
679
|
+
];
|
|
680
|
+
return lines.join("\n") + "\n";
|
|
681
|
+
}
|
|
682
|
+
function shellQuote(value) {
|
|
683
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
684
|
+
}
|
|
685
|
+
function renderManagedScript(name, contract, nativeContext, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false) {
|
|
515
686
|
if (nativeContext) {
|
|
516
687
|
const lifecycleInput = {
|
|
517
688
|
packageIdentity: String(contract.package),
|
|
@@ -549,6 +720,31 @@ fi
|
|
|
549
720
|
}
|
|
550
721
|
return `#!/bin/sh\nexit 0\n`;
|
|
551
722
|
}
|
|
723
|
+
if (isResourceWorker) {
|
|
724
|
+
if (name === "start-stop-status") {
|
|
725
|
+
return renderResourceWorkerStartStopStatus(resourceWorkerAdminport);
|
|
726
|
+
}
|
|
727
|
+
if (isDynamicRoute && name === "preinst") {
|
|
728
|
+
return renderDynamicPreinstMaterializer(contract);
|
|
729
|
+
}
|
|
730
|
+
if (isDynamicRoute && (name === "postinst" || name === "postupgrade")) {
|
|
731
|
+
return renderDynamicCanonicalMirror();
|
|
732
|
+
}
|
|
733
|
+
if (name === "postuninst") {
|
|
734
|
+
const projectRoot = isDynamicRoute ? 'PROJECT="${SYNOPKG_PKGDEST}/compose"\n' : "";
|
|
735
|
+
const deleteRoots = isDynamicRoute
|
|
736
|
+
? ["PKGVAR", "PROJECT", "SECRETS"]
|
|
737
|
+
: ["PKGVAR", "SECRETS"];
|
|
738
|
+
return `#!/bin/sh
|
|
739
|
+
set -eu
|
|
740
|
+
PKGVAR="\${SYNOPKG_PKGVAR}"
|
|
741
|
+
PKGHOME="\${SYNOPKG_PKGHOME}"
|
|
742
|
+
SECRETS="\${PKGHOME}/secrets"
|
|
743
|
+
${projectRoot}${renderDeleteAppDataBlock(deleteRoots)}exit 0
|
|
744
|
+
`;
|
|
745
|
+
}
|
|
746
|
+
return `#!/bin/sh\nexit 0\n`;
|
|
747
|
+
}
|
|
552
748
|
if (name === "start-stop-status") {
|
|
553
749
|
return `#!/bin/sh
|
|
554
750
|
${BROKER_INSTALLER_VARS}
|
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|