@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.
- package/README.md +2 -2
- package/coding-agents/skill.md +43 -1
- 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/account.js +4 -0
- package/dist/testing/src/business.js +3 -3
- package/dist/testing/src/cli.js +8 -2
- package/dist/testing/src/deploy.d.ts +36 -0
- package/dist/testing/src/deploy.js +640 -0
- package/docs/agents.md +63 -1
- package/docs/events.md +243 -0
- package/package.json +2 -1
|
@@ -0,0 +1,640 @@
|
|
|
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>] [--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
|
|
7
|
+
// flow deploy status [--env staging|production] [--json] what is live, its version, when, its URL
|
|
8
|
+
// flow logs [--env staging|production] [--since 1h] [--follow] [--json] the hosted agent's log lines
|
|
9
|
+
// flow deploy secret <NAME> [--env staging|production] [--json] a secret, its value read from stdin
|
|
10
|
+
// flow deploy secrets --from-env [<file>] [--env staging|production] [--json] every value in the agent's .env
|
|
11
|
+
//
|
|
12
|
+
// The folder is packed without node_modules, .git, .env files and the framework's local results
|
|
13
|
+
// (.flow); its package-lock.json is installed with `npm ci` in the build, and the build runs
|
|
14
|
+
// hosting's lint (its findings are printed here). Nothing here prints a key or a secret's value.
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { parseArgs } from "node:util";
|
|
21
|
+
import { RUN } from "../../src/names.js";
|
|
22
|
+
const DEFAULT_CLOUD = "https://flow.engineer/observe";
|
|
23
|
+
export const DEPLOY_LINE = ` flow deploy [--env staging|production] [--dir <folder>] [--version <v>] [--no-secrets] [--json]
|
|
24
|
+
Put this agent on Flow's hosting, so it answers while this machine
|
|
25
|
+
sleeps (flow deploy --help: status, promote, secret, secrets)
|
|
26
|
+
flow logs [--env staging|production] [--since 1h] [--follow] [--json]
|
|
27
|
+
The hosted agent's recent log lines`;
|
|
28
|
+
export const LOGS_USAGE = `Usage:
|
|
29
|
+
flow logs [--env staging|production] [--since 1h] [--follow] [--json]
|
|
30
|
+
|
|
31
|
+
--env Which (default: staging).
|
|
32
|
+
--since How far back: 30s, 15m, 1h (the default) or 2d, at most 30d.
|
|
33
|
+
--follow Keep printing new lines until Ctrl-C.
|
|
34
|
+
--json One JSON object per line on stdout: { time, severity, text }.
|
|
35
|
+
--dir, --cloud, FLOW_FRAMEWORK_CLOUD_KEY: as for flow deploy (flow deploy --help).`;
|
|
36
|
+
export const DEPLOY_USAGE = `Usage:
|
|
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)
|
|
43
|
+
flow deploy status [--env staging|production] [--json] what is live, its version, when, and its URL
|
|
44
|
+
flow deploy secret <NAME> [--env staging|production] one secret, the value on stdin
|
|
45
|
+
flow deploy secrets --from-env [<file>] [--env staging|production]
|
|
46
|
+
every value in <file> now (default: the agent's .env);
|
|
47
|
+
flow deploy does this itself
|
|
48
|
+
|
|
49
|
+
--env Where (default: staging). Production takes only an agent whose buildAgent passes
|
|
50
|
+
options.host on to createAgent, with no database of its own.
|
|
51
|
+
--dir The agent's folder (default: .): package.json, package-lock.json, src/agent.ts.
|
|
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.
|
|
55
|
+
--cloud The cloud's address (default: FLOW_FRAMEWORK_CLOUD, else ${DEFAULT_CLOUD}).
|
|
56
|
+
--from-env With secrets: send each non-empty value in the file as a secret (never the CI key,
|
|
57
|
+
the cloud's address, or a name Flow sets itself, such as the
|
|
58
|
+
FLOW_FRAMEWORK_TRACES_* that flow link writes). Prints names only.
|
|
59
|
+
--json Print one JSON answer on stdout (progress goes to stderr).
|
|
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
|
+
|
|
65
|
+
status shows both environments unless --env names one. The last deploy this folder made is kept
|
|
66
|
+
in .flow/deploys.json (never packed).
|
|
67
|
+
|
|
68
|
+
Environment (or the agent folder's .env):
|
|
69
|
+
FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key (oci_...): flow setup writes it, or make one on
|
|
70
|
+
your agent's Developers page. CHINWAG_CLOUD_KEY, its older name, works too.`;
|
|
71
|
+
/** The CLI's own settings in a .env: never sent as secrets. */
|
|
72
|
+
const CLI_NAMES = new Set(["FLOW_FRAMEWORK_CLOUD_KEY", "CHINWAG_CLOUD_KEY", "FLOW_FRAMEWORK_CLOUD", "CHINWAG_CLOUD"]);
|
|
73
|
+
/** Names the host sets on the agent's service itself, which it refuses as secrets. */
|
|
74
|
+
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_)/;
|
|
75
|
+
const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
76
|
+
/** A name `secrets --from-env` sends: two characters at least, as the host takes them. */
|
|
77
|
+
const ENV_SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
78
|
+
/**
|
|
79
|
+
* Left out of the packed folder: dependencies (the build runs npm ci), git, every .env, and the
|
|
80
|
+
* framework's local results (.flow, .chinwag before the rename), which the hosted agent never reads.
|
|
81
|
+
*/
|
|
82
|
+
export const PACK_EXCLUDES = ["node_modules", ".git", ".env", ".env.*", ".DS_Store", ".flow", ".chinwag"];
|
|
83
|
+
export const MAX_BUNDLE = 50 * 1024 * 1024;
|
|
84
|
+
const GIVE_UP_MS = 20 * 60 * 1000;
|
|
85
|
+
/**
|
|
86
|
+
* A .env file's names and values: NAME=value lines, `export ` allowed, values in single or double
|
|
87
|
+
* quotes taken as written (double quotes understand \n), an unquoted value up to a ` #` comment.
|
|
88
|
+
*/
|
|
89
|
+
export function parseEnv(text) {
|
|
90
|
+
const out = new Map();
|
|
91
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
92
|
+
const line = raw.trim();
|
|
93
|
+
if (!line || line.startsWith("#"))
|
|
94
|
+
continue;
|
|
95
|
+
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
96
|
+
if (!m)
|
|
97
|
+
continue;
|
|
98
|
+
let value = m[2];
|
|
99
|
+
const quote = value[0];
|
|
100
|
+
if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0) {
|
|
101
|
+
value = value.slice(1, value.lastIndexOf(quote));
|
|
102
|
+
if (quote === '"')
|
|
103
|
+
value = value.replace(/\\n/g, "\n");
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
value = value.replace(/\s+#.*$/, "").trim();
|
|
107
|
+
}
|
|
108
|
+
out.set(m[1], value);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function readEnvFile(file) {
|
|
113
|
+
try {
|
|
114
|
+
return parseEnv(readFileSync(file, "utf8"));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return new Map();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The agent's folder as a .tgz at `bundle`, without PACK_EXCLUDES. */
|
|
121
|
+
export function pack(dir, bundle) {
|
|
122
|
+
execFileSync("tar", ["-czf", bundle, ...PACK_EXCLUDES.map((p) => `--exclude=${p}`), "-C", dir, "."], { stdio: ["ignore", "ignore", "inherit"] });
|
|
123
|
+
}
|
|
124
|
+
class Refused extends Error {
|
|
125
|
+
code;
|
|
126
|
+
/** The cloud has no such route (an older Flow): a plain 404 or 405, not one of its JSON errors. */
|
|
127
|
+
missing;
|
|
128
|
+
constructor(message, code, missing = false) {
|
|
129
|
+
super(message);
|
|
130
|
+
this.code = code;
|
|
131
|
+
this.missing = missing;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
class Usage extends Error {
|
|
135
|
+
}
|
|
136
|
+
/** What this Flow's cloud cannot do yet; the CLI side is ready for when it can. */
|
|
137
|
+
class NotAvailable extends Error {
|
|
138
|
+
}
|
|
139
|
+
/** `flow deploy ...`. */
|
|
140
|
+
export function deployCli(argv, env = process.env, options = {}) {
|
|
141
|
+
return cloudCli("deploy", argv, env, options);
|
|
142
|
+
}
|
|
143
|
+
/** `flow logs ...`. */
|
|
144
|
+
export function logsCli(argv, env = process.env, options = {}) {
|
|
145
|
+
return cloudCli("logs", argv, env, options);
|
|
146
|
+
}
|
|
147
|
+
async function cloudCli(top, argv, env, options) {
|
|
148
|
+
const usage = top === "logs" ? LOGS_USAGE : DEPLOY_USAGE;
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = parseArgs({
|
|
152
|
+
args: argv,
|
|
153
|
+
allowPositionals: true,
|
|
154
|
+
options: {
|
|
155
|
+
env: { type: "string" },
|
|
156
|
+
dir: { type: "string", default: "." },
|
|
157
|
+
version: { type: "string" },
|
|
158
|
+
cloud: { type: "string" },
|
|
159
|
+
"from-env": { type: "boolean" },
|
|
160
|
+
"no-secrets": { type: "boolean" },
|
|
161
|
+
since: { type: "string" },
|
|
162
|
+
follow: { type: "boolean", short: "f" },
|
|
163
|
+
json: { type: "boolean" },
|
|
164
|
+
help: { type: "boolean", short: "h" },
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
console.error(`${error.message}\n\n${usage}`);
|
|
170
|
+
return 2;
|
|
171
|
+
}
|
|
172
|
+
const { values, positionals } = parsed;
|
|
173
|
+
if (values.help) {
|
|
174
|
+
console.log(usage);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
const json = Boolean(values.json);
|
|
178
|
+
const say = json ? (line) => console.error(line) : (line) => console.log(line);
|
|
179
|
+
const fail = (code, message, exit, next) => {
|
|
180
|
+
if (json)
|
|
181
|
+
console.log(JSON.stringify({ ok: false, error: { code, message }, next: next ?? null }));
|
|
182
|
+
console.error(next ? `${message}\nNext: ${next}` : message);
|
|
183
|
+
return exit;
|
|
184
|
+
};
|
|
185
|
+
const command = top === "logs" ? "logs" : positionals[0];
|
|
186
|
+
const args = top === "logs" ? positionals : positionals.slice(1);
|
|
187
|
+
const given = values.env;
|
|
188
|
+
if (given !== undefined && given !== "staging" && given !== "production")
|
|
189
|
+
return fail("usage", `--env is staging or production\n\n${usage}`, 2);
|
|
190
|
+
if (command !== "logs" && (values.since !== undefined || values.follow))
|
|
191
|
+
return fail("usage", `--since and --follow go with flow logs\n\n${usage}`, 2);
|
|
192
|
+
const environment = given ?? "staging";
|
|
193
|
+
const dir = resolve(values.dir);
|
|
194
|
+
const dotEnv = readEnvFile(join(dir, ".env"));
|
|
195
|
+
const setting = (...names) => {
|
|
196
|
+
for (const name of names)
|
|
197
|
+
if (env[name])
|
|
198
|
+
return env[name];
|
|
199
|
+
for (const name of names)
|
|
200
|
+
if (dotEnv.get(name))
|
|
201
|
+
return dotEnv.get(name);
|
|
202
|
+
return undefined;
|
|
203
|
+
};
|
|
204
|
+
const key = setting("FLOW_FRAMEWORK_CLOUD_KEY", "CHINWAG_CLOUD_KEY");
|
|
205
|
+
if (!key)
|
|
206
|
+
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`);
|
|
207
|
+
const cloud = (values.cloud || setting("FLOW_FRAMEWORK_CLOUD", "CHINWAG_CLOUD") || DEFAULT_CLOUD).replace(/\/$/, "");
|
|
208
|
+
const api = async (method, path, body) => {
|
|
209
|
+
const res = await fetch(cloud + path, {
|
|
210
|
+
method,
|
|
211
|
+
headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
212
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
213
|
+
});
|
|
214
|
+
const text = await res.text();
|
|
215
|
+
let answer = {};
|
|
216
|
+
try {
|
|
217
|
+
answer = JSON.parse(text);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// not JSON: the status says it
|
|
221
|
+
}
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
const why = answer.error?.message ?? `${res.status} ${res.statusText}`;
|
|
224
|
+
const missing = (res.status === 404 || res.status === 405) && typeof answer.error?.code !== "string";
|
|
225
|
+
throw new Refused(answer.error?.hint ? `${why} (${answer.error.hint})` : why, answer.error?.code ?? "refused", missing);
|
|
226
|
+
}
|
|
227
|
+
return answer;
|
|
228
|
+
};
|
|
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) };
|
|
230
|
+
try {
|
|
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}`);
|
|
234
|
+
if (command === "logs") {
|
|
235
|
+
if (name !== undefined)
|
|
236
|
+
throw new Usage(`flow logs takes no arguments.\n\n${usage}`);
|
|
237
|
+
return await logs(ctx, environment, values.since ?? "1h", Boolean(values.follow));
|
|
238
|
+
}
|
|
239
|
+
if (extra !== undefined || (name !== undefined && command !== "secret" && command !== "secrets"))
|
|
240
|
+
throw new Usage(`Too many arguments.\n\n${usage}`);
|
|
241
|
+
if (command === "promote")
|
|
242
|
+
return await promote(ctx, !values["no-secrets"]);
|
|
243
|
+
if (command === "status")
|
|
244
|
+
return await status(ctx, given ? [given] : ["staging", "production"]);
|
|
245
|
+
if (command === "secret")
|
|
246
|
+
return await secret(ctx, name, environment, options.readStdin ?? (() => readFileSync(0, "utf8")));
|
|
247
|
+
if (command === "secrets") {
|
|
248
|
+
if (!values["from-env"])
|
|
249
|
+
throw new Usage(`secrets takes --from-env [<file>]\n\n${usage}`);
|
|
250
|
+
return await secretsFromEnv(ctx, resolve(name ? name : join(dir, ".env")), environment);
|
|
251
|
+
}
|
|
252
|
+
if (command !== undefined)
|
|
253
|
+
throw new Usage(`Unknown command: deploy ${command}\n\n${usage}`);
|
|
254
|
+
return await deploy(ctx, dir, environment, values.version, !values["no-secrets"]);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (error instanceof Usage)
|
|
258
|
+
return fail("usage", error.message, 2);
|
|
259
|
+
if (error instanceof NotAvailable)
|
|
260
|
+
return fail("not_available", error.message, 1);
|
|
261
|
+
if (error instanceof Refused)
|
|
262
|
+
return fail(error.code, `Refused: ${error.message}`, 1);
|
|
263
|
+
return fail("network", `Could not reach ${cloud}: ${error.message}`, 1);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function deploy(ctx, dir, env, version, withSecrets) {
|
|
267
|
+
for (const file of ["package.json", "package-lock.json", "src/agent.ts"]) {
|
|
268
|
+
if (!existsSync(join(dir, file)))
|
|
269
|
+
throw new Usage(`${join(dir, file)} is missing: deploy the agent's folder (npm install makes package-lock.json).`);
|
|
270
|
+
}
|
|
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
|
+
}
|
|
276
|
+
const tmp = mkdtempSync(join(tmpdir(), "flow-deploy-"));
|
|
277
|
+
try {
|
|
278
|
+
const bundle = join(tmp, "agent.tgz");
|
|
279
|
+
pack(dir, bundle);
|
|
280
|
+
const size = statSync(bundle).size;
|
|
281
|
+
if (size > MAX_BUNDLE)
|
|
282
|
+
throw new Usage(`The packed folder is ${mb(size)}, over ${mb(MAX_BUNDLE)}. Leave large files out of it.`);
|
|
283
|
+
ctx.say(`Packed ${dir} (${mb(size)})${version ? `, version ${version}` : ""}.`);
|
|
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");
|
|
289
|
+
const put = await fetch(made.upload_url, { method: "PUT", headers: { "content-type": "application/gzip" }, body: readFileSync(bundle) });
|
|
290
|
+
if (!put.ok)
|
|
291
|
+
throw new Refused(`the upload failed (${put.status})`, "upload_failed");
|
|
292
|
+
await ctx.api("POST", `/v1/hosting/deploys/${made.deploy.id}/start`);
|
|
293
|
+
remember(ctx.dir, env, made.deploy.id);
|
|
294
|
+
ctx.say(`Deploy ${made.deploy.id} to ${env}: building.`);
|
|
295
|
+
return await follow(ctx, made.deploy.id);
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
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");
|
|
321
|
+
const { deploy } = await ctx.api("POST", "/v1/hosting/promote");
|
|
322
|
+
remember(ctx.dir, "production", deploy.id);
|
|
323
|
+
ctx.say(`Promoting staging's ${deploy.version ?? "live version"} to production (${deploy.id}).`);
|
|
324
|
+
return await follow(ctx, deploy.id);
|
|
325
|
+
}
|
|
326
|
+
async function secret(ctx, name, env, readStdin) {
|
|
327
|
+
if (!name || !SECRET_NAME.test(name))
|
|
328
|
+
throw new Usage(`A secret's name is upper case letters, digits and _ (like ANTHROPIC_API_KEY).\n\n${DEPLOY_USAGE}`);
|
|
329
|
+
if (process.stdin.isTTY)
|
|
330
|
+
console.error(`Paste ${name}'s value for ${env}, then press Enter and Ctrl-D:`);
|
|
331
|
+
const value = readStdin().replace(/\r?\n$/, "");
|
|
332
|
+
if (!value)
|
|
333
|
+
throw new Usage("No value on stdin.");
|
|
334
|
+
const saved = await ctx.api("PUT", `/v1/hosting/secrets/${name}`, { environment: env, value });
|
|
335
|
+
const next = `${RUN} deploy${env === "production" ? " --env production" : ""}`;
|
|
336
|
+
if (ctx.json)
|
|
337
|
+
console.log(JSON.stringify({ ok: true, secret: { name: saved.name, last4: saved.last4, environment: env }, next }));
|
|
338
|
+
ctx.say(`Saved ${saved.name} (••••${saved.last4}) for ${env}. It takes effect on the next deploy.`);
|
|
339
|
+
ctx.say(`Next: ${next}`);
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
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) {
|
|
360
|
+
const entries = [...parseEnv(readFileSync(file, "utf8"))].filter(([, value]) => value !== "");
|
|
361
|
+
const out = { saved: [], skipped: [], refused: [], unchanged: [] };
|
|
362
|
+
const all = sentHashes(ctx.dir);
|
|
363
|
+
const scope = `${ctx.keyId}:${env}`;
|
|
364
|
+
const known = { ...(all[scope] ?? {}) };
|
|
365
|
+
for (const [name, value] of entries) {
|
|
366
|
+
if (CLI_NAMES.has(name))
|
|
367
|
+
continue;
|
|
368
|
+
if (HOST_NAMES.test(name)) {
|
|
369
|
+
out.skipped.push({ name, why: "set by Flow" });
|
|
370
|
+
if (!onlyChanged)
|
|
371
|
+
ctx.say(` ${name} skipped (set by Flow)`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (!ENV_SECRET_NAME.test(name)) {
|
|
375
|
+
out.skipped.push({ name, why: "not upper case letters, digits and _" });
|
|
376
|
+
ctx.say(` ${name} skipped: a secret's name is upper case letters, digits and _`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const hash = secretHash(ctx, env, name, value);
|
|
380
|
+
if (onlyChanged && known[name] === hash) {
|
|
381
|
+
out.unchanged.push(name);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
await ctx.api("PUT", `/v1/hosting/secrets/${name}`, { environment: env, value });
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
if (!(error instanceof Refused))
|
|
389
|
+
throw error;
|
|
390
|
+
out.refused.push({ name, message: error.message });
|
|
391
|
+
console.error(` ${name} refused: ${error.message}`);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
out.saved.push(name);
|
|
395
|
+
known[name] = hash;
|
|
396
|
+
if (!onlyChanged)
|
|
397
|
+
ctx.say(` ${name} set`);
|
|
398
|
+
}
|
|
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;
|
|
418
|
+
if (ctx.json)
|
|
419
|
+
console.log(JSON.stringify({ ok: refused.length === 0, environment: env, saved, skipped, refused, next }));
|
|
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
|
|
432
|
+
ctx.say(`Next: ${next}`);
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
/** Follows a deploy until it is live, refused or failed, printing each change. */
|
|
436
|
+
async function follow(ctx, id) {
|
|
437
|
+
const until = Date.now() + GIVE_UP_MS;
|
|
438
|
+
let last = "";
|
|
439
|
+
for (;;) {
|
|
440
|
+
const { deploy } = await ctx.api("GET", `/v1/hosting/deploys/${id}`);
|
|
441
|
+
if (deploy.status !== last) {
|
|
442
|
+
last = deploy.status;
|
|
443
|
+
if (!["live", "refused", "failed", "replaced"].includes(deploy.status))
|
|
444
|
+
ctx.say(` ${deploy.status}…`);
|
|
445
|
+
}
|
|
446
|
+
if (["live", "refused", "failed", "replaced"].includes(deploy.status)) {
|
|
447
|
+
for (const f of deploy.findings ?? [])
|
|
448
|
+
ctx.say(` ${f.level === "error" ? "✗" : "!"} ${f.code}: ${f.message}`);
|
|
449
|
+
const live = deploy.status === "live" || deploy.status === "replaced";
|
|
450
|
+
const url = live ? (deploy.url ?? (await address(ctx, deploy.environment))) : null;
|
|
451
|
+
const next = !live
|
|
452
|
+
? deploy.status === "refused"
|
|
453
|
+
? `fix the errors above, then ${RUN} deploy${deploy.environment === "production" ? " --env production" : ""}`
|
|
454
|
+
: `see the deploy's page in the dashboard, then ${RUN} deploy again`
|
|
455
|
+
: deploy.environment === "staging"
|
|
456
|
+
? `try it in your agent's Playground (Staging) in the dashboard, then ${RUN} deploy promote`
|
|
457
|
+
: "open your agent's Playground (Production) in the dashboard";
|
|
458
|
+
if (ctx.json) {
|
|
459
|
+
const { id: deployId, environment, status, version, findings, error } = deploy;
|
|
460
|
+
console.log(JSON.stringify({ ok: live, deploy: { id: deployId, environment, status, version, findings: findings ?? [], error }, url, next }));
|
|
461
|
+
}
|
|
462
|
+
if (live) {
|
|
463
|
+
ctx.say(`Live in ${deploy.environment}.`);
|
|
464
|
+
ctx.say(`URL: ${url ?? NO_URL}`);
|
|
465
|
+
ctx.say(`Next: ${next}`);
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
if (!ctx.json)
|
|
469
|
+
console.error(deploy.status === "refused" ? "Refused: fix the errors above and deploy again." : `Failed: ${deploy.error ?? "the build or the service did not start"}.`);
|
|
470
|
+
else
|
|
471
|
+
ctx.say(deploy.status === "refused" ? "Refused." : `Failed: ${deploy.error ?? "the build or the service did not start"}.`);
|
|
472
|
+
return 1;
|
|
473
|
+
}
|
|
474
|
+
if (Date.now() > until) {
|
|
475
|
+
ctx.say(`Still ${deploy.status} after 20 minutes. Its page in the dashboard shows where it is.`);
|
|
476
|
+
return 1;
|
|
477
|
+
}
|
|
478
|
+
await new Promise((r) => setTimeout(r, ctx.pollMs));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const NO_URL = "not available on this Flow yet (the dashboard's Playground opens it)";
|
|
482
|
+
/** The service's address, when the cloud tells it (GET /v1/hosting/address); null when it does not. */
|
|
483
|
+
async function address(ctx, environment) {
|
|
484
|
+
try {
|
|
485
|
+
const { url } = await ctx.api("GET", `/v1/hosting/address?environment=${encodeURIComponent(environment)}`);
|
|
486
|
+
return typeof url === "string" && url ? url : null;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Where `flow deploy` keeps the last deploy it made per environment, for `deploy status`: in .flow, never packed. */
|
|
493
|
+
const STATE = join(".flow", "deploys.json");
|
|
494
|
+
function remember(dir, environment, id) {
|
|
495
|
+
try {
|
|
496
|
+
const file = join(dir, STATE);
|
|
497
|
+
const known = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
498
|
+
mkdirSync(join(dir, ".flow"), { recursive: true });
|
|
499
|
+
writeFileSync(file, `${JSON.stringify({ ...known, [environment]: { id } }, null, 2)}\n`);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// only a convenience for status
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function remembered(dir, environment) {
|
|
506
|
+
try {
|
|
507
|
+
const id = JSON.parse(readFileSync(join(dir, STATE), "utf8"))[environment]?.id;
|
|
508
|
+
return typeof id === "string" && /^hdp_[0-9A-Za-z]+$/.test(id) ? id : undefined;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** `flow deploy status`: per environment, what is live, its version, when, and its URL. */
|
|
515
|
+
async function status(ctx, environments) {
|
|
516
|
+
const found = [];
|
|
517
|
+
for (const environment of environments) {
|
|
518
|
+
let deploys = [];
|
|
519
|
+
let from = null;
|
|
520
|
+
try {
|
|
521
|
+
deploys = (await ctx.api("GET", `/v1/hosting/deploys?environment=${environment}`)).deploys ?? [];
|
|
522
|
+
from = "list";
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (!(error instanceof Refused) || !error.missing)
|
|
526
|
+
throw error;
|
|
527
|
+
// This Flow lists deploys by CI key only to a dashboard member: the last one this folder made.
|
|
528
|
+
const id = remembered(ctx.dir, environment);
|
|
529
|
+
if (id) {
|
|
530
|
+
deploys = [(await ctx.api("GET", `/v1/hosting/deploys/${id}`)).deploy];
|
|
531
|
+
from = "this_folder";
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const newestFirst = [...deploys].sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")));
|
|
535
|
+
const live = newestFirst.find((d) => d.status === "live") ?? null;
|
|
536
|
+
const top = newestFirst[0] ?? null;
|
|
537
|
+
const serving = live ?? top;
|
|
538
|
+
const pending = top && top !== serving && !["live", "refused", "failed", "replaced"].includes(top.status) ? { id: top.id, status: top.status, version: top.version } : null;
|
|
539
|
+
const url = live ? (live.url ?? (await address(ctx, environment))) : null;
|
|
540
|
+
found.push({ environment, deploy: serving, pending, url, from });
|
|
541
|
+
}
|
|
542
|
+
const anyLive = found.some((f) => f.deploy?.status === "live");
|
|
543
|
+
const next = anyLive
|
|
544
|
+
? `${RUN} logs${found.length === 1 && found[0].environment === "production" ? " --env production" : ""}`
|
|
545
|
+
: `${RUN} deploy`;
|
|
546
|
+
if (ctx.json) {
|
|
547
|
+
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 }));
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
for (const f of found) {
|
|
551
|
+
if (!f.deploy) {
|
|
552
|
+
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)" : ""}.`);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const d = f.deploy;
|
|
556
|
+
const when = d.updated_at ?? d.created_at;
|
|
557
|
+
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)" : ""}`);
|
|
558
|
+
if (d.status === "live")
|
|
559
|
+
ctx.say(` URL: ${f.url ?? NO_URL}`);
|
|
560
|
+
if (f.pending)
|
|
561
|
+
ctx.say(` then ${f.pending.id}${f.pending.version ? ` (${f.pending.version})` : ""}: ${f.pending.status}…`);
|
|
562
|
+
}
|
|
563
|
+
ctx.say(`Next: ${next}`);
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
const pick = (d) => ({
|
|
567
|
+
id: d.id,
|
|
568
|
+
environment: d.environment,
|
|
569
|
+
status: d.status,
|
|
570
|
+
version: d.version,
|
|
571
|
+
source: d.source ?? null,
|
|
572
|
+
created_at: d.created_at ?? null,
|
|
573
|
+
updated_at: d.updated_at ?? null,
|
|
574
|
+
});
|
|
575
|
+
/** --since as seconds: 30s, 15m, 1h, 2d. */
|
|
576
|
+
export function sinceSeconds(since) {
|
|
577
|
+
const m = since.match(/^(\d+)(s|m|h|d)$/);
|
|
578
|
+
if (!m)
|
|
579
|
+
return undefined;
|
|
580
|
+
const n = Number(m[1]);
|
|
581
|
+
return n * { s: 1, m: 60, h: 3600, d: 86400 }[m[2]];
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* `flow logs`: the hosted agent's log lines, from GET /v1/hosting/logs?environment=&since=&after=
|
|
585
|
+
* → { lines: [{ time, severity, text }], next } (next: the cursor for the lines after these).
|
|
586
|
+
* A Flow without that route says so and exits 1.
|
|
587
|
+
*/
|
|
588
|
+
async function logs(ctx, environment, since, followLines) {
|
|
589
|
+
const seconds = sinceSeconds(since);
|
|
590
|
+
if (seconds === undefined || seconds <= 0 || seconds > 30 * 86400)
|
|
591
|
+
throw new Usage(`--since is a duration such as 30s, 15m, 1h or 2d (at most 30d).\n\n${LOGS_USAGE}`);
|
|
592
|
+
const from = new Date(ctx.now() - seconds * 1000).toISOString();
|
|
593
|
+
let after;
|
|
594
|
+
let polls = 0;
|
|
595
|
+
for (;;) {
|
|
596
|
+
const query = new URLSearchParams({ environment, ...(after ? { after } : { since: from }) });
|
|
597
|
+
let page;
|
|
598
|
+
try {
|
|
599
|
+
page = await ctx.api("GET", `/v1/hosting/logs?${query}`);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
if (error instanceof Refused && error.missing) {
|
|
603
|
+
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.`);
|
|
604
|
+
}
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
for (const line of page.lines ?? []) {
|
|
608
|
+
if (ctx.json)
|
|
609
|
+
console.log(JSON.stringify({ time: line.time, severity: line.severity ?? null, text: line.text }));
|
|
610
|
+
else
|
|
611
|
+
console.log(`${line.time} ${(line.severity ?? "").padEnd(7)} ${line.text}`);
|
|
612
|
+
}
|
|
613
|
+
if (page.next)
|
|
614
|
+
after = page.next;
|
|
615
|
+
polls++;
|
|
616
|
+
if (!followLines || (ctx.maxPolls !== undefined && polls >= ctx.maxPolls))
|
|
617
|
+
return 0;
|
|
618
|
+
await new Promise((r) => setTimeout(r, ctx.pollMs));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
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;
|
|
624
|
+
try {
|
|
625
|
+
sha = execFileSync("git", ["-C", dir, "rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return undefined;
|
|
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
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
|