@junoflow/cli 0.6.0 → 0.9.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.
- package/README.md +189 -73
- package/app-client.js +306 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +4 -2
- package/package.json +9 -4
- package/src/api.js +182 -39
- package/src/app-dev/build.js +134 -0
- package/src/app-dev/harness.js +108 -0
- package/src/app-dev/panel.html +336 -0
- package/src/app-dev/panel.js +290 -0
- package/src/app-dev/server.js +347 -0
- package/src/app-dev/stream.js +224 -0
- package/src/app-dev/watch.js +47 -0
- package/src/app-project.js +133 -0
- package/src/authoring-skills.js +2 -1
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +102 -0
- package/src/commands/app-dev.js +141 -0
- package/src/commands/app-gen-types.js +37 -0
- package/src/commands/app-init.js +46 -0
- package/src/commands/app-pull.js +106 -0
- package/src/commands/check.js +31 -15
- package/src/commands/deploy.js +57 -4
- package/src/commands/flow-check.js +4 -22
- package/src/commands/flow-deploy.js +11 -8
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +12 -5
- package/src/commands/flow-pull.js +69 -40
- package/src/commands/pull.js +96 -102
- package/src/errors.js +3 -3
- package/src/exit.js +2 -2
- package/src/flow-project.js +47 -97
- package/src/index.js +62 -1
- package/src/project.js +76 -8
- package/src/watch.js +48 -11
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- package/verbs.json +51 -2
package/src/watch.js
CHANGED
|
@@ -28,23 +28,60 @@ export async function deployOnChange({ io, target, paths, once }) {
|
|
|
28
28
|
};
|
|
29
29
|
await iteration();
|
|
30
30
|
|
|
31
|
+
const watcher = watchPaths(paths, () => void iteration());
|
|
32
|
+
await new Promise((resolve) => {
|
|
33
|
+
process.once('SIGINT', () => {
|
|
34
|
+
watcher.close();
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* watchPaths calls onChange once edits to `paths` (`{path, recursive?,
|
|
43
|
+
* filter?}`) have settled for `debounceMs`, so an editor's save-and-rename is
|
|
44
|
+
* one call. `filter(filename)`, when given, drops an event its path does not
|
|
45
|
+
* want — the way to watch one file in a directory, since a watch on the file
|
|
46
|
+
* itself loses it the first time an editor saves by renaming over it. A path
|
|
47
|
+
* that does not exist is not watched: an app's public/ may be absent, since
|
|
48
|
+
* nothing but the page is required. Returns `{close()}`, which also drops a
|
|
49
|
+
* pending call.
|
|
50
|
+
*/
|
|
51
|
+
export function watchPaths(
|
|
52
|
+
paths,
|
|
53
|
+
onChange,
|
|
54
|
+
{ debounceMs = WATCH_DEBOUNCE_MS } = {}
|
|
55
|
+
) {
|
|
31
56
|
let timer = null;
|
|
32
57
|
const schedule = () => {
|
|
33
58
|
if (timer) clearTimeout(timer);
|
|
34
59
|
timer = setTimeout(() => {
|
|
35
60
|
timer = null;
|
|
36
|
-
|
|
37
|
-
},
|
|
61
|
+
onChange();
|
|
62
|
+
}, debounceMs);
|
|
38
63
|
};
|
|
39
64
|
|
|
40
|
-
const watchers =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
const watchers = [];
|
|
66
|
+
for (const { path, recursive = false, filter } of paths) {
|
|
67
|
+
try {
|
|
68
|
+
const w = watch(path, { recursive }, (_event, filename) => {
|
|
69
|
+
if (!filter || filter(filename == null ? null : String(filename))) {
|
|
70
|
+
schedule();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
// A watched directory removed mid-session is not worth a crash.
|
|
74
|
+
w.on('error', () => w.close());
|
|
75
|
+
watchers.push(w);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.code !== 'ENOENT') throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
close() {
|
|
82
|
+
if (timer) clearTimeout(timer);
|
|
83
|
+
timer = null;
|
|
45
84
|
for (const w of watchers) w.close();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
49
|
-
return 0;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
50
87
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { safeRelative } from './commands/init.js';
|
|
7
|
+
import { UserError } from './errors.js';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What every working copy bound by package.json's `juno` block shares — a
|
|
13
|
+
* flow's (flow-project.js) and an app's (app-project.js): reading the
|
|
14
|
+
* manifest and the binding, writing back what a route answered, and the
|
|
15
|
+
* clean-tree check before a pull. Pulling itself (pullArtifacts) is shared
|
|
16
|
+
* with an integration's working copy too. The layout is the server's
|
|
17
|
+
* (POST /api/{flows,apps}/scaffold); this module knows only the names a
|
|
18
|
+
* command reads or writes back, and which of them are artifact files.
|
|
19
|
+
*/
|
|
20
|
+
export const MANIFEST = 'manifest.json';
|
|
21
|
+
export const PACKAGE_JSON = 'package.json';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* page/tsconfig.json is a project file, not part of the page: the scaffold
|
|
25
|
+
* writes it, deploy never sends it (ingest would refuse it), and pull never
|
|
26
|
+
* writes or removes it.
|
|
27
|
+
*/
|
|
28
|
+
export const PAGE_TSCONFIG = 'page/tsconfig.json';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The directories whose files a version stores, per kind, beside
|
|
32
|
+
* manifest.json. Everything else in a working copy is generated (types/) or
|
|
33
|
+
* a project file (tsconfig.json, README.md, package.json …) that no pull
|
|
34
|
+
* writes or deletes.
|
|
35
|
+
*/
|
|
36
|
+
const ARTIFACT_DIRS = {
|
|
37
|
+
flow: ['src/', 'test/'],
|
|
38
|
+
integration: ['src/', 'test/'],
|
|
39
|
+
app: ['src/', 'test/', 'page/', 'public/'],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** isArtifactPath reports whether a `kind` version stores the file at path. */
|
|
43
|
+
export function isArtifactPath(kind, path) {
|
|
44
|
+
if (path === MANIFEST) return true;
|
|
45
|
+
if (path === PAGE_TSCONFIG) return false;
|
|
46
|
+
return ARTIFACT_DIRS[kind].some((dir) => path.startsWith(dir));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* pullArtifacts makes a working copy's artifact files the pulled version's.
|
|
51
|
+
* `stored` is that version's artifact files, path → content, as the version
|
|
52
|
+
* stores them and never as a scaffold renders them (a scaffold adds init-only
|
|
53
|
+
* files). Each one whose content differs is written.
|
|
54
|
+
*
|
|
55
|
+
* `base` is the artifact paths of the version the copy was pulled from or last
|
|
56
|
+
* deployed (`juno.base_version`), or null. A file is deleted only when the base
|
|
57
|
+
* had it and the pulled version does not: that is a file deleted in Juno. A
|
|
58
|
+
* file neither version has is one the author has not deployed yet, and stays.
|
|
59
|
+
* With no base, nothing is deleted. Returns `{written, removed}`, each sorted.
|
|
60
|
+
*/
|
|
61
|
+
export async function pullArtifacts(cwd, { stored, base, kind }) {
|
|
62
|
+
const written = await writeChangedFiles(cwd, stored);
|
|
63
|
+
const removed = [];
|
|
64
|
+
for (const path of base ?? []) {
|
|
65
|
+
if (Object.hasOwn(stored, path) || !isArtifactPath(kind, path)) continue;
|
|
66
|
+
try {
|
|
67
|
+
await rm(join(cwd, safeRelative(path)));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err.code === 'ENOENT') continue; // already gone locally
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
removed.push(path);
|
|
73
|
+
}
|
|
74
|
+
return { written, removed: removed.sort() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* baseArtifactPaths is pullArtifacts' `base`: null when the copy records no
|
|
79
|
+
* base, the pulled version's own paths when the base is that version, and
|
|
80
|
+
* otherwise what `read(base)` answers — the base version's artifact paths, or
|
|
81
|
+
* null when the instance no longer has it. Pull then deletes nothing, and says
|
|
82
|
+
* so on stderr.
|
|
83
|
+
*/
|
|
84
|
+
export async function baseArtifactPaths({
|
|
85
|
+
base,
|
|
86
|
+
version,
|
|
87
|
+
stored,
|
|
88
|
+
read,
|
|
89
|
+
id,
|
|
90
|
+
target,
|
|
91
|
+
io,
|
|
92
|
+
}) {
|
|
93
|
+
if (base === undefined) return null;
|
|
94
|
+
if (base === version) return Object.keys(stored);
|
|
95
|
+
const paths = await read(base);
|
|
96
|
+
if (paths === null) {
|
|
97
|
+
io.warn(
|
|
98
|
+
`${id} v${base}, this copy's base_version, no longer exists on ${target} — no local file was removed`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return paths;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* setDependencies makes package.json's `dependencies` the manifest's, in name
|
|
106
|
+
* order as the scaffold writes them, or removes the key when the manifest
|
|
107
|
+
* declares none. It is the one place pull keeps the two in step.
|
|
108
|
+
*/
|
|
109
|
+
export function setDependencies(pkg, manifest) {
|
|
110
|
+
const deps = manifest?.dependencies;
|
|
111
|
+
if (deps && typeof deps === 'object' && Object.keys(deps).length) {
|
|
112
|
+
pkg.dependencies = Object.fromEntries(
|
|
113
|
+
Object.keys(deps)
|
|
114
|
+
.sort()
|
|
115
|
+
.map((name) => [name, deps[name]])
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
delete pkg.dependencies;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** readManifestFile is manifest.json, as bytes and parsed. */
|
|
123
|
+
export async function readManifestFile(cwd) {
|
|
124
|
+
const manifestRaw = await readText(cwd, MANIFEST);
|
|
125
|
+
try {
|
|
126
|
+
return { manifest: JSON.parse(manifestRaw), manifestRaw };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new UserError(`${MANIFEST} is not valid JSON: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* readBoundPackage is package.json, refusing a directory that is not a
|
|
134
|
+
* `kind` working copy (no string `juno.<idKey>`) — every command but `init`
|
|
135
|
+
* acts on the current directory, and running one from the wrong place should
|
|
136
|
+
* say so rather than fail on a missing file.
|
|
137
|
+
*/
|
|
138
|
+
export async function readBoundPackage(cwd, { kind, idKey }) {
|
|
139
|
+
let pkg = null;
|
|
140
|
+
try {
|
|
141
|
+
pkg = JSON.parse(await readFile(join(cwd, PACKAGE_JSON), 'utf8'));
|
|
142
|
+
} catch {
|
|
143
|
+
// Missing or unreadable: reported below as not a working copy.
|
|
144
|
+
}
|
|
145
|
+
if (typeof pkg?.juno?.[idKey] !== 'string' || pkg.juno[idKey] === '') {
|
|
146
|
+
throw new UserError(
|
|
147
|
+
`${cwd} is not ${article(kind)} ${kind} working copy (no juno.${idKey} in package.json) — ` +
|
|
148
|
+
`run \`juno ${kind} init <id>\` to make one`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return pkg;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** baseVersionOf is `juno.base_version`, or undefined when there is none. */
|
|
155
|
+
export function baseVersionOf(pkg) {
|
|
156
|
+
return Number.isInteger(pkg.juno.base_version)
|
|
157
|
+
? pkg.juno.base_version
|
|
158
|
+
: undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* writePackageJSON writes package.json with a two-space indent and a trailing
|
|
163
|
+
* newline — the way npm writes it — so, with every key the caller did not
|
|
164
|
+
* touch left in place, the diff is only what changed.
|
|
165
|
+
*/
|
|
166
|
+
export async function writePackageJSON(cwd, pkg) {
|
|
167
|
+
await writeFile(
|
|
168
|
+
join(cwd, PACKAGE_JSON),
|
|
169
|
+
`${JSON.stringify(pkg, null, 2)}\n`,
|
|
170
|
+
'utf8'
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* writeChangedFiles writes files by working-copy path, creating directories
|
|
176
|
+
* as needed and skipping a file whose content is already what is on disk, so
|
|
177
|
+
* a watcher does not see a write that changed nothing. Returns the paths it
|
|
178
|
+
* wrote, sorted.
|
|
179
|
+
*/
|
|
180
|
+
export async function writeChangedFiles(cwd, files) {
|
|
181
|
+
const written = [];
|
|
182
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
183
|
+
const path = join(cwd, safeRelative(rel));
|
|
184
|
+
let current = null;
|
|
185
|
+
try {
|
|
186
|
+
current = await readFile(path, 'utf8');
|
|
187
|
+
} catch {
|
|
188
|
+
// Not there yet.
|
|
189
|
+
}
|
|
190
|
+
if (current === content) continue;
|
|
191
|
+
await mkdir(dirname(path), { recursive: true });
|
|
192
|
+
await writeFile(path, content, 'utf8');
|
|
193
|
+
written.push(rel);
|
|
194
|
+
}
|
|
195
|
+
return written.sort();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The canonical manifest a route answered, as manifest.json holds it. */
|
|
199
|
+
export function manifestText(manifest) {
|
|
200
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The .d.ts files of a route's `types`. Filtering by extension rather than
|
|
205
|
+
* naming them keeps a future file working without a CLI release.
|
|
206
|
+
*/
|
|
207
|
+
export function typeFiles(types) {
|
|
208
|
+
return Object.fromEntries(
|
|
209
|
+
Object.entries(types ?? {}).filter(([name]) => name.endsWith('.d.ts'))
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* requireCleanTree is pull's guard: without --force it refuses a dirty git
|
|
215
|
+
* tree, because an uncommitted edit is the one thing nothing can get back,
|
|
216
|
+
* and outside git it warns that `overwrites` has no undo.
|
|
217
|
+
*/
|
|
218
|
+
export async function requireCleanTree(cwd, { force, overwrites, io }) {
|
|
219
|
+
if (force) return;
|
|
220
|
+
const dirty = await dirtyPaths(cwd);
|
|
221
|
+
if (dirty === null) {
|
|
222
|
+
io.warn(
|
|
223
|
+
`not a git repository — pull will overwrite ${overwrites} with no undo`
|
|
224
|
+
);
|
|
225
|
+
} else if (dirty.length > 0) {
|
|
226
|
+
throw new UserError(
|
|
227
|
+
`working tree has uncommitted changes:\n${dirty.map((p) => ` ${p}`).join('\n')}\n` +
|
|
228
|
+
'Commit or stash them, or pass --force to overwrite.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns the paths git reports as changed, [] for a clean tree, or null when
|
|
235
|
+
* this is not a git repository at all — three states the caller treats
|
|
236
|
+
* differently, so they must not collapse into a boolean.
|
|
237
|
+
*/
|
|
238
|
+
export async function dirtyPaths(cwd) {
|
|
239
|
+
try {
|
|
240
|
+
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
|
|
241
|
+
cwd,
|
|
242
|
+
});
|
|
243
|
+
return stdout
|
|
244
|
+
.split('\n')
|
|
245
|
+
.map((line) => line.trim())
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function readText(cwd, name) {
|
|
253
|
+
try {
|
|
254
|
+
return await readFile(join(cwd, name), 'utf8');
|
|
255
|
+
} catch {
|
|
256
|
+
throw new UserError(`no ${name} in ${cwd}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function article(word) {
|
|
261
|
+
return /^[aeiou]/.test(word) ? 'an' : 'a';
|
|
262
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "The compiler options Juno type-checks an app's page/ with server-side: tsconfig.base.json for the browser, with the DOM's types and no @types package. internal/verify's tsconfig_preset_test.go asserts this file and verify.PageCompilerOptions() are identical. Extend it from page/tsconfig.json; do not fork it.",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
13
|
+
"types": []
|
|
14
|
+
}
|
|
15
|
+
}
|
package/verbs.json
CHANGED
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"command": "flow gen-types",
|
|
77
77
|
"effect": "read",
|
|
78
78
|
"working_copy": true,
|
|
79
|
-
"summary": "Regenerate
|
|
79
|
+
"summary": "Regenerate types/ from manifest.json"
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
"command": "flow check",
|
|
@@ -96,6 +96,12 @@
|
|
|
96
96
|
"working_copy": true,
|
|
97
97
|
"summary": "Overwrite the working copy with the flow's active version"
|
|
98
98
|
},
|
|
99
|
+
{
|
|
100
|
+
"command": "flow fixture",
|
|
101
|
+
"effect": "read",
|
|
102
|
+
"working_copy": true,
|
|
103
|
+
"summary": "Write a test fixture from a recorded run"
|
|
104
|
+
},
|
|
99
105
|
{ "command": "run list", "tool": "list_runs" },
|
|
100
106
|
{ "command": "run get", "tool": "get_run", "id_field": "run_id" },
|
|
101
107
|
{
|
|
@@ -148,7 +154,7 @@
|
|
|
148
154
|
"command": "integration pull",
|
|
149
155
|
"effect": "read",
|
|
150
156
|
"working_copy": true,
|
|
151
|
-
"summary": "Overwrite manifest.json, src/ and
|
|
157
|
+
"summary": "Overwrite manifest.json, src/, test/ and types/ with the active version"
|
|
152
158
|
},
|
|
153
159
|
{ "command": "device list", "tool": "list_devices" },
|
|
154
160
|
{ "command": "device get", "tool": "get_device", "id_field": "id" },
|
|
@@ -163,6 +169,49 @@
|
|
|
163
169
|
"tool": "release_control",
|
|
164
170
|
"id_field": "id"
|
|
165
171
|
},
|
|
172
|
+
{ "command": "app get", "tool": "get_app", "id_field": "id" },
|
|
173
|
+
{
|
|
174
|
+
"command": "app list-invocations",
|
|
175
|
+
"tool": "list_app_invocations",
|
|
176
|
+
"id_field": "id",
|
|
177
|
+
"flags": { "limit": { "field": "limit", "type": "integer" } }
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"command": "app init",
|
|
181
|
+
"effect": "read",
|
|
182
|
+
"working_copy": true,
|
|
183
|
+
"summary": "Write a working copy of an app (or a stub for a new id) into ./<id>"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"command": "app gen-types",
|
|
187
|
+
"effect": "read",
|
|
188
|
+
"working_copy": true,
|
|
189
|
+
"summary": "Regenerate types/ from manifest.json"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"command": "app check",
|
|
193
|
+
"effect": "change",
|
|
194
|
+
"working_copy": true,
|
|
195
|
+
"summary": "Typecheck and lint locally, then store the working copy as a draft and print its preview URL"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"command": "app deploy",
|
|
199
|
+
"effect": "change",
|
|
200
|
+
"working_copy": true,
|
|
201
|
+
"summary": "Verify, commit and activate the working copy as the app's next version"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"command": "app pull",
|
|
205
|
+
"effect": "read",
|
|
206
|
+
"working_copy": true,
|
|
207
|
+
"summary": "Overwrite the working copy with the app's active version"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"command": "app dev",
|
|
211
|
+
"effect": "local",
|
|
212
|
+
"working_copy": true,
|
|
213
|
+
"summary": "Serve this app locally on mocked data"
|
|
214
|
+
},
|
|
166
215
|
{ "command": "finding get", "tool": "get_finding", "id_field": "slug" },
|
|
167
216
|
{ "command": "webhook list", "tool": "list_webhooks" },
|
|
168
217
|
{
|