@flow-engineer/flow 0.3.3 → 0.3.4

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
@@ -50,7 +50,7 @@ Test it first in the playground (the link `npm start` prints: the test people an
50
50
 
51
51
  **Stability.** 0.x: the CLI's commands, `--json` fields and exit codes in [docs/agents.md](docs/agents.md) stay compatible within 0.2.x; a breaking change comes with a minor version bump and a release note. Pin the version in `package.json` for production.
52
52
 
53
- **Host it.** Coming soon: `npx @flow-engineer/flow deploy`, to keep the agent answering while the laptop sleeps.
53
+ **Host it.** `npx @flow-engineer/flow deploy` puts the agent on Flow's hosting, so it keeps answering while the laptop sleeps: staging first, then `deploy promote` to production; `deploy secrets --from-env .env` sends its keys as secrets. `deploy status` shows what is live and its URL, `flow logs` its log lines. It needs the agent's CI key (`FLOW_FRAMEWORK_CLOUD_KEY`, which `flow setup` writes) and a `buildAgent` that passes `options.host` on to `createAgent` ([docs/agents.md](docs/agents.md), "Host it").
54
54
 
55
55
  ### In a folder of your own
56
56
 
@@ -648,7 +648,7 @@ cd agents/my-agent && npm start
648
648
 
649
649
  **Which channels are real?** WhatsApp, SMS and your own UI (`customUI`) have production definitions today; you plug in any provider by writing `sendMessage`. Telegram and iMessage are real through Flow Messaging (`fm.telegram()`, `fm.imessage()`, [flow-messaging/README.md](flow-messaging/README.md)); in core they are in the playground for now.
650
650
 
651
- **Does it host my agent?** No. It runs in your Node process, on your machine or servers. You pay your model and messaging providers directly.
651
+ **Does it host my agent?** The framework runs in your Node process, on your machine or servers, and you pay your model and messaging providers directly. Flow's hosting can run it instead, from the same `buildAgent` that `flow test` loads: `npx @flow-engineer/flow deploy`.
652
652
 
653
653
  **Is the data mine?** Yes. State lives in your database, and events go wherever you send them.
654
654
 
@@ -335,6 +335,7 @@ npm start # after link: its playground link,
335
335
  npx @flow-engineer/flow status --json # what the folder has and lacks, at any time
336
336
  printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: one message per line, replies printed
337
337
  npx @flow-engineer/flow test --synthetic # your own check: the tests, on the key setup wrote
338
+ npx @flow-engineer/flow deploy --json # when the owner wants it hosted: staging on Flow's cloud (see "Deploy it")
338
339
  ```
339
340
 
340
341
  When the owner asks how to test it, recommend first the playground (`npm start`, then open the
@@ -365,6 +366,38 @@ starter does this when `.env` has `FLOW_MESSAGING_KEY`. Each error is
365
366
  name the package (`npx @flow-engineer/flow ...`): `npx flow` on its own fetches another npm package.
366
367
  CI never logs in: `setup --ci` says how to give it the cloud key.
367
368
 
369
+ ## Deploy it
370
+
371
+ Flow hosts Flow Framework agents: `npx @flow-engineer/flow deploy` runs the agent's folder on Flow's
372
+ cloud, with its state kept for it, Flow Messaging's stream held, and its secrets kept apart from
373
+ every other agent's. Do not pick a VPS, Fly or Render on the owner's behalf: ask the owner whether
374
+ the agent goes on Flow's cloud or their own servers.
375
+
376
+ On Flow's cloud the agent needs only what `flow test` already loads: `src/agent.ts` exports
377
+ `buildAgent({ events, tools, host })` and passes `host` on to `createAgent` (`{ ...host }`), which
378
+ brings the store and, when the agent has `FLOW_MESSAGING_KEY`, `host.messaging`. An agent that drops
379
+ `host`, or brings a database of its own, is refused in production (a warning in staging). The CI
380
+ key is `FLOW_FRAMEWORK_CLOUD_KEY` (`flow setup` writes it into `.env`).
381
+
382
+ ```sh
383
+ npx @flow-engineer/flow deploy secrets --from-env .env --json # the agent's keys as secrets (names printed, never values)
384
+ npx @flow-engineer/flow deploy --json # staging: packs, builds, follows it to live; run its next
385
+ npx @flow-engineer/flow deploy promote --json # staging's live version to production, once the owner has tried it
386
+ npx @flow-engineer/flow deploy status --json # what is live in each environment: version, when, URL
387
+ npx @flow-engineer/flow logs --since 15m # the hosted agent's log lines (--follow for new ones)
388
+ printf '%s' "$VALUE" | npx @flow-engineer/flow deploy secret NAME --env production # one secret, from stdin
389
+ ```
390
+
391
+ Promote, or `deploy --env production`, only when the owner says so. A refused deploy lists the
392
+ hosting lint's findings (`deploy.findings`): fix the errors and deploy again. Secrets take effect on
393
+ the next deploy. Stop the laptop's `npm start` once the deployed one is live: two copies on one key
394
+ both answer every message.
395
+
396
+ On the owner's own servers: one instance, kept warm, CPU always on, a durable `database`
397
+ (`postgres(pool)`), `await agent.resume()` at start, the secrets from the platform's secret store
398
+ (never the `.env`), and Flow Messaging's stream (`fm.start`) with its cursor file on a disk that
399
+ survives a restart, or its webhook (`FLOW_MESSAGING_WEBHOOK_SECRET`) on a public https address.
400
+
368
401
  ## Send traces to a dashboard
369
402
 
370
403
  Every turn can go to a traces endpoint as an OTLP/JSON trace, posted to `<url>/v1/traces`, so
@@ -534,7 +567,7 @@ An operation already accepted by the agent may still complete after its client s
534
567
  Enable this only in local startup code. The runner is off by default, rejects non-loopback or
535
568
  forwarded connections, and is not part of the hosted test cloud. It calls the actual CLI runner
536
569
  only locally. Hosted CLI support is deferred pending security review; do not add it to cloud startup
537
- or deployment wiring. See the hosted CLI TODO in the README. The runner invokes the CLI
570
+ or deployment wiring (that is `flow deploy`). See the hosted CLI TODO in the README. The runner invokes the CLI
538
571
  without a shell: quoting works, pipelines/expansion do not. The browser cannot override server
539
572
  or token flags, or use `skill --write` to write onto the developer's computer.
540
573
  For other embedded clients, `CliConfig.directory` overrides credential/conversation storage;
@@ -61,6 +61,8 @@ Exit status: 0 done, 1 usage or not signed in, 2 network, 3 refused, denied or e
61
61
  4 still waiting for approval (run ${RUN} login --wait), or flow.engineer asked to wait (try again after the time it gives).`;
62
62
  const NEXT = { login: `${RUN} login`, wait: `${RUN} login --wait`, setup: `${RUN} setup`, link: `${RUN} link` };
63
63
  const CHANNELS = ["telegram", "whatsapp", "imessage"];
64
+ /** After link and status: the step that keeps the agent answering once this machine sleeps. */
65
+ const DEPLOY_HINT = `To keep it answering while this machine sleeps: ${RUN} deploy (Flow's hosting, staging first).`;
64
66
  const EXIT = { ok: 0, usage: 1, network: 2, refused: 3, pending: 4 };
65
67
  class Stop extends Error {
66
68
  code;
@@ -824,6 +826,7 @@ async function status(json, out, say) {
824
826
  }
825
827
  if (next.length)
826
828
  say(`Next: ${next.join(", then ")}`);
829
+ say(DEPLOY_HINT);
827
830
  }
828
831
  /**
829
832
  * status's model line. Flow's key the active one: its credit from GET /credit. A key Flow does not
@@ -982,6 +985,7 @@ async function link(options, json, out, say) {
982
985
  if (REPLY_ONLY[c])
983
986
  say(REPLY_ONLY[c]);
984
987
  say(`Next: ${next.join(", then ")}.`);
988
+ say(DEPLOY_HINT);
985
989
  }
986
990
  /**
987
991
  * Asks flow.engineer's /try page for its direct link as JSON ({"channel", "message", "link", "page"}):
@@ -17,7 +17,8 @@
17
17
  // scenarios that did not pass. --json prints one report (testing/src/report.ts) on stdout at every exit,
18
18
  // and `flow show <run>` prints one run's record from the cloud (testing/src/show.ts).
19
19
  // `flow login`, `logout`, `whoami`, `setup` and `link` sign in to flow.engineer and get the agent's
20
- // keys and its phone link (testing/src/account.ts).
20
+ // keys and its phone link (testing/src/account.ts). `flow deploy` puts the agent on Flow's hosting
21
+ // (testing/src/deploy.ts).
21
22
  var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
22
23
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
23
24
  return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
@@ -41,6 +42,7 @@ import { checkEndpointCli } from "./check-endpoint.js";
41
42
  import { CHECK_LINE as TOOLS_CHECK_LINE, checkCli, mcpUpdateCli } from "./check.js";
42
43
  import { ACCOUNT_COMMANDS, ACCOUNT_LINES, accountCli } from "./account.js";
43
44
  import { CHAT_LINE, chatCli } from "./chat.js";
45
+ import { DEPLOY_LINE, deployCli, logsCli } from "./deploy.js";
44
46
  import { httpAgent } from "./http-agent.js";
45
47
  import { createInterface } from "node:readline/promises";
46
48
  import { displayNameOf, folderName, initHere, newStandalone, packageRoot } from "./new.js";
@@ -119,7 +121,7 @@ the CHINWAG_ names from before the rename still work):
119
121
  (the agent's Flow key, on its trial credit; flow setup writes it)`;
120
122
  const TEST_USAGE = `Usage:\n${TEST_LINE}\n\n${TEST_OPTIONS}`;
121
123
  const USAGE = `Usage:\n${NEW_LINE}\n${CHAT_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${CHECK_LINE}\n${TOOLS_CHECK_LINE}\n${MCP_LINE}\n${CLI_LINE}
122
- ${PLUGIN_LINE}\n${ACCOUNT_LINES}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
124
+ ${PLUGIN_LINE}\n${ACCOUNT_LINES}\n${DEPLOY_LINE}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
123
125
  /** What to do after new or init, from step 2; a model key comes first when none is set here. */
124
126
  function nextSteps() {
125
127
  // link signs in and writes the keys first, FLOW_KEY (the model, on Flow's free trial credit) among
@@ -155,6 +157,10 @@ if (process.argv[2] === "cli") {
155
157
  const { defineCli, runCli } = await import("../../cli/src/index.js");
156
158
  process.exit(await runCli(defineCli({ name: "flow" }), process.argv.slice(3)));
157
159
  }
160
+ if (process.argv[2] === "deploy")
161
+ process.exit(await deployCli(process.argv.slice(3)));
162
+ if (process.argv[2] === "logs")
163
+ process.exit(await logsCli(process.argv.slice(3)));
158
164
  if (process.argv[2] === "chat")
159
165
  process.exit(await chatCli(process.argv.slice(3)));
160
166
  if (ACCOUNT_COMMANDS.has(process.argv[2] ?? ""))
@@ -0,0 +1,34 @@
1
+ export declare const DEPLOY_LINE = " flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--json]\n Put this agent on Flow's hosting, so it answers while this machine\n sleeps (flow deploy --help: status, promote, secret, secrets)\n flow logs [--env staging|production] [--since 1h] [--follow] [--json]\n The hosted agent's recent log lines";
2
+ export declare const LOGS_USAGE = "Usage:\n flow logs [--env staging|production] [--since 1h] [--follow] [--json]\n\n --env Which (default: staging).\n --since How far back: 30s, 15m, 1h (the default) or 2d, at most 30d.\n --follow Keep printing new lines until Ctrl-C.\n --json One JSON object per line on stdout: { time, severity, text }.\n --dir, --cloud, FLOW_FRAMEWORK_CLOUD_KEY: as for flow deploy (flow deploy --help).";
3
+ export declare const DEPLOY_USAGE = "Usage:\n flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--json]\n flow deploy promote [--json] staging's live version to production\n flow deploy status [--env staging|production] [--json] what is live, its version, when, and its URL\n flow deploy secret <NAME> [--env staging|production] the value on stdin\n flow deploy secrets --from-env [<file>] [--env staging|production]\n every value in <file> (default: the agent's .env)\n\n --env Where (default: staging). Production takes only an agent whose buildAgent passes\n options.host on to createAgent, with no database of its own.\n --dir The agent's folder (default: .): package.json, package-lock.json, src/agent.ts.\n --version A label for this version (default: the folder's git commit, if any).\n --cloud The cloud's address (default: FLOW_FRAMEWORK_CLOUD, else https://flow.engineer/observe).\n --from-env With secrets: send each non-empty value in the file as a secret (never the CI key,\n the cloud's address, or a name Flow sets itself, such as the\n FLOW_FRAMEWORK_TRACES_* that flow link writes). Prints names only.\n --json Print one JSON answer on stdout (progress goes to stderr).\n\nstatus shows both environments unless --env names one. The last deploy this folder made is kept\nin .flow/deploys.json (never packed).\n\nEnvironment (or the agent folder's .env):\n FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key (oci_...): flow setup writes it, or make one on\n your agent's Developers page. CHINWAG_CLOUD_KEY, its older name, works too.";
4
+ /** Names the host sets on the agent's service itself, which it refuses as secrets. */
5
+ export declare const HOST_NAMES: RegExp;
6
+ /**
7
+ * Left out of the packed folder: dependencies (the build runs npm ci), git, every .env, and the
8
+ * framework's local results (.flow, .chinwag before the rename), which the hosted agent never reads.
9
+ */
10
+ export declare const PACK_EXCLUDES: string[];
11
+ export declare const MAX_BUNDLE: number;
12
+ /**
13
+ * A .env file's names and values: NAME=value lines, `export ` allowed, values in single or double
14
+ * quotes taken as written (double quotes understand \n), an unquoted value up to a ` #` comment.
15
+ */
16
+ export declare function parseEnv(text: string): Map<string, string>;
17
+ /** The agent's folder as a .tgz at `bundle`, without PACK_EXCLUDES. */
18
+ export declare function pack(dir: string, bundle: string): void;
19
+ export interface DeployOptions {
20
+ /** Where the value of `flow deploy secret` comes from (default: stdin). */
21
+ readStdin?: () => string;
22
+ /** Milliseconds between looks at a building deploy, or new log lines with --follow (default 3000). */
23
+ pollMs?: number;
24
+ /** With `flow logs --follow`: stop after this many looks (tests); default never. */
25
+ maxPolls?: number;
26
+ /** The clock, for --since (tests). */
27
+ now?: () => number;
28
+ }
29
+ /** `flow deploy ...`. */
30
+ export declare function deployCli(argv: string[], env?: NodeJS.ProcessEnv, options?: DeployOptions): Promise<number>;
31
+ /** `flow logs ...`. */
32
+ export declare function logsCli(argv: string[], env?: NodeJS.ProcessEnv, options?: DeployOptions): Promise<number>;
33
+ /** --since as seconds: 30s, 15m, 1h, 2d. */
34
+ export declare function sinceSeconds(since: string): number | undefined;
@@ -0,0 +1,535 @@
1
+ // `flow deploy`: puts the agent in this folder on Flow's hosting, through the cloud (observe's
2
+ // /v1/hosting routes) with the agent's CI key. Node's own modules only.
3
+ //
4
+ // flow deploy [--env staging|production] [--dir .] [--version <v>] [--json] pack, upload, build, go live
5
+ // flow deploy promote [--json] staging's live version to production
6
+ // flow deploy status [--env staging|production] [--json] what is live, its version, when, its URL
7
+ // flow logs [--env staging|production] [--since 1h] [--follow] [--json] the hosted agent's log lines
8
+ // flow deploy secret <NAME> [--env staging|production] [--json] a secret, its value read from stdin
9
+ // flow deploy secrets --from-env [<file>] [--env staging|production] [--json] every value in the agent's .env
10
+ //
11
+ // The folder is packed without node_modules, .git, .env files and the framework's local results
12
+ // (.flow); its package-lock.json is installed with `npm ci` in the build, and the build runs
13
+ // hosting's lint (its findings are printed here). Nothing here prints a key or a secret's value.
14
+ import { execFileSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join, resolve } from "node:path";
18
+ import { parseArgs } from "node:util";
19
+ import { RUN } from "../../src/names.js";
20
+ const DEFAULT_CLOUD = "https://flow.engineer/observe";
21
+ export const DEPLOY_LINE = ` flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--json]
22
+ Put this agent on Flow's hosting, so it answers while this machine
23
+ sleeps (flow deploy --help: status, promote, secret, secrets)
24
+ flow logs [--env staging|production] [--since 1h] [--follow] [--json]
25
+ The hosted agent's recent log lines`;
26
+ export const LOGS_USAGE = `Usage:
27
+ flow logs [--env staging|production] [--since 1h] [--follow] [--json]
28
+
29
+ --env Which (default: staging).
30
+ --since How far back: 30s, 15m, 1h (the default) or 2d, at most 30d.
31
+ --follow Keep printing new lines until Ctrl-C.
32
+ --json One JSON object per line on stdout: { time, severity, text }.
33
+ --dir, --cloud, FLOW_FRAMEWORK_CLOUD_KEY: as for flow deploy (flow deploy --help).`;
34
+ export const DEPLOY_USAGE = `Usage:
35
+ flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--json]
36
+ flow deploy promote [--json] staging's live version to production
37
+ flow deploy status [--env staging|production] [--json] what is live, its version, when, and its URL
38
+ flow deploy secret <NAME> [--env staging|production] the value on stdin
39
+ flow deploy secrets --from-env [<file>] [--env staging|production]
40
+ every value in <file> (default: the agent's .env)
41
+
42
+ --env Where (default: staging). Production takes only an agent whose buildAgent passes
43
+ options.host on to createAgent, with no database of its own.
44
+ --dir The agent's folder (default: .): package.json, package-lock.json, src/agent.ts.
45
+ --version A label for this version (default: the folder's git commit, if any).
46
+ --cloud The cloud's address (default: FLOW_FRAMEWORK_CLOUD, else ${DEFAULT_CLOUD}).
47
+ --from-env With secrets: send each non-empty value in the file as a secret (never the CI key,
48
+ the cloud's address, or a name Flow sets itself, such as the
49
+ FLOW_FRAMEWORK_TRACES_* that flow link writes). Prints names only.
50
+ --json Print one JSON answer on stdout (progress goes to stderr).
51
+
52
+ status shows both environments unless --env names one. The last deploy this folder made is kept
53
+ in .flow/deploys.json (never packed).
54
+
55
+ Environment (or the agent folder's .env):
56
+ FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key (oci_...): flow setup writes it, or make one on
57
+ your agent's Developers page. CHINWAG_CLOUD_KEY, its older name, works too.`;
58
+ /** The CLI's own settings in a .env: never sent as secrets. */
59
+ const CLI_NAMES = new Set(["FLOW_FRAMEWORK_CLOUD_KEY", "CHINWAG_CLOUD_KEY", "FLOW_FRAMEWORK_CLOUD", "CHINWAG_CLOUD"]);
60
+ /** Names the host sets on the agent's service itself, which it refuses as secrets. */
61
+ export const HOST_NAMES = /^(FLOW_AGENT_ID|FLOW_AGENT_NAME|FLOW_ENVIRONMENT|FLOW_STORE_URL|FLOW_STORE_TOKEN|FLOW_STORE_FRAMEWORK|FLOW_STORE_CHINWAG|FLOW_API_TOKEN|FLOW_PLAYGROUND_TOKEN|AGENT_VERSION|AGENT_DIR|PORT)$|^(FLOW_STORE_|CHINWAG_TRACES_|FLOW_FRAMEWORK_TRACES_|K_)/;
62
+ const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
63
+ /** A name `secrets --from-env` sends: two characters at least, as the host takes them. */
64
+ const ENV_SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
65
+ /**
66
+ * Left out of the packed folder: dependencies (the build runs npm ci), git, every .env, and the
67
+ * framework's local results (.flow, .chinwag before the rename), which the hosted agent never reads.
68
+ */
69
+ export const PACK_EXCLUDES = ["node_modules", ".git", ".env", ".env.*", ".DS_Store", ".flow", ".chinwag"];
70
+ export const MAX_BUNDLE = 50 * 1024 * 1024;
71
+ const GIVE_UP_MS = 20 * 60 * 1000;
72
+ /**
73
+ * A .env file's names and values: NAME=value lines, `export ` allowed, values in single or double
74
+ * quotes taken as written (double quotes understand \n), an unquoted value up to a ` #` comment.
75
+ */
76
+ export function parseEnv(text) {
77
+ const out = new Map();
78
+ for (const raw of text.split(/\r?\n/)) {
79
+ const line = raw.trim();
80
+ if (!line || line.startsWith("#"))
81
+ continue;
82
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
83
+ if (!m)
84
+ continue;
85
+ let value = m[2];
86
+ const quote = value[0];
87
+ if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0) {
88
+ value = value.slice(1, value.lastIndexOf(quote));
89
+ if (quote === '"')
90
+ value = value.replace(/\\n/g, "\n");
91
+ }
92
+ else {
93
+ value = value.replace(/\s+#.*$/, "").trim();
94
+ }
95
+ out.set(m[1], value);
96
+ }
97
+ return out;
98
+ }
99
+ function readEnvFile(file) {
100
+ try {
101
+ return parseEnv(readFileSync(file, "utf8"));
102
+ }
103
+ catch {
104
+ return new Map();
105
+ }
106
+ }
107
+ /** The agent's folder as a .tgz at `bundle`, without PACK_EXCLUDES. */
108
+ export function pack(dir, bundle) {
109
+ execFileSync("tar", ["-czf", bundle, ...PACK_EXCLUDES.map((p) => `--exclude=${p}`), "-C", dir, "."], { stdio: ["ignore", "ignore", "inherit"] });
110
+ }
111
+ class Refused extends Error {
112
+ code;
113
+ /** The cloud has no such route (an older Flow): a plain 404 or 405, not one of its JSON errors. */
114
+ missing;
115
+ constructor(message, code, missing = false) {
116
+ super(message);
117
+ this.code = code;
118
+ this.missing = missing;
119
+ }
120
+ }
121
+ class Usage extends Error {
122
+ }
123
+ /** What this Flow's cloud cannot do yet; the CLI side is ready for when it can. */
124
+ class NotAvailable extends Error {
125
+ }
126
+ /** `flow deploy ...`. */
127
+ export function deployCli(argv, env = process.env, options = {}) {
128
+ return cloudCli("deploy", argv, env, options);
129
+ }
130
+ /** `flow logs ...`. */
131
+ export function logsCli(argv, env = process.env, options = {}) {
132
+ return cloudCli("logs", argv, env, options);
133
+ }
134
+ async function cloudCli(top, argv, env, options) {
135
+ const usage = top === "logs" ? LOGS_USAGE : DEPLOY_USAGE;
136
+ let parsed;
137
+ try {
138
+ parsed = parseArgs({
139
+ args: argv,
140
+ allowPositionals: true,
141
+ options: {
142
+ env: { type: "string" },
143
+ dir: { type: "string", default: "." },
144
+ version: { type: "string" },
145
+ cloud: { type: "string" },
146
+ "from-env": { type: "boolean" },
147
+ since: { type: "string" },
148
+ follow: { type: "boolean", short: "f" },
149
+ json: { type: "boolean" },
150
+ help: { type: "boolean", short: "h" },
151
+ },
152
+ });
153
+ }
154
+ catch (error) {
155
+ console.error(`${error.message}\n\n${usage}`);
156
+ return 2;
157
+ }
158
+ const { values, positionals } = parsed;
159
+ if (values.help) {
160
+ console.log(usage);
161
+ return 0;
162
+ }
163
+ const json = Boolean(values.json);
164
+ const say = json ? (line) => console.error(line) : (line) => console.log(line);
165
+ const fail = (code, message, exit, next) => {
166
+ if (json)
167
+ console.log(JSON.stringify({ ok: false, error: { code, message }, next: next ?? null }));
168
+ console.error(next ? `${message}\nNext: ${next}` : message);
169
+ return exit;
170
+ };
171
+ const command = top === "logs" ? "logs" : positionals[0];
172
+ const args = top === "logs" ? positionals : positionals.slice(1);
173
+ const given = values.env;
174
+ if (given !== undefined && given !== "staging" && given !== "production")
175
+ return fail("usage", `--env is staging or production\n\n${usage}`, 2);
176
+ if (command !== "logs" && (values.since !== undefined || values.follow))
177
+ return fail("usage", `--since and --follow go with flow logs\n\n${usage}`, 2);
178
+ const environment = given ?? "staging";
179
+ const dir = resolve(values.dir);
180
+ const dotEnv = readEnvFile(join(dir, ".env"));
181
+ const setting = (...names) => {
182
+ for (const name of names)
183
+ if (env[name])
184
+ return env[name];
185
+ for (const name of names)
186
+ if (dotEnv.get(name))
187
+ return dotEnv.get(name);
188
+ return undefined;
189
+ };
190
+ const key = setting("FLOW_FRAMEWORK_CLOUD_KEY", "CHINWAG_CLOUD_KEY");
191
+ if (!key)
192
+ return fail("no_ci_key", "Set FLOW_FRAMEWORK_CLOUD_KEY to your agent's CI key (oci_...), in the environment or the agent's .env.", 2, `${RUN} setup`);
193
+ const cloud = (values.cloud || setting("FLOW_FRAMEWORK_CLOUD", "CHINWAG_CLOUD") || DEFAULT_CLOUD).replace(/\/$/, "");
194
+ const api = async (method, path, body) => {
195
+ const res = await fetch(cloud + path, {
196
+ method,
197
+ headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
198
+ body: body === undefined ? undefined : JSON.stringify(body),
199
+ });
200
+ const text = await res.text();
201
+ let answer = {};
202
+ try {
203
+ answer = JSON.parse(text);
204
+ }
205
+ catch {
206
+ // not JSON: the status says it
207
+ }
208
+ if (!res.ok) {
209
+ const why = answer.error?.message ?? `${res.status} ${res.statusText}`;
210
+ const missing = (res.status === 404 || res.status === 405) && typeof answer.error?.code !== "string";
211
+ throw new Refused(answer.error?.hint ? `${why} (${answer.error.hint})` : why, answer.error?.code ?? "refused", missing);
212
+ }
213
+ return answer;
214
+ };
215
+ const ctx = { api, say, json, dir, pollMs: options.pollMs ?? 3000, maxPolls: options.maxPolls, now: options.now ?? Date.now };
216
+ try {
217
+ const [name, extra] = args;
218
+ if (command === "logs") {
219
+ if (name !== undefined)
220
+ throw new Usage(`flow logs takes no arguments.\n\n${usage}`);
221
+ return await logs(ctx, environment, values.since ?? "1h", Boolean(values.follow));
222
+ }
223
+ if (extra !== undefined || (name !== undefined && command !== "secret" && command !== "secrets"))
224
+ throw new Usage(`Too many arguments.\n\n${usage}`);
225
+ if (command === "promote")
226
+ return await promote(ctx);
227
+ if (command === "status")
228
+ return await status(ctx, given ? [given] : ["staging", "production"]);
229
+ if (command === "secret")
230
+ return await secret(ctx, name, environment, options.readStdin ?? (() => readFileSync(0, "utf8")));
231
+ if (command === "secrets") {
232
+ if (!values["from-env"])
233
+ throw new Usage(`secrets takes --from-env [<file>]\n\n${usage}`);
234
+ return await secretsFromEnv(ctx, resolve(name ? name : join(dir, ".env")), environment);
235
+ }
236
+ if (command !== undefined)
237
+ throw new Usage(`Unknown command: deploy ${command}\n\n${usage}`);
238
+ return await deploy(ctx, dir, environment, values.version);
239
+ }
240
+ catch (error) {
241
+ if (error instanceof Usage)
242
+ return fail("usage", error.message, 2);
243
+ if (error instanceof NotAvailable)
244
+ return fail("not_available", error.message, 1);
245
+ if (error instanceof Refused)
246
+ return fail(error.code, `Refused: ${error.message}`, 1);
247
+ return fail("network", `Could not reach ${cloud}: ${error.message}`, 1);
248
+ }
249
+ }
250
+ async function deploy(ctx, dir, env, version) {
251
+ for (const file of ["package.json", "package-lock.json", "src/agent.ts"]) {
252
+ if (!existsSync(join(dir, file)))
253
+ throw new Usage(`${join(dir, file)} is missing: deploy the agent's folder (npm install makes package-lock.json).`);
254
+ }
255
+ version ??= gitVersion(dir);
256
+ const tmp = mkdtempSync(join(tmpdir(), "flow-deploy-"));
257
+ try {
258
+ const bundle = join(tmp, "agent.tgz");
259
+ pack(dir, bundle);
260
+ const size = statSync(bundle).size;
261
+ if (size > MAX_BUNDLE)
262
+ throw new Usage(`The packed folder is ${mb(size)}, over ${mb(MAX_BUNDLE)}. Leave large files out of it.`);
263
+ ctx.say(`Packed ${dir} (${mb(size)})${version ? `, version ${version}` : ""}.`);
264
+ const made = await ctx.api("POST", "/v1/hosting/deploys", { environment: env, ...(version ? { version } : {}) });
265
+ const put = await fetch(made.upload_url, { method: "PUT", headers: { "content-type": "application/gzip" }, body: readFileSync(bundle) });
266
+ if (!put.ok)
267
+ throw new Refused(`the upload failed (${put.status})`, "upload_failed");
268
+ await ctx.api("POST", `/v1/hosting/deploys/${made.deploy.id}/start`);
269
+ remember(ctx.dir, env, made.deploy.id);
270
+ ctx.say(`Deploy ${made.deploy.id} to ${env}: building.`);
271
+ return await follow(ctx, made.deploy.id);
272
+ }
273
+ finally {
274
+ rmSync(tmp, { recursive: true, force: true });
275
+ }
276
+ }
277
+ async function promote(ctx) {
278
+ const { deploy } = await ctx.api("POST", "/v1/hosting/promote");
279
+ remember(ctx.dir, "production", deploy.id);
280
+ ctx.say(`Promoting staging's ${deploy.version ?? "live version"} to production (${deploy.id}).`);
281
+ return await follow(ctx, deploy.id);
282
+ }
283
+ async function secret(ctx, name, env, readStdin) {
284
+ if (!name || !SECRET_NAME.test(name))
285
+ throw new Usage(`A secret's name is upper case letters, digits and _ (like ANTHROPIC_API_KEY).\n\n${DEPLOY_USAGE}`);
286
+ if (process.stdin.isTTY)
287
+ console.error(`Paste ${name}'s value for ${env}, then press Enter and Ctrl-D:`);
288
+ const value = readStdin().replace(/\r?\n$/, "");
289
+ if (!value)
290
+ throw new Usage("No value on stdin.");
291
+ const saved = await ctx.api("PUT", `/v1/hosting/secrets/${name}`, { environment: env, value });
292
+ const next = `${RUN} deploy${env === "production" ? " --env production" : ""}`;
293
+ if (ctx.json)
294
+ console.log(JSON.stringify({ ok: true, secret: { name: saved.name, last4: saved.last4, environment: env }, next }));
295
+ ctx.say(`Saved ${saved.name} (••••${saved.last4}) for ${env}. It takes effect on the next deploy.`);
296
+ ctx.say(`Next: ${next}`);
297
+ return 0;
298
+ }
299
+ /** Every non-empty value in a .env file as a secret, one each; the names only are printed. */
300
+ async function secretsFromEnv(ctx, file, env) {
301
+ if (!existsSync(file))
302
+ throw new Usage(`${file} is missing.`);
303
+ const entries = [...parseEnv(readFileSync(file, "utf8"))].filter(([, value]) => value !== "");
304
+ const saved = [];
305
+ const skipped = [];
306
+ const refused = [];
307
+ for (const [name, value] of entries) {
308
+ if (CLI_NAMES.has(name))
309
+ continue;
310
+ if (HOST_NAMES.test(name)) {
311
+ skipped.push({ name, why: "set by Flow" });
312
+ ctx.say(` ${name} skipped (set by Flow)`);
313
+ continue;
314
+ }
315
+ if (!ENV_SECRET_NAME.test(name)) {
316
+ skipped.push({ name, why: "not upper case letters, digits and _" });
317
+ ctx.say(` ${name} skipped: a secret's name is upper case letters, digits and _`);
318
+ continue;
319
+ }
320
+ try {
321
+ await ctx.api("PUT", `/v1/hosting/secrets/${name}`, { environment: env, value });
322
+ }
323
+ catch (error) {
324
+ if (!(error instanceof Refused))
325
+ throw error;
326
+ refused.push({ name, message: error.message });
327
+ ctx.say(` ${name} refused: ${error.message}`);
328
+ continue;
329
+ }
330
+ saved.push(name);
331
+ ctx.say(` ${name} set`);
332
+ }
333
+ const next = `${RUN} deploy${env === "production" ? " --env production" : ""}`;
334
+ if (ctx.json)
335
+ console.log(JSON.stringify({ ok: refused.length === 0, environment: env, saved, skipped, refused, next }));
336
+ ctx.say(saved.length ? `Saved ${saved.length} secret${saved.length === 1 ? "" : "s"} for ${env}. They take effect on the next deploy.` : `No secrets saved from ${file}.`);
337
+ if (saved.length)
338
+ ctx.say(`Next: ${next}`);
339
+ return refused.length ? 1 : 0;
340
+ }
341
+ /** Follows a deploy until it is live, refused or failed, printing each change. */
342
+ async function follow(ctx, id) {
343
+ const until = Date.now() + GIVE_UP_MS;
344
+ let last = "";
345
+ for (;;) {
346
+ const { deploy } = await ctx.api("GET", `/v1/hosting/deploys/${id}`);
347
+ if (deploy.status !== last) {
348
+ last = deploy.status;
349
+ if (!["live", "refused", "failed", "replaced"].includes(deploy.status))
350
+ ctx.say(` ${deploy.status}…`);
351
+ }
352
+ if (["live", "refused", "failed", "replaced"].includes(deploy.status)) {
353
+ for (const f of deploy.findings ?? [])
354
+ ctx.say(` ${f.level === "error" ? "✗" : "!"} ${f.code}: ${f.message}`);
355
+ const live = deploy.status === "live" || deploy.status === "replaced";
356
+ const url = live ? (deploy.url ?? (await address(ctx, deploy.environment))) : null;
357
+ const next = !live
358
+ ? deploy.status === "refused"
359
+ ? `fix the errors above, then ${RUN} deploy${deploy.environment === "production" ? " --env production" : ""}`
360
+ : `see the deploy's page in the dashboard, then ${RUN} deploy again`
361
+ : deploy.environment === "staging"
362
+ ? `try it in your agent's Playground (Staging) in the dashboard, then ${RUN} deploy promote`
363
+ : "open your agent's Playground (Production) in the dashboard";
364
+ if (ctx.json) {
365
+ const { id: deployId, environment, status, version, findings, error } = deploy;
366
+ console.log(JSON.stringify({ ok: live, deploy: { id: deployId, environment, status, version, findings: findings ?? [], error }, url, next }));
367
+ }
368
+ if (live) {
369
+ ctx.say(`Live in ${deploy.environment}.`);
370
+ ctx.say(`URL: ${url ?? NO_URL}`);
371
+ ctx.say(`Next: ${next}`);
372
+ return 0;
373
+ }
374
+ if (!ctx.json)
375
+ console.error(deploy.status === "refused" ? "Refused: fix the errors above and deploy again." : `Failed: ${deploy.error ?? "the build or the service did not start"}.`);
376
+ else
377
+ ctx.say(deploy.status === "refused" ? "Refused." : `Failed: ${deploy.error ?? "the build or the service did not start"}.`);
378
+ return 1;
379
+ }
380
+ if (Date.now() > until) {
381
+ ctx.say(`Still ${deploy.status} after 20 minutes. Its page in the dashboard shows where it is.`);
382
+ return 1;
383
+ }
384
+ await new Promise((r) => setTimeout(r, ctx.pollMs));
385
+ }
386
+ }
387
+ const NO_URL = "not available on this Flow yet (the dashboard's Playground opens it)";
388
+ /** The service's address, when the cloud tells it (GET /v1/hosting/address); null when it does not. */
389
+ async function address(ctx, environment) {
390
+ try {
391
+ const { url } = await ctx.api("GET", `/v1/hosting/address?environment=${encodeURIComponent(environment)}`);
392
+ return typeof url === "string" && url ? url : null;
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ /** Where `flow deploy` keeps the last deploy it made per environment, for `deploy status`: in .flow, never packed. */
399
+ const STATE = join(".flow", "deploys.json");
400
+ function remember(dir, environment, id) {
401
+ try {
402
+ const file = join(dir, STATE);
403
+ const known = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
404
+ mkdirSync(join(dir, ".flow"), { recursive: true });
405
+ writeFileSync(file, `${JSON.stringify({ ...known, [environment]: { id } }, null, 2)}\n`);
406
+ }
407
+ catch {
408
+ // only a convenience for status
409
+ }
410
+ }
411
+ function remembered(dir, environment) {
412
+ try {
413
+ const id = JSON.parse(readFileSync(join(dir, STATE), "utf8"))[environment]?.id;
414
+ return typeof id === "string" && /^hdp_[0-9A-Za-z]+$/.test(id) ? id : undefined;
415
+ }
416
+ catch {
417
+ return undefined;
418
+ }
419
+ }
420
+ /** `flow deploy status`: per environment, what is live, its version, when, and its URL. */
421
+ async function status(ctx, environments) {
422
+ const found = [];
423
+ for (const environment of environments) {
424
+ let deploys = [];
425
+ let from = null;
426
+ try {
427
+ deploys = (await ctx.api("GET", `/v1/hosting/deploys?environment=${environment}`)).deploys ?? [];
428
+ from = "list";
429
+ }
430
+ catch (error) {
431
+ if (!(error instanceof Refused) || !error.missing)
432
+ throw error;
433
+ // This Flow lists deploys by CI key only to a dashboard member: the last one this folder made.
434
+ const id = remembered(ctx.dir, environment);
435
+ if (id) {
436
+ deploys = [(await ctx.api("GET", `/v1/hosting/deploys/${id}`)).deploy];
437
+ from = "this_folder";
438
+ }
439
+ }
440
+ const newestFirst = [...deploys].sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")));
441
+ const live = newestFirst.find((d) => d.status === "live") ?? null;
442
+ const top = newestFirst[0] ?? null;
443
+ const serving = live ?? top;
444
+ const pending = top && top !== serving && !["live", "refused", "failed", "replaced"].includes(top.status) ? { id: top.id, status: top.status, version: top.version } : null;
445
+ const url = live ? (live.url ?? (await address(ctx, environment))) : null;
446
+ found.push({ environment, deploy: serving, pending, url, from });
447
+ }
448
+ const anyLive = found.some((f) => f.deploy?.status === "live");
449
+ const next = anyLive
450
+ ? `${RUN} logs${found.length === 1 && found[0].environment === "production" ? " --env production" : ""}`
451
+ : `${RUN} deploy`;
452
+ if (ctx.json) {
453
+ console.log(JSON.stringify({ ok: true, environments: Object.fromEntries(found.map((f) => [f.environment, f.deploy ? { deploy: pick(f.deploy), pending: f.pending, url: f.url, from: f.from } : null])), next }));
454
+ return 0;
455
+ }
456
+ for (const f of found) {
457
+ if (!f.deploy) {
458
+ ctx.say(`${f.environment}: no deploy known${f.from === null ? " (this Flow lists deploys to a dashboard member only, and this folder has made none: the dashboard shows them)" : ""}.`);
459
+ continue;
460
+ }
461
+ const d = f.deploy;
462
+ const when = d.updated_at ?? d.created_at;
463
+ ctx.say(`${f.environment}: ${d.status}${d.version ? `, version ${d.version}` : ""}, deploy ${d.id}${when ? `, ${d.status === "live" ? "deployed" : "updated"} ${when}` : ""}${f.from === "this_folder" ? " (the last deploy from this folder)" : ""}`);
464
+ if (d.status === "live")
465
+ ctx.say(` URL: ${f.url ?? NO_URL}`);
466
+ if (f.pending)
467
+ ctx.say(` then ${f.pending.id}${f.pending.version ? ` (${f.pending.version})` : ""}: ${f.pending.status}…`);
468
+ }
469
+ ctx.say(`Next: ${next}`);
470
+ return 0;
471
+ }
472
+ const pick = (d) => ({
473
+ id: d.id,
474
+ environment: d.environment,
475
+ status: d.status,
476
+ version: d.version,
477
+ source: d.source ?? null,
478
+ created_at: d.created_at ?? null,
479
+ updated_at: d.updated_at ?? null,
480
+ });
481
+ /** --since as seconds: 30s, 15m, 1h, 2d. */
482
+ export function sinceSeconds(since) {
483
+ const m = since.match(/^(\d+)(s|m|h|d)$/);
484
+ if (!m)
485
+ return undefined;
486
+ const n = Number(m[1]);
487
+ return n * { s: 1, m: 60, h: 3600, d: 86400 }[m[2]];
488
+ }
489
+ /**
490
+ * `flow logs`: the hosted agent's log lines, from GET /v1/hosting/logs?environment=&since=&after=
491
+ * → { lines: [{ time, severity, text }], next } (next: the cursor for the lines after these).
492
+ * A Flow without that route says so and exits 1.
493
+ */
494
+ async function logs(ctx, environment, since, followLines) {
495
+ const seconds = sinceSeconds(since);
496
+ if (seconds === undefined || seconds <= 0 || seconds > 30 * 86400)
497
+ throw new Usage(`--since is a duration such as 30s, 15m, 1h or 2d (at most 30d).\n\n${LOGS_USAGE}`);
498
+ const from = new Date(ctx.now() - seconds * 1000).toISOString();
499
+ let after;
500
+ let polls = 0;
501
+ for (;;) {
502
+ const query = new URLSearchParams({ environment, ...(after ? { after } : { since: from }) });
503
+ let page;
504
+ try {
505
+ page = await ctx.api("GET", `/v1/hosting/logs?${query}`);
506
+ }
507
+ catch (error) {
508
+ if (error instanceof Refused && error.missing) {
509
+ throw new NotAvailable(`Logs are not available on this Flow yet: its cloud has no logs route for a CI key. The dashboard shows the agent's deploys; ${RUN} deploy status shows what is live.`);
510
+ }
511
+ throw error;
512
+ }
513
+ for (const line of page.lines ?? []) {
514
+ if (ctx.json)
515
+ console.log(JSON.stringify({ time: line.time, severity: line.severity ?? null, text: line.text }));
516
+ else
517
+ console.log(`${line.time} ${(line.severity ?? "").padEnd(7)} ${line.text}`);
518
+ }
519
+ if (page.next)
520
+ after = page.next;
521
+ polls++;
522
+ if (!followLines || (ctx.maxPolls !== undefined && polls >= ctx.maxPolls))
523
+ return 0;
524
+ await new Promise((r) => setTimeout(r, ctx.pollMs));
525
+ }
526
+ }
527
+ function gitVersion(dir) {
528
+ try {
529
+ return execFileSync("git", ["-C", dir, "rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || undefined;
530
+ }
531
+ catch {
532
+ return undefined;
533
+ }
534
+ }
535
+ const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
package/docs/agents.md CHANGED
@@ -190,4 +190,41 @@ version in `package.json` for production.
190
190
 
191
191
  ## Host it
192
192
 
193
- Coming soon: `npx @flow-engineer/flow deploy`, to keep the agent answering while the laptop sleeps.
193
+ `npx @flow-engineer/flow deploy` puts the agent on Flow's hosting, so it answers while the laptop
194
+ sleeps. It needs `package.json`, `package-lock.json` and `src/agent.ts`, whose `buildAgent` passes
195
+ `options.host` on to `createAgent`, and the agent's CI key in `FLOW_FRAMEWORK_CLOUD_KEY` (the
196
+ environment, else `.env`; `flow setup` writes it).
197
+
198
+ ```sh
199
+ npx @flow-engineer/flow deploy secrets --from-env .env [--env staging|production] [--json]
200
+ npx @flow-engineer/flow deploy [--env staging|production] [--version <v>] [--json]
201
+ npx @flow-engineer/flow deploy promote [--json]
202
+ npx @flow-engineer/flow deploy status [--env staging|production] [--json]
203
+ npx @flow-engineer/flow logs [--env staging|production] [--since 1h] [--follow] [--json]
204
+ npx @flow-engineer/flow deploy secret NAME [--env staging|production] < value.txt
205
+ ```
206
+
207
+ - `deploy` packs the folder (without `node_modules`, `.git`, `.env*` and `.flow`, at most 50 MB),
208
+ uploads it, and follows the build until it is live, refused or failed. The default is staging;
209
+ production refuses an agent that ignores `host` or brings its own database, and the build
210
+ refuses an agent on a newer framework than Flow hosts yet. `--json` prints
211
+ `{ ok, deploy: { id, environment, status, version, findings, error }, url, next }` on stdout
212
+ (`url` is null when the cloud does not say it yet; the human output then says "URL: not available
213
+ on this Flow yet"); progress goes to stderr. The deploy's id is kept in `.flow/deploys.json`.
214
+ - `promote` moves staging's live version to production, with no new build.
215
+ - `status` shows, per environment (both unless `--env`), the deploy serving it (`status`, `version`,
216
+ `id`, `updated_at`), its URL and a newer one still building. `--json` prints
217
+ `{ ok, environments: { staging: { deploy, pending, url, from } | null, production: ... }, next }`;
218
+ `from` is `list` (the cloud's list) or `this_folder` (the last deploy from `.flow/deploys.json`, on
219
+ a Flow whose cloud does not list deploys to a CI key yet).
220
+ - `logs` prints the hosted agent's log lines from `--since` (30s, 15m, 1h, 2d; default 1h), and with
221
+ `--follow` keeps printing new ones; `--json` is one `{ time, severity, text }` per line. On a Flow
222
+ whose cloud has no logs route yet it says so: `error.code` `not_available`, exit 1.
223
+ - `secrets --from-env` sends each non-empty value in the file as a secret, never the CI key, the
224
+ cloud's address or a name Flow sets itself (`FLOW_FRAMEWORK_TRACES_*`, `FLOW_STORE_*`, `PORT`, ...);
225
+ `--json` prints `{ ok, environment, saved, skipped, refused, next }`. `secret NAME` reads one value
226
+ from stdin. Names only are printed, never values; a secret takes effect on the next deploy.
227
+ - Exit codes: 0 live or saved, 1 refused, failed or not reachable, 2 a usage problem (no CI key, a
228
+ missing file, a bad name). On an error, `--json` prints `{ ok: false, error: { code, message }, next }`.
229
+
230
+ Run each answer's `next`. Promote, or deploy to production, only when the owner says so.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-engineer/flow",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "An open-source runtime for chat agents that feel like a person texting back.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {