@synopackageland/cli 0.1.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.
Files changed (49) hide show
  1. package/dist/bin.js +3 -5
  2. package/dist/build-spk/broker-glue.d.ts +16 -0
  3. package/dist/build-spk/broker-glue.js +40 -0
  4. package/dist/build-spk/delete-app-data.d.ts +11 -0
  5. package/dist/build-spk/delete-app-data.js +56 -0
  6. package/dist/build-spk/generator.js +123 -70
  7. package/dist/build-spk/native-lifecycle.d.ts +51 -0
  8. package/dist/build-spk/native-lifecycle.js +205 -0
  9. package/dist/build-spk/native-payload.d.ts +15 -0
  10. package/dist/build-spk/native-payload.js +65 -39
  11. package/dist/cli.js +47 -8
  12. package/dist/deploy/deploy.d.ts +3 -0
  13. package/dist/deploy/deploy.js +13 -0
  14. package/dist/discovery.d.ts +2 -0
  15. package/dist/discovery.js +2 -0
  16. package/dist/host/digests.js +9 -1
  17. package/dist/host/dsm-client.d.ts +7 -0
  18. package/dist/host/dsm-client.js +25 -0
  19. package/dist/host/types.d.ts +6 -0
  20. package/dist/init.js +1 -0
  21. package/dist/native/load.d.ts +8 -0
  22. package/dist/native/load.js +11 -0
  23. package/dist/native/types.d.ts +28 -0
  24. package/dist/native/types.js +3 -0
  25. package/dist/native/validate.d.ts +6 -0
  26. package/dist/native/validate.js +144 -0
  27. package/dist/publish/curated-release.js +277 -91
  28. package/dist/publish/preflight.d.ts +24 -0
  29. package/dist/publish/preflight.js +116 -0
  30. package/dist/publish/publish.d.ts +4 -0
  31. package/dist/publish/publish.js +13 -5
  32. package/dist/release-bin.d.ts +2 -0
  33. package/dist/release-bin.js +19 -0
  34. package/dist/templates/native/native.yaml +16 -0
  35. package/dist/templates/native/synology-app.yaml +7 -0
  36. package/dist/types.d.ts +1 -1
  37. package/dist/validate.js +37 -4
  38. package/package.json +9 -8
  39. package/skills/reference/diagnose.md +15 -1
  40. package/templates/native/native.yaml +16 -0
  41. package/templates/native/synology-app.yaml +7 -0
  42. package/dist/deploy/package-source.d.ts +0 -7
  43. package/dist/deploy/package-source.js +0 -17
  44. package/dist/deploy/spk-info.d.ts +0 -12
  45. package/dist/deploy/spk-info.js +0 -41
  46. package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  47. package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
  48. package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  49. package/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
@@ -10,6 +10,8 @@ import { digestAppPackageClaims, digestCuratedReleaseClaims, normalizeCapabiliti
10
10
  import { CuratedTrustRootError, loadCuratedTrustRoot, resolveCuratedSigningTrust, } from "../attestation/keyring.js";
11
11
  import { loadContract } from "../contract/load.js";
12
12
  import { loadCompose } from "../compose/analyze.js";
13
+ import { loadNative } from "../native/load.js";
14
+ import { infoArchValue, isNativeArchFamily } from "../build-spk/dsm-arch.js";
13
15
  import { EMBEDDED_ATTESTATION_RELATIVE_PATH, buildSpk, extractSpk, } from "../build-spk/generator.js";
14
16
  import { findProjectRoot } from "../discovery.js";
15
17
  import { validateProject } from "../validate.js";
@@ -112,95 +114,172 @@ export async function releaseCurated(options) {
112
114
  const workspace = mkdtempSync(join(tmpdir(), "synopkgland-curated-release-"));
113
115
  try {
114
116
  const releaseProject = copyReleaseProject(options.project, workspace, packageIdentity);
115
- const outputPath = join(workspace, `${packageIdentity}.spk`);
116
117
  const buildFn = options.buildSpk ?? buildSpk;
117
- const built = await buildFn({
118
- project: releaseProject,
119
- outputPath,
120
- packageIdentity,
121
- displayname: prepared.displayname,
122
- spkVersion: prepared.version,
123
- embeddedAttestationToken: embedded.token,
124
- });
125
- if (!built.ok || !built.outputPath) {
126
- throw new CuratedReleaseError("CURATED_BUILD_FAILED");
127
- }
128
- await assertEmbeddedAttestationInSpk(built.outputPath, embedded.token, embeddedInput, options.signer);
129
- const artifact = digestArtifact(built.outputPath);
130
118
  if (!options.signer.signRelease) {
131
119
  throw new CuratedReleaseError("CURATED_RELEASE_SIGNER_MISSING");
132
120
  }
133
- const detachedInput = {
134
- packageIdentity,
135
- packageVersion: prepared.version,
136
- packageBuild: options.build,
137
- artifactDigest: artifact.sha256,
138
- embeddedAttestationDigest: embedded.attestationDigest,
139
- };
140
- let detached;
141
- try {
142
- detached = await options.signer.signRelease(detachedInput);
143
- }
144
- catch (error) {
145
- if (error instanceof CuratedReleaseError) {
146
- throw error;
147
- }
148
- throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
149
- }
150
- assertDetachedAttestation(detached, detachedInput);
151
- await verifySignedToken(options.signer, detached.token, "release");
152
- const artifactKey = buildImmutableSpkKey({
153
- package: packageIdentity,
154
- version: prepared.version,
155
- sha256: artifact.sha256,
156
- });
157
- const attestationKey = buildImmutableAttestationKey({
158
- package: packageIdentity,
159
- version: prepared.version,
160
- artifactDigest: artifact.sha256,
161
- });
162
- const tokenSha256 = digestBytes(Buffer.from(detached.token, "utf8"));
163
121
  if (!options.signer.verificationJwk) {
164
122
  throw new CuratedReleaseError("CURATED_ATTESTATION_SIGNATURE_INVALID");
165
123
  }
166
- const attestation = {
167
- packageId: packageIdentity,
168
- version: prepared.version,
169
- packageBuild: options.build,
170
- artifactDigest: artifact.sha256,
171
- embeddedAttestationDigest: embedded.attestationDigest,
172
- key: attestationKey,
173
- token: detached.token,
174
- digest: detached.attestationDigest,
175
- tokenSha256,
176
- verificationJwk: options.signer.verificationJwk,
177
- };
178
- const index = {
179
- packageId: packageIdentity,
180
- version: prepared.version,
181
- displayname: prepared.displayname,
182
- description: prepared.description,
183
- ...(prepared.maintainer ? { maintainer: prepared.maintainer } : {}),
184
- osMinVer: prepared.osMinVer,
185
- artifact: {
186
- key: artifactKey,
187
- size: artifact.size,
188
- md5: artifact.md5,
124
+ if (!prepared.isNative) {
125
+ // Compose single-artifact release
126
+ const outputPath = join(workspace, `${packageIdentity}.spk`);
127
+ const built = await buildFn({
128
+ project: releaseProject,
129
+ outputPath,
130
+ packageIdentity,
131
+ displayname: prepared.displayname,
132
+ spkVersion: prepared.version,
133
+ embeddedAttestationToken: embedded.token,
134
+ });
135
+ if (!built.ok || !built.outputPath) {
136
+ throw new CuratedReleaseError("CURATED_BUILD_FAILED");
137
+ }
138
+ await assertEmbeddedAttestationInSpk(built.outputPath, embedded.token, embeddedInput, options.signer);
139
+ const artifact = digestArtifact(built.outputPath);
140
+ const detachedInput = {
141
+ packageIdentity,
142
+ packageVersion: prepared.version,
143
+ packageBuild: options.build,
144
+ artifactDigest: artifact.sha256,
145
+ embeddedAttestationDigest: embedded.attestationDigest,
146
+ };
147
+ let detached;
148
+ try {
149
+ detached = await options.signer.signRelease(detachedInput);
150
+ }
151
+ catch (error) {
152
+ if (error instanceof CuratedReleaseError) {
153
+ throw error;
154
+ }
155
+ throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
156
+ }
157
+ assertDetachedAttestation(detached, detachedInput);
158
+ await verifySignedToken(options.signer, detached.token, "release");
159
+ const artifactKey = buildImmutableSpkKey({
160
+ package: packageIdentity,
161
+ version: prepared.version,
189
162
  sha256: artifact.sha256,
190
- },
191
- attestation: {
163
+ });
164
+ const attestationKey = buildImmutableAttestationKey({
165
+ package: packageIdentity,
166
+ version: prepared.version,
167
+ artifactDigest: artifact.sha256,
168
+ });
169
+ const tokenSha256 = digestBytes(Buffer.from(detached.token, "utf8"));
170
+ const attestation = {
171
+ packageId: packageIdentity,
172
+ version: prepared.version,
173
+ packageBuild: options.build,
174
+ artifactDigest: artifact.sha256,
175
+ embeddedAttestationDigest: embedded.attestationDigest,
192
176
  key: attestationKey,
177
+ token: detached.token,
193
178
  digest: detached.attestationDigest,
179
+ tokenSha256,
180
+ verificationJwk: options.signer.verificationJwk,
181
+ };
182
+ const index = {
183
+ packageId: packageIdentity,
184
+ version: prepared.version,
185
+ displayname: prepared.displayname,
186
+ description: prepared.description,
187
+ ...(prepared.maintainer ? { maintainer: prepared.maintainer } : {}),
188
+ osMinVer: prepared.osMinVer,
189
+ artifact: {
190
+ key: artifactKey,
191
+ size: artifact.size,
192
+ md5: artifact.md5,
193
+ sha256: artifact.sha256,
194
+ },
195
+ attestation: {
196
+ key: attestationKey,
197
+ digest: detached.attestationDigest,
198
+ packageBuild: options.build,
199
+ artifactDigest: artifact.sha256,
200
+ embeddedAttestationDigest: embedded.attestationDigest,
201
+ },
202
+ };
203
+ // This is the only transition to external publication. Both tokens have
204
+ // been claim-checked, signature-checked when the signer supplies a key,
205
+ // and the embedded token was read back from the final SPK first.
206
+ await publishCuratedRelease({
207
+ artifact: {
208
+ packageId: packageIdentity,
209
+ version: prepared.version,
210
+ path: built.outputPath,
211
+ key: artifactKey,
212
+ size: artifact.size,
213
+ md5: artifact.md5,
214
+ sha256: artifact.sha256,
215
+ },
216
+ attestation,
217
+ index,
218
+ }, options.publisher);
219
+ return {
220
+ packageIdentity,
194
221
  packageBuild: options.build,
222
+ version: prepared.version,
223
+ artifactKey,
195
224
  artifactDigest: artifact.sha256,
225
+ attestationKey,
196
226
  embeddedAttestationDigest: embedded.attestationDigest,
197
- },
198
- };
199
- // This is the only transition to external publication. Both tokens have
200
- // been claim-checked, signature-checked when the signer supplies a key,
201
- // and the embedded token was read back from the final SPK first.
202
- await publishCuratedRelease({
203
- artifact: {
227
+ attestationDigest: detached.attestationDigest,
228
+ };
229
+ }
230
+ // Native multi-arch release (1 Build -> N artifacts)
231
+ const targetFamilies = Object.keys(prepared.native?.targets ?? {}).filter(isNativeArchFamily);
232
+ if (targetFamilies.length === 0) {
233
+ throw new CuratedReleaseError("CURATED_NATIVE_TARGETS_EMPTY");
234
+ }
235
+ const builtArches = [];
236
+ for (const family of targetFamilies) {
237
+ const outputPath = join(workspace, `${packageIdentity}-${family}.spk`);
238
+ const built = await buildFn({
239
+ project: releaseProject,
240
+ outputPath,
241
+ packageIdentity,
242
+ displayname: prepared.displayname,
243
+ spkVersion: prepared.version,
244
+ embeddedAttestationToken: embedded.token,
245
+ archFamily: family,
246
+ });
247
+ if (!built.ok || !built.outputPath) {
248
+ throw new CuratedReleaseError("CURATED_BUILD_FAILED");
249
+ }
250
+ await assertEmbeddedAttestationInSpk(built.outputPath, embedded.token, embeddedInput, options.signer);
251
+ const artifact = digestArtifact(built.outputPath);
252
+ const detachedInput = {
253
+ packageIdentity,
254
+ packageVersion: prepared.version,
255
+ packageBuild: options.build,
256
+ artifactDigest: artifact.sha256,
257
+ embeddedAttestationDigest: embedded.attestationDigest,
258
+ };
259
+ let detached;
260
+ try {
261
+ detached = await options.signer.signRelease(detachedInput);
262
+ }
263
+ catch (error) {
264
+ if (error instanceof CuratedReleaseError) {
265
+ throw error;
266
+ }
267
+ throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
268
+ }
269
+ assertDetachedAttestation(detached, detachedInput);
270
+ await verifySignedToken(options.signer, detached.token, "release");
271
+ const artifactKey = buildImmutableSpkKey({
272
+ package: packageIdentity,
273
+ version: prepared.version,
274
+ sha256: artifact.sha256,
275
+ });
276
+ const attestationKey = buildImmutableAttestationKey({
277
+ package: packageIdentity,
278
+ version: prepared.version,
279
+ artifactDigest: artifact.sha256,
280
+ });
281
+ const tokenSha256 = digestBytes(Buffer.from(detached.token, "utf8"));
282
+ const releaseArtifact = {
204
283
  packageId: packageIdentity,
205
284
  version: prepared.version,
206
285
  path: built.outputPath,
@@ -208,19 +287,89 @@ export async function releaseCurated(options) {
208
287
  size: artifact.size,
209
288
  md5: artifact.md5,
210
289
  sha256: artifact.sha256,
290
+ };
291
+ const releaseAttestation = {
292
+ packageId: packageIdentity,
293
+ version: prepared.version,
294
+ packageBuild: options.build,
295
+ artifactDigest: artifact.sha256,
296
+ embeddedAttestationDigest: embedded.attestationDigest,
297
+ key: attestationKey,
298
+ token: detached.token,
299
+ digest: detached.attestationDigest,
300
+ tokenSha256,
301
+ verificationJwk: options.signer.verificationJwk,
302
+ };
303
+ const archPlatforms = infoArchValue(family).split(" ");
304
+ const archArtifact = {
305
+ key: artifactKey,
306
+ size: artifact.size,
307
+ md5: artifact.md5,
308
+ sha256: artifact.sha256,
309
+ arch: archPlatforms,
310
+ attestation: {
311
+ key: attestationKey,
312
+ digest: detached.attestationDigest,
313
+ packageBuild: options.build,
314
+ artifactDigest: artifact.sha256,
315
+ embeddedAttestationDigest: embedded.attestationDigest,
316
+ },
317
+ };
318
+ builtArches.push({
319
+ family,
320
+ outputPath: built.outputPath,
321
+ artifact,
322
+ artifactKey,
323
+ attestationKey,
324
+ detached,
325
+ tokenSha256,
326
+ releaseArtifact,
327
+ releaseAttestation,
328
+ archArtifact,
329
+ });
330
+ }
331
+ const primary = builtArches[0];
332
+ const allArchs = targetFamilies.flatMap((family) => infoArchValue(family).split(" "));
333
+ const indexPayload = {
334
+ packageId: packageIdentity,
335
+ version: prepared.version,
336
+ displayname: prepared.displayname,
337
+ description: prepared.description,
338
+ ...(prepared.maintainer ? { maintainer: prepared.maintainer } : {}),
339
+ osMinVer: prepared.osMinVer,
340
+ arch: allArchs,
341
+ artifact: {
342
+ key: primary.artifactKey,
343
+ size: primary.artifact.size,
344
+ md5: primary.artifact.md5,
345
+ sha256: primary.artifact.sha256,
346
+ },
347
+ attestation: {
348
+ key: primary.attestationKey,
349
+ digest: primary.detached.attestationDigest,
350
+ packageBuild: options.build,
351
+ artifactDigest: primary.artifact.sha256,
352
+ embeddedAttestationDigest: embedded.attestationDigest,
211
353
  },
212
- attestation,
213
- index,
214
- }, options.publisher);
354
+ artifacts: builtArches.map((item) => item.archArtifact),
355
+ };
356
+ // 5. Publication order: all artifacts -> all detached attestations -> index
357
+ for (const item of builtArches) {
358
+ await options.publisher.uploadArtifact(item.releaseArtifact);
359
+ }
360
+ for (const item of builtArches) {
361
+ await options.publisher.uploadAttestation(item.releaseAttestation);
362
+ }
363
+ await options.publisher.updateIndex(indexPayload);
215
364
  return {
216
365
  packageIdentity,
217
366
  packageBuild: options.build,
218
367
  version: prepared.version,
219
- artifactKey,
220
- artifactDigest: artifact.sha256,
221
- attestationKey,
368
+ artifactKey: primary.artifactKey,
369
+ artifactDigest: primary.artifact.sha256,
370
+ attestationKey: primary.attestationKey,
222
371
  embeddedAttestationDigest: embedded.attestationDigest,
223
- attestationDigest: detached.attestationDigest,
372
+ attestationDigest: primary.detached.attestationDigest,
224
373
  };
225
374
  }
226
375
  finally {
@@ -352,6 +501,25 @@ function prepareRelease(project, build) {
352
501
  if (!validation.ok) {
353
502
  throw new CuratedReleaseError("CURATED_VALIDATION_FAILED", validation.errors.map((error) => error.code).join(","));
354
503
  }
504
+ const nativePath = project.nativePath ?? join(project.root, "native.yaml");
505
+ const isNative = existsSync(nativePath);
506
+ if (isNative) {
507
+ const parsedNative = loadNative(nativePath);
508
+ if (!parsedNative) {
509
+ throw new CuratedReleaseError("CURATED_NATIVE_MISSING");
510
+ }
511
+ return {
512
+ isNative: true,
513
+ contract,
514
+ native: parsedNative.native,
515
+ packageIdentity,
516
+ version,
517
+ displayname: String(contract.displayname),
518
+ description: String(contract.description),
519
+ maintainer: typeof contract.maintainer === "string" ? contract.maintainer : undefined,
520
+ osMinVer: String(contract.os_min_ver),
521
+ };
522
+ }
355
523
  const parsedCompose = loadCompose(project.composePath);
356
524
  if (!parsedCompose) {
357
525
  throw new CuratedReleaseError("CURATED_COMPOSE_MISSING");
@@ -361,6 +529,7 @@ function prepareRelease(project, build) {
361
529
  throw new CuratedReleaseError("CURATED_COMPOSE_GATE_FAILED", composeGate.errors.join(","));
362
530
  }
363
531
  return {
532
+ isNative: false,
364
533
  contract,
365
534
  compose: parsedCompose.compose,
366
535
  packageIdentity,
@@ -376,15 +545,22 @@ function copyReleaseProject(source, workspace, packageIdentity) {
376
545
  mkdirSync(root, { recursive: true });
377
546
  const contractPath = join(root, "synology-app.yaml");
378
547
  const composePath = join(root, "compose.yaml");
548
+ const nativePath = join(root, "native.yaml");
379
549
  writeFileSync(contractPath, readFileSync(source.contractPath));
380
- writeFileSync(composePath, readFileSync(source.composePath));
550
+ if (existsSync(source.composePath)) {
551
+ writeFileSync(composePath, readFileSync(source.composePath));
552
+ }
553
+ const srcNative = source.nativePath ?? join(source.root, "native.yaml");
554
+ if (existsSync(srcNative)) {
555
+ writeFileSync(nativePath, readFileSync(srcNative));
556
+ }
381
557
  for (const name of ["icon.png", "README.md", "html"]) {
382
558
  const sourcePath = join(source.root, name);
383
559
  if (existsSync(sourcePath)) {
384
560
  cpSync(sourcePath, join(root, name), { recursive: true });
385
561
  }
386
562
  }
387
- return { root, contractPath, composePath };
563
+ return { root, contractPath, composePath, nativePath };
388
564
  }
389
565
  function digestArtifact(path) {
390
566
  const bytes = readFileSync(path);
@@ -403,11 +579,21 @@ function digestFile(path) {
403
579
  */
404
580
  export function digestReleaseBundle(project) {
405
581
  const files = [];
406
- for (const name of ["compose.yaml", "synology-app.yaml"]) {
407
- if (!existsSync(join(project.root, name)) || !statSync(join(project.root, name)).isFile()) {
408
- throw new CuratedReleaseError("CURATED_BUNDLE_FILE_MISSING", name);
409
- }
410
- files.push(name);
582
+ if (!existsSync(project.contractPath) || !statSync(project.contractPath).isFile()) {
583
+ throw new CuratedReleaseError("CURATED_BUNDLE_FILE_MISSING", "synology-app.yaml");
584
+ }
585
+ files.push("synology-app.yaml");
586
+ const hasCompose = existsSync(project.composePath) && statSync(project.composePath).isFile();
587
+ const nativePath = project.nativePath ?? join(project.root, "native.yaml");
588
+ const hasNative = existsSync(nativePath) && statSync(nativePath).isFile();
589
+ if (!hasCompose && !hasNative) {
590
+ throw new CuratedReleaseError("CURATED_BUNDLE_FILE_MISSING", "compose.yaml");
591
+ }
592
+ if (hasCompose) {
593
+ files.push("compose.yaml");
594
+ }
595
+ if (hasNative) {
596
+ files.push("native.yaml");
411
597
  }
412
598
  const htmlRoot = join(project.root, "html");
413
599
  if (existsSync(htmlRoot)) {
@@ -453,7 +639,7 @@ function collectCapabilities(contract, compose) {
453
639
  capabilities.push(`install.${name}:${parameter.type}`);
454
640
  }
455
641
  }
456
- if (Object.values(compose.services ?? {}).some((service) => service.privileged === true)) {
642
+ if (compose && Object.values(compose.services ?? {}).some((service) => service.privileged === true)) {
457
643
  capabilities.push("compose.privileged");
458
644
  }
459
645
  return normalizeCapabilities(capabilities);
@@ -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
+ });