@vatio-ai/cli 0.37.2

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.
@@ -0,0 +1,237 @@
1
+ // push / publish / rollback / status / diff / tools check.
2
+ //
3
+ // Every one of them is HTTP. The workspace is parsed by the platform
4
+ // (POST /deploy/check), so what is left here is: read the directory, send it,
5
+ // print what came back.
6
+ //
7
+ // `pull` is deliberately not here yet. It is the one command that writes over
8
+ // the developer's own files, and the Ruby version carries real subtlety doing
9
+ // it -- a comment where the logo would go, because a revision keeps the name
10
+ // and digest but never the bytes; `identity.pub` written back out; a specific
11
+ // key order in the file it emits. Porting that from memory is how you ship a
12
+ // command that quietly mangles a workspace, so it comes with a round-trip test
13
+ // (push, pull, diff clean) rather than with this.
14
+
15
+ import { stringify as stringifyYaml } from "yaml";
16
+
17
+ import { buildBundle, BundleError } from "../bundle.mjs";
18
+ import { ApiClient, CliClient } from "../api-client.mjs";
19
+ import { NotFoundError } from "../http.mjs";
20
+ import { detectGitSha, fail, takeEnv, takeFlag, takeValue } from "../support.mjs";
21
+
22
+ export function deployClient(config, workspace) {
23
+ const base = config.resolveBaseUrl();
24
+ if (!base) fail("VATIO_BASE_URL is required (env or `vatio config set base_url …`)");
25
+ const token = config.resolveToken();
26
+ if (!token) fail("VATIO_TOKEN is required (env or `vatio login`)");
27
+
28
+ return new ApiClient({ baseUrl: `${base}/api/v1/${workspace}`, token });
29
+ }
30
+
31
+ export function workspacesClient(config) {
32
+ const token = config.resolveToken();
33
+ if (!token) fail(`Run \`vatio login\` first (no token in ${config.configPath})`);
34
+ return new CliClient({ baseUrl: config.resolveBaseUrl(), token });
35
+ }
36
+
37
+ // One call answers every local question about a workspace: whether it loads,
38
+ // what manifest it builds, and what it would change. The parse behind it is the
39
+ // platform's, which is the point -- a CLI that parsed the directory itself
40
+ // could only ever be as current as the release the developer happened to
41
+ // install.
42
+ export async function checkWorkspace(config, workspace, { environment = null } = {}) {
43
+ const root = config.workspaceRootRequired();
44
+ let files;
45
+ try {
46
+ files = buildBundle(root);
47
+ } catch (error) {
48
+ if (error instanceof BundleError) fail(`vatio: ${error.message}`);
49
+ throw error;
50
+ }
51
+ return deployClient(config, workspace).check({ files, environment });
52
+ }
53
+
54
+ export function printDiagnostics(payload) {
55
+ for (const row of asArray(payload.errors)) console.error(`error: ${diagnosticLine(row)}`);
56
+ for (const row of asArray(payload.warnings)) console.error(`warning: ${diagnosticLine(row)}`);
57
+ }
58
+
59
+ // A diagnostic is { message, path } -- `path` is the file it is about, or null
60
+ // for the workspace as a whole. Most messages already open with their own path,
61
+ // which is where the platform read it from, so only prepend it when it is
62
+ // missing rather than printing `tools/x.js: tools/x.js: …`.
63
+ function diagnosticLine(row) {
64
+ if (!row || typeof row !== "object") return String(row);
65
+
66
+ const message = String(row.message ?? "");
67
+ const path = String(row.path ?? "");
68
+ if (path === "" || message.startsWith(`${path}:`)) return message;
69
+ return `${path}: ${message}`;
70
+ }
71
+
72
+ export async function toolsCheck(config, args) {
73
+ if (args[0] === "invoke") {
74
+ fail("`vatio tools invoke` was removed. Test tools inside `vatio chat` so access/auth run in the agent loop.");
75
+ }
76
+ if (args.length > 0 && args[0] !== "check") fail("Usage: vatio tools check");
77
+
78
+ const workspace = config.resolveWorkspaceRequired();
79
+ const payload = await checkWorkspace(config, workspace);
80
+ printDiagnostics(payload);
81
+ if (!payload.ok) fail(`tools check failed (${asArray(payload.errors).length} error(s))`);
82
+
83
+ const tools = asArray(payload.manifest?.tools);
84
+ console.log(`tools check ok (${tools.length} tool(s))`);
85
+ }
86
+
87
+ export async function push(config, args) {
88
+ // Null, not "preview": the platform owns that default, and sending it
89
+ // explicitly would make a bare `vatio push` and `vatio push --env preview`
90
+ // two different requests that only happen to agree today.
91
+ const environment = takeEnv(args, { fallback: null });
92
+ const workspace = config.resolveWorkspaceRequired();
93
+
94
+ // The remote has to exist before the workspace can be checked: the slug is in
95
+ // the URL of the call that checks it. It is created unnamed -- the push a few
96
+ // lines down applies `business.name` from the manifest anyway.
97
+ const clients = workspacesClient(config);
98
+ const created = !(await clients.workspaceExists(workspace));
99
+ if (created) {
100
+ await clients.createWorkspace({ slug: workspace });
101
+ console.log(`Created remote workspace ${workspace}`);
102
+ }
103
+
104
+ const check = await checkWorkspace(config, workspace, { environment });
105
+ printDiagnostics(check);
106
+ if (!check.ok) fail(`push aborted: check failed (${asArray(check.errors).length} error(s))`);
107
+
108
+ const client = deployClient(config, workspace);
109
+ const payload = await client.pushPreview({
110
+ manifest: check.manifest,
111
+ gitSha: detectGitSha(config.workspaceRootRequired()),
112
+ createdBy: process.env.USER ?? null,
113
+ environment
114
+ });
115
+
116
+ const landedOn = payload.environment ?? "preview";
117
+ console.log(`Pushed deployment #${payload.deployment_id} to ${landedOn} (git_sha=${JSON.stringify(payload.git_sha ?? null)})`);
118
+ // The link that opens without a login -- what makes a preview testable by
119
+ // someone who is not the developer who pushed it.
120
+ if (payload.share_url) console.log(`Open it: ${payload.share_url}`);
121
+ for (const warning of asArray(payload.warnings)) console.log(` warning: ${warning}`);
122
+ if (payload.preview_url) console.log(`Preview: ${payload.preview_url}`);
123
+
124
+ const hint = payload.whatsapp_preview_hint;
125
+ if (hint) {
126
+ console.log("");
127
+ console.log(hint.message);
128
+ console.log(` ${hint.url}`);
129
+ }
130
+ }
131
+
132
+ export async function publish(config, args) {
133
+ // Here `--env` names the preview being promoted, not a destination: the
134
+ // destination of a publish is always live.
135
+ const environment = takeEnv(args, { fallback: null });
136
+ const workspace = config.resolveWorkspaceRequired();
137
+ const payload = await deployClient(config, workspace).publish({
138
+ createdBy: process.env.USER ?? null,
139
+ environment
140
+ });
141
+ console.log(`Published live deployment #${payload.deployment_id} (git_sha=${JSON.stringify(payload.git_sha ?? null)})`);
142
+ }
143
+
144
+ export async function rollback(config) {
145
+ const workspace = config.resolveWorkspaceRequired();
146
+ const payload = await deployClient(config, workspace).rollback({ createdBy: process.env.USER ?? null });
147
+ console.log(`Rolled back to deployment #${payload.deployment_id} (from #${payload.rolled_back_from_id})`);
148
+ }
149
+
150
+ export async function status(config) {
151
+ const workspace = config.resolveWorkspaceRequired();
152
+ const payload = await deployClient(config, workspace).deployStatus();
153
+ console.log(JSON.stringify(payload, null, 2));
154
+
155
+ // A base with content that no deployed agent reads is knowledge the agent
156
+ // cannot reach, and nothing else says so -- it just answers without it. On
157
+ // stderr so piping `vatio status` into jq keeps working.
158
+ for (const base of asArray(payload.unreferenced_knowledge_bases)) {
159
+ console.error(
160
+ `Warning: knowledge base "${base.name}" has ${base.entries_count} entries but no deployed agent ` +
161
+ `reads it — add \`knowledge: [${base.name}]\` to vatio.yml and push.`
162
+ );
163
+ }
164
+ }
165
+
166
+ export async function diff(config, args) {
167
+ const environment = takeEnv(args, { fallback: "preview" });
168
+ const full = takeFlag(args, "--full");
169
+ const stat = takeFlag(args, "--stat");
170
+ const nameOnly = takeFlag(args, "--name-only");
171
+ const format = takeValue(args, "--format");
172
+ if (format && !["text", "json"].includes(format)) fail(`Unknown format ${JSON.stringify(format)} (text|json)`);
173
+
174
+ const workspace = config.resolveWorkspaceRequired();
175
+ // The diff comes back with the check -- one call instead of "parse here,
176
+ // fetch there, compare with a copy of the platform's rules that may have
177
+ // drifted". `--env live` is what answers "what would publishing change?"
178
+ const payload = await checkWorkspace(config, workspace, { environment });
179
+ printDiagnostics(payload);
180
+ if (!payload.ok) fail(`diff aborted: the workspace does not load (${asArray(payload.errors).length} error(s))`);
181
+
182
+ const local = payload.manifest;
183
+ const changes = asArray(payload.diff);
184
+
185
+ if (changes.length === 0) {
186
+ console.log(`No diff vs remote ${environment}`);
187
+ return;
188
+ }
189
+ if (full) {
190
+ console.error("Warning: --full prints complete tool source. Avoid using it in shared CI logs.");
191
+ let remote = {};
192
+ try {
193
+ remote = (await deployClient(config, workspace).deployedManifest({ environment })).manifest ?? {};
194
+ } catch (error) {
195
+ if (!(error instanceof NotFoundError)) throw error;
196
+ }
197
+ console.log("=== local ===");
198
+ console.log(stringifyYaml(local));
199
+ console.log(`=== remote ${environment} ===`);
200
+ console.log(stringifyYaml(remote));
201
+ return;
202
+ }
203
+ if (format === "json") {
204
+ console.log(JSON.stringify({ workspace, changes }, null, 2));
205
+ return;
206
+ }
207
+ if (stat) {
208
+ const counts = new Map();
209
+ for (const change of changes) counts.set(change.status, (counts.get(change.status) ?? 0) + 1);
210
+ const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(", ");
211
+ console.log(`${changes.length} change(s): ${summary}`);
212
+ return;
213
+ }
214
+ if (nameOnly) {
215
+ for (const change of changes) console.log(diffLabel(change));
216
+ return;
217
+ }
218
+
219
+ for (const change of changes) {
220
+ const fields = asArray(change.fields);
221
+ const suffix = fields.length === 0 ? "" : ` (${fields.join(", ")})`;
222
+ console.log(`${statusLetter(change.status)} ${diffLabel(change)}${suffix}`);
223
+ }
224
+ console.log("Use `vatio diff --full` to print complete tool source.");
225
+ }
226
+
227
+ function diffLabel(change) {
228
+ return change.type === change.key ? change.type : `${change.type}/${change.key}`;
229
+ }
230
+
231
+ function statusLetter(status) {
232
+ return { added: "A", removed: "D", modified: "M" }[status] ?? "?";
233
+ }
234
+
235
+ function asArray(value) {
236
+ return Array.isArray(value) ? value : [];
237
+ }
@@ -0,0 +1,256 @@
1
+ // `vatio kb` — knowledge bases.
2
+ //
3
+ // A base belongs to the workspace, not to a deployment: `knowledge:` in
4
+ // vatio.yml is a list of *names*, and a push neither fills nor empties one.
5
+ // That is why this is a command and not part of the manifest, and why live and
6
+ // preview read the same base.
7
+
8
+ import { basename } from "node:path";
9
+ import { readFileSync, statSync } from "node:fs";
10
+
11
+ import { deployClient } from "./deploy.mjs";
12
+ import { fail, takeValue } from "../support.mjs";
13
+
14
+ export async function kb(config, args) {
15
+ const workspace = config.resolveWorkspaceRequired();
16
+ const client = deployClient(config, workspace);
17
+ const sub = args.shift() ?? "list";
18
+
19
+ switch (sub) {
20
+ case "list": return await list(client, workspace);
21
+ case "show": return await show(client, workspace, args.shift());
22
+ case "create": return await create(client, workspace, args.shift());
23
+ case "rm": return await remove(client, workspace, args.shift());
24
+ case "add-source": return await addSource(client, workspace, args);
25
+ case "upload": return await upload(client, workspace, args.shift(), args);
26
+ case "rm-source": return await removeSource(client, workspace, args.shift(), args.shift());
27
+ case "reindex": return await reindex(client, workspace, args.shift(), args.shift());
28
+ default: fail(`Unknown kb subcommand: ${sub}\n\n${usage()}`);
29
+ }
30
+ }
31
+
32
+ function usage() {
33
+ return `usage:
34
+ vatio kb List knowledge bases
35
+ vatio kb show NAME One base and its sources
36
+ vatio kb create NAME Create a base
37
+ vatio kb rm NAME Delete a base (must be unreferenced)
38
+ vatio kb add-source BASE NAME URL Add a crawl
39
+ vatio kb upload BASE FILE... Upload files into a base
40
+ vatio kb rm-source BASE NAME Delete one source
41
+ vatio kb reindex BASE [NAME] Re-crawl one source, or every crawl`;
42
+ }
43
+
44
+ async function list(client, workspace) {
45
+ const bases = asArray((await client.knowledgeBases()).data);
46
+ if (bases.length === 0) {
47
+ console.log(`No knowledge bases on workspace ${workspace}`);
48
+ console.log("Create one with `vatio kb create docs`, then add `knowledge: [docs]` to vatio.yml");
49
+ return;
50
+ }
51
+
52
+ console.log(`Knowledge bases on workspace ${workspace} (${bases.length}):`);
53
+ for (const row of bases) {
54
+ console.log(` ${row.name} sources=${row.sources_count} entries=${row.entries_count}${referenceNote(row)}`);
55
+ }
56
+ }
57
+
58
+ // A base nothing references is indexed knowledge the agent cannot reach: it
59
+ // answers without it and nothing anywhere reports an error. This line is the
60
+ // only place that shows up before a bad conversation does.
61
+ function referenceNote(row) {
62
+ return row.referenced ? "" : " (not referenced by any deployed agent)";
63
+ }
64
+
65
+ async function show(client, workspace, name) {
66
+ if (!name) fail("usage: vatio kb show NAME");
67
+
68
+ const base = await client.knowledgeBase(name);
69
+ console.log(`${base.name} on workspace ${workspace}: entries=${base.entries_count}${referenceNote(base)}`);
70
+
71
+ const sources = asArray(base.sources);
72
+ if (sources.length === 0) {
73
+ console.log(
74
+ ` no sources yet — add one with \`vatio kb add-source ${name} NAME URL\` or \`vatio kb upload ${name} FILE\``
75
+ );
76
+ return;
77
+ }
78
+
79
+ for (const row of sources) {
80
+ const lastCrawled = row.last_crawled_at ?? "never";
81
+ console.log(
82
+ ` ${row.name} [${row.kind}] ${row.status} pages=${pagesDisplay(row)} ` +
83
+ `entries=${row.entries_count} last_indexed_at=${lastCrawled}`
84
+ );
85
+ console.log(row.kind === "upload" ? ` ${row.filename}` : ` ${row.site_url} ${patternsDisplay(row)}`);
86
+ if (row.last_error) console.log(` error: ${row.last_error}`);
87
+ }
88
+ }
89
+
90
+ async function create(client, workspace, name) {
91
+ if (!name) fail("usage: vatio kb create NAME");
92
+
93
+ const base = await client.createKnowledgeBase(name);
94
+ console.log(`Created knowledge base ${base.name} on workspace ${workspace}`);
95
+ console.log("It is empty and nothing reads it yet. Fill it with `vatio kb add-source` or `vatio kb upload`,");
96
+ console.log(`then add \`knowledge: [${base.name}]\` to vatio.yml and push.`);
97
+ }
98
+
99
+ async function remove(client, workspace, name) {
100
+ if (!name) fail("usage: vatio kb rm NAME");
101
+
102
+ await client.deleteKnowledgeBase(name);
103
+ console.log(`Deleted knowledge base ${name} on workspace ${workspace}`);
104
+ }
105
+
106
+ async function addSource(client, workspace, args) {
107
+ const includes = takeAll(args, "--include");
108
+ const excludes = takeAll(args, "--exclude");
109
+ const [base, name, siteUrl] = args;
110
+
111
+ if (!base || !name || !siteUrl) {
112
+ fail("usage: vatio kb add-source BASE NAME URL [--include PATH] [--exclude PATH]");
113
+ }
114
+
115
+ const row = await client.addKnowledgeCrawl({ base, name, siteUrl, includes, excludes });
116
+ console.log(`Added crawl ${row.name} to ${base} on workspace ${workspace} (status=${row.status})`);
117
+ console.log(`Crawling runs in the background — check progress with \`vatio kb show ${base}\``);
118
+ }
119
+
120
+ // Every file in one command, because `knowledge/*.md` is how a repo that
121
+ // predates knowledge bases holds its content, and moving it should be one line
122
+ // rather than one line per file.
123
+ async function upload(client, workspace, base, paths) {
124
+ if (!base || paths.length === 0) fail("usage: vatio kb upload BASE FILE...");
125
+
126
+ const missing = paths.filter((path) => !isFile(path));
127
+ if (missing.length > 0) fail(`No such file: ${missing.join(", ")}`);
128
+
129
+ await abortOnCrawlCollisions(client, base, paths);
130
+
131
+ for (const path of paths) {
132
+ const row = await client.uploadKnowledgeDocument({
133
+ base,
134
+ filename: basename(path),
135
+ content: readFileSync(path, "utf8")
136
+ });
137
+ console.log(`Uploaded ${basename(path)} to ${base} as ${row.name} (entries=${row.entries_count})`);
138
+ }
139
+ console.log(`Indexing runs in the background — check progress with \`vatio kb show ${base}\``);
140
+ }
141
+
142
+ // Checked for the whole batch before a single file goes up, so an upload of
143
+ // forty files either happens or does not. The platform refuses each collision
144
+ // on its own, but one file at a time means a collision on file twenty lands
145
+ // after nineteen are already in -- half an upload nobody asked for.
146
+ //
147
+ // Advisory, not the guarantee: the platform is what actually refuses. If this
148
+ // check and its naming rule ever drift, the worst case is that this misses a
149
+ // collision and the upload stops there instead, which is where it stopped
150
+ // before.
151
+ async function abortOnCrawlCollisions(client, base, paths) {
152
+ const sources = asArray((await client.knowledgeBase(base)).sources);
153
+ const crawls = new Map(sources.filter((row) => row.kind === "crawl").map((row) => [row.name, row]));
154
+ if (crawls.size === 0) return;
155
+
156
+ const collisions = paths
157
+ .map((path) => [path, crawls.get(sourceNameFor(path))])
158
+ .filter(([, crawl]) => crawl);
159
+ if (collisions.length === 0) return;
160
+
161
+ const lines = collisions.map(([path, crawl]) => {
162
+ const declaration = [crawl.site_url, ...asArray(crawl.include).map((p) => `include=${p}`)]
163
+ .filter(Boolean)
164
+ .join(" ");
165
+ return ` ${basename(path)} would land on "${crawl.name}", a crawl of ${declaration}`;
166
+ });
167
+
168
+ fail(
169
+ `Refusing to upload: ${collisions.length} of ${paths.length} file(s) collide with a crawl in ${base}.\n\n` +
170
+ `${lines.join("\n")}\n\n` +
171
+ "An upload cannot take over a crawl — its pages would keep answering lookups\n" +
172
+ "under a source that no longer says where they came from. Rename the file(s),\n" +
173
+ `or drop the crawl first with \`vatio kb rm-source ${base} NAME\`.\n\n` +
174
+ "Nothing was uploaded."
175
+ );
176
+ }
177
+
178
+ // Mirrors the platform's own source naming. Kept in step by the platform
179
+ // refusing anyway, not by this being right.
180
+ function sourceNameFor(path) {
181
+ const stem = basename(String(path)).replace(/\.[^.]*$/, "").toLowerCase();
182
+ const slug = stem.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
183
+ return slug.slice(0, 60).replace(/-+$/, "");
184
+ }
185
+
186
+ async function removeSource(client, workspace, base, name) {
187
+ if (!base || !name) fail("usage: vatio kb rm-source BASE NAME");
188
+
189
+ await client.deleteKnowledgeSource({ base, name });
190
+ console.log(`Deleted source ${name} from ${base} on workspace ${workspace}`);
191
+ }
192
+
193
+ // A bare `vatio kb reindex BASE` re-runs every crawl in the base. Uploads are
194
+ // skipped rather than failing the whole command: there is no upstream to
195
+ // re-read, so "reindex everything" cannot mean them.
196
+ async function reindex(client, workspace, base, name) {
197
+ if (!base) fail("usage: vatio kb reindex BASE [NAME]");
198
+
199
+ let targets;
200
+ if (name) {
201
+ targets = [name];
202
+ } else {
203
+ const crawls = asArray((await client.knowledgeBase(base)).sources).filter((row) => row.kind === "crawl");
204
+ if (crawls.length === 0) fail(`No crawls to reindex in ${base} on workspace ${workspace}`);
205
+ targets = crawls.map((row) => row.name);
206
+ }
207
+
208
+ for (const target of targets) {
209
+ const row = await client.reindexKnowledgeSource({ base, name: target });
210
+ console.log(`Reindexing ${row.name} in ${base} on workspace ${workspace} (status=${row.status})`);
211
+ }
212
+ console.log(`Crawling runs in the background — check progress with \`vatio kb show ${base}\``);
213
+ }
214
+
215
+ // pages_discovered_count is newer than pages_indexed_count -- a platform
216
+ // predating it omits the key entirely, so this falls back to the old
217
+ // single-number display rather than printing "pages=3/".
218
+ function pagesDisplay(row) {
219
+ const discovered = row.pages_discovered_count;
220
+ const indexed = row.pages_indexed_count;
221
+ return discovered ? `${indexed}/${discovered}` : String(indexed ?? "");
222
+ }
223
+
224
+ // No patterns at all means the source covers the whole site, which is the
225
+ // default and needs saying rather than printing blank.
226
+ function patternsDisplay(row) {
227
+ const includes = asArray(row.include);
228
+ const excludes = asArray(row.exclude);
229
+ if (includes.length === 0 && excludes.length === 0) return "whole site";
230
+
231
+ const parts = [];
232
+ if (includes.length > 0) parts.push(`include=${includes.join(",")}`);
233
+ if (excludes.length > 0) parts.push(`exclude=${excludes.join(",")}`);
234
+ return parts.join(" ");
235
+ }
236
+
237
+ function takeAll(args, name) {
238
+ const values = [];
239
+ for (;;) {
240
+ const value = takeValue(args, name);
241
+ if (value === null) return values;
242
+ values.push(value);
243
+ }
244
+ }
245
+
246
+ function isFile(path) {
247
+ try {
248
+ return statSync(path).isFile();
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ function asArray(value) {
255
+ return Array.isArray(value) ? value : [];
256
+ }