@norskvideo/ctl-sdk 0.1.5 → 0.1.7

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.
package/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./dev-url.js";
5
5
  export * from "./docker-runner.js";
6
6
  export * from "./http-proxy.js";
7
7
  export * from "./license-registration.js";
8
+ export * from "./license-staged.js";
8
9
  export * from "./license-stager.js";
9
10
  export * from "./license-v2.js";
10
11
  export * from "./manifest-fetch.js";
package/index.js CHANGED
@@ -7,6 +7,7 @@ export * from "./dev-url.js";
7
7
  export * from "./docker-runner.js";
8
8
  export * from "./http-proxy.js";
9
9
  export * from "./license-registration.js";
10
+ export * from "./license-staged.js";
10
11
  export * from "./license-stager.js";
11
12
  export * from "./license-v2.js";
12
13
  export * from "./manifest-fetch.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The staged copy corresponding to an operator-supplied licence path, if one
3
+ * exists under `licensesDir`. `exists` is injected so this stays unit-testable.
4
+ */
5
+ export declare function stagedLicenseCandidate(operatorPath: string, licensesDir: string | undefined, exists?: (path: string) => boolean): string | undefined;
6
+ /**
7
+ * Explain an unreadable licence in terms of the thing that's actually wrong.
8
+ *
9
+ * A permissions failure and a missing file are different mistakes: the first is
10
+ * "right file, wrong path for this process", the second is a typo. Naming the
11
+ * daemon user on an ENOENT would send the operator hunting a permissions
12
+ * problem that isn't there.
13
+ */
14
+ export declare function unreadableLicenseMessage(opts: {
15
+ operatorPath: string;
16
+ cause: string;
17
+ daemonUser: string;
18
+ licensesDir?: string;
19
+ stagedCandidate?: string;
20
+ }): string;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Diagnostics for a licence file the daemon cannot read.
3
+ *
4
+ * The licence-driven routes (`preview-license`, `from-license`) read the file
5
+ * daemon-side, and the daemon runs unprivileged as its service user — so an
6
+ * operator who passes the path they used at install time (`~/licence.json`, in
7
+ * *their* home) gets an EACCES that reads like a broken licence rather than a
8
+ * wrong path.
9
+ *
10
+ * The remedy is almost always sitting in `licensesDir`: the installers stage a
11
+ * copy there, deliberately keeping the operator's basename, so the file they
12
+ * meant can be found by name and named back to them. Shared by the hosts (ctl
13
+ * and mgr) so the two daemons explain the same mistake the same way.
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
+ /**
18
+ * The staged copy corresponding to an operator-supplied licence path, if one
19
+ * exists under `licensesDir`. `exists` is injected so this stays unit-testable.
20
+ */
21
+ export function stagedLicenseCandidate(operatorPath, licensesDir, exists = existsSync) {
22
+ if (!licensesDir)
23
+ return undefined;
24
+ // Already the staged copy — suggesting it back would be circular.
25
+ if (resolve(dirname(operatorPath)) === resolve(licensesDir))
26
+ return undefined;
27
+ const candidate = join(licensesDir, basename(operatorPath));
28
+ return exists(candidate) ? candidate : undefined;
29
+ }
30
+ /**
31
+ * Explain an unreadable licence in terms of the thing that's actually wrong.
32
+ *
33
+ * A permissions failure and a missing file are different mistakes: the first is
34
+ * "right file, wrong path for this process", the second is a typo. Naming the
35
+ * daemon user on an ENOENT would send the operator hunting a permissions
36
+ * problem that isn't there.
37
+ */
38
+ export function unreadableLicenseMessage(opts) {
39
+ const { operatorPath, cause, daemonUser, licensesDir, stagedCandidate } = opts;
40
+ const missing = /ENOENT/.test(cause);
41
+ const parts = [`cannot read license file '${operatorPath}': ${cause}`];
42
+ if (missing) {
43
+ parts.push("Check the path — nothing exists there.");
44
+ }
45
+ else {
46
+ parts.push(`The daemon runs as '${daemonUser}', which cannot read it.`);
47
+ }
48
+ if (stagedCandidate) {
49
+ parts.push(`A staged licence of the same name exists at '${stagedCandidate}' — pass that instead.`);
50
+ }
51
+ else if (!missing) {
52
+ parts.push(`Licences are staged under ${licensesDir ?? "the daemon's licenses directory"} at install time; pass the staged path, or copy the file there readable by '${daemonUser}'.`);
53
+ }
54
+ return parts.join(" ");
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -49,9 +49,11 @@ export type ImportProductTemplateBytesFn = (opts: {
49
49
  kind: "product-default";
50
50
  }>;
51
51
  }) => Promise<void>;
52
- /** The container operations stopAll/restoreAll depend on, behind an interface
53
- * so tests can drive them without shelling out to Docker. Defaults wire
54
- * straight to docker-runner + the readiness probe. */
52
+ /** Every container operation this service performs, behind an interface so
53
+ * tests can drive them without shelling out to Docker. Defaults wire straight
54
+ * to docker-runner + the readiness probe. Reach for these rather than the
55
+ * module functions: calling docker-runner directly re-creates the bypass that
56
+ * made an injected fake silently inert on add/remove. */
55
57
  export interface ProductContainerOps {
56
58
  run(image: string, hostPort: number): Promise<string>;
57
59
  remove(containerId: string): Promise<void>;
@@ -72,8 +74,8 @@ export interface ProductServiceOptions {
72
74
  /** Liveness probe for dev-mode products. Injectable for tests; defaults to a
73
75
  * fast timeout-bounded GET of the dev URL's `/manifest.json`. */
74
76
  isDevUrlAlive?: (baseUrl: string) => Promise<boolean>;
75
- /** Container lifecycle ops used by stopAll/restoreAll. Injectable for tests;
76
- * defaults to the real docker-runner functions. */
77
+ /** Container lifecycle ops used by add/remove/restart/stopAll/restoreAll.
78
+ * Injectable for tests; defaults to the real docker-runner functions. */
77
79
  containerOps?: ProductContainerOps;
78
80
  }
79
81
  export declare class ProductService {
@@ -76,25 +76,25 @@ export class ProductService {
76
76
  }
77
77
  port = allocated;
78
78
  logger.info(`Starting product container: ${spec.image} on host port ${port}`);
79
- containerId = await dockerRun(spec.image, port);
79
+ containerId = await this.containerOps.run(spec.image, port);
80
80
  baseUrl = specBaseUrl(spec, port);
81
81
  }
82
82
  try {
83
- await waitForReady(baseUrl);
83
+ await this.containerOps.waitForReady(baseUrl);
84
84
  }
85
85
  catch (e) {
86
86
  if (containerId)
87
- await dockerRm(containerId);
87
+ await this.containerOps.remove(containerId);
88
88
  throw e;
89
89
  }
90
90
  const manifest = await fetchManifest(baseUrl).catch(async (e) => {
91
91
  if (containerId)
92
- await dockerRm(containerId);
92
+ await this.containerOps.remove(containerId);
93
93
  throw e;
94
94
  });
95
95
  if (existing.some((p) => p.name === manifest.name)) {
96
96
  if (containerId)
97
- await dockerRm(containerId);
97
+ await this.containerOps.remove(containerId);
98
98
  throw new ProductError("NAME_CONFLICT", `product '${manifest.name}' already registered`);
99
99
  }
100
100
  const warnings = [];
@@ -111,7 +111,7 @@ export class ProductService {
111
111
  licenseFile = resolved.file;
112
112
  if (resolved.fatal) {
113
113
  if (containerId)
114
- await dockerRm(containerId);
114
+ await this.containerOps.remove(containerId);
115
115
  throw new ProductError("LICENSE_INVALID", resolved.fatal);
116
116
  }
117
117
  if (resolved.warning)
@@ -137,19 +137,19 @@ export class ProductService {
137
137
  const parsed = contents !== undefined ? parseLicenseContents(contents) : undefined;
138
138
  if (parsed?.kind === "invalid-v2") {
139
139
  if (containerId)
140
- await dockerRm(containerId);
140
+ await this.containerOps.remove(containerId);
141
141
  throw new ProductError("LICENSE_INVALID", parsed.reason);
142
142
  }
143
143
  if (parsed?.kind === "legacy") {
144
144
  if (containerId)
145
- await dockerRm(containerId);
145
+ await this.containerOps.remove(containerId);
146
146
  throw new ProductError("LICENSE_INVALID", notV2EnvelopeMessage("V1 licenses are no longer accepted at registration"));
147
147
  }
148
148
  if (parsed?.kind === "v2") {
149
149
  const check = checkProductEntitlement(parsed, manifest.name, spec.kind === "container" ? spec.image : undefined);
150
150
  if (!check.ok) {
151
151
  if (containerId)
152
- await dockerRm(containerId);
152
+ await this.containerOps.remove(containerId);
153
153
  throw new ProductError("LICENSE_INVALID", check.reason);
154
154
  }
155
155
  warnings.push(...check.warnings);
@@ -159,7 +159,7 @@ export class ProductService {
159
159
  // stable, human-readable name (replacing docker's random alias). Tracked
160
160
  // by id, so a rename failure is harmless — hence best-effort.
161
161
  if (containerId)
162
- await dockerRename(containerId, productContainerName(manifest.name));
162
+ await this.containerOps.rename(containerId, productContainerName(manifest.name));
163
163
  // Sanity-probe the config screen so we catch (a) URLs that don't
164
164
  // serve anything at the manifest-declared path, and (b) the common
165
165
  // "registered the vite dev port" mistake — both are hard rejections
@@ -170,7 +170,7 @@ export class ProductService {
170
170
  }
171
171
  catch (e) {
172
172
  if (containerId)
173
- await dockerRm(containerId);
173
+ await this.containerOps.remove(containerId);
174
174
  throw e;
175
175
  }
176
176
  }
@@ -227,7 +227,7 @@ export class ProductService {
227
227
  if (!target)
228
228
  throw new ProductError("NOT_FOUND", `product '${name}' not registered`);
229
229
  if (target.containerId)
230
- await dockerRm(target.containerId);
230
+ await this.containerOps.remove(target.containerId);
231
231
  await this.store.update((products) => products.filter((p) => p.name !== name));
232
232
  logger.info(`Product '${name}' removed`);
233
233
  }