@vatio-ai/cli 0.52.0 → 0.54.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 CHANGED
@@ -71,7 +71,7 @@ kb write|cat|rm-entry Entries, from a file or stdin
71
71
  kb follow|unfollow|refresh Read a site into a base, nightly
72
72
 
73
73
  secrets list|set|rm Credentials your tools read
74
- tokens list|create|revoke Publishable tokens for the widget and the SDK
74
+ tokens list|create|origins|revoke Publishable tokens and the origins each accepts
75
75
  widget [--env NAME] What the platform enforces, and the tokens there are
76
76
  auth --new-key Keypair that signs the visitor's JWT
77
77
 
package/bin/vatio.mjs CHANGED
@@ -17,6 +17,7 @@ import { helpText } from "../lib/help.mjs";
17
17
  import { login, logout } from "../lib/commands/auth.mjs";
18
18
  import { configCommand, doctor, init, version } from "../lib/commands/misc.mjs";
19
19
  import { diff, publish, push, rollback, status, toolsCheck } from "../lib/commands/deploy.mjs";
20
+ import { env } from "../lib/commands/env.mjs";
20
21
  import { secrets, tokens, widget } from "../lib/commands/workspace-admin.mjs";
21
22
  import { auth } from "../lib/commands/identity-key.mjs";
22
23
  import { chat } from "../lib/commands/chat.mjs";
@@ -70,6 +71,8 @@ async function run(command, args) {
70
71
  return await rollback(config);
71
72
  case "status":
72
73
  return await status(config);
74
+ case "env":
75
+ return await env(config, args);
73
76
  case "diff":
74
77
  return await diff(config, args);
75
78
  case "tools":
@@ -29,9 +29,10 @@ export class ApiClient extends HttpClient {
29
29
  return this.post("/deploy/check", body);
30
30
  }
31
31
 
32
- pushPreview({ manifest, gitSha = null, createdBy = null, environment = null }) {
32
+ pushPreview({ manifest, gitSha = null, gitBranch = null, createdBy = null, environment = null }) {
33
33
  const body = { manifest };
34
34
  if (gitSha) body.git_sha = gitSha;
35
+ if (gitBranch) body.git_branch = gitBranch;
35
36
  if (createdBy) body.created_by = createdBy;
36
37
  if (environment) body.environment = environment;
37
38
  return this.put("/deploy/preview", body);
@@ -58,18 +59,36 @@ export class ApiClient extends HttpClient {
58
59
  return this.get(`/deploy/revisions/${encode(id)}`);
59
60
  }
60
61
 
62
+ // --- environments ---------------------------------------------------
63
+
64
+ environments() {
65
+ return this.get("/deploy/environments");
66
+ }
67
+
68
+ environment(name) {
69
+ return this.get(`/deploy/environments/${encode(name)}`);
70
+ }
71
+
72
+ deleteEnvironment(name) {
73
+ return this.delete(`/deploy/environments/${encode(name)}`);
74
+ }
75
+
61
76
  // --- secrets --------------------------------------------------------
62
77
 
63
- listSecrets() {
64
- return this.get("/deploy/secrets");
78
+ // Without an environment, the value everywhere; with one, the value only
79
+ // that environment uses instead.
80
+ listSecrets({ environment = null } = {}) {
81
+ return this.get("/deploy/secrets", { environment });
65
82
  }
66
83
 
67
- upsertSecret({ key, value }) {
68
- return this.put(`/deploy/secrets/${encode(key)}`, { value });
84
+ upsertSecret({ key, value, environment = null }) {
85
+ const body = { value };
86
+ if (environment) body.environment = environment;
87
+ return this.put(`/deploy/secrets/${encode(key)}`, body);
69
88
  }
70
89
 
71
- deleteSecret({ key }) {
72
- return this.delete(`/deploy/secrets/${encode(key)}`);
90
+ deleteSecret({ key, environment = null }) {
91
+ return this.delete(`/deploy/secrets/${encode(key)}${query({ environment })}`);
73
92
  }
74
93
 
75
94
  // --- knowledge bases ------------------------------------------------
@@ -77,12 +96,16 @@ export class ApiClient extends HttpClient {
77
96
  // A base holds entries (markdown) and reads sites (a URL or a pattern). Two
78
97
  // nouns, and every call below is one of them.
79
98
 
80
- knowledgeBases() {
81
- return this.get("/knowledge_bases");
99
+ // Every read and write below is as one environment sees the base: a
100
+ // branch's, which keeps its changes on top of live, or `live` itself.
101
+ // Without one the platform means `preview`.
102
+
103
+ knowledgeBases({ environment = null } = {}) {
104
+ return this.get("/knowledge_bases", { environment });
82
105
  }
83
106
 
84
- knowledgeBase(name) {
85
- return this.get(`/knowledge_bases/${encode(name)}`);
107
+ knowledgeBase(name, { environment = null } = {}) {
108
+ return this.get(`/knowledge_bases/${encode(name)}`, { environment });
86
109
  }
87
110
 
88
111
  createKnowledgeBase(name) {
@@ -97,19 +120,21 @@ export class ApiClient extends HttpClient {
97
120
  return this.post(`/knowledge_bases/${encode(name)}/refresh`, {});
98
121
  }
99
122
 
100
- knowledgeEntry({ base, name }) {
101
- return this.get(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`);
123
+ knowledgeEntry({ base, name, environment = null, version = null }) {
124
+ return this.get(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`, { environment, version });
102
125
  }
103
126
 
104
127
  // Put this markdown under this name, whether or not the entry exists yet --
105
128
  // one call for "write" and "rewrite", because from the terminal they are the
106
129
  // same sentence.
107
- writeKnowledgeEntry({ base, name, content }) {
108
- return this.patch(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`, { content });
130
+ writeKnowledgeEntry({ base, name, content, environment = null }) {
131
+ const body = { content };
132
+ if (environment) body.environment = environment;
133
+ return this.patch(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`, body);
109
134
  }
110
135
 
111
- deleteKnowledgeEntry({ base, name }) {
112
- return this.delete(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`);
136
+ deleteKnowledgeEntry({ base, name, environment = null }) {
137
+ return this.delete(`/knowledge_bases/${encode(base)}/entries/${encode(name)}${query({ environment })}`);
113
138
  }
114
139
 
115
140
  followKnowledgeSite({ base, url }) {
@@ -127,22 +152,19 @@ export class ApiClient extends HttpClient {
127
152
  return this.post(`/knowledge_bases/${encode(base)}/sites/refresh`, { url });
128
153
  }
129
154
 
130
- // Writes land in the base's draft, which preview answers from; live moves
131
- // only on publish or rollback.
132
- knowledgeChanges(base) {
133
- return this.get(`/knowledge_bases/${encode(base)}/changes`);
155
+ // What the environment changed that live does not have yet.
156
+ knowledgeChanges(base, { environment = null } = {}) {
157
+ return this.get(`/knowledge_bases/${encode(base)}/changes`, { environment });
134
158
  }
135
159
 
136
- publishKnowledgeBase({ base, note }) {
137
- return this.post(`/knowledge_bases/${encode(base)}/publish`, { note });
138
- }
139
-
140
- knowledgeSnapshots(base) {
141
- return this.get(`/knowledge_bases/${encode(base)}/snapshots`);
160
+ publishKnowledgeBase({ base, environment = null }) {
161
+ const body = {};
162
+ if (environment) body.environment = environment;
163
+ return this.post(`/knowledge_bases/${encode(base)}/publish`, body);
142
164
  }
143
165
 
144
- rollbackKnowledgeBase({ base, number }) {
145
- return this.post(`/knowledge_bases/${encode(base)}/snapshots/${encode(String(number))}/rollback`, {});
166
+ knowledgeHistory(base) {
167
+ return this.get(`/knowledge_bases/${encode(base)}/history`);
146
168
  }
147
169
 
148
170
  // --- publishable tokens and the widget ------------------------------
@@ -151,12 +173,17 @@ export class ApiClient extends HttpClient {
151
173
  return this.get("/publishable_tokens");
152
174
  }
153
175
 
154
- createPublishableToken({ environment, label = null }) {
176
+ createPublishableToken({ environment, label = null, allowedOrigins = [] }) {
155
177
  const body = { environment };
156
178
  if (String(label ?? "").trim() !== "") body.label = String(label).trim();
179
+ if (allowedOrigins.length > 0) body.allowed_origins = allowedOrigins;
157
180
  return this.post("/publishable_tokens", body);
158
181
  }
159
182
 
183
+ setPublishableTokenOrigins({ prefix, allowedOrigins }) {
184
+ return this.patch(`/publishable_tokens/${encode(prefix)}`, { allowed_origins: allowedOrigins });
185
+ }
186
+
160
187
  revokePublishableToken({ prefix }) {
161
188
  return this.delete(`/publishable_tokens/${encode(prefix)}`);
162
189
  }
@@ -171,8 +198,15 @@ export class ApiClient extends HttpClient {
171
198
  return this.get("/test_phone_numbers");
172
199
  }
173
200
 
174
- addTestPhoneNumber({ phoneNumber }) {
175
- return this.post("/test_phone_numbers", { phone_number: phoneNumber });
201
+ addTestPhoneNumber({ phoneNumber, environment = null }) {
202
+ const body = { phone_number: phoneNumber };
203
+ if (environment) body.environment = environment;
204
+ return this.post("/test_phone_numbers", body);
205
+ }
206
+
207
+ // Which environment the phone reaches on the shared number.
208
+ pointTestPhoneNumber({ phoneNumber, environment }) {
209
+ return this.patch(`/test_phone_numbers/${waId(phoneNumber)}`, { environment });
176
210
  }
177
211
 
178
212
  verifyTestPhoneNumber({ phoneNumber, code }) {
@@ -240,8 +274,15 @@ export class ApiClient extends HttpClient {
240
274
  // Declares which Instagram account you mean. The code comes later, by DM,
241
275
  // once that account writes to the shared preview -- Instagram will not let
242
276
  // Vatio message an account that has not messaged it first.
243
- addTestInstagramAccount({ username }) {
244
- return this.post("/test_instagram_accounts", { username });
277
+ addTestInstagramAccount({ username, environment = null }) {
278
+ const body = { username };
279
+ if (environment) body.environment = environment;
280
+ return this.post("/test_instagram_accounts", body);
281
+ }
282
+
283
+ // Which environment the account reaches on the shared one.
284
+ pointTestInstagramAccount({ id, environment }) {
285
+ return this.patch(`/test_instagram_accounts/${encode(id)}`, { environment });
245
286
  }
246
287
 
247
288
  // Addressed by the code alone: the workspace is already in the path and the
@@ -314,6 +355,16 @@ export class CliClient extends HttpClient {
314
355
  }
315
356
  }
316
357
 
358
+ // A DELETE carries no body, so what it needs beyond the path rides as a query.
359
+ function query(params) {
360
+ const search = new URLSearchParams();
361
+ for (const [key, value] of Object.entries(params)) {
362
+ if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
363
+ }
364
+ const text = search.toString();
365
+ return text ? `?${text}` : "";
366
+ }
367
+
317
368
  // The path carries the digits: "+" and spaces do not survive a URL, and the
318
369
  // digits are what the API stores as `wa_id`.
319
370
  function waId(phoneNumber) {
package/lib/branch.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // The environment a git branch gets, so that working on a branch is working in
2
+ // its own environment without saying so: `vatio push` from `fix/pagos` lands on
3
+ // `fix-pagos`, and so do `vatio kb write`, `vatio secrets set` and `vatio chat`.
4
+ // The pull request opened from that branch deploys to the same one.
5
+ //
6
+ // The default branch is not a branch environment: there, and outside a
7
+ // repository, every command keeps the default it always had.
8
+ //
9
+ // The name is derived exactly as the platform derives it for a pull request
10
+ // (Vatio::Environment.for_branch, backend/app/vatio/environment.rb). Kept in
11
+ // step by hand; both are short.
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+
16
+ const RESERVED = new Set(["live", "preview", "staging", "sandbox", "main"]);
17
+ const MAX_LENGTH = 40;
18
+
19
+ export function environmentForBranch(ref) {
20
+ const raw = String(ref ?? "").replace(/^refs\/heads\//, "");
21
+ let slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
22
+ if (slug === "") return null;
23
+
24
+ if (RESERVED.has(slug)) slug = `branch-${slug}`;
25
+ if (slug.length <= MAX_LENGTH) return slug;
26
+
27
+ const hash = createHash("sha1").update(String(ref)).digest("hex").slice(0, 6);
28
+ return `${slug.slice(0, 33).replace(/-+$/, "")}-${hash}`;
29
+ }
30
+
31
+ // { branch, environment } on a branch other than the default one; null on the
32
+ // default branch, outside a repository, or with HEAD detached.
33
+ export function branchEnvironment(cwd) {
34
+ const branch = git(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]);
35
+ if (!branch) return null;
36
+
37
+ const remoteHead = git(cwd, ["symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"]);
38
+ const defaultBranch = remoteHead ? remoteHead.replace(/^[^/]+\//, "") : null;
39
+ if (branch === defaultBranch || (!defaultBranch && (branch === "main" || branch === "master"))) return null;
40
+
41
+ const environment = environmentForBranch(branch);
42
+ return environment ? { branch, environment } : null;
43
+ }
44
+
45
+ function git(cwd, args) {
46
+ try {
47
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
@@ -7,7 +7,7 @@
7
7
  // would say nothing about which one.
8
8
 
9
9
  import { deployClient } from "./deploy.mjs";
10
- import { fail, openBrowser } from "../support.mjs";
10
+ import { fail, openBrowser, takeEnv } from "../support.mjs";
11
11
 
12
12
  export async function whatsapp(config, args) {
13
13
  const workspace = config.resolveWorkspaceRequired();
@@ -33,18 +33,20 @@ export async function whatsapp(config, args) {
33
33
  console.log(`Disconnected WhatsApp from workspace ${workspace}. Vatio stops receiving its messages.`);
34
34
  return;
35
35
  }
36
- case "numbers": return await numbers(client, workspace, args);
36
+ case "numbers": return await numbers(client, workspace, args, config.workspaceRoot);
37
37
  default: fail(whatsappUsage());
38
38
  }
39
39
  }
40
40
 
41
41
  function whatsappUsage() {
42
42
  return `Usage: vatio whatsapp status|connect|check|activate|deactivate|disconnect
43
- vatio whatsapp numbers list|add PHONE|verify PHONE CODE|resend PHONE|remove PHONE
43
+ vatio whatsapp numbers list|add PHONE|point PHONE|verify PHONE CODE|resend PHONE|remove PHONE [--env NAME]
44
44
 
45
45
  The bare commands drive your own WhatsApp number, which serves live.
46
- \`numbers\` registers test phones on the shared WhatsApp preview, which always
47
- reach preview — the counterpart of \`vatio instagram accounts\`.
46
+ \`numbers\` registers test phones on the shared WhatsApp preview. Each reaches
47
+ the environment it points at: preview, or a branch's (\`add\` and \`point\`
48
+ take --env, and default to the branch you are on) — the counterpart of
49
+ \`vatio instagram accounts\`.
48
50
 
49
51
  connect
50
52
  Prints a link to finish in a browser. Connecting a number is Meta's
@@ -114,9 +116,10 @@ async function connectWhatsapp(client, workspace) {
114
116
  openBrowser(url);
115
117
  }
116
118
 
117
- async function numbers(client, workspace, args) {
119
+ async function numbers(client, workspace, args, cwd) {
118
120
  const sub = args.shift() ?? "list";
119
- const usage = "Usage: vatio whatsapp numbers list|add PHONE|verify PHONE CODE|resend PHONE|remove PHONE";
121
+ const usage = "Usage: vatio whatsapp numbers list|add PHONE|point PHONE|verify PHONE CODE|resend PHONE|remove PHONE [--env NAME]";
122
+ const environment = ["add", "point"].includes(sub) ? takeEnv(args, { fallback: null, cwd }) : null;
120
123
 
121
124
  if (sub === "list") {
122
125
  const list = asArray((await client.listTestPhoneNumbers()).test_phone_numbers);
@@ -128,15 +131,22 @@ async function numbers(client, workspace, args) {
128
131
  console.log(`Test phone numbers on workspace ${workspace} (${list.length}):`);
129
132
  for (const number of list) {
130
133
  const suffix = number.status === "verified" ? `verified ${number.verified_at}` : "pending verification";
131
- console.log(` ${number.phone_number} ${suffix}`);
134
+ console.log(` ${number.phone_number} → ${number.environment} ${suffix}`);
132
135
  }
133
136
  console.log("");
134
- console.log("They reach the preview deployment on the shared WhatsApp preview number, never live.");
137
+ console.log("Each reaches its environment on the shared WhatsApp preview number, never live.");
138
+ console.log("Point one elsewhere: vatio whatsapp numbers point PHONE --env NAME");
139
+ return;
140
+ }
141
+ if (sub === "point") {
142
+ const phoneNumber = requireArg(args, "vatio whatsapp numbers point +56912345678 [--env NAME]");
143
+ const number = await client.pointTestPhoneNumber({ phoneNumber, environment: environment ?? "preview" });
144
+ console.log(`${number.phone_number} now reaches ${number.environment} on workspace ${workspace}.`);
135
145
  return;
136
146
  }
137
147
  if (sub === "add") {
138
148
  const phoneNumber = requireArg(args, "vatio whatsapp numbers add +56912345678");
139
- const number = await client.addTestPhoneNumber({ phoneNumber });
149
+ const number = await client.addTestPhoneNumber({ phoneNumber, environment });
140
150
  if (number.already_verified) {
141
151
  console.log(`${number.phone_number} is already verified on workspace ${workspace} — nothing to do.`);
142
152
  } else if (number.moved_from) {
@@ -159,7 +169,7 @@ async function numbers(client, workspace, args) {
159
169
  ? `${number.phone_number} was already verified on workspace ${workspace}.`
160
170
  : `Verified ${number.phone_number} on workspace ${workspace}.`
161
171
  );
162
- console.log("Anything you send from that phone now reaches the preview deployment.");
172
+ console.log(`Anything you send from that phone now reaches ${number.environment}.`);
163
173
  return;
164
174
  }
165
175
  if (sub === "resend") {
@@ -196,18 +206,20 @@ export async function instagram(config, args) {
196
206
  console.log(`Disconnected Instagram from workspace ${workspace}. Vatio stops receiving its DMs.`);
197
207
  return;
198
208
  }
199
- case "accounts": return await instagramAccounts(client, workspace, args);
209
+ case "accounts": return await instagramAccounts(client, workspace, args, config.workspaceRoot);
200
210
  default: fail(instagramUsage());
201
211
  }
202
212
  }
203
213
 
204
214
  function instagramUsage() {
205
215
  return `Usage: vatio instagram status|connect|check|disconnect
206
- vatio instagram accounts list|add @HANDLE|verify CODE|resend ID|remove ID
216
+ vatio instagram accounts list|add @HANDLE|point ID|verify CODE|resend ID|remove ID [--env NAME]
207
217
 
208
218
  The bare commands drive your own Instagram account, which serves live.
209
- \`accounts\` registers test accounts on the shared Instagram preview, which
210
- always reach preview — the counterpart of \`vatio whatsapp numbers\`.`;
219
+ \`accounts\` registers test accounts on the shared Instagram preview. Each
220
+ reaches the environment it points at: preview, or a branch's (\`add\` and
221
+ \`point\` take --env, and default to the branch you are on) — the counterpart
222
+ of \`vatio whatsapp numbers\`.`;
211
223
  }
212
224
 
213
225
  function printInstagram(account, workspace) {
@@ -254,9 +266,10 @@ async function connectInstagram(client) {
254
266
  openBrowser(url);
255
267
  }
256
268
 
257
- async function instagramAccounts(client, workspace, args) {
269
+ async function instagramAccounts(client, workspace, args, cwd) {
258
270
  const sub = args.shift() ?? "list";
259
- const usage = "Usage: vatio instagram accounts list|add @HANDLE|verify CODE|resend ID|remove ID";
271
+ const usage = "Usage: vatio instagram accounts list|add @HANDLE|point ID|verify CODE|resend ID|remove ID [--env NAME]";
272
+ const environment = ["add", "point"].includes(sub) ? takeEnv(args, { fallback: null, cwd }) : null;
260
273
 
261
274
  if (sub === "list") {
262
275
  const list = asArray((await client.listTestInstagramAccounts()).test_instagram_accounts);
@@ -271,13 +284,19 @@ async function instagramAccounts(client, workspace, args) {
271
284
  account.status === "verified" ? `verified ${account.verified_at}`
272
285
  : account.status === "awaiting_code" ? "code sent — confirm it: vatio instagram accounts verify CODE"
273
286
  : `declared — DM @${account.dm_to} from that account to get a code`;
274
- console.log(` [${account.id}] @${account.username} ${suffix}`);
287
+ console.log(` [${account.id}] @${account.username} → ${account.environment} ${suffix}`);
275
288
  }
276
289
  return;
277
290
  }
291
+ if (sub === "point") {
292
+ const id = requireArg(args, "vatio instagram accounts point ID [--env NAME]");
293
+ const account = await client.pointTestInstagramAccount({ id, environment: environment ?? "preview" });
294
+ console.log(`@${account.username} now reaches ${account.environment} on workspace ${workspace}.`);
295
+ return;
296
+ }
278
297
  if (sub === "add") {
279
298
  const username = requireArg(args, "vatio instagram accounts add @yourhandle").replace(/^@/, "");
280
- const account = await client.addTestInstagramAccount({ username });
299
+ const account = await client.addTestInstagramAccount({ username, environment });
281
300
  console.log(`Declared @${account.username} on workspace ${workspace}.`);
282
301
  console.log("");
283
302
  // Meta will not let an app DM an account that has not written first, which
@@ -292,7 +311,7 @@ async function instagramAccounts(client, workspace, args) {
292
311
  const code = requireArg(args, "vatio instagram accounts verify 123456");
293
312
  const account = await client.verifyTestInstagramAccount({ code });
294
313
  console.log(`Verified @${account.username} on workspace ${workspace}.`);
295
- console.log("DMs from that account now reach the preview deployment.");
314
+ console.log(`DMs from that account now reach ${account.environment}.`);
296
315
  return;
297
316
  }
298
317
  if (sub === "resend") {
@@ -28,7 +28,7 @@ export async function chat(config, args) {
28
28
  }
29
29
  }
30
30
 
31
- const environment = takeEnv(args, { fallback: "preview" });
31
+ const environment = takeEnv(args, { fallback: "preview", cwd: config.workspaceRoot });
32
32
  const timeout = Number(takeValue(args, "--timeout") ?? 120);
33
33
  const lastValue = takeValue(args, "--last");
34
34
  const last = Number(lastValue ?? 10);
@@ -18,6 +18,7 @@ import { buildBundle, BundleError } from "../bundle.mjs";
18
18
  import { ApiClient, CliClient } from "../api-client.mjs";
19
19
  import { NotFoundError } from "../http.mjs";
20
20
  import { deviceLogin } from "./auth.mjs";
21
+ import { branchEnvironment } from "../branch.mjs";
21
22
  import { detectGitSha, fail, paint, takeEnv, takeFlag, takeValue } from "../support.mjs";
22
23
 
23
24
  // The first command that makes something exist on a server, so the first one
@@ -99,15 +100,16 @@ export async function toolsCheck(config, args) {
99
100
  export async function push(config, args) {
100
101
  // Null, not "preview": the platform owns that default, and sending it
101
102
  // explicitly would make a bare `vatio push` and `vatio push --env preview`
102
- // two different requests that only happen to agree today.
103
- const environment = takeEnv(args, { fallback: null });
103
+ // two different requests that only happen to agree today. On a branch, the
104
+ // branch's own environment.
105
+ const environment = takeEnv(args, { fallback: null, cwd: config.workspaceRoot });
104
106
  const workspace = config.resolveWorkspaceRequired();
105
107
 
106
108
  await ensureAuthorized(config);
107
109
 
108
110
  // The remote has to exist before the workspace can be checked: the slug is in
109
- // the URL of the call that checks it. It is created unnamed -- the push a few
110
- // lines down applies `business.name` from the manifest anyway.
111
+ // the URL of the call that checks it. It is created unnamed; the display name
112
+ // is a workspace setting edited in the console, not part of a push.
111
113
  const clients = workspacesClient(config);
112
114
  const created = !(await clients.workspaceExists(workspace));
113
115
  if (created) {
@@ -123,15 +125,15 @@ export async function push(config, args) {
123
125
  const payload = await client.pushPreview({
124
126
  manifest: check.manifest,
125
127
  gitSha: detectGitSha(config.workspaceRootRequired()),
128
+ gitBranch: branchEnvironment(config.workspaceRootRequired())?.branch ?? null,
126
129
  createdBy: process.env.USER ?? null,
127
130
  environment
128
131
  });
129
132
 
130
133
  const landedOn = payload.environment ?? "preview";
131
134
  console.log(`Pushed deployment #${payload.deployment_id} to ${landedOn} (git_sha=${JSON.stringify(payload.git_sha ?? null)})`);
132
- // The link that opens without a login -- what makes a preview testable by
133
- // someone who is not the developer who pushed it.
134
- if (payload.share_url) console.log(`Open it: ${payload.share_url}`);
135
+ // Where to try it: the console's test screen for that environment.
136
+ if (payload.share_url) console.log(`Try it: ${payload.share_url}`);
135
137
  for (const warning of asArray(payload.warnings)) console.log(` warning: ${warning}`);
136
138
  if (payload.preview_url) console.log(`Preview: ${payload.preview_url}`);
137
139
 
@@ -154,10 +156,11 @@ function printNotices(payload) {
154
156
  }
155
157
 
156
158
  export async function publish(config, args) {
157
- // Here `--env` names the preview being promoted, not a destination: the
158
- // destination of a publish is always live. Without it the platform promotes
159
- // the most recent push, whichever preview it landed on.
160
- const environment = takeEnv(args, { fallback: null });
159
+ // Here `--env` names the environment being promoted, not a destination: the
160
+ // destination of a publish is always live. On a branch it is the branch's;
161
+ // otherwise the platform promotes the most recent push, whichever preview it
162
+ // landed on. Its knowledge changes go live with it.
163
+ const environment = takeEnv(args, { fallback: null, cwd: config.workspaceRoot });
161
164
  const workspace = config.resolveWorkspaceRequired();
162
165
  const payload = await deployClient(config, workspace).publish({
163
166
  createdBy: process.env.USER ?? null,
@@ -166,6 +169,8 @@ export async function publish(config, args) {
166
169
  const from = asArray(payload.published_from);
167
170
  const source = from.length > 0 ? ` from ${from.join(", ")}` : "";
168
171
  console.log(`Published live deployment #${payload.deployment_id}${source} (git_sha=${JSON.stringify(payload.git_sha ?? null)})`);
172
+ const knowledge = asArray(payload.knowledge);
173
+ if (knowledge.length > 0) console.log(`Knowledge now live: ${knowledge.join(", ")}`);
169
174
  printNotices(payload);
170
175
  }
171
176
 
@@ -193,7 +198,7 @@ export async function status(config) {
193
198
  }
194
199
 
195
200
  export async function diff(config, args) {
196
- const environment = takeEnv(args, { fallback: "preview" });
201
+ const environment = takeEnv(args, { fallback: "preview", cwd: config.workspaceRoot });
197
202
  const full = takeFlag(args, "--full");
198
203
  const stat = takeFlag(args, "--stat");
199
204
  const nameOnly = takeFlag(args, "--name-only");
@@ -0,0 +1,90 @@
1
+ // `vatio env` — where you are working, and how to point things at it.
2
+ //
3
+ // On a branch other than the default one, every command works in the branch's
4
+ // own environment (see branch.mjs): its agent, its knowledge changes on top of
5
+ // live, its secret values. This is the command that says which one that is and
6
+ // hands over what an app needs to talk to it -- the publishable token and the
7
+ // widget snippet -- and the link to try it in the console.
8
+
9
+ import { deployClient } from "./deploy.mjs";
10
+ import { fail, takeEnv } from "../support.mjs";
11
+
12
+ export async function env(config, args) {
13
+ const workspace = config.resolveWorkspaceRequired();
14
+ const client = deployClient(config, workspace);
15
+ const sub = ["list", "show", "rm"].includes(args[0]) ? args.shift() : "show";
16
+
17
+ if (sub === "list") return await list(client, workspace);
18
+
19
+ const named = args.find((arg) => !arg.startsWith("-"));
20
+ if (named) args.splice(args.indexOf(named), 1);
21
+ const name = named ?? takeEnv(args, { fallback: "preview", cwd: config.workspaceRoot });
22
+
23
+ if (sub === "rm") return await remove(client, workspace, name);
24
+ return await show(client, workspace, name);
25
+ }
26
+
27
+ async function list(client, workspace) {
28
+ const rows = asArray((await client.environments()).data);
29
+ console.log(`Environments on workspace ${workspace} (${rows.length}):`);
30
+ for (const row of rows) {
31
+ const branch = row.git_branch ? ` branch=${row.git_branch}` : "";
32
+ const deployed = row.deployment_id ? ` deployment=#${row.deployment_id}` : " (nothing pushed)";
33
+ const knowledge = row.knowledge_changes_count ? ` knowledge_changes=${row.knowledge_changes_count}` : "";
34
+ const active = row.last_activity_at ? ` last_activity=${row.last_activity_at}` : "";
35
+ console.log(` ${row.name}${branch}${deployed}${knowledge}${active}`);
36
+ }
37
+ }
38
+
39
+ async function show(client, workspace, name) {
40
+ let row;
41
+ try {
42
+ row = await client.environment(name);
43
+ } catch (error) {
44
+ if (error?.status !== 404) throw error;
45
+ console.log(`${name} on workspace ${workspace} does not exist yet — \`vatio push\` creates it.`);
46
+ return;
47
+ }
48
+
49
+ console.log(`${row.name} on workspace ${workspace}`);
50
+ if (row.git_branch) console.log(` branch: ${row.git_branch}`);
51
+ console.log(row.deployment_id
52
+ ? ` deployment: #${row.deployment_id}${row.git_sha ? ` (${row.git_sha.slice(0, 7)})` : ""}`
53
+ : " deployment: none yet — `vatio push`");
54
+ if (row.share_url) console.log(` try it: ${row.share_url}`);
55
+ if (row.page_url) console.log(` page with the bubble: ${row.page_url}`);
56
+ if (row.knowledge_url) console.log(` knowledge diff: ${row.knowledge_url}`);
57
+
58
+ if (row.token) {
59
+ console.log(` publishable token: ${row.token}`);
60
+ console.log("\n widget:");
61
+ console.log(` <script src="${row.widget_script_url}"`);
62
+ console.log(` data-workspace="${workspace}"`);
63
+ console.log(` data-token="${row.token}"></script>`);
64
+ console.log("\n SDK: new VatioChat({ workspace, token }) with the token above talks to this environment.");
65
+ }
66
+
67
+ const changes = asArray(row.knowledge_changes);
68
+ if (changes.length > 0) {
69
+ console.log(`\n knowledge changes live does not have (${changes.length}):`);
70
+ for (const change of changes) {
71
+ const conflict = change.conflict ? " CONFLICT: live changed it after you did" : "";
72
+ console.log(` ${change.change.padEnd(8)} ${change.base}/${change.name} v${change.version}${conflict}`);
73
+ }
74
+ }
75
+
76
+ const secrets = asArray(row.secrets);
77
+ if (secrets.length > 0) console.log(`\n secrets with their own value here: ${secrets.join(", ")}`);
78
+ }
79
+
80
+ async function remove(client, workspace, name) {
81
+ if (!name || name === "live") fail("usage: vatio env rm NAME (live cannot be removed)");
82
+
83
+ const result = await client.deleteEnvironment(name);
84
+ console.log(`Removed ${name} from workspace ${workspace} (${result.agents ?? 0} agents, ${result.chats ?? 0} chats)`);
85
+ console.log("Its unpublished knowledge changes and secret values went with it.");
86
+ }
87
+
88
+ function asArray(value) {
89
+ return Array.isArray(value) ? value : [];
90
+ }