@synopackageland/cli 0.1.0 → 0.2.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 (43) hide show
  1. package/dist/bin.js +0 -0
  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 +26 -3
  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/templates/native/native.yaml +16 -0
  29. package/dist/templates/native/synology-app.yaml +7 -0
  30. package/dist/types.d.ts +1 -1
  31. package/dist/validate.js +37 -4
  32. package/package.json +8 -8
  33. package/skills/reference/diagnose.md +15 -1
  34. package/templates/native/native.yaml +16 -0
  35. package/templates/native/synology-app.yaml +7 -0
  36. package/dist/deploy/package-source.d.ts +0 -7
  37. package/dist/deploy/package-source.js +0 -17
  38. package/dist/deploy/spk-info.d.ts +0 -12
  39. package/dist/deploy/spk-info.js +0 -41
  40. package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  41. package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
  42. package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  43. 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,16 @@
1
+ schema: 1
2
+
3
+ targets:
4
+ x86_64:
5
+ path: ./dist/app-x86_64.tar.gz
6
+ strip_components: 1
7
+ aarch64:
8
+ path: ./dist/app-aarch64.tar.gz
9
+ strip_components: 1
10
+
11
+ service:
12
+ exec: bin/app
13
+ args:
14
+ - "-data=${SYNOPKG_PKGVAR}"
15
+ env:
16
+ TMPDIR: ${SYNOPKG_PKGVAR}/tmp
@@ -0,0 +1,7 @@
1
+ schema: 1
2
+ package: com.example.native-app
3
+ version: 0.1.0
4
+ displayname: Native App
5
+ description: A minimal Syno Package Land native binary app template.
6
+ maintainer: Syno Package Land
7
+ os_min_ver: "7.2-60000"
package/dist/types.d.ts CHANGED
@@ -56,4 +56,4 @@ export interface ComposeDocument {
56
56
  secrets?: Record<string, unknown>;
57
57
  [key: string]: unknown;
58
58
  }
59
- export type TemplateName = "hello-web" | "hello-web-page" | "hello-files" | "compose-import";
59
+ export type TemplateName = "hello-web" | "hello-web-page" | "hello-files" | "compose-import" | "native";
package/dist/validate.js CHANGED
@@ -1,25 +1,58 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { collectInstallParameters, loadContract } from "./contract/load.js";
3
4
  import { validateContract } from "./contract/validate.js";
4
5
  import { collectComposeInterpolations, collectVolumeSourceInterpolations, loadCompose, validateAdminPortMapping, validateComposeHostBinds, validateComposeRunAs, validateComposeSecurity, } from "./compose/analyze.js";
5
- import { COMPOSE_FILENAME, CONTRACT_FILENAME } from "./discovery.js";
6
+ import { loadNative } from "./native/load.js";
7
+ import { validateNative } from "./native/validate.js";
8
+ import { COMPOSE_FILENAME, CONTRACT_FILENAME, NATIVE_FILENAME, } from "./discovery.js";
6
9
  import { declaredPrivilegeFlags } from "./compose/privilege.js";
7
10
  export function validateProject(project, options = {}) {
8
11
  const profile = options.profile ?? "personal";
9
12
  const errors = [];
10
13
  const contractFile = CONTRACT_FILENAME;
11
14
  const composeFile = COMPOSE_FILENAME;
15
+ const nativeFile = NATIVE_FILENAME;
12
16
  const parsed = loadContract(project.contractPath);
13
17
  errors.push(...validateContract(parsed, contractFile));
14
- if (!existsSync(project.composePath)) {
18
+ const hasCompose = existsSync(project.composePath);
19
+ const nativePath = project.nativePath ?? join(project.root, NATIVE_FILENAME);
20
+ const hasNative = existsSync(nativePath);
21
+ // 2. native.yaml 與 compose.yaml 互斥:兩個都有 → 拒絕;兩個都沒有 → 拒絕。
22
+ if (hasCompose && hasNative) {
23
+ errors.push({
24
+ code: "PROJECT_RUNTIME_EXCLUSIVE",
25
+ span: { file: nativeFile, line: 1, column: 1 },
26
+ message: "專案同時存在 compose.yaml 與 native.yaml,兩者互斥。",
27
+ suggestion: "請選擇使用 compose.yaml(容器 App)或 native.yaml(原生二進位 App),並刪除另一個檔案。",
28
+ });
29
+ return { ok: false, errors };
30
+ }
31
+ if (!hasCompose && !hasNative) {
15
32
  errors.push({
16
33
  code: "COMPOSE_MISSING",
17
34
  span: { file: composeFile, line: 1, column: 1 },
18
- message: "找不到 compose.yaml。",
19
- suggestion: "請在專案根目錄建立 compose.yaml。",
35
+ message: "找不到 compose.yaml 或 native.yaml。",
36
+ suggestion: "請在專案根目錄建立 compose.yaml 或 native.yaml。",
20
37
  });
21
38
  return { ok: false, errors };
22
39
  }
40
+ if (hasNative) {
41
+ const parsedNative = loadNative(nativePath);
42
+ if (!parsedNative) {
43
+ errors.push({
44
+ code: "NATIVE_LOAD_FAILED",
45
+ span: { file: nativeFile, line: 1, column: 1 },
46
+ message: "無法讀取 native.yaml。",
47
+ suggestion: "請確認 native.yaml 存在且格式為合法 YAML。",
48
+ });
49
+ return { ok: false, errors };
50
+ }
51
+ errors.push(...validateNative(parsedNative, nativeFile, { profile }));
52
+ const warnings = errors.filter((item) => item.severity === "warning");
53
+ const hardErrors = errors.filter((item) => item.severity !== "warning");
54
+ return { ok: hardErrors.length === 0, errors: hardErrors, warnings };
55
+ }
23
56
  const parsedCompose = loadCompose(project.composePath);
24
57
  if (!parsedCompose) {
25
58
  return { ok: false, errors };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
8
  },
9
9
  "files": [
10
10
  "dist",
@@ -17,13 +17,9 @@
17
17
  "test": "npm run build && node --import tsx --test test/**/*.test.ts",
18
18
  "prepublishOnly": "npm run build"
19
19
  },
20
- "publishConfig": {
21
- "access": "public",
22
- "registry": "https://registry.npmjs.org"
23
- },
24
20
  "dependencies": {
25
- "@synopackageland/dev-sdk": "0.1.0",
26
- "@synopackageland/package-source": "0.1.0",
21
+ "@synopackageland/dev-sdk": "^0.2.0",
22
+ "@synopackageland/package-source": "^0.2.0",
27
23
  "ajv": "^8.17.1",
28
24
  "commander": "^13.1.0",
29
25
  "jose": "^6.0.10",
@@ -34,5 +30,9 @@
34
30
  "@types/node": "^22.13.10",
35
31
  "tsx": "^4.19.3",
36
32
  "typescript": "^5.8.2"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org"
37
37
  }
38
38
  }
@@ -10,7 +10,21 @@
10
10
  | `CONTRACT_SCHEMA_UNSUPPORTED` | `schema` 必須為整數 `1` |
11
11
  | `CONTRACT_INVALID_STRING`/`CONTRACT_ADMINPORT_INVALID` | 修正字串或 `adminport` 型別 |
12
12
  | `INSTALL_STRING_*`/`INSTALL_PARAMETER_UNREFERENCED` | 修正 install 預設值;在 compose 加 `${參數名}` |
13
- | `COMPOSE_MISSING` | 建立 `compose.yaml` |
13
+ | `COMPOSE_MISSING` | 建立 `compose.yaml`(容器 App)或 `native.yaml`(原生 binary App) |
14
+ | `PROJECT_RUNTIME_EXCLUSIVE` | `compose.yaml` 與 `native.yaml` 互斥,刪掉其中一個 |
15
+ | `NATIVE_SCHEMA_INVALID` | `native.yaml` 的 `schema` 只接受 `1` |
16
+ | `NATIVE_TARGETS_REQUIRED` / `NATIVE_TARGETS_EMPTY` | 在 `targets` 底下至少宣告一個 `x86_64` 或 `aarch64` |
17
+ | `NATIVE_ARCH_UNKNOWN` | `targets` 的 key 只接受 `x86_64`、`aarch64` |
18
+ | `NATIVE_TARGET_SOURCE_EXCLUSIVE` | 每個 target 只能有 `path:` 或 `url:` 其中一個 |
19
+ | `NATIVE_PATH_FORBIDDEN_IN_LISTING` | Curated 上架只接受 `url:` + `sha256:`;本機開發才可以用 `path:` |
20
+ | `NATIVE_SHA256_REQUIRED_IN_LISTING` | 補上 `sha256:`,CI 要能自己重抓比對 |
21
+ | `NATIVE_SHA256_INVALID` | `sha256` 必須是 64 個 hex 字元 |
22
+ | `NATIVE_STRIP_COMPONENTS_INVALID` | `strip_components` 必須是 >= 0 的整數 |
23
+ | `NATIVE_EXEC_REQUIRED` / `NATIVE_EXEC_INVALID_PATH` | `service.exec` 必填,且必須是相對 `SYNOPKG_PKGDEST` 的路徑,不可為絕對路徑或含 `..` |
24
+ | `NATIVE_PKGVAR_REQUIRED` | `service.args` 或 `service.env` 至少要用一次 `${SYNOPKG_PKGVAR}`,否則升級時資料會被連同 `target/` 一起換掉 |
25
+ | `NATIVE_SERVICE_USER_FORBIDDEN` | 拿掉 `service.user`:DSM 自己建 package user,`SYNOPKG_PKGVAR` 已經是對的 owner |
26
+ | `NATIVE_SERVICE_SHELL_FIELD_FORBIDDEN` | 拿掉 `command:` / `pre_start:` 這類 shell 欄位;`args` 是陣列,逐項傳給 exec,不經 shell |
27
+ | `NATIVE_ARGS_INVALID` / `NATIVE_ENV_INVALID` | `args` 必須是字串陣列,`env` 必須是 string→string |
14
28
  | `COMPOSE_PRIVILEGED_FORBIDDEN` 等安全碼 | 移除 privileged、host 網路、socket 掛載等 |
15
29
  | `COMPOSE_SERVICE_NOT_FOUND` | 修正 `adminport` 對應的 service |
16
30
  | `CONTRACT_ADMINPORT_UNMAPPED` | 在 compose 發布與 `adminport` 相同的 host port |
@@ -0,0 +1,16 @@
1
+ schema: 1
2
+
3
+ targets:
4
+ x86_64:
5
+ path: ./dist/app-x86_64.tar.gz
6
+ strip_components: 1
7
+ aarch64:
8
+ path: ./dist/app-aarch64.tar.gz
9
+ strip_components: 1
10
+
11
+ service:
12
+ exec: bin/app
13
+ args:
14
+ - "-data=${SYNOPKG_PKGVAR}"
15
+ env:
16
+ TMPDIR: ${SYNOPKG_PKGVAR}/tmp
@@ -0,0 +1,7 @@
1
+ schema: 1
2
+ package: com.example.native-app
3
+ version: 0.1.0
4
+ displayname: Native App
5
+ description: A minimal Syno Package Land native binary app template.
6
+ maintainer: Syno Package Land
7
+ os_min_ver: "7.2-60000"
@@ -1,7 +0,0 @@
1
- import { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, type PublishTarget, type PublishSpkResult } from "@synology/package-source/publish-spk";
2
- import { resolveNextSpkVersion } from "@synology/package-source/next-version";
3
- import type { IndexManifest } from "@synology/package-source/index-manifest";
4
- export { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, resolveNextSpkVersion, };
5
- export type { PublishTarget, PublishSpkResult, IndexManifest };
6
- export declare function resolvePackageSourceManifest(origin?: string): Promise<IndexManifest | null>;
7
- export declare function publishBuiltSpk(spkPath: string, target?: PublishTarget): Promise<PublishSpkResult>;
@@ -1,17 +0,0 @@
1
- import { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, } from "@synology/package-source/publish-spk";
2
- import { resolveNextSpkVersion } from "@synology/package-source/next-version";
3
- export { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, resolveNextSpkVersion, };
4
- const LIVE_TEST_FALLBACK_ORIGIN = "https://syno-package-source-throwaway.bidacumen.workers.dev";
5
- export async function resolvePackageSourceManifest(origin = resolvePublishTarget().origin) {
6
- const manifest = await fetchPackageSourceManifest(origin);
7
- if (manifest) {
8
- return manifest;
9
- }
10
- if (origin !== LIVE_TEST_FALLBACK_ORIGIN) {
11
- return fetchPackageSourceManifest(LIVE_TEST_FALLBACK_ORIGIN);
12
- }
13
- return null;
14
- }
15
- export async function publishBuiltSpk(spkPath, target = resolvePublishTarget()) {
16
- return publishSpkToSource(spkPath, target);
17
- }