@flow-engineer/flow 0.3.3 → 0.4.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,36 @@
1
+ // Flow-Signature on a business's pushed events: `t=<unix seconds>,v1=<hex HMAC-SHA256>` of
2
+ // "t.{t}.{raw body}", with the secret the business was given (whsec_...). t may be at most 5 minutes
3
+ // off. Several v1 values are allowed, so a secret can be rotated without dropping events.
4
+ import { createHmac, timingSafeEqual } from "node:crypto";
5
+ /** How far a signature's `t` may be from now. */
6
+ export const SIGNATURE_TOLERANCE_MS = 5 * 60_000;
7
+ /** The hex HMAC for `t` (unix seconds) and the raw body. */
8
+ export function eventSignature(secret, t, body) {
9
+ return createHmac("sha256", secret).update(`t.${t}.${body}`).digest("hex");
10
+ }
11
+ /** The Flow-Signature header for this body, signed now (ms since the epoch). */
12
+ export function signEvent(secret, body, now = Date.now()) {
13
+ const t = Math.floor(now / 1000);
14
+ return `t=${t},v1=${eventSignature(secret, t, body)}`;
15
+ }
16
+ /** Whether the header signs this body with this secret, at most 5 minutes from `now` (ms). */
17
+ export function verifyEventSignature(secret, header, body, now = Date.now()) {
18
+ if (!header)
19
+ return false;
20
+ let t = null;
21
+ const v1 = [];
22
+ for (const part of header.split(",")) {
23
+ const [k, v] = part.trim().split("=", 2);
24
+ if (k === "t" && v && /^\d+$/.test(v))
25
+ t = Number(v);
26
+ else if (k === "v1" && v)
27
+ v1.push(v);
28
+ }
29
+ if (t === null || !v1.length || Math.abs(now - t * 1000) > SIGNATURE_TOLERANCE_MS)
30
+ return false;
31
+ const want = Buffer.from(eventSignature(secret, t, body), "hex");
32
+ return v1.some((s) => {
33
+ const got = Buffer.from(s, "hex");
34
+ return got.length === want.length && timingSafeEqual(got, want);
35
+ });
36
+ }
@@ -0,0 +1,15 @@
1
+ export interface EventTestList {
2
+ /** Whether a business-started send may go to this address. */
3
+ allows(address: string): boolean;
4
+ /** Whether a list applies at all (false on production). */
5
+ enforced: boolean;
6
+ }
7
+ /** The numbers in a comma-separated list: spaces dropped, anything not E.164 left out. */
8
+ export declare function parseTestNumbers(value: string | undefined): string[];
9
+ export declare function eventTestList(options?: {
10
+ env?: Record<string, string | undefined>;
11
+ variable?: string;
12
+ numbers?: string[];
13
+ testing?: boolean;
14
+ madeUp?: RegExp;
15
+ }): EventTestList;
@@ -0,0 +1,24 @@
1
+ // The test list: off production, a send the business starts (an event's message, a nudge, a held
2
+ // send when it comes due) goes only to addresses on it, so a staging copy of real data never
3
+ // reaches a real customer. Replies to people who wrote first are never checked here.
4
+ //
5
+ // - FLOW_ENVIRONMENT=production: no list, everyone.
6
+ // - Otherwise: the numbers in `variable` (default FLOW_TEST_NUMBERS; comma-separated E.164, spaces
7
+ // fine, anything else ignored), or `numbers`; none sends nothing (fail closed).
8
+ // - `testing` adds the addresses `madeUp` matches (a test run's made-up numbers).
9
+ const E164 = /^\+\d{8,15}$/;
10
+ /** The numbers in a comma-separated list: spaces dropped, anything not E.164 left out. */
11
+ export function parseTestNumbers(value) {
12
+ return (value ?? "")
13
+ .split(",")
14
+ .map((n) => n.replace(/\s+/g, ""))
15
+ .filter((n) => E164.test(n));
16
+ }
17
+ export function eventTestList(options = {}) {
18
+ const env = options.env ?? process.env;
19
+ if (env.FLOW_ENVIRONMENT === "production")
20
+ return { enforced: false, allows: () => true };
21
+ const listed = new Set(options.numbers ? parseTestNumbers(options.numbers.join(",")) : parseTestNumbers(env[options.variable ?? "FLOW_TEST_NUMBERS"]));
22
+ const madeUp = options.madeUp;
23
+ return { enforced: true, allows: (address) => listed.has(address) || (options.testing === true && madeUp !== undefined && madeUp.test(address)) };
24
+ }
@@ -13,3 +13,4 @@ export type { TracesOptions } from "./traces.ts";
13
13
  export { tool, toSpec, type ChatAuth, type ChatAuthContext, type CredentialProvider, type Credentials, type SignIn, type Tool, type ToolContext, type ToolHints } from "./tools.ts";
14
14
  export type { Attachment, ChannelInfo, ChatMessage, Deliver, Incoming, ListRow, Media, Model, ModelRequest, ModelResponse, Outbound, PersonMessage, Template, TemplateButton, TemplateDefinition, ToolCall, ToolSpec, Transcribe } from "./types.ts";
15
15
  export { z } from "zod";
16
+ export * from "./business-events/index.ts";
package/dist/src/index.js CHANGED
@@ -10,3 +10,4 @@ export { flowModel, flowModelURL, openAICompatible } from "./model.js";
10
10
  export { Store } from "./store.js";
11
11
  export { tool, toSpec } from "./tools.js";
12
12
  export { z } from "zod";
13
+ export * from "./business-events/index.js";
@@ -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"}):
@@ -3,7 +3,8 @@
3
3
  // cloud's commands move and with copies of the agent's own channels, so its sends go through what
4
4
  // the agent wraps around them and come to the cloud with the run's time. The person writes through
5
5
  // agent.message(), as a provider's webhook would, so they are a person the agent can nudge.
6
- import { createHmac, randomBytes } from "node:crypto";
6
+ import { randomBytes } from "node:crypto";
7
+ import { signEvent } from "../../src/index.js";
7
8
  /** One minute: the clock moves this far at a time, and what came due runs at each step. */
8
9
  export const STEP_MS = 60_000;
9
10
  export function businessRuns(ctx) {
@@ -56,11 +57,10 @@ export function businessRuns(ctx) {
56
57
  };
57
58
  const event = async (run, command) => {
58
59
  const body = JSON.stringify(command.envelope);
59
- const t = Math.floor(run.clock / 1000);
60
60
  const secret = command.signature === "bad" ? `whsec_test_${randomBytes(18).toString("hex")}` : run.secret;
61
61
  const headers = { "content-type": "application/json", "idempotency-key": command.envelope.id };
62
62
  if (command.signature !== "missing")
63
- headers["flow-signature"] = `t=${t},v1=${createHmac("sha256", secret).update(`t.${t}.${body}`).digest("hex")}`;
63
+ headers["flow-signature"] = signEvent(secret, body, run.clock);
64
64
  const response = await run.agent.fetch(new Request(`http://agent.test${command.path ?? "/v1/events"}`, { method: "POST", headers, body }));
65
65
  const text = await response.text();
66
66
  let parsed = text;
@@ -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,36 @@
1
+ export declare const DEPLOY_LINE = " flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--no-secrets] [--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>] [--no-secrets] [--json]\n one command, first deploy included: sends the\n .env's new or changed values as secrets, then\n packs, builds and follows it to live\n flow deploy promote [--no-secrets] [--json] staging's live version to production (the\n .env's new or changed values sent there first)\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] one secret, the value on stdin\n flow deploy secrets --from-env [<file>] [--env staging|production]\n every value in <file> now (default: the agent's .env);\n flow deploy does this itself\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, with +dirty\n when the folder has uncommitted changes).\n --no-secrets Deploy without sending the .env's values as secrets.\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\nflow deploy (and deploy promote, for production) sends each non-empty .env value as a secret (the\nsame names as secrets --from-env sends) before the service starts, and later only the new or changed ones: .flow/secrets.json keeps a\nhash per name, never a value. It prints names only.\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;
35
+ /** The folder's git commit, with +dirty when the folder has changes not committed (what is packed is the folder as it is). */
36
+ export declare function gitVersion(dir: string): string | undefined;