@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,7 +1,9 @@
1
1
  export interface ComposeEnvironment {
2
2
  SYNOPKG_PKGVAR: string;
3
- SYNO_APP_SECRETS_DIR: string;
3
+ SYNOPKGLAND_UID: string;
4
+ SYNOPKGLAND_GID: string;
4
5
  }
6
+ /** Local Docker development only. DSM storage is prepared by the SDK as the package account. */
5
7
  export declare function prepareComposeEnvironment(projectRoot: string): ComposeEnvironment;
6
- export declare function materializeComposePaths(projectRoot: string, composePath: string): ComposeEnvironment;
7
- export declare function composeEnvDirsExist(projectRoot: string): boolean;
8
+ export declare function materializeComposePaths(projectRoot: string, _composePath?: string): ComposeEnvironment;
9
+ export declare function composeEnvDirsExist(root: string): boolean;
@@ -1,36 +1,28 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { loadStandaloneManifest } from "../standalone/manifest.js";
4
+ import { prepareLocalCompose } from "../standalone/compose.js";
5
+ /** Local Docker development only. DSM storage is prepared by the SDK as the package account. */
3
6
  export function prepareComposeEnvironment(projectRoot) {
4
7
  const varDir = join(projectRoot, ".synopkgland", "var");
5
- const secretsDir = join(projectRoot, ".synopkgland", "secrets");
6
- mkdirSync(varDir, { recursive: true });
7
- mkdirSync(secretsDir, { recursive: true });
8
+ mkdirSync(varDir, { recursive: true, mode: 0o700 });
8
9
  return {
9
10
  SYNOPKG_PKGVAR: varDir,
10
- SYNO_APP_SECRETS_DIR: secretsDir,
11
+ SYNOPKGLAND_UID: String(process.getuid?.() ?? ""),
12
+ SYNOPKGLAND_GID: String(process.getgid?.() ?? ""),
11
13
  };
12
14
  }
13
- export function materializeComposePaths(projectRoot, composePath) {
15
+ export function materializeComposePaths(projectRoot, _composePath) {
16
+ const loaded = loadStandaloneManifest(projectRoot);
17
+ if (!loaded.ok)
18
+ throw new Error(`[${loaded.errors[0].code}] ${loaded.errors[0].message}`);
19
+ if (!loaded.manifest.compose)
20
+ throw new Error("[COMPOSE_REQUEST_INVALID] Local Docker commands require the unified Compose declaration.");
14
21
  const env = prepareComposeEnvironment(projectRoot);
15
- if (!existsSync(composePath)) {
16
- return env;
17
- }
18
- const source = readFileSync(composePath, "utf8");
19
- for (const match of source.matchAll(/\$\{SYNOPKG_PKGVAR\}([^:\s"'`]+)/g)) {
20
- const subpath = match[1].replace(/^\/+/, "");
21
- if (subpath) {
22
- mkdirSync(join(env.SYNOPKG_PKGVAR, subpath), { recursive: true });
23
- }
24
- }
25
- for (const match of source.matchAll(/\$\{SYNO_APP_SECRETS_DIR\}([^:\s"'`]+)/g)) {
26
- const subpath = match[1].replace(/^\/+/, "");
27
- if (subpath) {
28
- mkdirSync(join(env.SYNO_APP_SECRETS_DIR, subpath), { recursive: true });
29
- }
30
- }
22
+ prepareLocalCompose(loaded.manifest, env.SYNOPKG_PKGVAR);
31
23
  return env;
32
24
  }
33
- export function composeEnvDirsExist(projectRoot) {
34
- return (existsSync(join(projectRoot, ".synopkgland", "var")) &&
35
- existsSync(join(projectRoot, ".synopkgland", "secrets")));
25
+ export function composeEnvDirsExist(root) {
26
+ return (existsSync(join(root, ".synopkgland/var")) &&
27
+ existsSync(join(root, ".synopkgland/local-compose/docker-compose.yml")));
36
28
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { execFile } from "node:child_process";
2
3
  import { promisify } from "node:util";
3
4
  import { materializeComposePaths } from "./env.js";
@@ -10,7 +11,7 @@ export function evaluateComposeReady(containers) {
10
11
  }
11
12
  const hasHealthcheck = containers.some((c) => c.Health && c.Health !== "");
12
13
  if (hasHealthcheck) {
13
- const ready = containers.every((c) => !c.Health || c.Health === "healthy");
14
+ const ready = containers.every((c) => c.State === "running" && (!c.Health || c.Health === "healthy"));
14
15
  return { ready, hasHealthcheck: true };
15
16
  }
16
17
  const ready = containers.every((c) => c.State === "running");
@@ -19,7 +20,8 @@ export function evaluateComposeReady(containers) {
19
20
  export async function pollUntilComposeReady(inspect, options = {}) {
20
21
  const timeoutMs = options.timeoutMs ?? 60_000;
21
22
  const intervalMs = options.intervalMs ?? 1_000;
22
- const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
23
+ const sleep = options.sleep ??
24
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
23
25
  const deadline = Date.now() + timeoutMs;
24
26
  while (Date.now() < deadline) {
25
27
  const containers = await inspect();
@@ -39,12 +41,23 @@ export class RecordingComposeRunner {
39
41
  this.invocations.push({ command: "compose", args: ["up", "-d"] });
40
42
  }
41
43
  async waitReady(_projectRoot) {
42
- this.invocations.push({ command: "compose", args: ["ps", "--format", "json"] });
44
+ this.invocations.push({
45
+ command: "compose",
46
+ args: ["ps", "--format", "json"],
47
+ });
43
48
  }
44
49
  }
45
50
  async function runDockerCompose(projectRoot, args, env) {
46
- const { stdout } = await execFileAsync("docker", ["compose", ...args], {
47
- cwd: projectRoot,
51
+ const { stdout } = await execFileAsync("docker", [
52
+ "compose",
53
+ "--project-name",
54
+ "synopkgland-local-" +
55
+ createHash("sha256").update(projectRoot).digest("hex").slice(0, 12),
56
+ "-f",
57
+ "docker-compose.yml",
58
+ ...args,
59
+ ], {
60
+ cwd: join(projectRoot, ".synopkgland/local-compose"),
48
61
  encoding: "utf8",
49
62
  maxBuffer: 10 * 1024 * 1024,
50
63
  env: env ? { ...process.env, ...env } : process.env,
@@ -52,7 +65,11 @@ async function runDockerCompose(projectRoot, args, env) {
52
65
  return stdout;
53
66
  }
54
67
  async function inspectComposeContainers(projectRoot) {
55
- const stdout = await runDockerCompose(projectRoot, ["ps", "--format", "json"]);
68
+ const stdout = await runDockerCompose(projectRoot, [
69
+ "ps",
70
+ "--format",
71
+ "json",
72
+ ]);
56
73
  const lines = stdout
57
74
  .split("\n")
58
75
  .map((line) => line.trim())
@@ -3,7 +3,7 @@ import { collectInstallParameters } from "./load.js";
3
3
  * Install parameter types that the schema still accepts but that have no working
4
4
  * install path on the live package-only route.
5
5
  *
6
- * Measured on vt-nas 2026-08-27: both end in DSM broken/150 with no Container
6
+ * Measured on the test NAS 2026-08-27: both end in DSM broken/150 with no Container
7
7
  * Manager project, while a no-parameter control package installed fine on the
8
8
  * same NAS. `secret` renders its password field and fails; `homes` renders no
9
9
  * input at all. `check` reports these as warnings so build keeps working
@@ -33,7 +33,7 @@ export function validateContract(parsed, contractFile) {
33
33
  }
34
34
  // DSM 7.4 Package Center refuses an SPK whose package identity exceeds 32
35
35
  // characters -- "Invalid file format", before the install starts and with
36
- // nothing written to synopkg.log. Measured on 10.17.83.200: 32 installs, 33
36
+ // nothing written to synopkg.log. Measured on a DSM 7.4 test NAS: 32 installs, 33
37
37
  // does not.
38
38
  if (typeof contract.package === "string" && contract.package.length > DSM_PACKAGE_IDENTITY_MAX) {
39
39
  const pos = getValuePosition(doc, ["package"]) ?? { line: 1, column: 1 };
@@ -164,7 +164,7 @@ function validateInstallParameters(parsed, contractFile) {
164
164
  }
165
165
  }
166
166
  // A45/A46: both types are in the schema enum but neither has a working
167
- // install path on the live package-only route -- measured on vt-nas as DSM
167
+ // install path on the live package-only route -- measured on the test NAS as DSM
168
168
  // broken/150 with no Container Manager project, against a no-parameter
169
169
  // control that installed fine. Refusing here is what makes the failure
170
170
  // honest: `homes` used to validate, build a valid SPK, and only break at
@@ -1,5 +1,4 @@
1
- import { type AppLaunchMode } from './app-launch.js';
2
- import { type PackageIcons } from "../build-spk/dsm-icons.js";
1
+ import { type AppLaunchMode } from "./app-launch.js";
3
2
  import type { AppContract } from "../types.js";
4
3
  /** DSM Web Station portal alias max length (error 1311 above this). */
5
4
  export declare const WEB_STATION_ALIAS_MAX = 20;
@@ -37,13 +36,3 @@ export declare function webStationAlias(packageId: string): string;
37
36
  /** DSM serves a package's `ui` directory under this root; the gateway CGI lives in it. */
38
37
  export declare function gatewayEntryPath(packageId: string, adminurl: unknown): string;
39
38
  export declare function trustedAppEntryPath(contract: AppContract): string;
40
- export declare const DSM_RESERVED_COOKIES: readonly ["id", "did"];
41
- export declare function extractContractCookieNames(contract: AppContract): string[];
42
- export declare function cookieStripHeaders(contract: AppContract): Array<{
43
- name: string;
44
- value: string;
45
- }>;
46
- export declare function webStationServiceName(alias: string): string;
47
- export declare function webStationServiceRoot(alias: string): string;
48
- export declare function renderWebStationResource(contract: AppContract): string;
49
- export declare function renderDsmWindowConfig(contract: AppContract, icons: PackageIcons): string;
@@ -1,8 +1,5 @@
1
- import { APP_LAUNCH_MODES, DEFAULT_WEB_LAUNCH, isAppLaunchMode, resolveAppLaunch, dsmLauncherType } from './app-launch.js';
1
+ import { APP_LAUNCH_MODES, DEFAULT_WEB_LAUNCH, isAppLaunchMode, resolveAppLaunch, } from "./app-launch.js";
2
2
  import { createHash } from "node:crypto";
3
- import { uiTitleFields } from "../build-spk/ui-texts.js";
4
- import { windowIconTemplate } from "../build-spk/dsm-icons.js";
5
- import { contractAppTitle } from "./i18n.js";
6
3
  /** DSM Web Station portal alias max length (error 1311 above this). */
7
4
  export const WEB_STATION_ALIAS_MAX = 20;
8
5
  export const WEB_LAUNCH_MODES = APP_LAUNCH_MODES;
@@ -27,7 +24,12 @@ export function resolvedWebLaunch(contract) {
27
24
  }
28
25
  return resolveAppLaunch(readWebIntegration(contract)?.launch, true);
29
26
  }
30
- export const WEB_ACCESS_MODES = ["dsm-users", "dsm-admins", "dsm-groups", "public"];
27
+ export const WEB_ACCESS_MODES = [
28
+ "dsm-users",
29
+ "dsm-admins",
30
+ "dsm-groups",
31
+ "public",
32
+ ];
31
33
  export const DEFAULT_WEB_ACCESS_MODE = "dsm-users";
32
34
  /** Local or qualified directory group names (exact case and domain); also what the gateway accepts at startup. */
33
35
  export const DSM_GROUP_NAME = /^(?:[A-Za-z0-9._-]{1,64}|[A-Za-z0-9._-]{1,64}\\[A-Za-z0-9._-](?:[A-Za-z0-9._ -]{0,126}[A-Za-z0-9._-])?|[A-Za-z0-9._-](?:[A-Za-z0-9._ -]{0,126}[A-Za-z0-9._-])?@[A-Za-z0-9._-]{1,64})$(?![\s\S])/;
@@ -48,7 +50,9 @@ export function resolvedWebAccess(contract) {
48
50
  if (!isWebAccessMode(mode)) {
49
51
  return undefined;
50
52
  }
51
- const groups = Array.isArray(web?.groups) ? web.groups.filter((g) => typeof g === "string") : [];
53
+ const groups = Array.isArray(web?.groups)
54
+ ? web.groups.filter((g) => typeof g === "string")
55
+ : [];
52
56
  return { mode, groups };
53
57
  }
54
58
  /** True when the App's container is reached only through the DSM-authenticated gateway CGI. */
@@ -83,71 +87,3 @@ export function trustedAppEntryPath(contract) {
83
87
  }
84
88
  return `/${alias}${extra}`;
85
89
  }
86
- export const DSM_RESERVED_COOKIES = ["id", "did"];
87
- export function extractContractCookieNames(contract) {
88
- const web = readWebIntegration(contract);
89
- if (!web || !Array.isArray(web.cookies)) {
90
- return [];
91
- }
92
- const reserved = new Set(DSM_RESERVED_COOKIES);
93
- const result = [];
94
- for (const item of web.cookies) {
95
- if (typeof item === "string") {
96
- const name = item.trim();
97
- if (name && !reserved.has(name) && !result.includes(name)) {
98
- result.push(name);
99
- }
100
- }
101
- }
102
- return result;
103
- }
104
- export function cookieStripHeaders(contract) {
105
- const names = extractContractCookieNames(contract);
106
- const value = names.length > 0
107
- ? names.map((name) => `${name}=$cookie_${name}`).join("; ")
108
- : "_synopkgland=";
109
- return [{ name: "Cookie", value }];
110
- }
111
- export function webStationServiceName(alias) {
112
- return `PROXY-${alias}`;
113
- }
114
- export function webStationServiceRoot(alias) {
115
- return `web/synopkgland-${alias}`;
116
- }
117
- export function renderWebStationResource(contract) {
118
- const alias = webStationAlias(String(contract.package));
119
- const launch = resolvedWebLaunch(contract);
120
- // DSM synowebapi crashes on type=reverse_proxy. Package acquire of reverse_proxy
121
- const service = {
122
- service: webStationServiceName(alias),
123
- display_name: String(contract.displayname ?? contract.package),
124
- type: "static",
125
- support_alias: true,
126
- support_server: false,
127
- root: webStationServiceRoot(alias),
128
- };
129
- const portal = {
130
- service: webStationServiceName(alias),
131
- name: alias,
132
- type: "alias",
133
- alias,
134
- };
135
- if (launch === "dsm-window") {
136
- portal.app = String(contract.package);
137
- }
138
- return `${JSON.stringify({ webservice: { services: [service], portals: [portal] } }, null, 2)}\n`;
139
- }
140
- export function renderDsmWindowConfig(contract, icons) {
141
- const packageId = String(contract.package);
142
- return `${JSON.stringify({
143
- ".url": {
144
- [packageId]: {
145
- type: dsmLauncherType(resolvedWebLaunch(contract), false),
146
- allUsers: true,
147
- ...uiTitleFields(contractAppTitle(contract)),
148
- icon: windowIconTemplate(icons),
149
- url: trustedAppEntryPath(contract),
150
- },
151
- },
152
- }, null, 2)}\n`;
153
- }
@@ -0,0 +1,36 @@
1
+ import { CtlStore, type Host } from './store.js';
2
+ import { type JsonRequest } from './transport.js';
3
+ import type { Descriptor } from './descriptor.js';
4
+ export interface CtlIO {
5
+ store?: CtlStore;
6
+ out?: (line: string) => void;
7
+ isTTY?: boolean;
8
+ prompt?: (message: string, secret?: boolean) => Promise<string>;
9
+ requestFor?: (host: Host) => JsonRequest;
10
+ }
11
+ export declare function listView(host: string, apps: Descriptor[]): {
12
+ host: string;
13
+ package: {
14
+ id: string;
15
+ version: string;
16
+ displayName: string;
17
+ };
18
+ aliases: string[];
19
+ protocolVersion: 1;
20
+ developedBy: string;
21
+ provenance: string;
22
+ hash: string;
23
+ commands: {
24
+ parameters: string | Record<string, any>;
25
+ examples: string[];
26
+ summary: string;
27
+ cli: string[];
28
+ effect: "read" | "write" | "destructive";
29
+ permission: "public" | "authenticated" | "administrators";
30
+ paramsSchema?: Record<string, any>;
31
+ resultSchema?: Record<string, any>;
32
+ flow: import("./descriptor.js").Flow;
33
+ id: string;
34
+ }[];
35
+ }[];
36
+ export declare function runCtl(argv: string[], io?: CtlIO): Promise<void>;
@@ -0,0 +1,218 @@
1
+ import { readBounded } from './json.js';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { Writable } from 'node:stream';
5
+ import { CtlError, errorData, ctlError } from './errors.js';
6
+ import { CtlStore } from './store.js';
7
+ import { CtlClient, boundedMap } from './service.js';
8
+ import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } from './input.js';
9
+ import { canonicalOrigin, createRequest } from './transport.js';
10
+ import { loginDsm, logoutDsm } from './dsm-auth.js';
11
+ import { publicOperation } from './redaction.js';
12
+ const USAGE = 'synopkgland ctl host add|list|show|login|logout|remove|revoke; ctl list [--host a,b|--all-hosts] [--app pkg] [--refresh]; ctl <alias|package> <command...> [typed flags|--input file|-] [--dry-run] [--yes]';
13
+ async function prompt(message, secret = false) {
14
+ if (!process.stdin.isTTY)
15
+ throw new CtlError('CTL_INTERACTION_REQUIRED', 'Interactive input required.', 'Use --username and --password-stdin for host authentication.', 2);
16
+ process.stderr.write(message);
17
+ const muted = new Writable({ write(chunk, _encoding, done) { if (!secret)
18
+ process.stderr.write(chunk); done(); } });
19
+ const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
20
+ try {
21
+ return await rl.question('');
22
+ }
23
+ finally {
24
+ rl.close();
25
+ if (secret)
26
+ process.stderr.write('\n');
27
+ }
28
+ }
29
+ function take(args, flag, value = false) {
30
+ const index = args.findIndex(a => a === flag || a.startsWith(flag + '='));
31
+ if (index < 0)
32
+ return undefined;
33
+ const token = args[index];
34
+ let answer = true;
35
+ let count = 1;
36
+ if (value) {
37
+ if (token.includes('='))
38
+ answer = token.slice(flag.length + 1);
39
+ else {
40
+ answer = args[index + 1];
41
+ count = 2;
42
+ }
43
+ if (typeof answer !== 'string' || !answer || answer.startsWith('--'))
44
+ throw new CtlError('CTL_ARGUMENT_INVALID', `Missing value for ${flag}.`, 'run: synopkgland ctl --help');
45
+ }
46
+ args.splice(index, count);
47
+ if (args.some(a => a === flag || a.startsWith(flag + '=')))
48
+ throw new CtlError('CTL_ARGUMENT_INVALID', `Duplicate option ${flag}.`, 'Use each option once.');
49
+ return answer;
50
+ }
51
+ function q(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
52
+ function example(host, app, op) {
53
+ const base = `synopkgland ctl ${app.payload.aliases[0] ?? app.payload.package.id} ${op.cli.join(' ')} --host ${host}`;
54
+ return (op.examples?.length ? op.examples : [{}]).map(params => {
55
+ const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => ['string', 'number', 'boolean'].includes(typeof value) && !RESERVED_FLAGS.has(flagName(key)) && op.paramsSchema?.properties?.[key]);
56
+ if (simple)
57
+ return base + Object.entries(params).map(([key, value]) => ' --' + flagName(key) + ' ' + q(String(value))).join('') + (op.effect === 'read' ? '' : ' --yes');
58
+ return `printf '%s' ${q(JSON.stringify(params))} | ${base} --input -` + (op.effect === 'read' ? '' : ' --yes');
59
+ });
60
+ }
61
+ export function listView(host, apps) { return apps.map(app => ({ host, package: app.payload.package, aliases: app.payload.aliases, protocolVersion: app.version, developedBy: app.payload.provenance.developedBy, provenance: 'self-declared', hash: app.sha256, commands: Object.entries(app.payload.commands).map(([id, original]) => { const op = publicOperation(original); return { id, ...op, parameters: op.paramsSchema ?? 'Undescribed parameters; use --input', examples: example(host, app, op) }; }) })); }
62
+ export async function runCtl(argv, io = {}) {
63
+ const args = [...argv], store = io.store ?? new CtlStore(), out = io.out ?? console.log, tty = io.isTTY ?? !!process.stdout.isTTY, ask = io.prompt ?? prompt;
64
+ let json = argv.includes('--json') || !tty;
65
+ const emit = (value) => out(json ? JSON.stringify(value) : JSON.stringify(value, null, 2));
66
+ try {
67
+ json = !!take(args, '--json') || !tty;
68
+ const hostOption = take(args, '--host', true), allHosts = !!take(args, '--all-hosts'), yes = !!take(args, '--yes'), dryRun = !!take(args, '--dry-run'), refresh = !!take(args, '--refresh'), appFilter = take(args, '--app', true), approve = !!take(args, '--approve-installed'), help = !!take(args, '--help');
69
+ if (!args.length || help && args.length === 0) {
70
+ emit({ ok: true, data: { usage: USAGE } });
71
+ return;
72
+ }
73
+ const client = (host) => new CtlClient(store, host, io.requestFor?.(host));
74
+ const discoverOptions = { refresh, app: appFilter, approve, confirm: tty ? async (packages) => /^y(es)?$/i.test(await ask('Allow DSM session to fixed description endpoints for ' + packages.map(p => p.id + '@' + p.version).join(', ') + '? [y/N] ')) : undefined };
75
+ if (args[0] === 'host') {
76
+ const action = args[1], name = args[2];
77
+ if (help || !action) {
78
+ emit({ ok: true, data: { usage: USAGE, actions: ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke'] } });
79
+ return;
80
+ }
81
+ if (action === 'list') {
82
+ const hosts = await boundedMap(store.list(), 4, async (host) => { const session = await store.credentials.get(store.key(host)); return { name: host.name, url: host.origin, username: host.username, authenticated: await client(host).valid(), lastUsed: host.lastUsed ?? null, rememberedDevice: !!session?.deviceId }; });
83
+ emit({ ok: true, data: hosts });
84
+ return;
85
+ }
86
+ if (!name)
87
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Host name is required.', 'run: synopkgland ctl host list');
88
+ if (action === 'add' || action === 'login') {
89
+ const remaining = args.slice(3);
90
+ const url = take(remaining, '--url', true), usernameOption = take(remaining, '--username', true), passwordStdin = !!take(remaining, '--password-stdin'), tunnel = !!take(remaining, '--ssh-tunnel'), noRemember = !!take(remaining, '--no-remember-device'), ca = take(remaining, '--ca', true);
91
+ if (remaining.length)
92
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland ctl host --help', 3, remaining);
93
+ if (action === 'add' && store.list().some(h => h.name === name))
94
+ throw new CtlError('CTL_HOST_EXISTS', `Host ${name} already registered.`, `run: synopkgland ctl host login ${name}`);
95
+ if (action === 'login' && (url || usernameOption || tunnel || ca))
96
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Login uses the registered URL/account; add a separately named host to change them.', `run: synopkgland ctl host show ${name}`);
97
+ const old = action === 'login' ? store.get(name) : undefined;
98
+ if (action === 'add' && !url)
99
+ throw new CtlError('CTL_URL_REQUIRED', '--url is required.', `run: synopkgland ctl host add ${name} --url https://${name}:5001`);
100
+ const host = old ?? { name, origin: canonicalOrigin(url, tunnel), username: usernameOption ?? await ask('DSM account: '), approved: {}, ...(tunnel ? { tunnel: true } : {}), ...(ca ? { ca } : {}) };
101
+ const previous = old ? await store.credentials.get(store.key(host)) : null;
102
+ const secret = passwordStdin ? readBounded(0, 4096).replace(/[\r\n]+$/, '') : await ask('DSM password: ', true);
103
+ const session = await loginDsm(host.origin, host.username, secret, { remember: !noRemember, deviceId: previous?.deviceId, otp: () => ask('DSM OTP: ', true), request: io.requestFor?.(host), ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined });
104
+ try {
105
+ if (old)
106
+ await store.credentials.set(store.key(host), session);
107
+ else
108
+ await store.add(host, session);
109
+ }
110
+ catch (e) {
111
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin)).catch(() => { });
112
+ throw e;
113
+ }
114
+ host.lastUsed = new Date().toISOString();
115
+ store.save(host);
116
+ emit({ ok: true, data: { name: host.name, url: host.origin, username: host.username, authenticated: true, rememberedDevice: !!session.deviceId } });
117
+ return;
118
+ }
119
+ const host = store.get(name);
120
+ if (action === 'show') {
121
+ emit({ ok: true, data: { ...host, authenticated: await client(host).valid() } });
122
+ return;
123
+ }
124
+ if (action === 'revoke') {
125
+ if (!args[3])
126
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Package ID is required.', `run: synopkgland ctl host show ${name}`);
127
+ emit({ ok: true, data: store.revoke(name, args[3]) });
128
+ return;
129
+ }
130
+ if (action === 'logout' || action === 'remove') {
131
+ if (action === 'remove' && !yes && (!tty || !/^y(es)?$/i.test(await ask(`Remove all local data for ${name}? [y/N] `))))
132
+ throw new CtlError('CTL_CONFIRM_REQUIRED', 'Host removal requires --yes.', `run: synopkgland ctl host remove ${name} --yes`, 7);
133
+ const session = await store.credentials.get(store.key(host));
134
+ let loggedOut = false;
135
+ let logoutError;
136
+ if (session?.cookie) {
137
+ try {
138
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, 10000, host.ca ? readFileSync(host.ca, 'utf8') : undefined));
139
+ loggedOut = true;
140
+ }
141
+ catch (error) {
142
+ logoutError = errorData(error);
143
+ }
144
+ }
145
+ if (action === 'remove') {
146
+ emit({ ok: true, data: { ...await store.remove(name), loggedOut, ...(logoutError ? { logoutError } : {}) } });
147
+ return;
148
+ }
149
+ if (logoutError)
150
+ throw new CtlError('CTL_LOGOUT_FAILED', 'DSM logout was not confirmed; local session retained for retry.', `run: synopkgland ctl host logout ${name}`, 6);
151
+ if (session)
152
+ await store.credentials.set(store.key(host), { ...session, cookie: '', token: '' });
153
+ emit({ ok: true, data: { name, loggedOut, registrationRetained: true } });
154
+ return;
155
+ }
156
+ throw new CtlError('CTL_COMMAND_UNKNOWN', `Unknown host action ${action}.`, 'run: synopkgland ctl host --help', 3, ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke']);
157
+ }
158
+ const names = selectHosts(store.list().map(h => h.name), hostOption, allHosts);
159
+ const inputAt = args.indexOf('--input');
160
+ let commonInput;
161
+ if (inputAt >= 0 && args[inputAt + 1] === '-') {
162
+ try {
163
+ commonInput = readBounded(0);
164
+ }
165
+ catch {
166
+ throw new CtlError('CTL_PARAMS_INVALID', 'Cannot read bounded UTF-8 stdin input.', 'Use a JSON input file of at most 1 MiB.');
167
+ }
168
+ }
169
+ const results = await boundedMap(names, 4, async (name) => {
170
+ try {
171
+ const host = store.get(name), ctl = client(host);
172
+ if (args[0] === 'list') {
173
+ if (args.length !== 1)
174
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Unexpected list arguments.', `run: synopkgland ctl list --host ${name}`);
175
+ const result = await ctl.discover(discoverOptions);
176
+ return { host: name, ok: true, data: { apps: listView(name, result.apps), diagnostics: result.diagnostics, cacheHits: result.cacheHits } };
177
+ }
178
+ if (args[0] === 'help' || help) {
179
+ const nameArg = args[0] === 'help' ? args[1] : args[0];
180
+ const data = await ctl.discover(discoverOptions);
181
+ const app = selectApp(data.apps, nameArg ?? '');
182
+ const path = args[0] === 'help' ? args.slice(2) : args.slice(1);
183
+ const view = listView(name, [app])[0];
184
+ return { host: name, ok: true, data: path.length ? { ...view, commands: [view.commands.find(c => c.id === selectOperation(app, path).id)] } : view };
185
+ }
186
+ const tail = args.slice(1);
187
+ const noWait = !!take(tail, '--no-wait'), wait = !!take(tail, '--wait'), timeout = take(tail, '--timeout', true);
188
+ if (noWait && wait)
189
+ throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --wait or --no-wait.', 'run: synopkgland ctl list --json');
190
+ if (timeout && (!/^\d+$/.test(timeout) || Number(timeout) < 1 || Number(timeout) > 3600))
191
+ throw new CtlError('CTL_ARGUMENT_INVALID', '--timeout must be 1–3600 seconds.', 'Use --timeout 60.');
192
+ const firstFlag = tail.findIndex(token => token.startsWith('--'));
193
+ const path = firstFlag < 0 ? tail : tail.slice(0, firstFlag), params = firstFlag < 0 ? [] : tail.slice(firstFlag);
194
+ const data = await ctl.execute(args[0], path, params, { ...discoverOptions, yes, dryRun, stdin: commonInput, wait: !noWait, timeoutMs: timeout ? Number(timeout) * 1000 : undefined });
195
+ return { host: name, ok: true, data };
196
+ }
197
+ catch (error) {
198
+ return { host: name, ok: false, error: errorData(error), exitCode: ctlError(error).exitCode };
199
+ }
200
+ });
201
+ const failed = results.filter(r => !r.ok);
202
+ if (names.length === 1) {
203
+ const result = results[0];
204
+ emit(result.ok ? { ok: true, data: result.data } : { ok: false, error: result.error });
205
+ if (!result.ok)
206
+ process.exitCode = result.exitCode;
207
+ }
208
+ else {
209
+ emit({ ok: failed.length === 0, results: results.map(({ host, ok, ...rest }) => ({ host, ok, ...('data' in rest ? { data: rest.data } : { error: rest.error }) })) });
210
+ if (failed.length)
211
+ process.exitCode = 8;
212
+ }
213
+ }
214
+ catch (error) {
215
+ emit({ ok: false, error: errorData(error) });
216
+ process.exitCode = ctlError(error).exitCode;
217
+ }
218
+ }
@@ -0,0 +1,81 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ export declare const DESCRIPTION_METHOD = "synopkgland.describe";
3
+ export declare const DESCRIPTION_FILE = "synopkgland-api.json";
4
+ export type Operation = {
5
+ summary: string;
6
+ cli: string[];
7
+ effect: 'read' | 'write' | 'destructive';
8
+ permission: 'public' | 'authenticated' | 'administrators';
9
+ paramsSchema?: Record<string, any>;
10
+ resultSchema?: Record<string, any>;
11
+ examples?: Record<string, unknown>[];
12
+ flow: Flow;
13
+ };
14
+ export type Flow = {
15
+ type: 'invoke';
16
+ method: string;
17
+ } | {
18
+ type: 'job';
19
+ start: string;
20
+ poll: string;
21
+ result?: string;
22
+ jobId: string;
23
+ idParam: string;
24
+ state: string;
25
+ done: string[];
26
+ failed: string[];
27
+ intervalMs: number;
28
+ timeoutMs: number;
29
+ } | {
30
+ type: 'paginate';
31
+ method: string;
32
+ items: string;
33
+ offsetParam: string;
34
+ limitParam: string;
35
+ pageSize: number;
36
+ maxPages: number;
37
+ } | {
38
+ type: 'ndjson';
39
+ method: string;
40
+ } | {
41
+ type: 'upload';
42
+ method: string;
43
+ fileParam: string;
44
+ parentParam: string;
45
+ nameParam: string;
46
+ };
47
+ export interface Binding {
48
+ effect: 'read' | 'write' | 'destructive';
49
+ permission: 'public' | 'authenticated' | 'administrators';
50
+ }
51
+ export interface ApiPayload {
52
+ package: {
53
+ id: string;
54
+ version: string;
55
+ displayName: string;
56
+ };
57
+ aliases: string[];
58
+ provenance: {
59
+ developedBy: string;
60
+ generator: string;
61
+ };
62
+ entry: string;
63
+ transport: {
64
+ path: string;
65
+ auth: string;
66
+ };
67
+ commands: Record<string, Operation>;
68
+ bindings: Record<string, Binding>;
69
+ }
70
+ export interface Descriptor {
71
+ format: 'synopkgland.api';
72
+ version: 1;
73
+ payload: ApiPayload;
74
+ sha256: string;
75
+ }
76
+ export declare const ajv: Ajv2020.Ajv2020;
77
+ export declare function makeDescriptor(payload: ApiPayload | any): Descriptor;
78
+ export declare function validateDescriptor(value: any, id: string, version: string): Descriptor;
79
+ export declare function safeSchema(schema: unknown, depth?: number): void;
80
+ export declare function flowMethods(flow: Flow): string[];
81
+ export declare function validateFlow(op: Operation, bindings: Record<string, Binding>): void;