@clawops/cli 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +122 -2
  2. package/dist/agent-activity-25FAWLAI.js +16 -0
  3. package/dist/{apply-HJ5GAKHW.js → apply-F5SQ2ZOS.js} +12 -12
  4. package/dist/{automation-MKCJ46I6.js → automation-NBYQ6MFZ.js} +1 -1
  5. package/dist/backup-flows-NDP5JTR3.js +29 -0
  6. package/dist/{bootstrap-NN647RCY.js → bootstrap-32TZXVDF.js} +5 -5
  7. package/dist/bootstrap.sh.tmpl +4 -0
  8. package/dist/chunk-27335SIH.js +51 -0
  9. package/dist/{chunk-X56CQ6GB.js → chunk-4MHDW2NB.js} +1 -1
  10. package/dist/{chunk-E4NJSJ54.js → chunk-4PY6PN4G.js} +51 -7
  11. package/dist/{chunk-3Z5TDXCJ.js → chunk-55UMO37O.js} +1 -1
  12. package/dist/{chunk-IQQTB7ER.js → chunk-66R52R7K.js} +10 -14
  13. package/dist/chunk-755X5OGS.js +213 -0
  14. package/dist/{chunk-QDNFADJI.js → chunk-AVERNLUY.js} +6 -6
  15. package/dist/{chunk-R3DTA35B.js → chunk-AX65JMDZ.js} +20 -20
  16. package/dist/{chunk-T5XCMYAV.js → chunk-CQOYUWR7.js} +766 -210
  17. package/dist/chunk-E6BRQK67.js +289 -0
  18. package/dist/{chunk-64PONSA5.js → chunk-GFIR2IIJ.js} +3 -1
  19. package/dist/chunk-LV7UO5CG.js +82 -0
  20. package/dist/{chunk-GIEVAGEK.js → chunk-N5ZYUTZS.js} +16 -1
  21. package/dist/{chunk-GFUK6KDR.js → chunk-OUQVKIIA.js} +372 -34
  22. package/dist/{chunk-XFN3YDZE.js → chunk-PASAVVNS.js} +1 -1
  23. package/dist/chunk-PCSX6Y3U.js +297 -0
  24. package/dist/chunk-POYS3EVQ.js +14 -0
  25. package/dist/{chunk-ELU3XQEA.js → chunk-RDUKRIRC.js} +1 -1
  26. package/dist/chunk-SX75L2T3.js +46 -0
  27. package/dist/{chunk-2Z7J3ERQ.js → chunk-TYNMOYVM.js} +1 -1
  28. package/dist/{chunk-2I7KCMXZ.js → chunk-UAJJEDCJ.js} +2 -2
  29. package/dist/{chunk-NUDIHZEH.js → chunk-WPZIJONN.js} +14 -12
  30. package/dist/{chunk-BELTQGHT.js → chunk-YPVWF7IA.js} +4 -4
  31. package/dist/{chunk-3OLUJPCB.js → chunk-Z3XLWZWF.js} +1 -1
  32. package/dist/cli.js +383 -925
  33. package/dist/conn-M2V5HBPJ.js +9 -0
  34. package/dist/{context-7VI3R6EK.js → context-EHSLWKSH.js} +6 -6
  35. package/dist/{docker-3PDO4TS7.js → docker-IKM5GRMC.js} +2 -2
  36. package/dist/{flows-RI7FGHAM.js → flows-6B2I6TRW.js} +7 -7
  37. package/dist/{generate-Q2BGVMH7.js → generate-4YYNW3IU.js} +9 -8
  38. package/dist/{harden-3KKCR7BL.js → harden-UHAOVQMZ.js} +5 -5
  39. package/dist/init-HOPXDNWY.js +13 -0
  40. package/dist/{mcp-wire-FQOMM7MK.js → mcp-wire-BXF4DJFT.js} +26 -19
  41. package/dist/migrate-flow-24XA4GZL.js +25 -0
  42. package/dist/{package-JFVGCNKU.js → package-HYGHRX35.js} +3 -1
  43. package/dist/{pool-PIIKJ4L2.js → pool-NT4XBSUT.js} +2 -2
  44. package/dist/{privileged-JFA6LLTV.js → privileged-5Y6O4H7Z.js} +1 -1
  45. package/dist/{ready-KDNRBNSU.js → ready-IJEUBFVX.js} +2 -2
  46. package/dist/{remote-config-3HZLUWEA.js → remote-config-OZYBMBSK.js} +3 -3
  47. package/dist/{runtime-X3KW5TCP.js → runtime-VW53TU2W.js} +5 -1
  48. package/dist/server-E3YA32ND.js +53 -0
  49. package/dist/ssh-HOM7GF3M.js +13 -0
  50. package/dist/{stack-config-LUTS2XAD.js → stack-config-ICQ5YHUL.js} +2 -2
  51. package/dist/stack-delete-NTIGKWUF.js +13 -0
  52. package/dist/{state-AOAYLYY5.js → state-AKZP2S4T.js} +2 -2
  53. package/dist/{store-OVB3DAJR.js → store-YOLQYXMB.js} +1 -1
  54. package/dist/{tailscale-cutover-6YP2QKFM.js → tailscale-cutover-V42HO5UM.js} +3 -3
  55. package/dist/{validate-JXKN5WD6.js → validate-DZE6LFEB.js} +1 -1
  56. package/dist/{wait-WO72TRRQ.js → wait-DOUNNJNN.js} +2 -2
  57. package/package.json +3 -1
  58. package/spec/mcp-tools.yaml +282 -35
  59. package/dist/logs-5A7YYA53.js +0 -15
  60. package/dist/server-B7I6HBQH.js +0 -46
  61. package/dist/ssh-VAI5DU5M.js +0 -11
  62. package/dist/upgrade-ULD4KFYC.js +0 -88
  63. /package/dist/{chunk-NUL5F6YV.js → chunk-SOZ62ZXW.js} +0 -0
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getConfig,
4
+ getConfigDir,
5
+ setConfig
6
+ } from "./chunk-GFIR2IIJ.js";
7
+
8
+ // src/config/init.ts
9
+ import { spawnSync } from "child_process";
10
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
11
+ import path from "path";
12
+
13
+ // src/providers/state-bucket.ts
14
+ function deriveStateBucket(provider, scope) {
15
+ if (provider === "azure") {
16
+ return { ok: true, name: "clawops-state" };
17
+ }
18
+ if (!scope.account) {
19
+ return {
20
+ ok: false,
21
+ needs: provider === "aws" ? "an AWS account id \u2014 set AWS_PROFILE, or run `aws sso login --profile <name>`" : "a GCP project \u2014 run `gcloud config set project <id>`, or set GOOGLE_CLOUD_PROJECT"
22
+ };
23
+ }
24
+ if (provider === "gcp") return { ok: true, name: `clawops-state-${scope.account}` };
25
+ if (!scope.region) return { ok: false, needs: "a region" };
26
+ return { ok: true, name: `clawops-state-${scope.account}-${scope.region}` };
27
+ }
28
+ function stateUrlFor(provider, bucket) {
29
+ const verdict = validateStateBucket(provider, bucket);
30
+ if (verdict !== true) throw new Error(`Not a usable ${provider} state backend name: ${verdict}`);
31
+ const scheme = provider === "aws" ? "s3://" : provider === "gcp" ? "gs://" : "azblob://";
32
+ return provider === "azure" ? `${scheme}${bucket}` : `${scheme}${bucket}/clawops`;
33
+ }
34
+ var IPV4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
35
+ function validateStateBucket(provider, name) {
36
+ const n = name.trim();
37
+ if (n === "") return "Required";
38
+ if (n !== name) return "No leading or trailing spaces";
39
+ if (n.length < 3) return `Too short \u2014 ${labelFor(provider)} names are at least 3 characters`;
40
+ const dotted = provider === "gcp" && n.includes(".");
41
+ const max = dotted ? 222 : 63;
42
+ if (n.length > max) return `Too long \u2014 ${labelFor(provider)} names are at most ${max} characters`;
43
+ if (dotted && n.split(".").some((part) => part.length > 63)) {
44
+ return "Each dot-separated part of a Cloud Storage name is at most 63 characters";
45
+ }
46
+ if (n !== n.toLowerCase()) return "Lowercase only";
47
+ if (provider === "azure") {
48
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(n)) {
49
+ return "Letters, digits and single hyphens only, starting and ending with a letter or digit";
50
+ }
51
+ return true;
52
+ }
53
+ if (!/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/.test(n)) {
54
+ return "Letters, digits, dots, hyphens and underscores only, starting and ending with a letter or digit";
55
+ }
56
+ if (n.includes("..")) return "No consecutive dots";
57
+ if (IPV4.test(n)) return "Cannot be formatted as an IP address";
58
+ if (provider === "aws") {
59
+ if (n.startsWith("xn--")) return 'Cannot start with "xn--"';
60
+ if (n.startsWith("sthree-")) return 'Cannot start with "sthree-"';
61
+ if (n.endsWith("-s3alias")) return 'Cannot end with "-s3alias"';
62
+ if (n.endsWith("--ol-s3")) return 'Cannot end with "--ol-s3"';
63
+ if (n.includes("_")) return "Underscores are not allowed in S3 bucket names";
64
+ return true;
65
+ }
66
+ if (n.startsWith("goog")) return 'Cannot start with "goog"';
67
+ if (n.includes("google")) return 'Cannot contain "google"';
68
+ return true;
69
+ }
70
+ function labelFor(provider) {
71
+ if (provider === "aws") return "S3 bucket";
72
+ if (provider === "gcp") return "Cloud Storage bucket";
73
+ return "blob container";
74
+ }
75
+ async function resolveScopeAccount(provider, signal) {
76
+ if (provider === "azure") return void 0;
77
+ try {
78
+ if (provider === "aws") {
79
+ const { callerAccount } = await import("./preflight-TFD4N5ZU.js");
80
+ return await callerAccount(signal);
81
+ }
82
+ const { resolveProjectId } = await import("./preflight-IE2YWBAC.js");
83
+ return resolveProjectId();
84
+ } catch {
85
+ return void 0;
86
+ }
87
+ }
88
+
89
+ // src/config/init.ts
90
+ var SUPPORTED_PROVIDERS = ["gcp", "aws", "azure", "local"];
91
+ var PROVIDER_DEFAULTS = {
92
+ gcp: { region: "us-central1", credEnv: "GOOGLE_APPLICATION_CREDENTIALS", stateScheme: "gs://" },
93
+ aws: { region: "us-east-1", credEnv: "AWS_PROFILE", stateScheme: "s3://" },
94
+ azure: { region: "eastus", credEnv: "AZURE_CLIENT_ID", stateScheme: "azblob://" }
95
+ };
96
+ async function initStack(opts) {
97
+ const provider = opts.provider;
98
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
99
+ return {
100
+ ok: false,
101
+ reason: `Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`
102
+ };
103
+ }
104
+ const stackName = opts.stackName ?? "default";
105
+ const existing = getConfig();
106
+ if (existing?.stacks[stackName] && !opts.force) {
107
+ return {
108
+ ok: false,
109
+ reason: `Stack "${stackName}" already exists in ${path.join(getConfigDir(), "config.json")} (${existing.stacks[stackName].stateUrl}). Pass force to overwrite its settings, or use a different stack name to add another one.`
110
+ };
111
+ }
112
+ const configDir = getConfigDir();
113
+ mkdirSync(configDir, { recursive: true });
114
+ const keyPath = opts.keyPath ?? path.join(configDir, "id_ed25519");
115
+ const knownHostsPath = path.join(configDir, "known_hosts");
116
+ let keyGenerated = false;
117
+ if (!existsSync(keyPath)) {
118
+ if (opts.keyPath) return { ok: false, reason: `SSH key not found at ${keyPath}` };
119
+ const gen = spawnSync(
120
+ "ssh-keygen",
121
+ ["-t", "ed25519", "-f", keyPath, "-N", "", "-C", "clawops", "-q"],
122
+ { stdio: ["ignore", "pipe", "pipe"] }
123
+ );
124
+ if (gen.error || gen.status !== 0) {
125
+ const reason = gen.error?.message ?? gen.stderr?.toString().trim() ?? `exit ${gen.status}`;
126
+ return {
127
+ ok: false,
128
+ reason: `Could not generate an SSH key: ${reason}
129
+ Generate one yourself and point clawops at it:
130
+ ssh-keygen -t ed25519 -f ${keyPath} -N '' -C clawops
131
+ clawops init --key-path ${keyPath}`
132
+ };
133
+ }
134
+ keyGenerated = true;
135
+ }
136
+ if (!existsSync(knownHostsPath)) writeFileSync(knownHostsPath, "", "utf-8");
137
+ if (provider === "local") {
138
+ if (!opts.host) return { ok: false, reason: "host is required for the local provider" };
139
+ const config = merge(existing, stackName, provider, keyPath, knownHostsPath, {
140
+ provider,
141
+ stateUrl: "file://~/.clawops/state",
142
+ credentialsRef: { source: "file", envVars: [] },
143
+ localOpts: {
144
+ host: opts.host,
145
+ sshUser: opts.sshUser ?? "root",
146
+ sshPort: opts.sshPort ?? 22,
147
+ sshKeyPath: keyPath
148
+ }
149
+ });
150
+ setConfig(config);
151
+ return {
152
+ ok: true,
153
+ configPath: path.join(configDir, "config.json"),
154
+ stackName,
155
+ provider,
156
+ stateUrl: "file://~/.clawops/state",
157
+ keyPath,
158
+ keyGenerated
159
+ };
160
+ }
161
+ const defaults = PROVIDER_DEFAULTS[provider];
162
+ const region = opts.region ?? defaults.region;
163
+ let stateUrl;
164
+ if (opts.stateUrl) {
165
+ stateUrl = opts.stateUrl;
166
+ } else {
167
+ const account = await resolveScopeAccount(provider);
168
+ const derived = deriveStateBucket(provider, { account, region });
169
+ if (!derived.ok) {
170
+ return {
171
+ ok: false,
172
+ reason: `Could not name a state backend for ${provider}: clawops needs ${derived.needs}. Authenticate first, or name the backend yourself, e.g. ${defaults.stateScheme}your-bucket/clawops`
173
+ };
174
+ }
175
+ stateUrl = stateUrlFor(provider, derived.name);
176
+ }
177
+ setConfig(
178
+ merge(existing, stackName, provider, keyPath, knownHostsPath, {
179
+ provider,
180
+ stateUrl,
181
+ region,
182
+ credentialsRef: { source: "env", envVars: [defaults.credEnv] }
183
+ })
184
+ );
185
+ return {
186
+ ok: true,
187
+ configPath: path.join(configDir, "config.json"),
188
+ stackName,
189
+ provider,
190
+ stateUrl,
191
+ region,
192
+ keyPath,
193
+ keyGenerated
194
+ };
195
+ }
196
+ function merge(existing, stackName, provider, keyPath, knownHostsPath, stack) {
197
+ return {
198
+ ...existing ?? {},
199
+ version: 1,
200
+ defaults: { stack: stackName, provider },
201
+ stacks: { ...existing?.stacks ?? {}, [stackName]: stack },
202
+ ssh: { keyPath, knownHostsPath }
203
+ };
204
+ }
205
+
206
+ export {
207
+ deriveStateBucket,
208
+ validateStateBucket,
209
+ resolveScopeAccount,
210
+ SUPPORTED_PROVIDERS,
211
+ PROVIDER_DEFAULTS,
212
+ initStack
213
+ };
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ getProvider
4
+ } from "./chunk-VUFMZEIM.js";
2
5
  import {
3
6
  readLocalState
4
- } from "./chunk-ELU3XQEA.js";
7
+ } from "./chunk-RDUKRIRC.js";
5
8
  import {
6
9
  getConfigDir,
7
10
  requireConfig
8
- } from "./chunk-64PONSA5.js";
11
+ } from "./chunk-GFIR2IIJ.js";
9
12
  import {
10
13
  UsageError
11
14
  } from "./chunk-KGXPLI7W.js";
12
- import {
13
- getProvider
14
- } from "./chunk-VUFMZEIM.js";
15
15
 
16
16
  // src/cli/context.ts
17
17
  function buildContext(args) {
@@ -41,7 +41,7 @@ function buildContext(args) {
41
41
  `Stack "${stackName}" not found in config. Run \`clawops init\` or use \`--stack\` to specify a different stack.`
42
42
  );
43
43
  }
44
- const { getOrCreateStack } = await import("./automation-MKCJ46I6.js");
44
+ const { getOrCreateStack } = await import("./automation-NBYQ6MFZ.js");
45
45
  stackCache = await getOrCreateStack({
46
46
  stack: stackName,
47
47
  stateUrl: stackConfig.stateUrl,
@@ -2,31 +2,31 @@
2
2
  import {
3
3
  resolveSecrets
4
4
  } from "./chunk-BOPSG2LI.js";
5
- import {
6
- saveOverlay
7
- } from "./chunk-6ZFIFDBJ.js";
8
5
  import {
9
6
  atomicWriteConfig,
10
7
  deepMerge,
11
8
  readRemoteConfig,
12
9
  restartGateway
13
- } from "./chunk-2I7KCMXZ.js";
14
- import {
15
- writeStackConfig
16
- } from "./chunk-3Z5TDXCJ.js";
10
+ } from "./chunk-UAJJEDCJ.js";
17
11
  import {
18
12
  validatePlan
19
- } from "./chunk-XFN3YDZE.js";
13
+ } from "./chunk-PASAVVNS.js";
14
+ import {
15
+ writeStackConfig
16
+ } from "./chunk-55UMO37O.js";
20
17
  import {
21
18
  buildContext
22
- } from "./chunk-QDNFADJI.js";
19
+ } from "./chunk-AVERNLUY.js";
20
+ import {
21
+ saveOverlay
22
+ } from "./chunk-6ZFIFDBJ.js";
23
23
  import {
24
24
  GATEWAY_PORT
25
25
  } from "./chunk-VOEJYRIK.js";
26
26
  import {
27
27
  getConfig,
28
28
  setConfig
29
- } from "./chunk-64PONSA5.js";
29
+ } from "./chunk-GFIR2IIJ.js";
30
30
  import {
31
31
  UsageError
32
32
  } from "./chunk-KGXPLI7W.js";
@@ -54,7 +54,7 @@ ${validation.errors.join("\n")}`
54
54
  });
55
55
  const stack = await ctx.getStack();
56
56
  const { guardPrivateOnlyApply } = await import("./private-only-DIA26I2E.js");
57
- const { probeSsh } = await import("./tailscale-cutover-6YP2QKFM.js");
57
+ const { probeSsh } = await import("./tailscale-cutover-V42HO5UM.js");
58
58
  await guardPrivateOnlyApply(plan, ctx, opts?.probeTailnet ?? ((conn2) => probeSsh(conn2, opts?.signal)));
59
59
  await writeStackConfig(stack, plan);
60
60
  if (plan.metadata.stackVersion !== void 0) {
@@ -84,7 +84,7 @@ The diff you reviewed may no longer reflect what will be applied.
84
84
  changeSummary[op] = count;
85
85
  }
86
86
  }
87
- const { waitForSsh } = await import("./wait-WO72TRRQ.js");
87
+ const { waitForSsh } = await import("./wait-DOUNNJNN.js");
88
88
  const conn = await connectionInfoFor(ctx, outputs);
89
89
  if (opts?.skipReadiness) {
90
90
  return { outputs, changeSummary, durationMs: Date.now() - start };
@@ -93,7 +93,7 @@ The diff you reviewed may no longer reflect what will be applied.
93
93
  signal: opts?.signal,
94
94
  onProgress: (line) => reportProgress(opts, line)
95
95
  });
96
- const { waitForGateway } = await import("./ready-KDNRBNSU.js");
96
+ const { waitForGateway } = await import("./ready-IJEUBFVX.js");
97
97
  try {
98
98
  await waitForGateway(readySession, {
99
99
  signal: opts?.signal,
@@ -129,7 +129,7 @@ function expandHome(p) {
129
129
  return p.replace(/^~/, process.env["HOME"] ?? "~");
130
130
  }
131
131
  async function applyConfigOverlay(plan, outputs, ctx, signal) {
132
- const { connect } = await import("./ssh-VAI5DU5M.js");
132
+ const { connect } = await import("./ssh-HOM7GF3M.js");
133
133
  const session = await connect({ ...await connectionInfoFor(ctx, outputs), signal });
134
134
  try {
135
135
  const remote = await readRemoteConfig(session, signal);
@@ -158,7 +158,7 @@ async function applyConfigOverlay(plan, outputs, ctx, signal) {
158
158
  async function installProviderPlugins(session, cfg, openclawVersion, signal) {
159
159
  const [{ requiredPlugins, installCommand }, { STATE_DIR_HOST_LINUX }, yaml, { readFileSync }, { join }, { resolveSpecDir }] = await Promise.all([
160
160
  import("./plugins-DTST3OER.js"),
161
- import("./runtime-X3KW5TCP.js"),
161
+ import("./runtime-VW53TU2W.js"),
162
162
  import("./js-yaml-PTEEG4FO.js"),
163
163
  import("fs"),
164
164
  import("path"),
@@ -169,7 +169,7 @@ async function installProviderPlugins(session, cfg, openclawVersion, signal) {
169
169
  );
170
170
  const needed = requiredPlugins(cfg, catalog);
171
171
  if (needed.length === 0) return;
172
- const { execPrivileged } = await import("./privileged-JFA6LLTV.js");
172
+ const { execPrivileged } = await import("./privileged-5Y6O4H7Z.js");
173
173
  const image = `ghcr.io/openclaw/openclaw:${openclawVersion}`;
174
174
  for (const plugin of needed) {
175
175
  const result = await execPrivileged(
@@ -187,7 +187,7 @@ async function installProviderPlugins(session, cfg, openclawVersion, signal) {
187
187
  }
188
188
  async function verifyProviders(session, cfg, signal) {
189
189
  const { missingProviders } = await import("./plugins-DTST3OER.js");
190
- const { execPrivileged } = await import("./privileged-JFA6LLTV.js");
190
+ const { execPrivileged } = await import("./privileged-5Y6O4H7Z.js");
191
191
  const result = await execPrivileged(
192
192
  session,
193
193
  "docker exec openclaw openclaw plugins list --json",
@@ -213,10 +213,10 @@ async function loadChannelCatalog() {
213
213
  return yaml.load(readFileSync(join(resolveSpecDir(), "integrations.yaml"), "utf-8"));
214
214
  }
215
215
  async function installChannelPlugins(session, cfg, openclawVersion, signal) {
216
- const [{ requiredChannelPlugins, channelInstallCommand }, { STATE_DIR_HOST_LINUX }] = await Promise.all([import("./channels-KBUR42IU.js"), import("./runtime-X3KW5TCP.js")]);
216
+ const [{ requiredChannelPlugins, channelInstallCommand }, { STATE_DIR_HOST_LINUX }] = await Promise.all([import("./channels-KBUR42IU.js"), import("./runtime-VW53TU2W.js")]);
217
217
  const needed = requiredChannelPlugins(cfg, await loadChannelCatalog());
218
218
  if (needed.length === 0) return;
219
- const { execPrivileged } = await import("./privileged-JFA6LLTV.js");
219
+ const { execPrivileged } = await import("./privileged-5Y6O4H7Z.js");
220
220
  const image = `ghcr.io/openclaw/openclaw:${openclawVersion}`;
221
221
  for (const plugin of needed) {
222
222
  const result = await execPrivileged(
@@ -234,7 +234,7 @@ async function installChannelPlugins(session, cfg, openclawVersion, signal) {
234
234
  }
235
235
  async function verifyChannels(session, cfg, signal) {
236
236
  const { missingChannels, CHANNELS_LIST_CMD } = await import("./channels-KBUR42IU.js");
237
- const { execPrivileged } = await import("./privileged-JFA6LLTV.js");
237
+ const { execPrivileged } = await import("./privileged-5Y6O4H7Z.js");
238
238
  const result = await execPrivileged(session, CHANNELS_LIST_CMD, signal);
239
239
  if (result.code !== 0) return;
240
240
  const missing = missingChannels(cfg, result.stdout, await loadChannelCatalog());