flowviant 0.27.1 → 0.28.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/bin/lib/config.mjs +1 -1
- package/bin/lib/deploy.mjs +267 -0
- package/bin/lib/env.mjs +21 -2
- package/bin/lib/fleet.mjs +16 -1
- package/package.json +2 -2
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
|
-
export const VERSION = '0.
|
|
7
|
+
export const VERSION = '0.28.0';
|
|
8
8
|
|
|
9
9
|
// The model EVERY daemon Claude turn runs on — pinned so autonomous work never
|
|
10
10
|
// inherits your interactive `~/.claude/settings.json` default. That matters: a
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare DevOps — the daemon runs the user's own `wrangler`. Broker-not-
|
|
3
|
+
* host: no cloud credential ever reaches Flowviant. A deploy-authorized daemon
|
|
4
|
+
* claims deploy jobs off the roster, runs build → push prod secrets → deploy →
|
|
5
|
+
* verify, and reports the outcome. It also reports its parsed
|
|
6
|
+
* .flowviant/deploy.json so the app can list targets, and (basic) observes
|
|
7
|
+
* out-of-band deployments.
|
|
8
|
+
*
|
|
9
|
+
* Every log line that leaves the machine passes through the env scrubber —
|
|
10
|
+
* wrangler output routinely echoes secrets.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
17
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
18
|
+
import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
|
|
19
|
+
|
|
20
|
+
const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
|
|
21
|
+
|
|
22
|
+
async function post(tail, body) {
|
|
23
|
+
const res = await fetch(deployUrl(tail), {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
27
|
+
'User-Agent': USER_AGENT,
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
},
|
|
30
|
+
signal: AbortSignal.timeout(30_000),
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
const json = await res.json().catch(() => ({}));
|
|
34
|
+
if (!res.ok || json?.success === false) {
|
|
35
|
+
throw new Error(`${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
|
|
36
|
+
}
|
|
37
|
+
return json?.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read + parse .flowviant/deploy.json from the repo root. Returns [] if none. */
|
|
41
|
+
export function readDeployConfig(repoRoot) {
|
|
42
|
+
const path = join(repoRoot, '.flowviant', 'deploy.json');
|
|
43
|
+
if (!existsSync(path)) return [];
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
|
|
47
|
+
// Keep only fields the server + runner need; the daemon holds the commands.
|
|
48
|
+
return targets
|
|
49
|
+
.filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
|
|
50
|
+
.slice(0, 20);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
warn(`deploy: .flowviant/deploy.json is not valid JSON — ${e.message}`);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Report the parsed config to the server (only when it changed). */
|
|
58
|
+
let lastConfigJson = null;
|
|
59
|
+
export async function reportDeployConfig(repoRoot) {
|
|
60
|
+
const targets = readDeployConfig(repoRoot);
|
|
61
|
+
const json = JSON.stringify(targets);
|
|
62
|
+
if (json === lastConfigJson) return;
|
|
63
|
+
// Scrub command strings before the server sees them — a command line can embed
|
|
64
|
+
// an internal host or a synced secret. Only redacted metadata leaves the box.
|
|
65
|
+
const scrubCmds = (o) =>
|
|
66
|
+
o && typeof o === 'object'
|
|
67
|
+
? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, scrub(String(v ?? ''))]))
|
|
68
|
+
: o;
|
|
69
|
+
const meta = targets.map((t) => ({
|
|
70
|
+
id: t.id,
|
|
71
|
+
label: t.label,
|
|
72
|
+
provider: t.provider || 'cloudflare',
|
|
73
|
+
command: scrub(String(t.command ?? '')),
|
|
74
|
+
build: t.build ? scrub(String(t.build)) : t.build,
|
|
75
|
+
commands: scrubCmds(t.commands),
|
|
76
|
+
healthcheck: t.healthcheck,
|
|
77
|
+
healthStatus: t.healthStatus,
|
|
78
|
+
pushSecrets: t.pushSecrets,
|
|
79
|
+
}));
|
|
80
|
+
try {
|
|
81
|
+
await post('deploy-config', { pubkey: myPubB64(), targets: meta });
|
|
82
|
+
lastConfigJson = json;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
warn(`deploy: could not report config — ${e.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Run a shell command ASYNC (never blocks the daemon's event loop — the
|
|
89
|
+
* reconcile poll + the deploy heartbeat must keep firing during a long
|
|
90
|
+
* deploy). Captures combined + scrubbed output; resolves {ok,out,code}. */
|
|
91
|
+
function run(command, { cwd, env, input }) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const child = spawn(command, { cwd, env, shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
94
|
+
let buf = '';
|
|
95
|
+
const cap = (d) => {
|
|
96
|
+
buf += d.toString();
|
|
97
|
+
if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024); // bound memory
|
|
98
|
+
};
|
|
99
|
+
child.stdout.on('data', cap);
|
|
100
|
+
child.stderr.on('data', cap);
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
try {
|
|
103
|
+
child.kill('SIGKILL');
|
|
104
|
+
} catch {
|
|
105
|
+
/* already gone */
|
|
106
|
+
}
|
|
107
|
+
}, 30 * 60_000);
|
|
108
|
+
// A broken pipe (child exits before draining stdin — e.g. a fast-failing
|
|
109
|
+
// `wrangler secret put`) surfaces as an ASYNC 'error' on the stdin stream,
|
|
110
|
+
// which the try/catch below can't catch. Without a listener it's an uncaught
|
|
111
|
+
// exception that kills the whole daemon. Swallow it.
|
|
112
|
+
child.stdin.on('error', () => {});
|
|
113
|
+
if (input != null) {
|
|
114
|
+
try {
|
|
115
|
+
child.stdin.write(input);
|
|
116
|
+
child.stdin.end();
|
|
117
|
+
} catch {
|
|
118
|
+
/* stdin closed */
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
child.stdin.end();
|
|
122
|
+
}
|
|
123
|
+
child.on('close', (code) => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
resolve({ ok: code === 0, code: code ?? -1, out: scrub(buf) });
|
|
126
|
+
});
|
|
127
|
+
child.on('error', (e) => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
resolve({ ok: false, code: -1, out: scrub(`${buf}\n${e.message}`) });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tailLines = (s, n = 40) => s.split('\n').filter(Boolean).slice(-n);
|
|
135
|
+
|
|
136
|
+
/** Health-check a deployed target: GET the URL, expect `status`. Retries a few
|
|
137
|
+
* times for propagation lag. */
|
|
138
|
+
async function verifyHealth(url, status) {
|
|
139
|
+
for (let i = 0; i < 4; i++) {
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
|
|
142
|
+
if (res.status === Number(status)) return true; // coerce — a string "200" in deploy.json must still match
|
|
143
|
+
} catch {
|
|
144
|
+
/* not up yet */
|
|
145
|
+
}
|
|
146
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const claiming = new Set(); // in-flight guard (single-flight per daemon process)
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Process queued deploy jobs from the roster. `ctx` = { repoRoot, baseRef,
|
|
155
|
+
* myPubB64 }. Each job: claim → build → push prod secrets → deploy → verify →
|
|
156
|
+
* report. Runs concurrently but one-per-jobId.
|
|
157
|
+
*/
|
|
158
|
+
export function processDeployJobs(jobs, ctx) {
|
|
159
|
+
if (!Array.isArray(jobs) || !jobs.length) return;
|
|
160
|
+
for (const job of jobs) {
|
|
161
|
+
// Defend against a malformed roster element — `job.id` on a null would throw
|
|
162
|
+
// synchronously here (outside the per-job try below) and wedge the whole
|
|
163
|
+
// reconcile loop, since this runs unguarded from the fleet tick.
|
|
164
|
+
if (!job || typeof job.id !== 'string') continue;
|
|
165
|
+
if (claiming.has(job.id)) continue;
|
|
166
|
+
claiming.add(job.id);
|
|
167
|
+
void (async () => {
|
|
168
|
+
let beat = null;
|
|
169
|
+
try {
|
|
170
|
+
const claimed = await post('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => null);
|
|
171
|
+
if (!claimed?.claimed) return; // another daemon won the claim
|
|
172
|
+
// Keep the claim fresh while we run — a long deploy must never be
|
|
173
|
+
// re-queued out from under us (that would double-deploy). The async
|
|
174
|
+
// run() below keeps the event loop free so this fires.
|
|
175
|
+
beat = setInterval(() => {
|
|
176
|
+
void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => {});
|
|
177
|
+
}, 60_000);
|
|
178
|
+
const targets = readDeployConfig(ctx.repoRoot);
|
|
179
|
+
const target = targets.find((t) => t.id === job.targetId);
|
|
180
|
+
if (!target) {
|
|
181
|
+
await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
|
|
185
|
+
const outcome = await runDeploy(job, target, ctx);
|
|
186
|
+
await report(job, ctx, outcome);
|
|
187
|
+
if (outcome.ok) ok(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} done${outcome.healthOk === false ? ' (health failed)' : ''}`)}`);
|
|
188
|
+
else warn(`deploy: ${job.targetId} → ${job.env} failed — ${outcome.message}`);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
warn(`deploy job ${job.id} errored: ${e.message}`);
|
|
191
|
+
await report(job, ctx, { ok: false, message: e.message }).catch(() => {});
|
|
192
|
+
} finally {
|
|
193
|
+
if (beat) clearInterval(beat);
|
|
194
|
+
claiming.delete(job.id);
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function runDeploy(job, target, ctx) {
|
|
201
|
+
const env = { ...process.env, ...deployCreds() }; // inject infra creds; never a file
|
|
202
|
+
delete env.FLEET_TOKEN; // the deploy command has no business reading it; keep it out of a command that might echo its env
|
|
203
|
+
const logs = [];
|
|
204
|
+
// Rollback is a single wrangler command; deploy is build → secrets → deploy.
|
|
205
|
+
if (job.kind === 'rollback') {
|
|
206
|
+
const cmd = target.commands?.[`rollback:${job.env}`] || `npx wrangler rollback`;
|
|
207
|
+
const r = await run(cmd, { cwd: ctx.repoRoot, env });
|
|
208
|
+
logs.push(...tailLines(r.out));
|
|
209
|
+
return { ok: r.ok, message: r.ok ? 'rolled back' : logs.slice(-6).join('\n'), logs };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (target.build) {
|
|
213
|
+
const b = await run(target.build, { cwd: ctx.repoRoot, env });
|
|
214
|
+
logs.push(...tailLines(b.out));
|
|
215
|
+
if (!b.ok) return { ok: false, message: `build failed:\n${logs.slice(-6).join('\n')}`, logs };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Push prod app secrets to the provider's secret store (never written local).
|
|
219
|
+
// `name` is always a validated vault key (alnum/underscore) or skipped, and
|
|
220
|
+
// the VALUE goes only via stdin — never argv (no prod plaintext in ps).
|
|
221
|
+
if (job.env === 'prod' && Array.isArray(target.pushSecrets) && target.pushSecrets.length) {
|
|
222
|
+
const secrets = appSecretsFor('prod');
|
|
223
|
+
for (const name of target.pushSecrets) {
|
|
224
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !(name in secrets)) {
|
|
225
|
+
warn(`deploy: pushSecret "${name}" invalid or not in the vault at prod scope — skipping`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const putCmd = target.commands?.['secretPut']
|
|
229
|
+
? target.commands['secretPut'].replace('{name}', name)
|
|
230
|
+
: `npx wrangler secret put ${name} --env production`;
|
|
231
|
+
const s = await run(putCmd, { cwd: ctx.repoRoot, env, input: secrets[name] });
|
|
232
|
+
if (!s.ok) return { ok: false, message: `pushing secret ${name} failed`, logs };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const cmd = target.commands?.[job.env] || target.command;
|
|
237
|
+
const d = await run(cmd, { cwd: ctx.repoRoot, env });
|
|
238
|
+
logs.push(...tailLines(d.out));
|
|
239
|
+
if (!d.ok) return { ok: false, message: `deploy failed:\n${logs.slice(-8).join('\n')}`, logs };
|
|
240
|
+
|
|
241
|
+
// Extract the wrangler version/deployment id if present.
|
|
242
|
+
const idMatch = d.out.match(/Current Version ID:\s*([0-9a-f-]+)/i);
|
|
243
|
+
const deploymentId = idMatch ? idMatch[1] : null;
|
|
244
|
+
|
|
245
|
+
let healthOk = null;
|
|
246
|
+
if (target.healthcheck) {
|
|
247
|
+
healthOk = await verifyHealth(target.healthcheck, target.healthStatus ?? 200);
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
healthOk,
|
|
252
|
+
deploymentId,
|
|
253
|
+
message: healthOk === false ? 'deployed, but health check failed' : 'deployed',
|
|
254
|
+
logs,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function report(job, ctx, outcome) {
|
|
259
|
+
return post('deploy-report', {
|
|
260
|
+
jobId: job.id,
|
|
261
|
+
pubkey: ctx.myPubB64(),
|
|
262
|
+
ok: !!outcome.ok,
|
|
263
|
+
deploymentId: outcome.deploymentId ?? null,
|
|
264
|
+
healthOk: outcome.healthOk ?? null,
|
|
265
|
+
message: scrub(outcome.message || ''),
|
|
266
|
+
}).catch((e) => warn(`deploy: could not report outcome — ${e.message}`));
|
|
267
|
+
}
|
package/bin/lib/env.mjs
CHANGED
|
@@ -301,11 +301,30 @@ function removeStaleEnvFile(wt, rel) {
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
/**
|
|
304
|
+
/** Deploy-scope credentials (e.g. CLOUDFLARE_API_TOKEN) as a NAME→value map —
|
|
305
|
+
* injected into the deploy command's process env, NEVER written to a file. */
|
|
306
|
+
export function deployCreds() {
|
|
307
|
+
const out = {};
|
|
308
|
+
for (const v of values) if (v.scope === 'deploy' && v.value) out[v.name] = v.value;
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** app-scope secrets for one environment as a NAME→value map — for pushing to
|
|
313
|
+
* the provider's secret store on a prod deploy (`wrangler secret put`). */
|
|
314
|
+
export function appSecretsFor(env) {
|
|
315
|
+
const out = {};
|
|
316
|
+
for (const v of values) if (v.scope === 'app' && v.env === env && v.value) out[v.name] = v.value;
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Write the decrypted env into ONE worktree. Never call on the wiki worktree.
|
|
321
|
+
* v1 materializes the 'dev' app env (agent test runs + local preview); prod
|
|
322
|
+
* app secrets go to the provider at deploy, deploy creds are injected only. */
|
|
305
323
|
export function materializeInto(wt) {
|
|
306
324
|
if (!wt || !existsSync(wt)) return;
|
|
307
325
|
const byFile = new Map();
|
|
308
326
|
for (const v of values) {
|
|
327
|
+
if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
|
|
309
328
|
if (!isSafeTarget(v.targetFile)) continue;
|
|
310
329
|
const list = byFile.get(v.targetFile) ?? [];
|
|
311
330
|
list.push(v);
|
|
@@ -445,7 +464,7 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
445
464
|
for (const k of bundle.keys) {
|
|
446
465
|
try {
|
|
447
466
|
const plain = openSealed(k.ciphertext, projectPub, projectPriv);
|
|
448
|
-
opened.push({ name: k.name, env: k.env, targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
|
|
467
|
+
opened.push({ name: k.name, env: k.env, scope: k.scope ?? 'app', targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
|
|
449
468
|
} catch {
|
|
450
469
|
allOpened = false;
|
|
451
470
|
warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -59,8 +59,10 @@ import {
|
|
|
59
59
|
envQueryParams,
|
|
60
60
|
handleRosterEnv,
|
|
61
61
|
materializeInto,
|
|
62
|
+
myPubB64,
|
|
62
63
|
scrub as envScrub,
|
|
63
64
|
} from './env.mjs';
|
|
65
|
+
import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
64
66
|
|
|
65
67
|
async function fetchRoster(haveIds) {
|
|
66
68
|
const url = new URL(FLEET_URL);
|
|
@@ -293,6 +295,7 @@ export async function runFleetDaemon() {
|
|
|
293
295
|
};
|
|
294
296
|
const processMergeJobs = (jobs) => {
|
|
295
297
|
for (const job of jobs ?? []) {
|
|
298
|
+
if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
|
|
296
299
|
if (merging.has(job.id)) continue;
|
|
297
300
|
merging.add(job.id);
|
|
298
301
|
(async () => {
|
|
@@ -372,6 +375,7 @@ export async function runFleetDaemon() {
|
|
|
372
375
|
const cleaning = new Set();
|
|
373
376
|
const processCleanupJobs = (jobs) => {
|
|
374
377
|
for (const job of jobs ?? []) {
|
|
378
|
+
if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
|
|
375
379
|
if (cleaning.has(job.id)) continue;
|
|
376
380
|
cleaning.add(job.id);
|
|
377
381
|
(async () => {
|
|
@@ -887,7 +891,10 @@ export async function runFleetDaemon() {
|
|
|
887
891
|
// turn) until we report reground-done; the bare drain flushes anything
|
|
888
892
|
// whose earlier mint failed.
|
|
889
893
|
enqueueSweep(roster.codeMapJob);
|
|
890
|
-
for (const j of roster.regroundJobs ?? [])
|
|
894
|
+
for (const j of roster.regroundJobs ?? []) {
|
|
895
|
+
if (!j || typeof j.intentId !== 'string') continue; // a null element would throw + wedge the loop
|
|
896
|
+
enqueueReground(j.intentId, j.prUrl, j.title);
|
|
897
|
+
}
|
|
891
898
|
void drainWiki();
|
|
892
899
|
|
|
893
900
|
// Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
|
|
@@ -905,6 +912,14 @@ export async function runFleetDaemon() {
|
|
|
905
912
|
}
|
|
906
913
|
});
|
|
907
914
|
|
|
915
|
+
// Deploy: a deploy-authorized daemon reports its .flowviant/deploy.json and
|
|
916
|
+
// runs queued deploy jobs (the server only sends deployJobs to authorized
|
|
917
|
+
// machines). Config report is cheap + dedup'd; jobs are single-flight.
|
|
918
|
+
if (roster.env?.deployAuthorized) {
|
|
919
|
+
void reportDeployConfig(repoRoot);
|
|
920
|
+
processDeployJobs(roster.deployJobs, { repoRoot, baseRef, myPubB64 });
|
|
921
|
+
}
|
|
922
|
+
|
|
908
923
|
// Stop workers whose agent left the roster (removed in the app).
|
|
909
924
|
for (const [id, w] of [...workers]) {
|
|
910
925
|
if (!rosterIds.has(id)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"homepage": "https://flowviant.com",
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
|
-
"url": "https://github.com/flowviant/cli.git"
|
|
33
|
+
"url": "git+https://github.com/flowviant/cli.git"
|
|
34
34
|
},
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"bugs": {
|