@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
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));
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Broker installer glue every Managed Profile App carries, independent of
3
+ * runtime. Compose and native Apps install through the same socket, so the
4
+ * Local Attestation gate and the registration-status probe live here once.
5
+ */
6
+ /** Shell variable preamble locating the Broker installer and its socket. */
7
+ export declare const BROKER_INSTALLER_VARS = "INSTALLER=\"${BROKER_INSTALLER:-/var/packages/SynoPkgLandService/target/bin/synopkgland-installer}\"\nSOCKET=\"${BROKER_SOCKET:-/var/packages/SynoPkgLandService/var/installer.sock}\"";
8
+ /**
9
+ * The `status` probe: asks the Broker whether this App is still registered.
10
+ * `removed` exits 150 so DSM shows the App as removed by the platform;
11
+ * `unknown` exits 4. Anything else falls through to the caller, which decides
12
+ * what "running" means for its runtime.
13
+ */
14
+ export declare function renderBrokerStatusProbe(packageIdentity: string): string;
15
+ /** `preinst` / `preupgrade`: refuse to install when the Broker socket is absent. */
16
+ export declare const BROKER_SOCKET_REQUIRED_SCRIPT = "#!/bin/sh\nSOCKET=\"${BROKER_SOCKET:-/var/packages/SynoPkgLandService/var/installer.sock}\"\nif [ ! -S \"$SOCKET\" ]; then\n echo \"SynoPkgLandService installer socket is not available\" >&2\n exit 1\nfi\nexit 0\n";
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The Broker installer glue every Managed Profile App carries, independent of
3
+ * runtime. Compose and native Apps install through the same socket, so the
4
+ * Local Attestation gate and the registration-status probe live here once.
5
+ */
6
+ /** Shell variable preamble locating the Broker installer and its socket. */
7
+ export const BROKER_INSTALLER_VARS = `INSTALLER="\${BROKER_INSTALLER:-/var/packages/SynoPkgLandService/target/bin/synopkgland-installer}"
8
+ SOCKET="\${BROKER_SOCKET:-/var/packages/SynoPkgLandService/var/installer.sock}"`;
9
+ /**
10
+ * The `status` probe: asks the Broker whether this App is still registered.
11
+ * `removed` exits 150 so DSM shows the App as removed by the platform;
12
+ * `unknown` exits 4. Anything else falls through to the caller, which decides
13
+ * what "running" means for its runtime.
14
+ */
15
+ export function renderBrokerStatusProbe(packageIdentity) {
16
+ return ` if [ -x "$INSTALLER" ] && [ -S "$SOCKET" ]; then
17
+ "$INSTALLER" --socket "$SOCKET" --phase status --attempt-id "status-$$" \\
18
+ --package-identity "${packageIdentity}" --package-version "\${SYNOPKG_PKGVER}" \\
19
+ --package-path "\${SYNOPKG_PKGDEST}" --bundle-path "\${SYNOPKG_PKGDEST}" \\
20
+ --pkg-var "\${SYNOPKG_PKGVAR}" --pkg-home "\${SYNOPKG_PKGHOME}" >/tmp/synopkgland-status.$$ 2>/dev/null || true
21
+ if grep -q '"registrationStatus":"removed"' /tmp/synopkgland-status.$$ 2>/dev/null; then
22
+ rm -f /tmp/synopkgland-status.$$
23
+ exit 150
24
+ fi
25
+ if grep -q '"registrationStatus":"unknown"' /tmp/synopkgland-status.$$ 2>/dev/null; then
26
+ rm -f /tmp/synopkgland-status.$$
27
+ exit 4
28
+ fi
29
+ rm -f /tmp/synopkgland-status.$$
30
+ fi`;
31
+ }
32
+ /** `preinst` / `preupgrade`: refuse to install when the Broker socket is absent. */
33
+ export const BROKER_SOCKET_REQUIRED_SCRIPT = `#!/bin/sh
34
+ SOCKET="\${BROKER_SOCKET:-/var/packages/SynoPkgLandService/var/installer.sock}"
35
+ if [ ! -S "$SOCKET" ]; then
36
+ echo "SynoPkgLandService installer socket is not available" >&2
37
+ exit 1
38
+ fi
39
+ exit 0
40
+ `;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The Delete App Data block shared by the Compose and native postuninst scripts.
3
+ *
4
+ * The uninstall wizard sets `delete_app_data` (and DSM mirrors it as
5
+ * `wizard_delete_app_data`); unchecked is the default, so an absent variable
6
+ * must leave the data in place. `delete_root` refuses to follow a symlink out
7
+ * of `@appdata`/`@apphome`, empties the directory rather than removing the
8
+ * mount point DSM owns, and reports remnants instead of failing the script —
9
+ * a non-zero postuninst blocks uninstall.
10
+ */
11
+ export declare function renderDeleteAppDataBlock(roots: readonly string[]): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The Delete App Data block shared by the Compose and native postuninst scripts.
3
+ *
4
+ * The uninstall wizard sets `delete_app_data` (and DSM mirrors it as
5
+ * `wizard_delete_app_data`); unchecked is the default, so an absent variable
6
+ * must leave the data in place. `delete_root` refuses to follow a symlink out
7
+ * of `@appdata`/`@apphome`, empties the directory rather than removing the
8
+ * mount point DSM owns, and reports remnants instead of failing the script —
9
+ * a non-zero postuninst blocks uninstall.
10
+ */
11
+ export function renderDeleteAppDataBlock(roots) {
12
+ const calls = roots.map((name) => ` delete_root "$${name}"`).join("\n");
13
+ return `should_delete=0
14
+ case "\${delete_app_data:-}" in
15
+ true|True|TRUE|yes|1) should_delete=1 ;;
16
+ esac
17
+ case "\${wizard_delete_app_data:-}" in
18
+ true|True|TRUE|yes|1) should_delete=1 ;;
19
+ esac
20
+ if [ "$should_delete" -eq 1 ]; then
21
+ delete_root() {
22
+ target="$1"
23
+ if [ ! -e "$target" ] && [ ! -L "$target" ]; then
24
+ return 0
25
+ fi
26
+ if [ -L "$target" ]; then
27
+ real=$(readlink -f "$target" 2>/dev/null || true)
28
+ case "$real" in
29
+ */@appdata/*|*/@apphome/*)
30
+ ;;
31
+ *)
32
+ echo "Delete App Data skipped symlink: $target" >&2
33
+ return 0
34
+ ;;
35
+ esac
36
+ else
37
+ real="$target"
38
+ fi
39
+ if [ ! -e "$real" ]; then
40
+ return 0
41
+ fi
42
+ chmod -R u+w "$real" 2>/dev/null || true
43
+ if [ -d "$real" ]; then
44
+ find "$real" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
45
+ leftover=$(find "$real" -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1 || true)
46
+ if [ -n "$leftover" ]; then
47
+ echo "Delete App Data remnant: $real" >&2
48
+ fi
49
+ else
50
+ rm -f "$real" || echo "Delete App Data remnant: $real" >&2
51
+ fi
52
+ }
53
+ ${calls}
54
+ fi
55
+ `;
56
+ }
@@ -11,6 +11,11 @@ import { validateProject } from "../validate.js";
11
11
  import { writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
12
12
  import { infoArchValue } from "./dsm-arch.js";
13
13
  import { createReproducibleTar } from "./reproducible-archive.js";
14
+ import { renderDeleteAppDataBlock } from "./delete-app-data.js";
15
+ import { BROKER_INSTALLER_VARS, BROKER_SOCKET_REQUIRED_SCRIPT, renderBrokerStatusProbe, } from "./broker-glue.js";
16
+ import { loadNative } from "../native/load.js";
17
+ import { materializeNativePayload } from "./native-payload.js";
18
+ import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, } from "./native-lifecycle.js";
14
19
  const FILE_MODE = 0o644;
15
20
  const SCRIPT_MODE = 0o755;
16
21
  const INSTALL_UIFILE = "install_uifile";
@@ -48,6 +53,82 @@ export async function buildSpk(options) {
48
53
  if (options.spkVersion) {
49
54
  contract.version = options.spkVersion;
50
55
  }
56
+ const nativePath = options.project.nativePath ?? join(options.project.root, "native.yaml");
57
+ const isNative = existsSync(nativePath);
58
+ if (isNative) {
59
+ if (!options.archFamily) {
60
+ return { ok: false, errors: ["NATIVE_ARCH_FAMILY_REQUIRED"] };
61
+ }
62
+ const parsedNative = loadNative(nativePath);
63
+ if (!parsedNative) {
64
+ return { ok: false, errors: ["NATIVE_LOAD_FAILED"] };
65
+ }
66
+ const targetSource = parsedNative.native.targets[options.archFamily];
67
+ if (!targetSource) {
68
+ return { ok: false, errors: ["NATIVE_TARGET_NOT_DECLARED"] };
69
+ }
70
+ const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
71
+ try {
72
+ writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily), "utf8");
73
+ writePackageCenterIcons(staging);
74
+ mkdirSync(join(staging, "conf"), { recursive: true });
75
+ writeFileSync(join(staging, "conf", "privilege"), renderNativePrivilege(), "utf8");
76
+ writeFileSync(join(staging, "privilege"), renderNativePrivilege(), "utf8");
77
+ mkdirSync(join(staging, "WIZARD_UIFILES"), { recursive: true });
78
+ writeFileSync(join(staging, "WIZARD_UIFILES", INSTALL_UIFILE), JSON.stringify(renderInstallWizard(contract, undefined), null, 2), "utf8");
79
+ writeFileSync(join(staging, "WIZARD_UIFILES", UPGRADE_UIFILE), JSON.stringify(renderUpgradeWizard(contract, undefined), null, 2), "utf8");
80
+ writeFileSync(join(staging, "WIZARD_UIFILES", UNINSTALL_UIFILE), JSON.stringify(renderUninstallWizard(), null, 2), "utf8");
81
+ mkdirSync(join(staging, "scripts"), { recursive: true });
82
+ for (const name of SCRIPT_NAMES) {
83
+ const scriptPath = join(staging, "scripts", name);
84
+ writeFileSync(scriptPath, renderManagedScript(name, contract, { config: parsedNative.native }), "utf8");
85
+ chmodSync(scriptPath, SCRIPT_MODE);
86
+ }
87
+ const packageDir = join(staging, "package");
88
+ mkdirSync(packageDir, { recursive: true });
89
+ // 1. Materialize native payload
90
+ try {
91
+ await materializeNativePayload({
92
+ source: targetSource,
93
+ projectRoot: options.project.root,
94
+ destDir: packageDir,
95
+ });
96
+ }
97
+ catch (err) {
98
+ const code = err && typeof err === "object" && "code" in err
99
+ ? String(err.code)
100
+ : "NATIVE_PAYLOAD_ERROR";
101
+ return { ok: false, errors: [code] };
102
+ }
103
+ // Check collision with App Bundle members before copying
104
+ if (existsSync(join(packageDir, "synology-app.yaml")) ||
105
+ existsSync(join(packageDir, "native.yaml"))) {
106
+ return { ok: false, errors: ["NATIVE_PAYLOAD_BUNDLE_COLLISION"] };
107
+ }
108
+ copyFileSync(options.project.contractPath, join(packageDir, "synology-app.yaml"));
109
+ copyFileSync(nativePath, join(packageDir, "native.yaml"));
110
+ if (options.embeddedAttestationToken !== undefined) {
111
+ const attestationPath = join(packageDir, EMBEDDED_ATTESTATION_RELATIVE_PATH);
112
+ mkdirSync(join(packageDir, ".synopkgland"), { recursive: true });
113
+ writeFileSync(attestationPath, options.embeddedAttestationToken, "utf8");
114
+ chmodSync(attestationPath, FILE_MODE);
115
+ }
116
+ chmodSync(join(staging, "INFO"), FILE_MODE);
117
+ chmodSync(join(staging, "privilege"), FILE_MODE);
118
+ chmodSync(join(staging, "WIZARD_UIFILES", INSTALL_UIFILE), FILE_MODE);
119
+ chmodSync(join(staging, "WIZARD_UIFILES", UPGRADE_UIFILE), FILE_MODE);
120
+ chmodSync(join(staging, "WIZARD_UIFILES", UNINSTALL_UIFILE), FILE_MODE);
121
+ chmodSync(join(packageDir, "synology-app.yaml"), FILE_MODE);
122
+ chmodSync(join(packageDir, "native.yaml"), FILE_MODE);
123
+ const outputPath = options.outputPath ??
124
+ join(options.project.root, `${String(contract.package)}-${options.archFamily}.spk`);
125
+ await createDeterministicSpk(staging, outputPath);
126
+ return { ok: true, outputPath, errors: [] };
127
+ }
128
+ finally {
129
+ rmSync(staging, { recursive: true, force: true });
130
+ }
131
+ }
51
132
  const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
52
133
  try {
53
134
  writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily), "utf8");
@@ -430,30 +511,52 @@ function renderUninstallWizard() {
430
511
  ],
431
512
  }];
432
513
  }
433
- function renderManagedScript(name, contract) {
514
+ function renderManagedScript(name, contract, nativeContext) {
515
+ if (nativeContext) {
516
+ const lifecycleInput = {
517
+ packageIdentity: String(contract.package),
518
+ config: nativeContext.config,
519
+ };
520
+ if (name === "start-stop-status") {
521
+ return renderNativeStartStopStatus(lifecycleInput);
522
+ }
523
+ if (name === "preinst" || name === "preupgrade") {
524
+ return BROKER_SOCKET_REQUIRED_SCRIPT;
525
+ }
526
+ if (name === "postinst" || name === "postupgrade") {
527
+ const installerScript = renderInstallerCallScript(contract, name === "postupgrade" ? "update" : "install");
528
+ // The service process needs its scratch directory before it can start,
529
+ // and the installer call is the script's exit status, so widen the
530
+ // existing mkdir rather than appending after it.
531
+ return installerScript.replace('mkdir -p "${SYNOPKG_PKGVAR}" "${SYNOPKG_PKGHOME}/secrets"', 'mkdir -p "${SYNOPKG_PKGVAR}" "${SYNOPKG_PKGVAR}/tmp" "${SYNOPKG_PKGHOME}/secrets"');
532
+ }
533
+ if (name === "preuninst") {
534
+ // Compose installer uninstall (with UPGRADE early exit) + stop native process
535
+ const installerScript = renderInstallerCallScript(contract, "uninstall").replace("set -eu\n", 'set -eu\nif [ "${SYNOPKG_PKG_STATUS:-}" = "UPGRADE" ]; then\n exit 0\nfi\n');
536
+ // Stop the process BEFORE deregistering: `set -eu` aborts the script if
537
+ // the installer fails, so a stop appended after it would never run. The
538
+ // stop itself is best effort -- DSM already calls `start-stop-status
539
+ // stop` directly (N0), and a non-zero preuninst blocks uninstall.
540
+ const preuninstStopBlock = `CONTROL="/var/packages/\${SYNOPKG_PKGNAME:-${contract.package}}/scripts/start-stop-status"
541
+ if [ -x "$CONTROL" ]; then
542
+ "$CONTROL" stop 2>/dev/null || true
543
+ fi
544
+ `;
545
+ return installerScript.replace('"$INSTALLER"', `${preuninstStopBlock}"$INSTALLER"`);
546
+ }
547
+ if (name === "postuninst") {
548
+ return renderNativePostuninst(lifecycleInput);
549
+ }
550
+ return `#!/bin/sh\nexit 0\n`;
551
+ }
434
552
  if (name === "start-stop-status") {
435
553
  return `#!/bin/sh
436
- INSTALLER="\${BROKER_INSTALLER:-/var/packages/SynoPkgLandService/target/bin/synopkgland-installer}"
437
- SOCKET="\${BROKER_SOCKET:-/var/packages/SynoPkgLandService/var/installer.sock}"
554
+ ${BROKER_INSTALLER_VARS}
438
555
  case "$1" in
439
556
  start) exit 0 ;;
440
557
  stop) exit 0 ;;
441
558
  status)
442
- if [ -x "$INSTALLER" ] && [ -S "$SOCKET" ]; then
443
- "$INSTALLER" --socket "$SOCKET" --phase status --attempt-id "status-$$" \\
444
- --package-identity "${contract.package}" --package-version "\${SYNOPKG_PKGVER}" \\
445
- --package-path "\${SYNOPKG_PKGDEST}" --bundle-path "\${SYNOPKG_PKGDEST}" \\
446
- --pkg-var "\${SYNOPKG_PKGVAR}" --pkg-home "\${SYNOPKG_PKGHOME}" >/tmp/synopkgland-status.$$ 2>/dev/null || true
447
- if grep -q '"registrationStatus":"removed"' /tmp/synopkgland-status.$$ 2>/dev/null; then
448
- rm -f /tmp/synopkgland-status.$$
449
- exit 150
450
- fi
451
- if grep -q '"registrationStatus":"unknown"' /tmp/synopkgland-status.$$ 2>/dev/null; then
452
- rm -f /tmp/synopkgland-status.$$
453
- exit 4
454
- fi
455
- rm -f /tmp/synopkgland-status.$$
456
- fi
559
+ ${renderBrokerStatusProbe(String(contract.package))}
457
560
  exit 0
458
561
  ;;
459
562
  *) exit 1 ;;
@@ -461,14 +564,7 @@ esac
461
564
  `;
462
565
  }
463
566
  if (name === "preinst" || name === "preupgrade") {
464
- return `#!/bin/sh
465
- SOCKET="\${BROKER_SOCKET:-/var/packages/SynoPkgLandService/var/installer.sock}"
466
- if [ ! -S "$SOCKET" ]; then
467
- echo "SynoPkgLandService installer socket is not available" >&2
468
- exit 1
469
- fi
470
- exit 0
471
- `;
567
+ return BROKER_SOCKET_REQUIRED_SCRIPT;
472
568
  }
473
569
  if (name === "postinst" || name === "postupgrade") {
474
570
  return renderInstallerCallScript(contract, name === "postupgrade" ? "update" : "install");
@@ -482,50 +578,7 @@ set -eu
482
578
  PKGVAR="\${SYNOPKG_PKGVAR}"
483
579
  PKGHOME="\${SYNOPKG_PKGHOME}"
484
580
  SECRETS="\${PKGHOME}/secrets"
485
- should_delete=0
486
- case "\${delete_app_data:-}" in
487
- true|True|TRUE|yes|1) should_delete=1 ;;
488
- esac
489
- case "\${wizard_delete_app_data:-}" in
490
- true|True|TRUE|yes|1) should_delete=1 ;;
491
- esac
492
- if [ "$should_delete" -eq 1 ]; then
493
- delete_root() {
494
- target="$1"
495
- if [ ! -e "$target" ] && [ ! -L "$target" ]; then
496
- return 0
497
- fi
498
- if [ -L "$target" ]; then
499
- real=$(readlink -f "$target" 2>/dev/null || true)
500
- case "$real" in
501
- */@appdata/*|*/@apphome/*)
502
- ;;
503
- *)
504
- echo "Delete App Data skipped symlink: $target" >&2
505
- return 0
506
- ;;
507
- esac
508
- else
509
- real="$target"
510
- fi
511
- if [ ! -e "$real" ]; then
512
- return 0
513
- fi
514
- chmod -R u+w "$real" 2>/dev/null || true
515
- if [ -d "$real" ]; then
516
- find "$real" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
517
- leftover=$(find "$real" -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1 || true)
518
- if [ -n "$leftover" ]; then
519
- echo "Delete App Data remnant: $real" >&2
520
- fi
521
- else
522
- rm -f "$real" || echo "Delete App Data remnant: $real" >&2
523
- fi
524
- }
525
- delete_root "$PKGVAR"
526
- delete_root "$SECRETS"
527
- fi
528
- exit 0
581
+ ${renderDeleteAppDataBlock(["PKGVAR", "SECRETS"])}exit 0
529
582
  `;
530
583
  }
531
584
  return `#!/bin/sh
@@ -0,0 +1,51 @@
1
+ import type { NativeConfig } from "../native/types.js";
2
+ export interface NativeLifecycleInput {
3
+ packageIdentity: string;
4
+ config: NativeConfig;
5
+ }
6
+ /**
7
+ * Renders `conf/privilege` content.
8
+ * For non-container native packages, DSM accepts `{"defaults":{"run-as":"package"}}`.
9
+ */
10
+ export declare function renderNativePrivilege(): string;
11
+ /**
12
+ * Renders `scripts/start-stop-status`.
13
+ *
14
+ * Requirements & hard facts from N0:
15
+ * - Start must be backgrounded via `nohup setsid ... &` because Package Center uses a oneshot unit.
16
+ * - PID is tracked in `$PKGVAR/<pkg>.pid`.
17
+ * - Status exit codes: 0 = running, 1 = PID file present but process dead, 3 = stopped / no PID file.
18
+ * - Fallbacks for `SYNOPKG_PKGDEST` and `SYNOPKG_PKGVAR` via `:-/var/packages/<pkg>/target` and `:-/var/packages/<pkg>/var`.
19
+ * - `exec` is relative to `SYNOPKG_PKGDEST` (no `target/` subfolder).
20
+ * - Safe quoting for `exec`, `args`, and `env`.
21
+ */
22
+ export declare function renderNativeStartStopStatus(input: NativeLifecycleInput): string;
23
+ /**
24
+ * Renders `scripts/postinst`.
25
+ *
26
+ * Requirements from N0:
27
+ * - DSM creates the package user/group and sets up `SYNOPKG_PKGVAR` (drwxr-xr-x) before postinst runs.
28
+ * - Does NOT create any users (no synouser/useradd/adduser).
29
+ * - Does NOT chown.
30
+ * - Only ensures that subdirectories (like `$SYNOPKG_PKGVAR/tmp`) exist.
31
+ */
32
+ export declare function renderNativePostinst(input: NativeLifecycleInput): string;
33
+ /**
34
+ * Renders `scripts/preuninst`.
35
+ *
36
+ * Requirements from N0:
37
+ * - Non-zero exit blocks uninstallation, so it must be best-effort (`|| true`).
38
+ * - Skips when `SYNOPKG_PKG_STATUS=UPGRADE`.
39
+ * - Calls stop via `/var/packages/${SYNOPKG_PKGNAME:-<pkg>}/scripts/start-stop-status stop`.
40
+ * - Does NOT use postfix stripping like `%/target`.
41
+ */
42
+ export declare function renderNativePreuninst(input: NativeLifecycleInput): string;
43
+ /**
44
+ * Renders `scripts/postuninst`.
45
+ *
46
+ * Shares `renderDeleteAppDataBlock` with the Compose generator so both runtimes
47
+ * honour the same unchecked-by-default wizard variable and the same refusal to
48
+ * follow a symlink out of `@appdata`. N0 showed DSM does not reclaim the
49
+ * package user on uninstall; deleting a system account is not this script's job.
50
+ */
51
+ export declare function renderNativePostuninst(input: NativeLifecycleInput): string;
@@ -0,0 +1,205 @@
1
+ import { renderDeleteAppDataBlock } from "./delete-app-data.js";
2
+ import { BROKER_INSTALLER_VARS, renderBrokerStatusProbe, } from "./broker-glue.js";
3
+ /**
4
+ * Escapes a literal string for safe inclusion in POSIX `/bin/sh` single quotes.
5
+ * A single quote is represented as `'\''` (close single quote, literal escaped single quote, reopen single quote).
6
+ */
7
+ function quoteLiteral(str) {
8
+ return `'${str.replaceAll("'", "'\\''")}'`;
9
+ }
10
+ /**
11
+ * Escapes a token (exec path, arg, or env value) so that any `${SYNOPKG_PKGVAR}` placeholder
12
+ * expands safely to the shell variable `"${PKGVAR}"`, while all literal parts are securely
13
+ * quoted in single quotes, preventing shell injection or word splitting.
14
+ */
15
+ function escapeShellTokenWithPkgvar(value, pkgvarExpr = '"${PKGVAR}"') {
16
+ if (value === "") {
17
+ return "''";
18
+ }
19
+ const placeholder = "${SYNOPKG_PKGVAR}";
20
+ const parts = value.split(placeholder);
21
+ const segments = [];
22
+ for (let i = 0; i < parts.length; i++) {
23
+ if (parts[i].length > 0) {
24
+ segments.push(quoteLiteral(parts[i]));
25
+ }
26
+ if (i < parts.length - 1) {
27
+ segments.push(pkgvarExpr);
28
+ }
29
+ }
30
+ return segments.length === 0 ? "''" : segments.join("");
31
+ }
32
+ /**
33
+ * Renders `conf/privilege` content.
34
+ * For non-container native packages, DSM accepts `{"defaults":{"run-as":"package"}}`.
35
+ */
36
+ export function renderNativePrivilege() {
37
+ return `${JSON.stringify({
38
+ defaults: {
39
+ "run-as": "package",
40
+ },
41
+ }, null, 2)}\n`;
42
+ }
43
+ /**
44
+ * Renders `scripts/start-stop-status`.
45
+ *
46
+ * Requirements & hard facts from N0:
47
+ * - Start must be backgrounded via `nohup setsid ... &` because Package Center uses a oneshot unit.
48
+ * - PID is tracked in `$PKGVAR/<pkg>.pid`.
49
+ * - Status exit codes: 0 = running, 1 = PID file present but process dead, 3 = stopped / no PID file.
50
+ * - Fallbacks for `SYNOPKG_PKGDEST` and `SYNOPKG_PKGVAR` via `:-/var/packages/<pkg>/target` and `:-/var/packages/<pkg>/var`.
51
+ * - `exec` is relative to `SYNOPKG_PKGDEST` (no `target/` subfolder).
52
+ * - Safe quoting for `exec`, `args`, and `env`.
53
+ */
54
+ export function renderNativeStartStopStatus(input) {
55
+ const pkg = input.packageIdentity;
56
+ const service = input.config.service;
57
+ const pkgDestFallback = `\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}`;
58
+ const pkgVarFallback = `\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}`;
59
+ const execToken = `"$PKGDEST/"${escapeShellTokenWithPkgvar(service.exec)}`;
60
+ const argTokens = (service.args ?? []).map((arg) => escapeShellTokenWithPkgvar(arg));
61
+ const envLines = [];
62
+ if (service.env) {
63
+ for (const [key, value] of Object.entries(service.env)) {
64
+ const escapedKey = quoteLiteral(key);
65
+ const escapedVal = escapeShellTokenWithPkgvar(value);
66
+ envLines.push(` export ${escapedKey}=${escapedVal}`);
67
+ }
68
+ }
69
+ const envBlock = envLines.length > 0 ? `\n${envLines.join("\n")}` : "";
70
+ const commandLine = [execToken, ...argTokens].join(" ");
71
+ return `#!/bin/sh
72
+ ${BROKER_INSTALLER_VARS}
73
+ PKGDEST="${pkgDestFallback}"
74
+ PKGVAR="${pkgVarFallback}"
75
+ PIDFILE="$PKGVAR/${pkg}.pid"
76
+ LOGFILE="$PKGVAR/${pkg}.log"
77
+
78
+ pid_alive() {
79
+ if [ ! -f "$PIDFILE" ]; then
80
+ return 1
81
+ fi
82
+ pid=$(cat "$PIDFILE" 2>/dev/null) || return 1
83
+ [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
84
+ }
85
+
86
+ case "$1" in
87
+ start)
88
+ if pid_alive; then
89
+ exit 0
90
+ fi
91
+ mkdir -p "$PKGVAR"
92
+ rm -f "$PIDFILE"
93
+ (
94
+ cd /${envBlock}
95
+ nohup setsid ${commandLine} >> "$LOGFILE" 2>&1 </dev/null &
96
+ echo $! > "$PIDFILE"
97
+ )
98
+ # Verify the launch honestly: an exec that dies immediately (missing
99
+ # binary, wrong arch, bad loader) must not leave Package Center showing
100
+ # "started". N0 validated this re-check on hardware.
101
+ sleep 1
102
+ if pid_alive; then
103
+ exit 0
104
+ fi
105
+ rm -f "$PIDFILE"
106
+ exit 1
107
+ ;;
108
+ stop)
109
+ if [ -f "$PIDFILE" ]; then
110
+ pid=$(cat "$PIDFILE" 2>/dev/null) || pid=""
111
+ if [ -n "$pid" ]; then
112
+ if kill -0 "$pid" 2>/dev/null; then
113
+ kill "$pid" 2>/dev/null || true
114
+ n=0
115
+ while [ "$n" -lt 30 ]; do
116
+ if ! kill -0 "$pid" 2>/dev/null; then
117
+ break
118
+ fi
119
+ sleep 0.2
120
+ n=$((n + 1))
121
+ done
122
+ if kill -0 "$pid" 2>/dev/null; then
123
+ kill -9 "$pid" 2>/dev/null || true
124
+ fi
125
+ fi
126
+ fi
127
+ rm -f "$PIDFILE"
128
+ fi
129
+ exit 0
130
+ ;;
131
+ status)
132
+ ${renderBrokerStatusProbe(pkg)}
133
+ if pid_alive; then
134
+ exit 0
135
+ fi
136
+ if [ -f "$PIDFILE" ]; then
137
+ exit 1
138
+ fi
139
+ exit 3
140
+ ;;
141
+ *)
142
+ exit 1
143
+ ;;
144
+ esac
145
+ `;
146
+ }
147
+ /**
148
+ * Renders `scripts/postinst`.
149
+ *
150
+ * Requirements from N0:
151
+ * - DSM creates the package user/group and sets up `SYNOPKG_PKGVAR` (drwxr-xr-x) before postinst runs.
152
+ * - Does NOT create any users (no synouser/useradd/adduser).
153
+ * - Does NOT chown.
154
+ * - Only ensures that subdirectories (like `$SYNOPKG_PKGVAR/tmp`) exist.
155
+ */
156
+ export function renderNativePostinst(input) {
157
+ const pkg = input.packageIdentity;
158
+ const pkgVarFallback = `\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}`;
159
+ return `#!/bin/sh
160
+ set -eu
161
+ PKGVAR="${pkgVarFallback}"
162
+ mkdir -p "$PKGVAR" "$PKGVAR/tmp"
163
+ exit 0
164
+ `;
165
+ }
166
+ /**
167
+ * Renders `scripts/preuninst`.
168
+ *
169
+ * Requirements from N0:
170
+ * - Non-zero exit blocks uninstallation, so it must be best-effort (`|| true`).
171
+ * - Skips when `SYNOPKG_PKG_STATUS=UPGRADE`.
172
+ * - Calls stop via `/var/packages/${SYNOPKG_PKGNAME:-<pkg>}/scripts/start-stop-status stop`.
173
+ * - Does NOT use postfix stripping like `%/target`.
174
+ */
175
+ export function renderNativePreuninst(input) {
176
+ const pkg = input.packageIdentity;
177
+ return `#!/bin/sh
178
+ if [ "\${SYNOPKG_PKG_STATUS:-}" = "UPGRADE" ]; then
179
+ exit 0
180
+ fi
181
+
182
+ CONTROL="/var/packages/\${SYNOPKG_PKGNAME:-${pkg}}/scripts/start-stop-status"
183
+ if [ -x "$CONTROL" ]; then
184
+ "$CONTROL" stop 2>/dev/null || true
185
+ fi
186
+ exit 0
187
+ `;
188
+ }
189
+ /**
190
+ * Renders `scripts/postuninst`.
191
+ *
192
+ * Shares `renderDeleteAppDataBlock` with the Compose generator so both runtimes
193
+ * honour the same unchecked-by-default wizard variable and the same refusal to
194
+ * follow a symlink out of `@appdata`. N0 showed DSM does not reclaim the
195
+ * package user on uninstall; deleting a system account is not this script's job.
196
+ */
197
+ export function renderNativePostuninst(input) {
198
+ const pkg = input.packageIdentity;
199
+ const pkgVarFallback = `\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}`;
200
+ return `#!/bin/sh
201
+ set -eu
202
+ PKGVAR="${pkgVarFallback}"
203
+ ${renderDeleteAppDataBlock(["PKGVAR"])}exit 0
204
+ `;
205
+ }
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Materializes one target's binary tree into `destDir`, which becomes the
3
+ * payload root (SYNOPKG_PKGDEST). Shared by `buildNativePayload` and by the SPK
4
+ * generator, which needs the tree in its own staging directory so the App
5
+ * Bundle files can sit alongside the binaries in a single package.tgz.
6
+ */
7
+ export declare function materializeNativePayload(options: {
8
+ source: NativeSource;
9
+ projectRoot: string;
10
+ destDir: string;
11
+ download?: (url: string) => Promise<Buffer>;
12
+ }): Promise<{
13
+ sourceSha256: string;
14
+ entries: string[];
15
+ }>;
1
16
  export type NativeArchiveKind = "tar.gz" | "tar.xz" | "zip" | "raw";
2
17
  export interface NativeSource {
3
18
  path?: string;