@flow-engineer/flow 0.3.4 → 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";
@@ -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;
@@ -1,6 +1,6 @@
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";
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
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.";
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
4
  /** Names the host sets on the agent's service itself, which it refuses as secrets. */
5
5
  export declare const HOST_NAMES: RegExp;
6
6
  /**
@@ -32,3 +32,5 @@ export declare function deployCli(argv: string[], env?: NodeJS.ProcessEnv, optio
32
32
  export declare function logsCli(argv: string[], env?: NodeJS.ProcessEnv, options?: DeployOptions): Promise<number>;
33
33
  /** --since as seconds: 30s, 15m, 1h, 2d. */
34
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;
@@ -1,8 +1,9 @@
1
1
  // `flow deploy`: puts the agent in this folder on Flow's hosting, through the cloud (observe's
2
2
  // /v1/hosting routes) with the agent's CI key. Node's own modules only.
3
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
4
+ // flow deploy [--env staging|production] [--dir .] [--version <v>] [--no-secrets] [--json]
5
+ // pack, send .env's new or changed values as secrets, upload, build, go live
6
+ // flow deploy promote [--no-secrets] [--json] staging's live version to production
6
7
  // flow deploy status [--env staging|production] [--json] what is live, its version, when, its URL
7
8
  // flow logs [--env staging|production] [--since 1h] [--follow] [--json] the hosted agent's log lines
8
9
  // flow deploy secret <NAME> [--env staging|production] [--json] a secret, its value read from stdin
@@ -12,13 +13,14 @@
12
13
  // (.flow); its package-lock.json is installed with `npm ci` in the build, and the build runs
13
14
  // hosting's lint (its findings are printed here). Nothing here prints a key or a secret's value.
14
15
  import { execFileSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
15
17
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
18
  import { tmpdir } from "node:os";
17
19
  import { join, resolve } from "node:path";
18
20
  import { parseArgs } from "node:util";
19
21
  import { RUN } from "../../src/names.js";
20
22
  const DEFAULT_CLOUD = "https://flow.engineer/observe";
21
- export const DEPLOY_LINE = ` flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--json]
23
+ export const DEPLOY_LINE = ` flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--no-secrets] [--json]
22
24
  Put this agent on Flow's hosting, so it answers while this machine
23
25
  sleeps (flow deploy --help: status, promote, secret, secrets)
24
26
  flow logs [--env staging|production] [--since 1h] [--follow] [--json]
@@ -32,23 +34,34 @@ export const LOGS_USAGE = `Usage:
32
34
  --json One JSON object per line on stdout: { time, severity, text }.
33
35
  --dir, --cloud, FLOW_FRAMEWORK_CLOUD_KEY: as for flow deploy (flow deploy --help).`;
34
36
  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 [--env staging|production] [--dir <folder>] [--version <v>] [--no-secrets] [--json]
38
+ one command, first deploy included: sends the
39
+ .env's new or changed values as secrets, then
40
+ packs, builds and follows it to live
41
+ flow deploy promote [--no-secrets] [--json] staging's live version to production (the
42
+ .env's new or changed values sent there first)
37
43
  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
44
+ flow deploy secret <NAME> [--env staging|production] one secret, the value on stdin
39
45
  flow deploy secrets --from-env [<file>] [--env staging|production]
40
- every value in <file> (default: the agent's .env)
46
+ every value in <file> now (default: the agent's .env);
47
+ flow deploy does this itself
41
48
 
42
49
  --env Where (default: staging). Production takes only an agent whose buildAgent passes
43
50
  options.host on to createAgent, with no database of its own.
44
51
  --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).
52
+ --version A label for this version (default: the folder's git commit, if any, with +dirty
53
+ when the folder has uncommitted changes).
54
+ --no-secrets Deploy without sending the .env's values as secrets.
46
55
  --cloud The cloud's address (default: FLOW_FRAMEWORK_CLOUD, else ${DEFAULT_CLOUD}).
47
56
  --from-env With secrets: send each non-empty value in the file as a secret (never the CI key,
48
57
  the cloud's address, or a name Flow sets itself, such as the
49
58
  FLOW_FRAMEWORK_TRACES_* that flow link writes). Prints names only.
50
59
  --json Print one JSON answer on stdout (progress goes to stderr).
51
60
 
61
+ flow deploy (and deploy promote, for production) sends each non-empty .env value as a secret (the
62
+ same names as secrets --from-env sends) before the service starts, and later only the new or changed ones: .flow/secrets.json keeps a
63
+ hash per name, never a value. It prints names only.
64
+
52
65
  status shows both environments unless --env names one. The last deploy this folder made is kept
53
66
  in .flow/deploys.json (never packed).
54
67
 
@@ -144,6 +157,7 @@ async function cloudCli(top, argv, env, options) {
144
157
  version: { type: "string" },
145
158
  cloud: { type: "string" },
146
159
  "from-env": { type: "boolean" },
160
+ "no-secrets": { type: "boolean" },
147
161
  since: { type: "string" },
148
162
  follow: { type: "boolean", short: "f" },
149
163
  json: { type: "boolean" },
@@ -212,9 +226,11 @@ async function cloudCli(top, argv, env, options) {
212
226
  }
213
227
  return answer;
214
228
  };
215
- const ctx = { api, say, json, dir, pollMs: options.pollMs ?? 3000, maxPolls: options.maxPolls, now: options.now ?? Date.now };
229
+ const ctx = { api, say, json, dir, pollMs: options.pollMs ?? 3000, maxPolls: options.maxPolls, now: options.now ?? Date.now, keyId: createHash("sha256").update(key).digest("hex").slice(0, 16) };
216
230
  try {
217
231
  const [name, extra] = args;
232
+ if (values["no-secrets"] && command !== undefined && command !== "promote")
233
+ throw new Usage(`--no-secrets goes with flow deploy and deploy promote\n\n${usage}`);
218
234
  if (command === "logs") {
219
235
  if (name !== undefined)
220
236
  throw new Usage(`flow logs takes no arguments.\n\n${usage}`);
@@ -223,7 +239,7 @@ async function cloudCli(top, argv, env, options) {
223
239
  if (extra !== undefined || (name !== undefined && command !== "secret" && command !== "secrets"))
224
240
  throw new Usage(`Too many arguments.\n\n${usage}`);
225
241
  if (command === "promote")
226
- return await promote(ctx);
242
+ return await promote(ctx, !values["no-secrets"]);
227
243
  if (command === "status")
228
244
  return await status(ctx, given ? [given] : ["staging", "production"]);
229
245
  if (command === "secret")
@@ -235,7 +251,7 @@ async function cloudCli(top, argv, env, options) {
235
251
  }
236
252
  if (command !== undefined)
237
253
  throw new Usage(`Unknown command: deploy ${command}\n\n${usage}`);
238
- return await deploy(ctx, dir, environment, values.version);
254
+ return await deploy(ctx, dir, environment, values.version, !values["no-secrets"]);
239
255
  }
240
256
  catch (error) {
241
257
  if (error instanceof Usage)
@@ -247,12 +263,16 @@ async function cloudCli(top, argv, env, options) {
247
263
  return fail("network", `Could not reach ${cloud}: ${error.message}`, 1);
248
264
  }
249
265
  }
250
- async function deploy(ctx, dir, env, version) {
266
+ async function deploy(ctx, dir, env, version, withSecrets) {
251
267
  for (const file of ["package.json", "package-lock.json", "src/agent.ts"]) {
252
268
  if (!existsSync(join(dir, file)))
253
269
  throw new Usage(`${join(dir, file)} is missing: deploy the agent's folder (npm install makes package-lock.json).`);
254
270
  }
255
- version ??= gitVersion(dir);
271
+ if (version === undefined) {
272
+ version = gitVersion(dir);
273
+ if (version?.endsWith("+dirty"))
274
+ console.error("Warning: deploying uncommitted changes; commit first so the version names what runs.");
275
+ }
256
276
  const tmp = mkdtempSync(join(tmpdir(), "flow-deploy-"));
257
277
  try {
258
278
  const bundle = join(tmp, "agent.tgz");
@@ -262,6 +282,10 @@ async function deploy(ctx, dir, env, version) {
262
282
  throw new Usage(`The packed folder is ${mb(size)}, over ${mb(MAX_BUNDLE)}. Leave large files out of it.`);
263
283
  ctx.say(`Packed ${dir} (${mb(size)})${version ? `, version ${version}` : ""}.`);
264
284
  const made = await ctx.api("POST", "/v1/hosting/deploys", { environment: env, ...(version ? { version } : {}) });
285
+ // The agent exists on the host once its first deploy is made, so its secrets go now, before
286
+ // the build starts: the service's first version starts with them.
287
+ if (withSecrets)
288
+ await secretsFirst(ctx, env, "deploy");
265
289
  const put = await fetch(made.upload_url, { method: "PUT", headers: { "content-type": "application/gzip" }, body: readFileSync(bundle) });
266
290
  if (!put.ok)
267
291
  throw new Refused(`the upload failed (${put.status})`, "upload_failed");
@@ -274,7 +298,26 @@ async function deploy(ctx, dir, env, version) {
274
298
  rmSync(tmp, { recursive: true, force: true });
275
299
  }
276
300
  }
277
- async function promote(ctx) {
301
+ /**
302
+ * The .env's new or changed values as secrets for `env`, before what would start without them;
303
+ * a refused one stops it there. The names sent are printed.
304
+ */
305
+ async function secretsFirst(ctx, env, what) {
306
+ const file = join(ctx.dir, ".env");
307
+ if (!existsSync(file))
308
+ return;
309
+ const sent = await sendSecrets(ctx, file, env, true);
310
+ if (sent.refused.length) {
311
+ const one = sent.refused.length === 1;
312
+ throw new Refused(`${sent.refused.map((r) => r.name).join(", ")} could not be saved as ${one ? "a secret" : "secrets"} for ${env} (${sent.refused.map((r) => `${r.name}: ${r.message}`).join("; ")}), so this ${what} did not start. Fix or remove ${one ? "it" : "them"} in .env, then ${RUN} deploy${what === "promote" ? " promote" : ""} again (or add --no-secrets)`, "secret_refused");
313
+ }
314
+ if (sent.saved.length)
315
+ ctx.say(`Sent ${sent.saved.length} secret${sent.saved.length === 1 ? "" : "s"} from .env to ${env}: ${sent.saved.join(", ")}.`);
316
+ }
317
+ async function promote(ctx, withSecrets) {
318
+ // Production keeps secrets of its own: the .env's go there first, so the promoted version has them.
319
+ if (withSecrets)
320
+ await secretsFirst(ctx, "production", "promote");
278
321
  const { deploy } = await ctx.api("POST", "/v1/hosting/promote");
279
322
  remember(ctx.dir, "production", deploy.id);
280
323
  ctx.say(`Promoting staging's ${deploy.version ?? "live version"} to production (${deploy.id}).`);
@@ -296,47 +339,98 @@ async function secret(ctx, name, env, readStdin) {
296
339
  ctx.say(`Next: ${next}`);
297
340
  return 0;
298
341
  }
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.`);
342
+ /** Where flow deploy keeps a hash per secret it sent (never a value), to send only new or changed ones. */
343
+ const SENT = join(".flow", "secrets.json");
344
+ const secretHash = (ctx, env, name, value) => createHash("sha256").update(`${ctx.keyId}\0${env}\0${name}\0${value}`).digest("hex");
345
+ function sentHashes(dir) {
346
+ try {
347
+ const known = JSON.parse(readFileSync(join(dir, SENT), "utf8"));
348
+ return known && typeof known === "object" ? known : {};
349
+ }
350
+ catch {
351
+ return {};
352
+ }
353
+ }
354
+ /**
355
+ * Every non-empty value in a .env file as a secret, one each, the names only printed; with
356
+ * `onlyChanged`, the ones .flow/secrets.json shows were already sent are left alone. Each one saved
357
+ * is recorded there as a hash.
358
+ */
359
+ async function sendSecrets(ctx, file, env, onlyChanged) {
303
360
  const entries = [...parseEnv(readFileSync(file, "utf8"))].filter(([, value]) => value !== "");
304
- const saved = [];
305
- const skipped = [];
306
- const refused = [];
361
+ const out = { saved: [], skipped: [], refused: [], unchanged: [] };
362
+ const all = sentHashes(ctx.dir);
363
+ const scope = `${ctx.keyId}:${env}`;
364
+ const known = { ...(all[scope] ?? {}) };
307
365
  for (const [name, value] of entries) {
308
366
  if (CLI_NAMES.has(name))
309
367
  continue;
310
368
  if (HOST_NAMES.test(name)) {
311
- skipped.push({ name, why: "set by Flow" });
312
- ctx.say(` ${name} skipped (set by Flow)`);
369
+ out.skipped.push({ name, why: "set by Flow" });
370
+ if (!onlyChanged)
371
+ ctx.say(` ${name} skipped (set by Flow)`);
313
372
  continue;
314
373
  }
315
374
  if (!ENV_SECRET_NAME.test(name)) {
316
- skipped.push({ name, why: "not upper case letters, digits and _" });
375
+ out.skipped.push({ name, why: "not upper case letters, digits and _" });
317
376
  ctx.say(` ${name} skipped: a secret's name is upper case letters, digits and _`);
318
377
  continue;
319
378
  }
379
+ const hash = secretHash(ctx, env, name, value);
380
+ if (onlyChanged && known[name] === hash) {
381
+ out.unchanged.push(name);
382
+ continue;
383
+ }
320
384
  try {
321
385
  await ctx.api("PUT", `/v1/hosting/secrets/${name}`, { environment: env, value });
322
386
  }
323
387
  catch (error) {
324
388
  if (!(error instanceof Refused))
325
389
  throw error;
326
- refused.push({ name, message: error.message });
327
- ctx.say(` ${name} refused: ${error.message}`);
390
+ out.refused.push({ name, message: error.message });
391
+ console.error(` ${name} refused: ${error.message}`);
328
392
  continue;
329
393
  }
330
- saved.push(name);
331
- ctx.say(` ${name} set`);
394
+ out.saved.push(name);
395
+ known[name] = hash;
396
+ if (!onlyChanged)
397
+ ctx.say(` ${name} set`);
332
398
  }
333
- const next = `${RUN} deploy${env === "production" ? " --env production" : ""}`;
399
+ if (out.saved.length) {
400
+ try {
401
+ mkdirSync(join(ctx.dir, ".flow"), { recursive: true });
402
+ writeFileSync(join(ctx.dir, SENT), `${JSON.stringify({ ...all, [scope]: known }, null, 2)}\n`);
403
+ }
404
+ catch {
405
+ // only saves sending them again next time
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+ /** `flow deploy secrets --from-env`: every non-empty value in a .env file as a secret now. */
411
+ async function secretsFromEnv(ctx, file, env) {
412
+ if (!existsSync(file))
413
+ throw new Usage(`${file} is missing.`);
414
+ const { saved, skipped, refused } = await sendSecrets(ctx, file, env, false);
415
+ const deployCmd = `${RUN} deploy${env === "production" ? " --env production" : ""}`;
416
+ const neverDeployed = refused.length > 0 && refused.every((r) => /never been deployed/.test(r.message));
417
+ const next = neverDeployed ? `${deployCmd} (it sends the .env's values itself)` : deployCmd;
334
418
  if (ctx.json)
335
419
  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
420
  if (saved.length)
421
+ ctx.say(`Saved ${saved.length} secret${saved.length === 1 ? "" : "s"} for ${env}. They take effect on the next deploy.`);
422
+ if (refused.length) {
423
+ console.error(neverDeployed
424
+ ? `Refused: this agent has not been deployed yet, so ${env} took no secrets. ${deployCmd} deploys it and sends the .env's values as secrets before it starts.`
425
+ : `Refused: ${refused.length} secret${refused.length === 1 ? "" : "s"} not saved (${refused.map((r) => r.name).join(", ")}).`);
426
+ console.error(`Next: ${next}`);
427
+ return 1;
428
+ }
429
+ if (!saved.length)
430
+ ctx.say(`No secrets saved from ${file}.`);
431
+ else
338
432
  ctx.say(`Next: ${next}`);
339
- return refused.length ? 1 : 0;
433
+ return 0;
340
434
  }
341
435
  /** Follows a deploy until it is live, refused or failed, printing each change. */
342
436
  async function follow(ctx, id) {
@@ -524,12 +618,23 @@ async function logs(ctx, environment, since, followLines) {
524
618
  await new Promise((r) => setTimeout(r, ctx.pollMs));
525
619
  }
526
620
  }
527
- function gitVersion(dir) {
621
+ /** The folder's git commit, with +dirty when the folder has changes not committed (what is packed is the folder as it is). */
622
+ export function gitVersion(dir) {
623
+ let sha;
528
624
  try {
529
- return execFileSync("git", ["-C", dir, "rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || undefined;
625
+ sha = execFileSync("git", ["-C", dir, "rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
530
626
  }
531
627
  catch {
532
628
  return undefined;
533
629
  }
630
+ if (!sha)
631
+ return undefined;
632
+ try {
633
+ const changes = execFileSync("git", ["-C", dir, "status", "--porcelain", "--", "."], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
634
+ return changes ? `${sha}+dirty` : sha;
635
+ }
636
+ catch {
637
+ return sha;
638
+ }
534
639
  }
535
640
  const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
package/docs/agents.md CHANGED
@@ -173,6 +173,13 @@ preview card's title becomes the phone's notification instead of the message's t
173
173
  action use `link` (`agent.nudge({ to, text, link: { label, url } })`, or the agent's send with
174
174
  `link`), not a URL in the text.
175
175
 
176
+ When the business's backend should tell people what happened (an order shipped, an item is back),
177
+ mount `flowEvents` instead of writing a route: one signed `POST /v1/events`, each event sent by its
178
+ delivery level (critical at once, standard after quiet hours, optional merged or dropped), with the
179
+ agent's own wording checked against the template's values. The envelope, signing, answers and a
180
+ mounting sample are in `docs/events.md` in the package
181
+ (https://github.com/flow-engineer/flow/blob/main/docs/events.md).
182
+
176
183
  ## Shared test numbers and privacy
177
184
 
178
185
  The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an
@@ -195,23 +202,40 @@ sleeps. It needs `package.json`, `package-lock.json` and `src/agent.ts`, whose `
195
202
  `options.host` on to `createAgent`, and the agent's CI key in `FLOW_FRAMEWORK_CLOUD_KEY` (the
196
203
  environment, else `.env`; `flow setup` writes it).
197
204
 
205
+ One command deploys, the first time included:
206
+
207
+ ```sh
208
+ npx @flow-engineer/flow deploy
209
+ ```
210
+
211
+ It sends the `.env`'s values as the agent's secrets, then builds and follows it to live. The rest:
212
+
198
213
  ```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]
214
+ npx @flow-engineer/flow deploy [--env staging|production] [--version <v>] [--no-secrets] [--json]
215
+ npx @flow-engineer/flow deploy promote [--no-secrets] [--json]
202
216
  npx @flow-engineer/flow deploy status [--env staging|production] [--json]
203
217
  npx @flow-engineer/flow logs [--env staging|production] [--since 1h] [--follow] [--json]
204
218
  npx @flow-engineer/flow deploy secret NAME [--env staging|production] < value.txt
219
+ npx @flow-engineer/flow deploy secrets --from-env [<file>] [--env staging|production] [--json]
205
220
  ```
206
221
 
222
+ - `deploy` first sends each non-empty value in the agent's `.env` as a secret for that
223
+ environment, before the service starts, so the first live version has its keys: never the CI
224
+ key, the cloud's address or a name Flow sets itself (`FLOW_FRAMEWORK_TRACES_*`, `FLOW_STORE_*`,
225
+ `PORT`, ...). Later deploys send only the new or changed ones (`.flow/secrets.json` keeps a hash
226
+ per name, never a value). It prints the names it sent, never values; `--no-secrets` sends none.
227
+ A secret the host refuses stops the deploy before it starts, naming it.
207
228
  - `deploy` packs the folder (without `node_modules`, `.git`, `.env*` and `.flow`, at most 50 MB),
208
229
  uploads it, and follows the build until it is live, refused or failed. The default is staging;
209
230
  production refuses an agent that ignores `host` or brings its own database, and the build
210
231
  refuses an agent on a newer framework than Flow hosts yet. `--json` prints
211
232
  `{ ok, deploy: { id, environment, status, version, findings, error }, url, next }` on stdout
212
233
  (`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.
234
+ on this Flow yet"); progress goes to stderr. The deploy's id is kept in `.flow/deploys.json`. The
235
+ version is the folder's git commit, `<sha>+dirty` (with a warning) when the folder has
236
+ uncommitted changes, since what is packed is the folder as it is; `--version` names it yourself.
237
+ - `promote` moves staging's live version to production, with no new build. Production keeps its
238
+ own secrets: promote sends the `.env`'s new or changed values there first (`--no-secrets` sends none).
215
239
  - `status` shows, per environment (both unless `--env`), the deploy serving it (`status`, `version`,
216
240
  `id`, `updated_at`), its URL and a newer one still building. `--json` prints
217
241
  `{ ok, environments: { staging: { deploy, pending, url, from } | null, production: ... }, next }`;
@@ -220,10 +244,11 @@ npx @flow-engineer/flow deploy secret NAME [--env staging|production] < value.tx
220
244
  - `logs` prints the hosted agent's log lines from `--since` (30s, 15m, 1h, 2d; default 1h), and with
221
245
  `--follow` keeps printing new ones; `--json` is one `{ time, severity, text }` per line. On a Flow
222
246
  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.
247
+ - `secret NAME` (one value from stdin) and `secrets --from-env` (every value in a file, now) are for
248
+ changing a secret by hand; `deploy` already sends the `.env`. `--json` prints
249
+ `{ ok, environment, saved, skipped, refused, next }`. Before the agent's first deploy the host has
250
+ no agent to keep them for: they are refused, exit 1, and `next` is `deploy`. A secret takes effect
251
+ on the next deploy.
227
252
  - Exit codes: 0 live or saved, 1 refused, failed or not reachable, 2 a usage problem (no CI key, a
228
253
  missing file, a bad name). On an error, `--json` prints `{ ok: false, error: { code, message }, next }`.
229
254