@synopackageland/cli 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +33 -18
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -846
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/unsupported-types.js +1 -1
  14. package/dist/contract/validate.js +2 -2
  15. package/dist/contract/web-launch.d.ts +1 -12
  16. package/dist/contract/web-launch.js +10 -74
  17. package/dist/ctl/cli.d.ts +36 -0
  18. package/dist/ctl/cli.js +218 -0
  19. package/dist/ctl/descriptor.d.ts +81 -0
  20. package/dist/ctl/descriptor.js +93 -0
  21. package/dist/ctl/dsm-auth.d.ts +10 -0
  22. package/dist/ctl/dsm-auth.js +30 -0
  23. package/dist/ctl/errors.d.ts +17 -0
  24. package/dist/ctl/errors.js +32 -0
  25. package/dist/ctl/flows.d.ts +11 -0
  26. package/dist/ctl/flows.js +52 -0
  27. package/dist/ctl/input.d.ts +20 -0
  28. package/dist/ctl/input.js +128 -0
  29. package/dist/ctl/json.d.ts +2 -0
  30. package/dist/ctl/json.js +38 -0
  31. package/dist/ctl/redaction.d.ts +15 -0
  32. package/dist/ctl/redaction.js +198 -0
  33. package/dist/ctl/service.d.ts +38 -0
  34. package/dist/ctl/service.js +156 -0
  35. package/dist/ctl/store.d.ts +51 -0
  36. package/dist/ctl/store.js +93 -0
  37. package/dist/ctl/transfers.d.ts +14 -0
  38. package/dist/ctl/transfers.js +74 -0
  39. package/dist/ctl/transport.d.ts +11 -0
  40. package/dist/ctl/transport.js +79 -0
  41. package/dist/deploy/deploy.d.ts +4 -3
  42. package/dist/deploy/deploy.js +5 -210
  43. package/dist/deploy/plan.js +26 -7
  44. package/dist/dev.js +5 -4
  45. package/dist/e2e/dsm.d.ts +2 -2
  46. package/dist/e2e/dsm.js +10 -6
  47. package/dist/error-registry.js +1020 -834
  48. package/dist/host/share-permission-lookup.d.ts +1 -1
  49. package/dist/host/share-permission.d.ts +1 -1
  50. package/dist/host/share-permission.js +1 -1
  51. package/dist/info.js +120 -28
  52. package/dist/init.js +188 -64
  53. package/dist/inspect.js +21 -6
  54. package/dist/runtime/executor-digests.json +5 -5
  55. package/dist/skill.js +51 -9
  56. package/dist/standalone/api-description-inspect.d.ts +4 -0
  57. package/dist/standalone/api-description-inspect.js +12 -0
  58. package/dist/standalone/api-description.d.ts +7 -0
  59. package/dist/standalone/api-description.js +73 -0
  60. package/dist/standalone/build.d.ts +3 -0
  61. package/dist/standalone/build.js +140 -33
  62. package/dist/standalone/compose-errors.d.ts +3 -0
  63. package/dist/standalone/compose-errors.js +243 -0
  64. package/dist/standalone/compose.d.ts +97 -10
  65. package/dist/standalone/compose.js +306 -75
  66. package/dist/standalone/daemon.js +111 -29
  67. package/dist/standalone/dsm-window.js +39 -8
  68. package/dist/standalone/identity-gateway.d.ts +9 -2
  69. package/dist/standalone/identity-gateway.js +282 -66
  70. package/dist/standalone/inspect-spk.d.ts +12 -0
  71. package/dist/standalone/inspect-spk.js +666 -136
  72. package/dist/standalone/lifecycle.js +124 -42
  73. package/dist/standalone/manifest.d.ts +8 -1
  74. package/dist/standalone/manifest.js +303 -86
  75. package/dist/standalone/resource.js +6 -2
  76. package/dist/standalone/spk.d.ts +4 -1
  77. package/dist/standalone/spk.js +138 -39
  78. package/dist/template-paths.d.ts +0 -2
  79. package/dist/template-paths.js +0 -5
  80. package/dist/test-command.d.ts +1 -1
  81. package/dist/test-command.js +1 -1
  82. package/dist/validate.d.ts +3 -3
  83. package/dist/validate.js +32 -140
  84. package/package.json +5 -3
  85. package/schema/synopkgland-api-v1.schema.json +423 -0
  86. package/skills/SKILL.md +4 -2
  87. package/skills/reference/compose.md +349 -0
  88. package/skills/reference/create.md +9 -18
  89. package/skills/reference/ctl.md +41 -0
  90. package/skills/reference/deploy.md +1 -1
  91. package/skills/reference/files.md +5 -1
  92. package/skills/reference/identity-gateway.md +8 -144
  93. package/skills/reference/wrap.md +10 -39
  94. package/assets/go/daemon/fixture_linux_test.go +0 -15
  95. package/assets/go/daemon/fixture_other_test.go +0 -7
  96. package/assets/go/daemon/go.mod +0 -5
  97. package/assets/go/daemon/main.go +0 -636
  98. package/assets/go/daemon/main_test.go +0 -315
  99. package/assets/go/daemon/network.go +0 -107
  100. package/assets/go/daemon/network_test.go +0 -86
  101. package/assets/go/daemon/process_linux.go +0 -55
  102. package/assets/go/daemon/process_linux_test.go +0 -161
  103. package/assets/go/daemon/process_other.go +0 -14
  104. package/assets/go/daemon/testdata/child.go +0 -44
  105. package/assets/go/gateway/auth.go +0 -101
  106. package/assets/go/gateway/auth_test.go +0 -216
  107. package/assets/go/gateway/cgi.go +0 -133
  108. package/assets/go/gateway/cgi_test.go +0 -156
  109. package/assets/go/gateway/csrf.go +0 -73
  110. package/assets/go/gateway/csrf_test.go +0 -155
  111. package/assets/go/gateway/directory.go +0 -108
  112. package/assets/go/gateway/directory_test.go +0 -202
  113. package/assets/go/gateway/gateway.go +0 -412
  114. package/assets/go/gateway/gateway_test.go +0 -676
  115. package/assets/go/gateway/go.mod +0 -5
  116. package/assets/go/gateway/groups.go +0 -66
  117. package/assets/go/gateway/main.go +0 -45
  118. package/dist/build-spk/gateway.d.ts +0 -31
  119. package/dist/build-spk/gateway.js +0 -60
  120. package/dist/compose/privilege.d.ts +0 -14
  121. package/dist/compose/privilege.js +0 -102
@@ -1,22 +1,109 @@
1
- import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
2
- import { type Daemon } from './daemon.js';
3
- import type { StandaloneError, StandaloneManifest } from './manifest.js';
1
+ import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
2
+ import { type Daemon } from "./daemon.js";
3
+ import type { StandaloneError, StandaloneManifest } from "./manifest.js";
4
+ export interface ComposePlan {
5
+ ingress?: {
6
+ address: string;
7
+ subnet: string;
8
+ port: number;
9
+ service: string;
10
+ candidate: number;
11
+ };
12
+ schema: number;
13
+ project_name: string;
14
+ package: string;
15
+ inputs: Array<{
16
+ path: string;
17
+ sha256: string;
18
+ content: string;
19
+ }>;
20
+ override: string;
21
+ effective: string;
22
+ sha256: string;
23
+ mounts_blocked: boolean;
24
+ acceptance_blockers: string[];
25
+ services: Array<{
26
+ service: string;
27
+ user: string;
28
+ container_root: boolean;
29
+ egress: string;
30
+ network: string;
31
+ mounts: unknown[] | null;
32
+ }>;
33
+ images: Array<{
34
+ service: string;
35
+ source: string;
36
+ reference: string;
37
+ pinned: boolean;
38
+ }>;
39
+ image_proofs?: Record<string, unknown>;
40
+ lock: Record<string, unknown>;
41
+ }
4
42
  export interface ComposeApp {
43
+ /** Original manifest declaration; runtime semantics belong to the SDK planner. */
44
+ declaration: Record<string, unknown>;
5
45
  file: string;
6
- targets: Daemon['targets'];
46
+ sdk: string;
47
+ targets: Daemon["targets"];
7
48
  services: Record<string, {
8
49
  address_env: string;
9
50
  port: number;
10
51
  }>;
52
+ web?: {
53
+ service: string;
54
+ port: number;
55
+ host_port?: number;
56
+ base_path?: string;
57
+ health?: {
58
+ path: string;
59
+ status: number;
60
+ };
61
+ };
11
62
  document: Record<string, any>;
63
+ plan: ComposePlan;
12
64
  }
13
- /** Explicit host-network policy; ordinary Compose keeps its existing rejection rules. */
14
- export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void): ComposeApp | undefined;
15
- export declare function composeDaemon(m: Pick<StandaloneManifest, 'compose'>): Daemon;
16
- export declare function composeNetwork(m: Pick<StandaloneManifest, 'compose' | 'identityGateway'>): {
17
- ports: number[];
65
+ export declare const COMPOSE_FIELDS: readonly ["resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
66
+ export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void, context?: {
67
+ package?: string;
68
+ sdk?: string;
69
+ mode?: string;
70
+ composeMounts?: boolean;
71
+ resolveImages?: boolean;
72
+ architecture?: NativeArchFamily;
73
+ imageProofs?: unknown;
74
+ }): ComposeApp | undefined;
75
+ export declare function composeDaemon(m: Pick<StandaloneManifest, "compose">): Daemon;
76
+ export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "identityGateway" | "spkVersion">): {
18
77
  public_ports: number[];
78
+ readiness?: {
79
+ version: string;
80
+ plan_sha256: string;
81
+ health: {
82
+ path: string;
83
+ status: number;
84
+ } | undefined;
85
+ } | undefined;
86
+ ingress?: {
87
+ candidate: number;
88
+ } | undefined;
89
+ mode: string;
90
+ ports: number[];
19
91
  };
20
92
  export declare function composeSource(c: ComposeApp): string;
21
- /** preinst only materializes values in the incoming payload; DSM creates var before acquire. */
93
+ /** Retain upstream bytes, the true override and a lock without secret values. */
94
+ export declare function writeComposePayload(c: ComposeApp, root: string, target: string, gateway?: unknown): void;
95
+ /** Only package identity values are materialized, in the incoming tree. */
22
96
  export declare function renderComposeInstall(pkg: string): string;
97
+ /** The same SDK entry point supplies the package-account install materializer. */
98
+ export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
99
+ /** Only the build resolves registry metadata; check and inspect remain offline. */
100
+ export declare function lockComposeImages(m: StandaloneManifest, arch: NativeArchFamily, resolveImages: boolean, imageProofs?: unknown): StandaloneManifest;
101
+ export declare function composeInputs(root: string, file: string, envFile?: string): ComposePlan["inputs"];
102
+ /** Local development uses the same SDK model and package-account preparer. */
103
+ export declare function prepareLocalCompose(m: StandaloneManifest, pkgvar: string): void;
104
+ /** DSM first-install preinst runs before pkgvar exists and before worker acquire. */
105
+ export declare function dsmComposeProblem(c: ComposeApp): {
106
+ code: string;
107
+ message: string;
108
+ } | undefined;
109
+ export declare const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";
@@ -1,90 +1,321 @@
1
- import { lstatSync, readFileSync } from 'node:fs';
2
- import { isAbsolute, relative, resolve } from 'node:path';
3
- import { parse, stringify } from 'yaml';
4
- import { readDaemon } from './daemon.js';
5
- const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
6
- const only = (v, keys) => Object.keys(v).every(k => keys.includes(k));
7
- const vars = new Set(['SYNOPKGLAND_UID', 'SYNOPKGLAND_GID', 'SYNOPKG_PKGVAR']);
8
- /** Explicit host-network policy; ordinary Compose keeps its existing rejection rules. */
9
- export function readComposeApp(value, root, arches, fail) {
1
+ import { COMPOSE_ERRORS } from "./compose-errors.js";
2
+ import { spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { parse } from "yaml";
8
+ import { CLI_VERSION } from "../cli-version.js";
9
+ import { goChildEnv } from "../runtime/go-env.js";
10
+ import { sdkSource } from "../runtime/sdk-source.js";
11
+ import { readDaemon } from "./daemon.js";
12
+ export const COMPOSE_FIELDS = [
13
+ "resource_prefix",
14
+ "files",
15
+ "env_files",
16
+ "profiles",
17
+ "web",
18
+ "data",
19
+ "shares",
20
+ "network",
21
+ "services",
22
+ "helpers",
23
+ "sdk",
24
+ ];
25
+ const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
26
+ const planners = new Map();
27
+ /** Build one trusted SDK tool per process. Never execute the App's payload. */
28
+ function planner(sdk) {
29
+ if (process.env.SYNOPKGLAND_COMPOSE_PLANNER &&
30
+ process.env.SYNOPKGLAND_SDK_DIR)
31
+ return process.env.SYNOPKGLAND_COMPOSE_PLANNER;
32
+ const source = sdkSource(sdk);
33
+ const key = source.goDir;
34
+ const cached = planners.get(key);
35
+ if (cached)
36
+ return cached;
37
+ if (!existsSync(join(source.goDir, "compose/cmd/plan/main.go")))
38
+ throw new Error("[SDK_COMPOSE_UNSUPPORTED] This SDK has no unified Compose planner; use the matching SDK/CLI release.");
39
+ const work = mkdtempSync(join(tmpdir(), "synopkgland-compose-tool-"));
40
+ const out = join(work, "plan");
41
+ const result = spawnSync("go", ["build", "-trimpath", "-buildvcs=false", "-o", out, "./compose/cmd/plan"], {
42
+ cwd: source.goDir,
43
+ env: goChildEnv(),
44
+ encoding: "utf8",
45
+ timeout: 120_000,
46
+ maxBuffer: 2 << 20,
47
+ });
48
+ if (result.status !== 0) {
49
+ rmSync(work, { recursive: true, force: true });
50
+ throw new Error(`[SDK_COMPOSE_BUILD_FAILED] Cannot build the SDK planner: ${result.stderr || result.error?.message}`);
51
+ }
52
+ planners.set(key, out);
53
+ process.once("exit", () => rmSync(work, { recursive: true, force: true }));
54
+ return out;
55
+ }
56
+ export function readComposeApp(value, root, arches, fail, context = {}) {
10
57
  if (value === undefined)
11
58
  return;
12
- const invalid = (why) => { fail({ code: 'STANDALONE_IDENTITY_GATEWAY_INVALID', message: `compose identity_gateway: ${why}` }); return undefined; };
13
- if (arches.some(a => a !== 'x86_64' && a !== 'aarch64'))
14
- return invalid('Container Manager supports x86_64 and aarch64, not armv7.');
15
- if (!record(value) || !only(value, ['file', 'targets', 'services']) || typeof value.file !== 'string')
16
- return invalid('accepts file, targets and services.');
17
- const path = resolve(root, value.file), rel = relative(root, path);
18
- if (!rel || isAbsolute(value.file) || rel.startsWith('..') || value.file.split(/[\\/]/).includes('..'))
19
- return invalid('file must be inside the project.');
20
- let doc;
21
- try {
22
- if (lstatSync(path).isSymbolicLink())
23
- return invalid('file must not be a symlink.');
24
- doc = parse(readFileSync(path, 'utf8'));
59
+ const invalid = (message, code = "COMPOSE_REQUEST_INVALID") => {
60
+ fail({ code, message });
61
+ return undefined;
62
+ };
63
+ if (arches.some((a) => a !== "x86_64" && a !== "aarch64"))
64
+ return invalid("Container Manager supports x86_64 and aarch64, not armv7.");
65
+ if (!record(value) ||
66
+ Object.keys(value).some((k) => !COMPOSE_FIELDS.includes(k)) ||
67
+ !Array.isArray(value.files))
68
+ return invalid("compose requires files, with optional env_files, profiles, web, data, shares, network, services, helpers and sdk; old file/targets syntax is removed.");
69
+ if (value.sdk !== undefined &&
70
+ (typeof value.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(value.sdk)))
71
+ return invalid("compose.sdk must name the matching SDK release.", "SDK_COMPOSE_UNSUPPORTED");
72
+ const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? CLI_VERSION);
73
+ let targets = {};
74
+ if (value.helpers !== undefined) {
75
+ const d = readDaemon({ exec: "_sso/gateway", targets: value.helpers }, root, arches, fail);
76
+ if (!d)
77
+ return;
78
+ targets = d.targets;
25
79
  }
26
- catch {
27
- return invalid('cannot read compose file.');
80
+ const { helpers: _, sdk: __, ...declaration } = value;
81
+ try {
82
+ const result = spawnSync(planner(sdk), [], {
83
+ input: JSON.stringify({
84
+ ...declaration,
85
+ root,
86
+ package: context.package ?? "ComposeApp",
87
+ identity_mode: context.mode ?? "none",
88
+ compose_mounts: context.composeMounts === true,
89
+ resolve_images: context.resolveImages === true,
90
+ registry_mirrors: context.resolveImages &&
91
+ process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS
92
+ ? JSON.parse(process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS)
93
+ : undefined,
94
+ architecture: context.architecture,
95
+ image_proofs: context.imageProofs,
96
+ }),
97
+ encoding: "utf8",
98
+ timeout: 65_000,
99
+ maxBuffer: 48 << 20,
100
+ });
101
+ let plan;
102
+ try {
103
+ plan = JSON.parse(result.stdout);
104
+ }
105
+ catch {
106
+ throw new Error(`[SDK_COMPOSE_FAILED] Planner did not return JSON: ${result.stderr || result.error?.message}`);
107
+ }
108
+ if (result.status !== 0 || plan.error)
109
+ throw new Error(plan.error ?? "[SDK_COMPOSE_FAILED] SDK planner failed.");
110
+ const document = parse(plan.effective);
111
+ const web = value.web;
112
+ return {
113
+ declaration: { ...value },
114
+ sdk,
115
+ file: join(root, String(value.files[0])),
116
+ targets,
117
+ web,
118
+ services: web
119
+ ? {
120
+ [web.service]: {
121
+ address_env: record(value.services?.[web.service])
122
+ ? (value.services[web.service].address_env ?? "")
123
+ : "",
124
+ port: plan.ingress?.port ?? web.host_port ?? web.port,
125
+ },
126
+ }
127
+ : {},
128
+ document,
129
+ plan,
130
+ };
28
131
  }
29
- const payload = readDaemon({ exec: '_sso/gateway', targets: value.targets }, root, arches, fail);
30
- if (!payload)
31
- return;
32
- if (!record(doc) || !only(doc, ['services']) || !record(doc.services) || !Object.keys(doc.services).length || !record(value.services))
33
- return invalid('only services are supported; every container needs a services binding policy.');
34
- if (Object.keys(value.services).sort().join() !== Object.keys(doc.services).sort().join())
35
- return invalid('declare a binding policy for every deployed container.');
36
- const ports = new Set();
37
- for (const [name, service] of Object.entries(doc.services)) {
38
- const policy = value.services[name];
39
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name) || !record(policy) || !only(policy, ['address_env', 'port']) || !/^[A-Z][A-Z0-9_]*$/.test(policy.address_env) || !Number.isInteger(policy.port) || policy.port < 1024 || policy.port > 65535 || ports.has(policy.port))
40
- return invalid(`services.${name} needs address_env and a unique unprivileged port.`);
41
- ports.add(policy.port);
42
- if (!record(service) || !only(service, ['image', 'network_mode', 'user', 'cap_drop', 'security_opt', 'environment', 'volumes', 'restart', 'command', 'entrypoint', 'working_dir']))
43
- return invalid(`${name}: unsupported Compose fields (no build, ports, devices, capabilities, namespaces or Docker socket).`);
44
- if (service.network_mode !== 'host')
45
- return invalid(`${name}: identity_gateway requires network_mode: host with the app bound to 127.0.0.1; bridge cannot reach the loopback issuer.`);
46
- if (typeof service.image !== 'string' || !service.image.trim() || /\s/.test(service.image))
47
- return invalid(`${name}: use a prebuilt image.`);
48
- if (service.user !== '${SYNOPKGLAND_UID}:${SYNOPKGLAND_GID}')
49
- return invalid(`${name}: user must be \${SYNOPKGLAND_UID}:\${SYNOPKGLAND_GID}, materialized from the package account.`);
50
- if (JSON.stringify(service.cap_drop) !== '["ALL"]' || JSON.stringify(service.security_opt) !== '["no-new-privileges:true"]')
51
- return invalid(`${name}: require cap_drop: [ALL] and security_opt: [no-new-privileges:true].`);
52
- if (!record(service.environment) || service.environment[policy.address_env] !== '127.0.0.1' || Object.values(service.environment).some(v => typeof v !== 'string'))
53
- return invalid(`${name}: environment.${policy.address_env} must be 127.0.0.1; verify the upstream's actual listen setting.`);
54
- if (service.restart !== undefined && service.restart !== 'no')
55
- return invalid(`${name}: restart must be no; Container Manager owns the lifecycle.`);
56
- if (service.volumes !== undefined && (!Array.isArray(service.volumes) || service.volumes.some((v) => typeof v !== 'string' || !/^\$\{SYNOPKG_PKGVAR\}:\/[A-Za-z0-9_./-]+(?::rw|:ro)?$/.test(v) || v.split(':')[1].split('/').some(p => p === '..' || p === '.') || /docker\.sock|container-manager\.sock/.test(v))))
57
- return invalid(`${name}: mount only the DSM-created \${SYNOPKG_PKGVAR} root, with a fixed container path.`);
58
- for (const key of ['command', 'entrypoint'])
59
- if (service[key] !== undefined && (!Array.isArray(service[key]) || !service[key].every((v) => typeof v === 'string')))
60
- return invalid(`${name}: ${key} must be argv.`);
132
+ catch (error) {
133
+ const message = error.message;
134
+ const m = /^\[([A-Z_]+)\]\s*([\s\S]*)$/.exec(message);
135
+ return invalid(m?.[2] ?? message, m?.[1] && COMPOSE_ERRORS[m[1]] ? m[1] : "SDK_COMPOSE_FAILED");
61
136
  }
62
- for (const match of JSON.stringify(doc).matchAll(/\$(?:\{([^}]+)\}|([A-Za-z_][A-Za-z0-9_]*))/g))
63
- if (!vars.has(match[1] ?? match[2]))
64
- return invalid('only package UID, GID and var interpolation is supported.');
65
- return { file: path, targets: payload.targets, services: value.services, document: doc };
66
137
  }
67
138
  export function composeDaemon(m) {
68
- return { exec: '_sso/gateway', args: ['run'], targets: m.compose.targets, env: {}, timeouts: { start: 30, stop: 30, status: 5, uninstall: 60 } };
139
+ return {
140
+ exec: "_sso/gateway",
141
+ args: ["run"],
142
+ targets: m.compose.targets,
143
+ env: {},
144
+ timeouts: { start: 120, stop: 30, status: 5, uninstall: 60 },
145
+ };
69
146
  }
70
147
  export function composeNetwork(m) {
71
- return { ports: Object.values(m.compose.services).map(s => s.port).sort((a, b) => a - b), public_ports: Object.values(m.identityGateway.listen).sort((a, b) => a - b) };
148
+ const c = m.compose;
149
+ const host = c.web &&
150
+ c.plan.services.find((s) => s.service === c.web.service)?.network ===
151
+ "host";
152
+ return {
153
+ mode: c.plan.mounts_blocked ? "settings" : host ? "host" : "bridge",
154
+ ports: c.web ? [c.plan.ingress?.port ?? c.web.host_port ?? c.web.port] : [],
155
+ ...(c.plan.ingress
156
+ ? { ingress: { candidate: c.plan.ingress.candidate } }
157
+ : {}),
158
+ ...(!host && c.web && !c.plan.mounts_blocked
159
+ ? {
160
+ readiness: {
161
+ version: m.spkVersion,
162
+ plan_sha256: c.plan.sha256,
163
+ health: c.web.health,
164
+ },
165
+ }
166
+ : {}),
167
+ public_ports: Object.values(m.identityGateway?.listen ?? {}).sort((a, b) => a - b),
168
+ };
72
169
  }
73
- export function composeSource(c) { return stringify(c.document); }
74
- /** preinst only materializes values in the incoming payload; DSM creates var before acquire. */
170
+ export function composeSource(c) {
171
+ return c.plan.effective;
172
+ }
173
+ /** Retain upstream bytes, the true override and a lock without secret values. */
174
+ export function writeComposePayload(c, root, target, gateway) {
175
+ for (const input of c.plan.inputs) {
176
+ const bytes = readFileSync(join(root, input.path));
177
+ if (createHash("sha256").update(bytes).digest("hex") !== input.sha256)
178
+ throw new Error("[COMPOSE_INPUT_CHANGED] Upstream input changed after planning; check and rebuild.");
179
+ const path = join(target, "upstream", input.path);
180
+ mkdirSync(dirname(path), { recursive: true });
181
+ writeFileSync(path, bytes, { mode: 0o600 });
182
+ }
183
+ mkdirSync(target, { recursive: true });
184
+ for (const [name, content] of Object.entries({
185
+ "docker-compose.yml": c.plan.effective,
186
+ "generated.override.yaml": c.plan.override,
187
+ "compose.lock.json": JSON.stringify(c.plan.lock, null, 2) + "\n",
188
+ "request.json": JSON.stringify({ declaration: c.declaration, sdk: c.sdk, gateway }, null, 2) + "\n",
189
+ "images.json": JSON.stringify(c.plan.image_proofs ?? {}, null, 2) + "\n",
190
+ }))
191
+ writeFileSync(join(target, name), content, { mode: 0o600 });
192
+ }
193
+ /** Only package identity values are materialized, in the incoming tree. */
75
194
  export function renderComposeInstall(pkg) {
76
195
  return `#!/bin/sh
77
196
  set -eu
78
- [ "$(id -u)" != 0 ] || exit 1
79
- umask 077
80
- project="\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose"
81
- [ -d "$project" ] && [ ! -L "$project" ] || exit 1
82
- [ ! -L "$project/.env" ] || exit 1
83
- {
84
- printf 'SYNOPKGLAND_UID=%s\\n' "$(id -u)"
85
- printf 'SYNOPKGLAND_GID=%s\\n' "$(id -g)"
86
- printf 'SYNOPKG_PKGVAR=%s\\n' '/var/packages/${pkg}/var'
87
- } > "$project/.env"
88
- chmod 600 "$project/.env"
197
+ exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
89
198
  `;
90
199
  }
200
+ /** The same SDK entry point supplies the package-account install materializer. */
201
+ export async function buildComposeControl(m, arch, work, run) {
202
+ if (!m.compose)
203
+ return;
204
+ const { pinnedGoBuildEnv, pinnedGoProblem } = await import("../build-spk/go-toolchain.js");
205
+ const { checkCgiBinary } = await import("./binary.js");
206
+ const { StandaloneBuildError } = await import("./spk.js");
207
+ const out = join(work, `compose-control-${arch}`);
208
+ const source = sdkSource(m.compose.sdk);
209
+ const result = await run("go", [
210
+ "build",
211
+ "-trimpath",
212
+ "-buildvcs=false",
213
+ "-ldflags",
214
+ "-s -w -buildid=",
215
+ "-o",
216
+ out,
217
+ "./compose/cmd/plan",
218
+ ], {
219
+ cwd: source.goDir,
220
+ env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
221
+ });
222
+ if (result.status !== 0 || result.missing)
223
+ throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose control: ${result.stderr}`);
224
+ const error = checkCgiBinary(work, out, arch);
225
+ if (error)
226
+ throw new StandaloneBuildError(error.code, error.message);
227
+ const toolchain = pinnedGoProblem(out);
228
+ if (toolchain)
229
+ throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", toolchain);
230
+ return out;
231
+ }
232
+ /** Only the build resolves registry metadata; check and inspect remain offline. */
233
+ export function lockComposeImages(m, arch, resolveImages, imageProofs) {
234
+ if (!m.compose)
235
+ return m;
236
+ let error;
237
+ const compose = readComposeApp(m.compose.declaration, m.root, m.arches, (e) => {
238
+ error = e;
239
+ }, {
240
+ package: m.package,
241
+ sdk: m.compose.sdk,
242
+ mode: m.identityGateway?.mode ?? "none",
243
+ composeMounts: m.capabilities.composeMounts,
244
+ resolveImages,
245
+ architecture: arch,
246
+ imageProofs,
247
+ });
248
+ if (!compose)
249
+ throw new Error(`[${error?.code ?? "SDK_COMPOSE_FAILED"}] ${error?.message ?? "Cannot lock images"}`);
250
+ return { ...m, compose };
251
+ }
252
+ export function composeInputs(root, file, envFile) {
253
+ const result = spawnSync(planner(CLI_VERSION), [
254
+ "sources",
255
+ "--root",
256
+ root,
257
+ "--file",
258
+ file,
259
+ ...(envFile ? ["--env", envFile] : []),
260
+ ], { encoding: "utf8", timeout: 65_000, maxBuffer: 48 << 20 });
261
+ const output = JSON.parse(result.stdout);
262
+ if (result.status !== 0 || !Array.isArray(output))
263
+ throw new Error(output.error ?? "[SDK_COMPOSE_FAILED] Cannot inventory upstream inputs.");
264
+ return output;
265
+ }
266
+ /** Local development uses the same SDK model and package-account preparer. */
267
+ export function prepareLocalCompose(m, pkgvar) {
268
+ if (!m.compose)
269
+ return;
270
+ if (m.compose.plan.mounts_blocked)
271
+ throw new Error("[COMPOSE_PROJECT_CONTROL_UNAVAILABLE] Unapproved Shared Folders cannot be mounted by local dev.");
272
+ const target = join(m.root, ".synopkgland/local-compose");
273
+ writeComposePayload(m.compose, m.root, target);
274
+ const result = spawnSync(planner(m.compose.sdk), [
275
+ "prepare",
276
+ "--project",
277
+ target,
278
+ "--package",
279
+ m.package,
280
+ "--pkgvar",
281
+ pkgvar,
282
+ ], { encoding: "utf8", timeout: 15_000 });
283
+ if (result.status !== 0)
284
+ throw new Error(result.stderr ||
285
+ "[SDK_COMPOSE_FAILED] SDK local storage preparation failed.");
286
+ if (m.compose.plan.ingress) {
287
+ const remembered = spawnSync(planner(m.compose.sdk), [
288
+ "remember-ingress",
289
+ "--project",
290
+ target,
291
+ "--package",
292
+ m.package,
293
+ "--pkgvar",
294
+ pkgvar,
295
+ ], { encoding: "utf8", timeout: 15_000 });
296
+ if (remembered.status !== 0)
297
+ throw new Error(remembered.stderr ||
298
+ "[COMPOSE_SUBNET_STATE_INVALID] Cannot persist the local ingress allocation.");
299
+ }
300
+ }
301
+ /** DSM first-install preinst runs before pkgvar exists and before worker acquire. */
302
+ export function dsmComposeProblem(c) {
303
+ if (c.plan.mounts_blocked)
304
+ return;
305
+ if (c.plan.services.some((s) => (s.mounts ?? []).some((v) => record(v) &&
306
+ typeof v.source === "string" &&
307
+ v.source.startsWith("${SYNOPKG_PKGVAR}/"))))
308
+ return {
309
+ code: "COMPOSE_PRIVATE_BIND_TIMING_UNSUPPORTED",
310
+ message: "DSM creates pkgvar after preinst, but docker-project acquires before postinst. Package-private binds cannot be initialized safely on a clean install; select a compatible named volume explicitly or keep this configuration unsupported. No root or automatic storage conversion is used.",
311
+ };
312
+ if (c.web &&
313
+ c.plan.services.find((s) => s.service === c.web.service)?.network ===
314
+ "bridge" &&
315
+ !c.web.health)
316
+ return {
317
+ code: "COMPOSE_HEALTH_REQUIRED",
318
+ message: "Bridge web ingress requires compose.web.health: {path: /health, status: 200}; choose the App's actual unauthenticated health endpoint and expected 2xx status.",
319
+ };
320
+ }
321
+ export const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";