@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.
- package/README.md +1 -1
- package/coding-agents/skill.md +14 -5
- package/dist/src/business-events/engine.d.ts +316 -0
- package/dist/src/business-events/engine.js +547 -0
- package/dist/src/business-events/index.d.ts +5 -0
- package/dist/src/business-events/index.js +5 -0
- package/dist/src/business-events/phrasing.d.ts +13 -0
- package/dist/src/business-events/phrasing.js +33 -0
- package/dist/src/business-events/quiet.d.ts +8 -0
- package/dist/src/business-events/quiet.js +25 -0
- package/dist/src/business-events/signature.d.ts +8 -0
- package/dist/src/business-events/signature.js +36 -0
- package/dist/src/business-events/testlist.d.ts +15 -0
- package/dist/src/business-events/testlist.js +24 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/testing/src/business.js +3 -3
- package/dist/testing/src/deploy.d.ts +4 -2
- package/dist/testing/src/deploy.js +138 -33
- package/docs/agents.md +34 -9
- package/docs/events.md +243 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
@@ -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 {
|
|
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"] =
|
|
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]
|
|
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]
|
|
5
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
305
|
-
const
|
|
306
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
+
out.saved.push(name);
|
|
395
|
+
known[name] = hash;
|
|
396
|
+
if (!onlyChanged)
|
|
397
|
+
ctx.say(` ${name} set`);
|
|
332
398
|
}
|
|
333
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
npx @flow-engineer/flow deploy
|
|
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
|
-
|
|
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`
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|