@synopackageland/cli 0.2.0 → 0.3.1

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/bin.js CHANGED
@@ -1,12 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadDotEnv } from "./load-env.js";
3
3
  import { runCli } from "./cli.js";
4
- import { runCuratedReleaseCli } from "./publish/curated-release.js";
5
4
  loadDotEnv();
6
- const execution = process.argv[2] === "release"
7
- ? runCuratedReleaseCli(process.argv)
8
- : runCli(process.argv).then(() => 0);
9
- execution.then((status) => {
5
+ // Release authority lives in the separate `synopkgland-release` binary, so the
6
+ // developer-facing tool has no publish surface at all.
7
+ runCli(process.argv).then(() => 0).then((status) => {
10
8
  process.exitCode = status;
11
9
  }).catch((error) => {
12
10
  console.error(error instanceof Error ? error.message : String(error));
@@ -315,11 +315,12 @@ function volumeMounts(compose) {
315
315
  }
316
316
  return mounts;
317
317
  }
318
+ // A Compose that mounts nothing reaches even less of the NAS than one that
319
+ // mounts only PKGVAR, so it earns the same consent line. Returning false for
320
+ // zero sources left the wizard step with no items at all, and DSM's Package
321
+ // Center rejects that SPK with "Invalid file format".
318
322
  function composeUsesOnlyAppSpace(compose) {
319
323
  const sources = volumeSources(compose);
320
- if (sources.length === 0) {
321
- return false;
322
- }
323
324
  return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
324
325
  }
325
326
  const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
@@ -354,7 +355,10 @@ function renderInstallWizard(contract, compose) {
354
355
  desc: capabilityText,
355
356
  });
356
357
  }
357
- if (!hasSharedFolder && !capabilityText && compose && composeUsesOnlyAppSpace(compose)) {
358
+ // No compose at all is the native branch: the binary gets PKGVAR and nothing
359
+ // else, so it earns the same line. Requiring a compose left native wizards
360
+ // empty, which DSM rejects as an invalid package.
361
+ if (!hasSharedFolder && !capabilityText && (!compose || composeUsesOnlyAppSpace(compose))) {
358
362
  items.push({
359
363
  desc: APP_OWN_SPACE_CONSENT,
360
364
  });
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import { followProjectLogs } from "./log.js";
16
16
  import { testProjectOnHost } from "./test-host.js";
17
17
  import { formatSkillList, readSkill, resolveSkillsRoot } from "./skill.js";
18
18
  import { publishProject, resolveCommunityAppsRoot } from "./publish/publish.js";
19
+ import { formatPreflight, runPublicReleasePreflight } from "./publish/preflight.js";
19
20
  import { createExecGitHubCli, createExecGitOps } from "./publish/github-pr.js";
20
21
  import { communityAppsGitHubRepo } from "./publish/github-repo.js";
21
22
  function collectKvOption(value, previous, flag, example) {
@@ -52,12 +53,11 @@ export async function runCli(argv) {
52
53
  program
53
54
  .command("validate")
54
55
  .description("驗證 App Contract 與 Compose")
55
- .option("--curated", "使用 Curated listing profile(未宣告 privilege 為紅燈)")
56
56
  .action((options, command) => {
57
57
  const global = command.parent?.opts() ?? {};
58
58
  const project = resolveProject(process.cwd(), global.project);
59
59
  const result = validateProject(project, {
60
- profile: options.curated ? "listing" : "personal",
60
+ profile: "personal",
61
61
  });
62
62
  if (result.warnings && result.warnings.length > 0) {
63
63
  console.error(formatErrors(result.warnings));
@@ -329,15 +329,31 @@ export async function runCli(argv) {
329
329
  console.log(`已產生 ${result.outputPath}`);
330
330
  });
331
331
  program
332
- .command("publish")
333
- .description(" Community Apps 建立 GitHub Pull Request(不上傳 SPK、不簽署)")
332
+ .command("public-release-submit")
333
+ .description("送審公開發佈:先跑完本機所有 Curated 檢查,通過才對 Community Apps 建立 Pull Request"
334
+ + "不上傳 SPK、不簽署、不發佈——實際發佈由 maintainer 合併後的 trusted CI 執行。")
335
+ .option("--check", "只跑本機檢查,不建立 Pull Request")
334
336
  .option("--base <branch>", "PR 目標分支", "master")
335
337
  .action(async (options, command) => {
336
338
  const global = command.parent?.opts() ?? {};
337
339
  const project = resolveProject(process.cwd(), global.project);
340
+ const preflight = await runPublicReleasePreflight(project);
341
+ if (!preflight.ok) {
342
+ // A gate's reasons belong on stderr so CI logs and `2>` capture them.
343
+ console.error(formatPreflight(preflight));
344
+ console.error("\n本機檢查未通過,未建立 Pull Request。");
345
+ process.exit(1);
346
+ }
347
+ console.log(formatPreflight(preflight));
348
+ if (options.check) {
349
+ console.log("\n本機檢查全部通過。加上 --check 以外的執行才會送審。");
350
+ return;
351
+ }
338
352
  const communityAppsRoot = resolveCommunityAppsRoot(project.root);
339
353
  if (!communityAppsRoot) {
340
- console.error("找不到 synopkgland-community-apps/apps。請在 monorepo 根目錄執行,或設定 SYNOPKGLAND_COMMUNITY_APPS_ROOT。");
354
+ console.error("送審需要 Community Apps 定義倉庫的本機副本。請先取得它,再從你的 App 專案目錄重跑:\n"
355
+ + " git clone https://github.com/synopackageland/synopkgland-community-apps.git\n"
356
+ + " export SYNOPKGLAND_COMMUNITY_APPS_ROOT=$PWD/synopkgland-community-apps");
341
357
  process.exit(1);
342
358
  }
343
359
  const repoRoot = dirname(communityAppsRoot);
@@ -2,6 +2,8 @@ import { contractSpan, makeError } from "../errors.js";
2
2
  import { collectInstallParameters, getValuePosition, stringViolations, } from "./load.js";
3
3
  import { validateContractSchema } from "./schema-validate.js";
4
4
  import { isWebLaunchMode, readWebIntegration } from "./web-launch.js";
5
+ /** DSM 7.4 Package Center rejects a longer package identity outright. */
6
+ const DSM_PACKAGE_IDENTITY_MAX = 32;
5
7
  const METADATA_STRING_FIELDS = [
6
8
  "package",
7
9
  "version",
@@ -21,6 +23,14 @@ export function validateContract(parsed, contractFile) {
21
23
  }
22
24
  errors.push(...validateDeveloperString(value, contractFile, field, getValuePosition(doc, [field]) ?? { line: 1, column: 1 }));
23
25
  }
26
+ // DSM 7.4 Package Center refuses an SPK whose package identity exceeds 32
27
+ // characters -- "Invalid file format", before the install starts and with
28
+ // nothing written to synopkg.log. Measured on 10.17.83.200: 32 installs, 33
29
+ // does not.
30
+ if (typeof contract.package === "string" && contract.package.length > DSM_PACKAGE_IDENTITY_MAX) {
31
+ const pos = getValuePosition(doc, ["package"]) ?? { line: 1, column: 1 };
32
+ errors.push(makeError("CONTRACT_PACKAGE_TOO_LONG", contractSpan(contractFile, pos.line, pos.column, "package"), `Package Identity 超過 DSM 上限 ${DSM_PACKAGE_IDENTITY_MAX} 字元(目前 ${contract.package.length})。`, "請縮短 package,否則 Package Center 會以「Invalid file format」拒絕安裝。"));
33
+ }
24
34
  if (contract.adminport !== undefined) {
25
35
  if (typeof contract.adminport !== "number" || !Number.isInteger(contract.adminport)) {
26
36
  const pos = getValuePosition(doc, ["adminport"]) ?? { line: 1, column: 1 };
@@ -1,6 +1,7 @@
1
1
  import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
2
2
  import type { ProjectRoot } from "../discovery.js";
3
3
  import type { NasHost } from "../host/types.js";
4
+ import { resolveDeploySpkVersion } from "./spk-version.js";
4
5
  import type { PublishSpkResult } from "@synopackageland/package-source/publish-spk";
5
6
  export interface DeployProjectOptions {
6
7
  project: ProjectRoot;
@@ -9,6 +10,7 @@ export interface DeployProjectOptions {
9
10
  host: NasHost;
10
11
  dev?: boolean;
11
12
  replaceCurated?: boolean;
13
+ spkVersionResolver?: typeof resolveDeploySpkVersion;
12
14
  catalogPublish?: (spkPath: string) => Promise<PublishSpkResult>;
13
15
  shareValues?: Record<string, string>;
14
16
  runAsValues?: Record<string, string>;
@@ -67,7 +67,8 @@ export async function deployProject(options) {
67
67
  : String(error),
68
68
  };
69
69
  }
70
- const spkVersion = await resolveDeploySpkVersion(options.project, options.host, identity.packageId);
70
+ const resolveSpkVersion = options.spkVersionResolver ?? resolveDeploySpkVersion;
71
+ const spkVersion = await resolveSpkVersion(options.project, options.host, identity.packageId);
71
72
  let archFamily = options.archFamily;
72
73
  if (archFamily === undefined && options.project.nativePath && existsSync(options.project.nativePath)) {
73
74
  archFamily = await options.host.getArchFamily?.();
@@ -0,0 +1,24 @@
1
+ import type { ProjectRoot } from "../discovery.js";
2
+ export interface PreflightCheck {
3
+ /** Stable id so scripts and docs can refer to a specific gate. */
4
+ id: string;
5
+ title: string;
6
+ ok: boolean;
7
+ detail?: string;
8
+ }
9
+ export interface PreflightResult {
10
+ ok: boolean;
11
+ checks: PreflightCheck[];
12
+ }
13
+ /**
14
+ * Every gate for a public release that can be decided on the developer's own
15
+ * machine. Submitting runs these first so a definition that CI would reject
16
+ * never reaches review; `--check` runs the same set and stops.
17
+ *
18
+ * Deliberately excludes anything needing release authority: Build allocation,
19
+ * attestation signing and catalog publication belong to trusted CI running
20
+ * from the merged revision, and no local run can stand in for a maintainer's
21
+ * approval.
22
+ */
23
+ export declare function runPublicReleasePreflight(project: ProjectRoot): Promise<PreflightResult>;
24
+ export declare function formatPreflight(result: PreflightResult): string;
@@ -0,0 +1,116 @@
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { isDevelopmentPackageIdentity } from "@synopackageland/package-source/index-manifest";
5
+ import { buildSpk } from "../build-spk/generator.js";
6
+ import { loadCompose } from "../compose/analyze.js";
7
+ import { loadContract } from "../contract/load.js";
8
+ import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
9
+ import { loadNative } from "../native/load.js";
10
+ import { validateProject } from "../validate.js";
11
+ import { validateCuratedCompose } from "./curated-release.js";
12
+ /**
13
+ * Every gate for a public release that can be decided on the developer's own
14
+ * machine. Submitting runs these first so a definition that CI would reject
15
+ * never reaches review; `--check` runs the same set and stops.
16
+ *
17
+ * Deliberately excludes anything needing release authority: Build allocation,
18
+ * attestation signing and catalog publication belong to trusted CI running
19
+ * from the merged revision, and no local run can stand in for a maintainer's
20
+ * approval.
21
+ */
22
+ export async function runPublicReleasePreflight(project) {
23
+ const checks = [];
24
+ const add = (check) => {
25
+ checks.push(check);
26
+ return check.ok;
27
+ };
28
+ const validation = validateProject(project, { profile: "listing" });
29
+ add({
30
+ id: "listing-validate",
31
+ title: "App Contract 與 runtime 通過 Curated listing profile",
32
+ ok: validation.ok,
33
+ detail: validation.ok ? undefined : validation.errors.map((error) => error.code).join(", "),
34
+ });
35
+ const contract = loadContract(project.contractPath).contract;
36
+ const packageIdentity = typeof contract.package === "string" ? contract.package : "";
37
+ const formalIdentity = packageIdentity.length > 0 && !isDevelopmentPackageIdentity(packageIdentity);
38
+ add({
39
+ id: "formal-identity",
40
+ title: "Package Identity 是正式身分(不是 .dev)",
41
+ ok: formalIdentity,
42
+ detail: formalIdentity
43
+ ? undefined
44
+ : packageIdentity.length === 0
45
+ ? "App Contract 缺少 package 欄位。"
46
+ : `Curated 不接受 development identity:${packageIdentity}`,
47
+ });
48
+ const nativePath = project.nativePath;
49
+ const isNative = nativePath !== undefined && existsSync(nativePath);
50
+ if (isNative) {
51
+ const parsedNative = loadNative(nativePath);
52
+ const families = Object.keys(parsedNative?.native.targets ?? {}).filter(isNativeArchFamily);
53
+ const pinned = families.filter((family) => {
54
+ const target = parsedNative?.native.targets?.[family];
55
+ return typeof target?.url === "string" && typeof target?.sha256 === "string";
56
+ });
57
+ add({
58
+ id: "curated-runtime",
59
+ title: "每個 native target 都以 url + sha256 釘住",
60
+ ok: families.length > 0 && pinned.length === families.length,
61
+ detail: families.length === 0
62
+ ? "native.yaml 沒有宣告任何 target。"
63
+ : pinned.length === families.length
64
+ ? undefined
65
+ : `缺少 url 或 sha256:${families.filter((f) => !pinned.includes(f)).join(", ")}`,
66
+ });
67
+ }
68
+ else {
69
+ const parsedCompose = loadCompose(project.composePath);
70
+ const gate = parsedCompose
71
+ ? validateCuratedCompose(parsedCompose.compose)
72
+ : { ok: false, errors: ["CURATED_COMPOSE_MISSING"] };
73
+ add({
74
+ id: "curated-runtime",
75
+ title: "Compose 只用釘住 digest 的公開 image,且沒有 build:",
76
+ ok: gate.ok,
77
+ detail: gate.ok ? undefined : gate.errors.join(", "),
78
+ });
79
+ }
80
+ // The strongest local gate: actually produce the artifact. For native this
81
+ // fetches each pinned asset and refuses it unless the bytes hash to the
82
+ // declared sha256, so a bad pin fails here instead of in CI.
83
+ if (checks.every((check) => check.ok)) {
84
+ const staging = mkdtempSync(join(tmpdir(), "synopkgland-preflight-"));
85
+ try {
86
+ const families = isNative
87
+ ? Object.keys(loadNative(nativePath)?.native.targets ?? {}).filter(isNativeArchFamily)
88
+ : [undefined];
89
+ for (const family of families) {
90
+ const built = await buildSpk({
91
+ project,
92
+ outputPath: join(staging, `${packageIdentity}-${family ?? "noarch"}.spk`),
93
+ ...(family ? { archFamily: family } : {}),
94
+ });
95
+ add({
96
+ id: family ? `spk-builds:${family}` : "spk-builds",
97
+ title: family ? `SPK 可建置(${family},含 sha256 比對)` : "SPK 可建置",
98
+ ok: built.ok,
99
+ detail: built.ok ? undefined : built.errors.join(", "),
100
+ });
101
+ if (!built.ok) {
102
+ break;
103
+ }
104
+ }
105
+ }
106
+ finally {
107
+ rmSync(staging, { recursive: true, force: true });
108
+ }
109
+ }
110
+ return { ok: checks.every((check) => check.ok), checks };
111
+ }
112
+ export function formatPreflight(result) {
113
+ return result.checks
114
+ .map((check) => `${check.ok ? "✔" : "✖"} ${check.title}${check.detail ? `\n ${check.detail}` : ""}`)
115
+ .join("\n");
116
+ }
@@ -1,6 +1,9 @@
1
1
  import type { ProjectRoot } from "../discovery.js";
2
+ import { type PreflightResult } from "./preflight.js";
2
3
  import type { GitHubCli, GitOps } from "./github-pr.js";
3
4
  export interface PublishProjectOptions {
5
+ /** Injected in tests; production runs the real local gate set. */
6
+ preflight?: (project: ProjectRoot) => Promise<PreflightResult>;
4
7
  project: ProjectRoot;
5
8
  communityAppsRoot?: string;
6
9
  baseBranch?: string;
@@ -10,6 +13,7 @@ export interface PublishProjectOptions {
10
13
  now?: () => Date;
11
14
  }
12
15
  export interface PublishProjectResult {
16
+ preflight?: PreflightResult;
13
17
  ok: boolean;
14
18
  code?: string;
15
19
  message?: string;
@@ -1,6 +1,6 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { loadContract } from "../contract/load.js";
3
- import { validateProject } from "../validate.js";
3
+ import { runPublicReleasePreflight } from "./preflight.js";
4
4
  import { assertDirectoryMatchesPackage, COMMUNITY_APPS_APPS_DIR, COMMUNITY_APPS_DIR, communityAppDirectory, resolveCommunityAppsRoot, } from "./community-path.js";
5
5
  import { listSyncedRelativePaths, syncPackagingDefinition } from "./sync-definition.js";
6
6
  function slugifyPackageIdentity(packageIdentity) {
@@ -28,12 +28,20 @@ function renderPullRequestBody(packageIdentity, appRelativePath, files) {
28
28
  ].join("\n");
29
29
  }
30
30
  export async function publishProject(options) {
31
- const validation = validateProject(options.project, { profile: "listing" });
32
- if (!validation.ok) {
31
+ // Every locally decidable gate runs before anything is pushed, so a
32
+ // definition CI would reject never costs a maintainer a review.
33
+ const preflight = options.preflight
34
+ ? await options.preflight(options.project)
35
+ : await runPublicReleasePreflight(options.project);
36
+ if (!preflight.ok) {
33
37
  return {
34
38
  ok: false,
35
- code: "VALIDATION_FAILED",
36
- message: validation.errors.map((error) => error.code).join(", "),
39
+ code: "PREFLIGHT_FAILED",
40
+ message: preflight.checks
41
+ .filter((check) => !check.ok)
42
+ .map((check) => `${check.id}${check.detail ? `: ${check.detail}` : ""}`)
43
+ .join("; "),
44
+ preflight,
37
45
  };
38
46
  }
39
47
  const communityRoot = options.communityAppsRoot ?? resolveCommunityAppsRoot(options.project.root);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Trusted-CI entry point. Kept out of the `synopkgland` developer binary on
4
+ * purpose: allocating a Package Build, signing attestations and publishing to
5
+ * the Curated Package Source require release authority that a developer's
6
+ * machine must never hold. Only CI running from the reviewed merged revision
7
+ * has the keyring, the ledger write credential and the publish credential.
8
+ */
9
+ import { loadDotEnv } from "./load-env.js";
10
+ import { runCuratedReleaseCli } from "./publish/curated-release.js";
11
+ loadDotEnv();
12
+ runCuratedReleaseCli(["node", "synopkgland-release", "release", ...process.argv.slice(2)])
13
+ .then((status) => {
14
+ process.exitCode = status;
15
+ })
16
+ .catch((error) => {
17
+ console.error(error instanceof Error ? error.message : String(error));
18
+ process.exit(1);
19
+ });
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
- "synopkgland": "./dist/bin.js"
7
+ "synopkgland": "./dist/bin.js",
8
+ "synopkgland-release": "./dist/release-bin.js"
8
9
  },
9
10
  "files": [
10
11
  "dist",