@synopackageland/cli 0.2.0 → 0.3.0

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));
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);
@@ -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.0",
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",