@flow-engineer/flow 0.2.0 → 0.2.1

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,580 @@
1
+ // `flow login | logout | whoami | setup | link`: sign in to flow.engineer once, then get the
2
+ // agent's keys into .env and a link to chat with it from a phone, with no key made by hand. `link`
3
+ // alone does it all: it signs in and runs setup first when this folder has not been set up.
4
+ // The calls to flow.engineer are in testing/src/flow-login.ts. Every command takes --json: one JSON
5
+ // object per line on stdout (login prints a "pending" line first, so a coding agent can show the
6
+ // link while it waits), every human line on stderr. Errors are {"error":{code,message,next}}, the
7
+ // next step the server's when it gave one. Every next step names the package (npx @flow-engineer/flow).
8
+ // Nothing reads stdin or needs a terminal, so a coding agent can run every command. login opens the
9
+ // browser itself and waits at most --timeout seconds; a code not approved by then stays in
10
+ // ~/.config/flow-framework/pending.json for `login --wait`, which polls the same code again.
11
+ // Exit status: 0 done, 1 usage or not signed in, 2 network, 3 the server refused (or the code was
12
+ // denied or expired), 4 the code is still waiting for approval, or flow.engineer asked to wait
13
+ // (429 slow_down: the wait is printed, and is retry_after with --json). A server's warnings go to
14
+ // stderr one per line, and into the JSON as `warnings`.
15
+ import { spawn } from "node:child_process";
16
+ import { chmodSync, existsSync, readFileSync, writeFileSync, writeSync } from "node:fs";
17
+ import { hostname } from "node:os";
18
+ import { basename, join } from "node:path";
19
+ import { parseArgs } from "node:util";
20
+ import { setTimeout as delay } from "node:timers/promises";
21
+ import { currentToken, deleteCredentials, deletePending, LoginRefused, loginUrl, pollToken, readPending, REQUEST_TIMEOUT_MS, revoke, saveCredentials, savePending, SETUP_ENV, setup, startDevice, TimedOut, tryLink, warningsOf, whoami, } from "./flow-login.js";
22
+ import { packageRoot } from "./new.js";
23
+ import { RUN } from "../../src/names.js";
24
+ export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link"]);
25
+ export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]
26
+ Sign in to flow.engineer: opens the browser, prints the link and code,
27
+ and waits up to --timeout (default 120) for approval; --wait goes on
28
+ waiting for the same code, --no-wait returns at once
29
+ flow logout [--json] End this machine's sign-in and delete its token
30
+ flow whoami [--json] Show the account you are signed in as
31
+ flow setup [--replace] [--ci] [--json]
32
+ Write this agent's keys (test cloud, traces, Flow Messaging) and each
33
+ open channel's sender into ./.env (in an agent's folder: package.json)
34
+ flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]
35
+ Print the link that opens a chat with this agent from a phone; signs in
36
+ and runs setup first when this folder is not set up yet`;
37
+ const USAGE = `Usage:\n${ACCOUNT_LINES}
38
+
39
+ The token is kept in ~/.config/flow-framework/credentials.json (mode 0600); FLOW_FRAMEWORK_TOKEN, when
40
+ set, is used instead. setup never prints a key, only its last 4 characters, and keeps any value
41
+ already in .env unless --replace (a name with no value counts as missing), and sets .env to 0600.
42
+ The token goes only to the server that issued it, over https (http only to localhost). CI never signs in: setup --ci says how to give it the agent's key.
43
+ Nothing asks for input. A code not approved in time is kept in ~/.config/flow-framework/pending.json
44
+ (mode 0600) until it expires, for ${RUN} login --wait. Signed in already, login says so; --force signs in again.
45
+
46
+ Exit status: 0 done, 1 usage or not signed in, 2 network, 3 refused, denied or expired,
47
+ 4 still waiting for approval (run ${RUN} login --wait), or flow.engineer asked to wait (try again after the time it gives).`;
48
+ const NEXT = { login: `${RUN} login`, wait: `${RUN} login --wait`, setup: `${RUN} setup`, link: `${RUN} link` };
49
+ const CHANNELS = ["telegram", "whatsapp", "imessage"];
50
+ const EXIT = { ok: 0, usage: 1, network: 2, refused: 3, pending: 4 };
51
+ class Stop extends Error {
52
+ code;
53
+ next;
54
+ exit;
55
+ /** For a 429: the seconds flow.engineer asked to wait, null when it gave none. */
56
+ retryAfter;
57
+ /** The server's warnings that came with the error. */
58
+ warnings = [];
59
+ constructor(code, message, next, exit) {
60
+ super(message);
61
+ this.code = code;
62
+ this.next = next;
63
+ this.exit = exit;
64
+ }
65
+ }
66
+ /**
67
+ * A next step to run (a command or a URL) goes after "Next:"; anything else is a sentence for the
68
+ * owner (a trial limit's next) and is printed on its own line as it came.
69
+ */
70
+ const isCommand = (next) => /^(?:npx|npm|flow|gh) |^https?:\/\//.test(next);
71
+ /** Runs one account command; resolves to the exit status. */
72
+ export async function accountCli(command, args) {
73
+ let values;
74
+ try {
75
+ ({ values } = parseArgs({
76
+ args,
77
+ strict: true,
78
+ options: {
79
+ json: { type: "boolean", default: false },
80
+ help: { type: "boolean", short: "h", default: false },
81
+ ...(command === "login" || command === "link" ? { "no-browser": { type: "boolean", default: false }, timeout: { type: "string", default: "120" } } : {}),
82
+ ...(command === "login"
83
+ ? {
84
+ wait: { type: "boolean", default: false },
85
+ "no-wait": { type: "boolean", default: false },
86
+ force: { type: "boolean", default: false },
87
+ }
88
+ : {}),
89
+ ...(command === "setup" ? { replace: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
90
+ ...(command === "link" ? { channel: { type: "string", default: "telegram" } } : {}),
91
+ },
92
+ }));
93
+ }
94
+ catch (error) {
95
+ console.error(`flow ${command}: ${error.message.split(". ")[0]}\n\n${USAGE}`);
96
+ return EXIT.usage;
97
+ }
98
+ if (values.help) {
99
+ console.log(USAGE);
100
+ return EXIT.ok;
101
+ }
102
+ const timeoutSeconds = Number(values.timeout);
103
+ if ((command === "login" || command === "link") && (values.wait && values["no-wait"] || !(values.timeout !== "" && timeoutSeconds >= 0))) {
104
+ console.error(`flow ${command}: ${values.wait && values["no-wait"] ? "--wait and --no-wait cannot go together" : "--timeout is a number of seconds, 0 or more"}.\n\n${USAGE}`);
105
+ return EXIT.usage;
106
+ }
107
+ const channel = String(values.channel);
108
+ if (command === "link" && !CHANNELS.includes(channel)) {
109
+ console.error(`flow link: --channel takes ${CHANNELS.join(", ")}.\n\n${USAGE}`);
110
+ return EXIT.usage;
111
+ }
112
+ const json = values.json === true;
113
+ // Straight to the file descriptors, never buffered: a coding agent's cut-off command keeps every line.
114
+ const out = (report) => writeAll(1, `${JSON.stringify(report)}\n`);
115
+ const say = (line) => writeAll(json ? 2 : 1, `${line}\n`);
116
+ try {
117
+ if (command === "login") {
118
+ await login({ noBrowser: values["no-browser"] === true, wait: values.wait === true, noWait: values["no-wait"] === true, force: values.force === true, timeoutSeconds }, json, out, say);
119
+ }
120
+ else if (command === "logout")
121
+ await logout(json, out, say);
122
+ else if (command === "whoami")
123
+ await showAccount(json, out, say);
124
+ else if (command === "setup")
125
+ await setupEnv(values.replace === true, values.ci === true, json, out, say);
126
+ else
127
+ await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds }, json, out, say);
128
+ return EXIT.ok;
129
+ }
130
+ catch (error) {
131
+ const stop = asStop(error);
132
+ printWarnings(stop.warnings);
133
+ writeAll(2, `${!stop.next ? stop.message : isCommand(stop.next) ? `${stop.message} Next: ${stop.next}` : `${stop.message}\n${stop.next}`}\n`);
134
+ if (json) {
135
+ out(stop instanceof Pending ? stop.report : {
136
+ error: { code: stop.code, message: stop.message, next: stop.next ?? null, ...(stop.retryAfter !== undefined ? { retry_after: stop.retryAfter } : {}) },
137
+ ...(stop.warnings.length ? { warnings: stop.warnings } : {}),
138
+ });
139
+ }
140
+ return stop.exit;
141
+ }
142
+ }
143
+ /** The server's warnings, one per line on stderr, as they came. */
144
+ function printWarnings(warnings) {
145
+ for (const warning of warnings)
146
+ writeAll(2, `${warning}\n`);
147
+ }
148
+ /** Writes all of the text to the descriptor before going on. */
149
+ function writeAll(fd, text) {
150
+ let bytes = Buffer.from(text);
151
+ while (bytes.length)
152
+ bytes = bytes.subarray(writeSync(fd, bytes));
153
+ }
154
+ function asStop(error) {
155
+ if (error instanceof Stop)
156
+ return error;
157
+ if (error instanceof LoginRefused) {
158
+ let stop;
159
+ if (error.status === 401)
160
+ stop = new Stop("not_signed_in", "Your sign-in has ended or was revoked.", error.next ?? NEXT.login, EXIT.refused);
161
+ else if (error.status === 429) {
162
+ // Asked to wait: retryable, so the same exit as a sign-in still waiting.
163
+ const wait = error.retryAfter;
164
+ stop = new Stop(error.code, `${error.message} ${wait !== undefined ? `Wait ${wait} s before trying again.` : "Wait a little before trying again."}`, error.next, EXIT.pending);
165
+ stop.retryAfter = wait ?? null;
166
+ }
167
+ // Status 0: refused here, before any request (an http login URL).
168
+ else
169
+ stop = new Stop(error.code, error.message, error.next, error.status === 0 ? EXIT.usage : EXIT.refused);
170
+ stop.warnings = warningsOf(error.fields);
171
+ return stop;
172
+ }
173
+ if (error instanceof TimedOut)
174
+ return new Stop("timeout", error.message, undefined, EXIT.network);
175
+ const cause = error.cause?.code;
176
+ return new Stop("network", `Could not reach ${loginUrl()}: ${cause ?? error.message}.`, undefined, EXIT.network);
177
+ }
178
+ /**
179
+ * The token to send and the server to send it to. A saved token goes only to the server that issued
180
+ * it: with FLOW_FRAMEWORK_LOGIN_URL now naming another one, it is not sent at all.
181
+ */
182
+ function signedIn() {
183
+ const url = loginUrl();
184
+ const current = currentToken();
185
+ if (!current)
186
+ throw new Stop("not_signed_in", "Not signed in to flow.engineer.", NEXT.login, EXIT.usage);
187
+ if (current.url && current.url !== url) {
188
+ throw new Stop("signed_in_elsewhere", `The saved sign-in is for ${current.url}, not ${url}; its token goes only to the server that issued it.`, `${RUN} login --force`, EXIT.usage);
189
+ }
190
+ return { token: current.token, url };
191
+ }
192
+ const POLL_MIN_MS = 1000;
193
+ const WAIT_NEXT = `approve in the browser, then run: ${NEXT.wait}`;
194
+ async function login(options, json, out, say) {
195
+ const url = loginUrl();
196
+ if (!options.force && !options.wait) {
197
+ const already = await signedInAccount(url);
198
+ if (already) {
199
+ if (json)
200
+ out({ status: "signed_in", already: true, account: already });
201
+ say(`Already signed in as ${already.email}. ${RUN} login --force signs in again.`);
202
+ return;
203
+ }
204
+ }
205
+ // A waiting code's expires_at is only a hint: an approved code can still be collected for a while
206
+ // after it, so the code is polled and the server's expired_token decides.
207
+ let pending = (options.wait || options.resume) && !options.fresh ? readPending() : undefined;
208
+ if (pending && pending.url !== url) {
209
+ say(`The waiting sign-in code is from ${pending.url}, not ${url}; starting a new sign-in.`);
210
+ deletePending();
211
+ pending = undefined;
212
+ }
213
+ else if (options.wait && !pending && !options.fresh)
214
+ say("No sign-in is waiting; starting a new one.");
215
+ const waitNext = options.resume ? `approve in the browser, then run: ${options.resume.then}` : WAIT_NEXT;
216
+ const resumed = pending !== undefined;
217
+ let opened = false;
218
+ if (!pending) {
219
+ const start = await startDevice(url, `flow ${JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8")).version}`, hostname());
220
+ pending = { ...start, expires_at: Date.now() + start.expires_in * 1000, url };
221
+ savePending(pending);
222
+ opened = !options.noBrowser && (await openBrowser(pending.verification_uri_complete || pending.verification_uri));
223
+ }
224
+ const page = pending.verification_uri_complete || pending.verification_uri;
225
+ const expiresIn = Math.max(0, Math.round((pending.expires_at - Date.now()) / 1000));
226
+ // Written straight to the file descriptors, so a coding agent whose command is cut off still has them.
227
+ if (json)
228
+ out({ status: "pending", verification_uri: pending.verification_uri, verification_uri_complete: page, user_code: pending.user_code, expires_in: expiresIn, opened_browser: opened, resumed, next: waitNext });
229
+ if (resumed)
230
+ say(`Still waiting for ${page} (code ${pending.user_code}).`);
231
+ else if (opened)
232
+ say(`Opened your browser at ${page}`);
233
+ else
234
+ say(`${options.noBrowser ? "Open" : "Could not open a browser here; open"} ${page}`);
235
+ if (!resumed)
236
+ say(`and check it shows the code ${pending.user_code}, then approve.`);
237
+ if (options.noWait)
238
+ throw new Pending(pending, null, waitNext, options.resume?.then);
239
+ let interval = pending.interval;
240
+ const startedAt = Date.now();
241
+ const stopAt = startedAt + options.timeoutSeconds * 1000;
242
+ for (;;) {
243
+ await delay(Math.max(0, Math.min(interval * 1000, stopAt - Date.now())));
244
+ let answer;
245
+ try {
246
+ // Each poll gets what is left of --timeout (at least POLL_MIN_MS, so --timeout 0 still asks once).
247
+ answer = await pollToken(url, pending.device_code, Math.max(POLL_MIN_MS, Math.min(REQUEST_TIMEOUT_MS, stopAt - Date.now())));
248
+ }
249
+ catch (error) {
250
+ if (error instanceof TimedOut && Date.now() >= stopAt)
251
+ throw new Pending(pending, options.timeoutSeconds, waitNext, options.resume?.then);
252
+ throw error;
253
+ }
254
+ if (answer.status === "slow_down")
255
+ interval += 5; // RFC 8628: each slow_down adds 5 seconds for good
256
+ if (answer.status === "authorization_pending" || answer.status === "slow_down") {
257
+ if (Date.now() >= stopAt)
258
+ throw new Pending(pending, options.timeoutSeconds, waitNext, options.resume?.then);
259
+ continue;
260
+ }
261
+ deletePending();
262
+ if (answer.status === "expired_token" && resumed) {
263
+ // A code from an earlier run ran out: once, a new sign-in with what is left of --timeout.
264
+ say("The pending sign-in code has expired; starting a new sign-in.");
265
+ const left = Math.max(0, options.timeoutSeconds - Math.floor((Date.now() - startedAt) / 1000));
266
+ return login({ ...options, force: true, fresh: true, timeoutSeconds: left }, json, out, say);
267
+ }
268
+ if (answer.status === "expired_token")
269
+ throw expired();
270
+ if (answer.status !== "signed_in")
271
+ throw new Stop("access_denied", "The sign-in was denied in the browser.", NEXT.login, EXIT.refused);
272
+ const path = saveCredentials({ token: answer.token, ...(answer.expires_at ? { expires_at: answer.expires_at } : {}), url });
273
+ const account = answer.account ?? (await whoami(url, answer.token));
274
+ if (json)
275
+ out({ status: "signed_in", account, token_path: path });
276
+ say(`Signed in as ${account.email}. The token is in ${path}.`);
277
+ if (!options.resume)
278
+ say(`Next, from your agent's folder: ${NEXT.setup}`);
279
+ return;
280
+ }
281
+ }
282
+ /** The account of the token in use, or undefined when there is none or the server no longer takes it. */
283
+ async function signedInAccount(url) {
284
+ const current = currentToken();
285
+ // A saved token from another server is never sent here: this server needs its own sign-in.
286
+ if (!current || (current.url && current.url !== url))
287
+ return undefined;
288
+ try {
289
+ return await whoami(url, current.token);
290
+ }
291
+ catch (error) {
292
+ if (error instanceof LoginRefused && error.status === 401)
293
+ return undefined;
294
+ throw error;
295
+ }
296
+ }
297
+ /** Not approved yet: the code stays in pending.json for `flow login --wait` (or `flow link` again), and the exit is 4. */
298
+ class Pending extends Stop {
299
+ report;
300
+ /** `waited` is the seconds this run polled, null when it did not (--no-wait). */
301
+ constructor(pending, waited, next = WAIT_NEXT, command = NEXT.wait) {
302
+ const page = pending.verification_uri_complete || pending.verification_uri;
303
+ super("pending", waited !== null ? `Not approved yet after ${waited} s; the code ${pending.user_code} still works at ${page}.` : `Approve the code ${pending.user_code} at ${page}.`, command, EXIT.pending);
304
+ this.report = { status: "pending", user_code: pending.user_code, verification_uri: pending.verification_uri, waited_s: waited ?? 0, next };
305
+ }
306
+ }
307
+ const expired = () => {
308
+ deletePending();
309
+ return new Stop("expired_token", "The code expired before it was approved.", NEXT.login, EXIT.refused);
310
+ };
311
+ /** The commands that open a page in the default browser here, in the order to try them. */
312
+ function browserCommands(page) {
313
+ if (process.platform === "darwin")
314
+ return [["open", page]];
315
+ if (process.platform === "win32")
316
+ return [["cmd", "/c", "start", "", page]];
317
+ const wsl = Boolean(process.env.WSL_DISTRO_NAME) || (existsSync("/proc/version") && /microsoft/i.test(readFileSync("/proc/version", "utf8")));
318
+ if (wsl)
319
+ return [["wslview", page], ["cmd.exe", "/c", "start", "", page]];
320
+ // xdg-open needs a display; without one (a server, a sandbox) there is no browser to open.
321
+ return process.env.DISPLAY || process.env.WAYLAND_DISPLAY ? [["xdg-open", page]] : [];
322
+ }
323
+ /** Opens the page in the default browser, never waiting for it; false when nothing here could (it is printed anyway). */
324
+ async function openBrowser(page) {
325
+ for (const [command, ...args] of browserCommands(page)) {
326
+ const started = await new Promise((done) => {
327
+ try {
328
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
329
+ child.once("spawn", () => done(true));
330
+ child.once("error", () => done(false));
331
+ child.unref();
332
+ }
333
+ catch {
334
+ done(false);
335
+ }
336
+ });
337
+ if (started)
338
+ return true;
339
+ }
340
+ return false;
341
+ }
342
+ async function logout(json, out, say) {
343
+ const current = currentToken();
344
+ if (!current) {
345
+ if (json)
346
+ out({ status: "signed_out" });
347
+ say("Not signed in; nothing to do.");
348
+ return;
349
+ }
350
+ await revoke(current.url ?? loginUrl(), current.token);
351
+ const deleted = deleteCredentials();
352
+ if (json)
353
+ out({ status: "signed_out", revoked: true, deleted_file: deleted });
354
+ say("Signed out: the token is revoked on flow.engineer and deleted from this machine.");
355
+ if (current.from === "env")
356
+ say("FLOW_FRAMEWORK_TOKEN is still set in this shell: unset it.");
357
+ }
358
+ async function showAccount(json, out, say) {
359
+ const { token, url } = signedIn();
360
+ const account = await whoami(url, token);
361
+ if (json)
362
+ out(account);
363
+ say(`Signed in to flow.engineer as ${account.email} (account ${account.account}).`);
364
+ }
365
+ const SECRET = new Set([SETUP_ENV.cloud_key, SETUP_ENV.traces_key, SETUP_ENV.messaging_key]);
366
+ const masked = (name, value) => (SECRET.has(name) ? `${/^[a-z]+_(?:test_|live_)?/.exec(value)?.[0] ?? ""}…${value.slice(-4)}` : value);
367
+ const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
368
+ /** The names set in a .env's lines, with their values unquoted. */
369
+ function envValues(lines) {
370
+ const values = new Map();
371
+ for (const line of lines) {
372
+ const match = ENV_LINE.exec(line);
373
+ if (match)
374
+ values.set(match[1], match[2].trim().replace(/^(["'])(.*)\1$/, "$2"));
375
+ }
376
+ return values;
377
+ }
378
+ const CHANNEL_NAME = { telegram: "Telegram", whatsapp: "WhatsApp", imessage: "iMessage" };
379
+ /** Each channel's sender in .env: the agent listens on a channel only when its sender is there. */
380
+ const SENDER_ENV = { telegram: SETUP_ENV.telegram_sender, whatsapp: SETUP_ENV.whatsapp_sender, imessage: SETUP_ENV.imessage_sender };
381
+ /** What a channel on Flow's shared senders cannot do: start a conversation. */
382
+ const REPLY_ONLY = {
383
+ whatsapp: "The agent can only reply after you write to it first, within 24 hours of your last message; it cannot message you first.",
384
+ imessage: "The agent can only reply after you write to it first; it cannot message you first.",
385
+ };
386
+ /** Refuses a folder that is not an agent's (no package.json), before anything signs in or is minted. */
387
+ function agentFolder(dir) {
388
+ if (existsSync(join(dir, "package.json")))
389
+ return;
390
+ throw new Stop("not_an_agent_folder", `${dir} has no package.json, so it is not an agent's folder; its keys would land in the wrong .env.`, `cd into your agent's folder, or make an agent here first: ${RUN} init`, EXIT.usage);
391
+ }
392
+ async function setupEnv(replace, ci, json, out, say, forLink) {
393
+ if (ci) {
394
+ const steps = [
395
+ `CI never signs in. It runs the tests with the agent's test cloud key, ${SETUP_ENV.cloud_key}, as a secret.`,
396
+ `Run ${NEXT.setup} on your machine first, then copy the key from .env into your CI without printing it, for GitHub:`,
397
+ ` grep '^${SETUP_ENV.cloud_key}=' .env | cut -d= -f2- | gh secret set ${SETUP_ENV.cloud_key}`,
398
+ `and in the workflow: ${RUN} test --synthetic --json`,
399
+ ];
400
+ if (json)
401
+ out({ ci: { secret: SETUP_ENV.cloud_key, steps } });
402
+ for (const line of steps)
403
+ say(line);
404
+ return;
405
+ }
406
+ const dir = process.cwd();
407
+ agentFolder(dir);
408
+ const { token, url } = signedIn();
409
+ const file = join(dir, ".env");
410
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
411
+ const lines = text ? text.split("\n") : [];
412
+ if (lines.at(-1) === "")
413
+ lines.pop();
414
+ const before = envValues(lines);
415
+ const named = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name ?? basename(dir);
416
+ // The server takes 1 to 64 letters, digits, spaces, -, _ and .: a package's scope and other characters go.
417
+ const name = named.replace(/^@[^/]*\//, "").replace(/[^A-Za-z0-9 ._-]/g, "-").slice(0, 64) || "my-agent";
418
+ // An empty value (`cp .env.example .env`) is missing: the server mints it.
419
+ const have = Object.values(SETUP_ENV).filter((n) => n !== SETUP_ENV.try_url && before.get(n));
420
+ let answer;
421
+ try {
422
+ // Every channel: each comes back with its shared sender only while it is open.
423
+ answer = await setup(url, token, { name, hostname: hostname(), channels: [...CHANNELS], have });
424
+ }
425
+ catch (error) {
426
+ // The keys minted anyway are not shown again, so whatever the error carries goes into .env first.
427
+ if (error instanceof LoginRefused) {
428
+ const written = writeEnv(file, lines, before, error.fields, replace);
429
+ if (written.some((w) => w.action === "added" || w.action === "replaced"))
430
+ say(`Kept in .env before the error: ${written.map((w) => `${w.name} ${w.action}`).join(", ")}.`);
431
+ }
432
+ throw error;
433
+ }
434
+ const warnings = warningsOf(answer);
435
+ const written = writeEnv(file, lines, before, answer, replace);
436
+ if (typeof answer.join_code !== "string" || !answer.join_code) {
437
+ const kept = written.filter((w) => w.action === "added" || w.action === "replaced").map((w) => w.name);
438
+ const stop = new Stop("no_join_code", `flow.engineer gave no join code${kept.length ? ` (the keys it made are in .env: ${kept.join(", ")})` : ""}, so there is no phone link yet.`, answer.next?.[0] ?? NEXT.setup, EXIT.refused);
439
+ stop.warnings = warnings;
440
+ throw stop;
441
+ }
442
+ printWarnings(warnings);
443
+ const open = CHANNELS.filter((c) => typeof answer[`${c}_sender`] === "string");
444
+ const links = Object.fromEntries(open.map((c) => [c, answer.try_urls?.[c] ?? (c === "telegram" && answer.try_url ? answer.try_url : tryLink(url, answer.join_code, c))]));
445
+ const next = [`${RUN} test --synthetic`, "npm start", ...(answer.next?.length ? answer.next : [NEXT.link])];
446
+ if (json) {
447
+ out({
448
+ agent: answer.agent_id, join_code: answer.join_code, env_written: { ".env": written }, minted: answer.minted ?? [],
449
+ dashboard_url: answer.dashboard_url ?? null, try_url: answer.try_url ?? null, telegram_link: answer.telegram_link ?? null,
450
+ whatsapp_link: answer.whatsapp_link ?? null, imessage_link: answer.imessage_link ?? null, try_urls: links, trial: answer.trial ?? null, next, warnings,
451
+ });
452
+ }
453
+ say(`Agent ${answer.agent_id}. In .env:`);
454
+ for (const w of written)
455
+ say(` ${w.name}=${w.value} ${w.action}${w.action === "kept" ? " (yours differs; --replace takes the new one)" : ""}`);
456
+ if (answer.dashboard_url)
457
+ say(`Its dashboard: ${answer.dashboard_url}`);
458
+ const people = answer.trial?.imessage?.contacts;
459
+ if (people && typeof people.used === "number" && typeof people.limit === "number")
460
+ say(`iMessage: ${people.used} of ${people.limit} people`);
461
+ if (!forLink) {
462
+ for (const c of open)
463
+ say(`On ${CHANNEL_NAME[c]}: ${links[c]}${trialNote(answer.trial?.[c])}`);
464
+ say(`Next: ${next.join(", then ")}`);
465
+ }
466
+ }
467
+ const trialNote = (t) => (t && typeof t.limit === "number" ? ` (free trial: ${t.used} of ${t.limit} messages used)` : "");
468
+ /** Merges setup's fields into .env's lines and writes the file (mode 0600) when anything changed. */
469
+ function writeEnv(file, lines, before, fields, replace) {
470
+ const written = [];
471
+ for (const [field, envName] of Object.entries(SETUP_ENV)) {
472
+ const value = fields[field];
473
+ if (typeof value !== "string" || !value)
474
+ continue;
475
+ const old = before.get(envName);
476
+ const at = lines.findIndex((line) => ENV_LINE.exec(line)?.[1] === envName);
477
+ let action;
478
+ if (!old) {
479
+ // Missing, or there with no value (`NAME=` from .env.example): the new value takes that line.
480
+ if (at >= 0)
481
+ lines[at] = `${envName}=${value}`;
482
+ else
483
+ lines.push(`${envName}=${value}`);
484
+ action = "added";
485
+ }
486
+ else if (old === value)
487
+ action = "unchanged";
488
+ else if (!replace)
489
+ action = "kept";
490
+ else {
491
+ lines[at] = `${envName}=${value}`;
492
+ action = "replaced";
493
+ }
494
+ before.set(envName, action === "kept" ? old : value);
495
+ written.push({ name: envName, action, value: masked(envName, action === "kept" ? old : value) });
496
+ }
497
+ if (written.some((w) => w.action === "added" || w.action === "replaced"))
498
+ writeFileSync(file, `${lines.join("\n")}\n`, { mode: 0o600 });
499
+ if (existsSync(file))
500
+ chmodSync(file, 0o600); // a .env that was there keeps its old mode on write
501
+ return written;
502
+ }
503
+ /** The names set in ./.env, with what the environment sets winning. */
504
+ function dotenvValue() {
505
+ const file = join(process.cwd(), ".env");
506
+ const saved = existsSync(file) ? envValues(readFileSync(file, "utf8").split("\n")) : new Map();
507
+ return (name) => process.env[name] || saved.get(name);
508
+ }
509
+ /**
510
+ * The phone link for this folder's join code. Not set up yet (no join code or no Flow Messaging key
511
+ * in .env, or no sender for a WhatsApp or iMessage link): it signs in first when it has to (the same
512
+ * login, resuming a code already waiting) and runs setup, then asks flow.engineer for the channel's
513
+ * page, so a channel that is not open yet answers with the server's next step instead of a link
514
+ * that does not work.
515
+ */
516
+ async function link(options, json, out, say) {
517
+ const again = `${NEXT.link}${options.channel === "telegram" ? "" : ` --channel ${options.channel}`}`;
518
+ const what = CHANNEL_NAME[options.channel];
519
+ const needsSender = options.channel !== "telegram";
520
+ let value = dotenvValue();
521
+ let restart = false;
522
+ if (!value(SETUP_ENV.join_code) || !value(SETUP_ENV.messaging_key) || (needsSender && !value(SENDER_ENV[options.channel]))) {
523
+ agentFolder(process.cwd());
524
+ if (!(await signedInAccount(loginUrl()))) {
525
+ say("Not signed in to flow.engineer yet: signing in first.");
526
+ await login({ noBrowser: options.noBrowser, wait: false, noWait: false, force: true, timeoutSeconds: options.timeoutSeconds, resume: { then: again } }, json, out, say);
527
+ }
528
+ say("Setting up this agent's keys in .env.");
529
+ await setupEnv(false, false, json, out, say, { channel: options.channel });
530
+ value = dotenvValue();
531
+ restart = true;
532
+ }
533
+ const joinCode = value(SETUP_ENV.join_code);
534
+ if (!joinCode)
535
+ throw new Stop("no_join_code", `Setup wrote no ${SETUP_ENV.join_code} into .env.`, NEXT.setup, EXIT.refused);
536
+ if (needsSender && !value(SENDER_ENV[options.channel])) {
537
+ throw new Stop("channel_not_open", `${what} is not open on Flow's shared senders yet, so setup got no ${SENDER_ENV[options.channel]}.`, NEXT.link, EXIT.refused);
538
+ }
539
+ // The server's own try_url when setup kept one for this join code and channel, else the same page built here.
540
+ const fromServer = value(SETUP_ENV.try_url);
541
+ const url = fromServer?.includes(`/try/${encodeURIComponent(joinCode)}?`) && URL.parse(fromServer)?.searchParams.get("c") === options.channel ? fromServer : tryLink(loginUrl(), joinCode, options.channel);
542
+ await checkTryPage(url);
543
+ const replyOnly = REPLY_ONLY[options.channel];
544
+ const sayToPerson = `Open this on your phone and send the message ${what} fills in: ${url}${replyOnly ? ` ${replyOnly}` : ""}`;
545
+ if (json)
546
+ out({ url, channel: options.channel, join_code: joinCode, reply_only: Boolean(replyOnly), say_to_person: sayToPerson });
547
+ say(url);
548
+ say(`Open it on a phone with ${what} and send the message it fills in, with your join code. From then on`);
549
+ say("that phone chats with this agent while it runs (npm start), with no public URL or tunnel.");
550
+ if (replyOnly)
551
+ say(replyOnly);
552
+ if (restart)
553
+ say("If the agent is running already, restart it (npm start) so it picks up the new .env.");
554
+ }
555
+ /**
556
+ * Asks flow.engineer for the try page as JSON: a channel it cannot open yet (or a join code it does
557
+ * not know) answers {"error", "error_description", "next"}, which stops here with that next step.
558
+ */
559
+ async function checkTryPage(url) {
560
+ let response;
561
+ try {
562
+ response = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
563
+ }
564
+ catch (error) {
565
+ if (error.name === "TimeoutError")
566
+ throw new TimedOut(url, REQUEST_TIMEOUT_MS);
567
+ throw error;
568
+ }
569
+ if (response.ok)
570
+ return void (await response.body?.cancel());
571
+ const text = await response.text();
572
+ let body = {};
573
+ try {
574
+ body = JSON.parse(text);
575
+ }
576
+ catch { }
577
+ const code = typeof body.error === "string" ? body.error : `http_${response.status}`;
578
+ const detail = typeof body.error_description === "string" ? body.error_description : `${url} answered ${response.status}.`;
579
+ throw new Stop(code, detail, typeof body.next === "string" && body.next ? body.next : undefined, EXIT.refused);
580
+ }
@@ -0,0 +1,3 @@
1
+ export declare const CHAT_LINE = " flow chat [--channel <name>] [--person <phone>] [--json]\n Talk to the agent in this folder from the terminal: one message per line\n of stdin, no account needed (only the model key in .env)";
2
+ /** Runs `flow chat`; resolves to the exit status. */
3
+ export declare function chatCli(args: string[], input?: NodeJS.ReadableStream): Promise<number>;