@devflow-one/cli 0.1.13 → 0.1.15
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 +11 -3
- package/package.json +1 -1
- package/src/args.js +1 -0
- package/src/cli.js +4 -3
- package/src/client.js +10 -4
- package/src/commands.js +29 -4
- package/src/contextResolver.js +4 -3
- package/src/gatewayChallenge.js +94 -0
- package/src/gitFacts.js +36 -3
package/README.md
CHANGED
|
@@ -99,6 +99,9 @@ so this action cannot run. Open that Chrome profile and retry.
|
|
|
99
99
|
- The **DevFlow desktop app must be running** — it hosts the gateway and writes
|
|
100
100
|
the token. v1 also needs the browser extension connected so a profile owns
|
|
101
101
|
project keys.
|
|
102
|
+
- A **matching desktop app version** — this CLI proves the gateway's identity
|
|
103
|
+
with a challenge before sending the token, which older desktop builds don't
|
|
104
|
+
answer. Update the desktop app alongside the CLI.
|
|
102
105
|
- Node **>= 20**.
|
|
103
106
|
|
|
104
107
|
## Install / run
|
|
@@ -127,7 +130,7 @@ cd cli && npm link # or link it onto your PATH as `devflow`
|
|
|
127
130
|
## Token & port (zero-config)
|
|
128
131
|
|
|
129
132
|
The CLI discovers the gateway automatically — no setup — by reading the token
|
|
130
|
-
file the desktop daemon writes on
|
|
133
|
+
file the desktop daemon writes on every start, under its userData dir:
|
|
131
134
|
|
|
132
135
|
| Platform | Token path |
|
|
133
136
|
| -------- | -------------------------------------------------------------- |
|
|
@@ -142,6 +145,9 @@ The file is written owner-only (`0600`). Override for CI / non-standard installs
|
|
|
142
145
|
| `DEVFLOW_GATEWAY_TOKEN` | Provide the token directly (skips the file lookup). |
|
|
143
146
|
| `DEVFLOW_GATEWAY_PORT` | Gateway port (default `8787`). |
|
|
144
147
|
|
|
148
|
+
The daemon mints a new token on every restart, so a `DEVFLOW_GATEWAY_TOKEN` pin
|
|
149
|
+
goes stale each time the desktop app restarts — prefer the file lookup.
|
|
150
|
+
|
|
145
151
|
Routing defaults can also come from the environment — handy in CI or a shell
|
|
146
152
|
profile. They lose to the equivalent flag and win over anything inferred:
|
|
147
153
|
`DEVFLOW_PROJECT`, `DEVFLOW_ISSUE`, `DEVFLOW_REPO`, `DEVFLOW_PROFILE`.
|
|
@@ -191,8 +197,9 @@ devflow mr create "Title" --issue ABC-123 # head branch from the branch templa
|
|
|
191
197
|
devflow mr find ABC-123
|
|
192
198
|
devflow pipeline status 42 deadbeef --issue ABC-9
|
|
193
199
|
|
|
194
|
-
# commit a managed worktree with the project's commit-message template
|
|
195
|
-
|
|
200
|
+
# commit a managed worktree with the project's commit-message template; it stages tracked
|
|
201
|
+
# changes only (add --include-untracked for new files) and pushing needs --force
|
|
202
|
+
devflow commit template /wt/ABC-123 --values '{"summary":"fix login"}' --project ABC --force
|
|
196
203
|
devflow commit template /wt/ABC-123 --no-push --project ABC # commit locally, skip push
|
|
197
204
|
|
|
198
205
|
# read a PR/MR's title + description, then edit it: --title/--description replace,
|
|
@@ -208,6 +215,7 @@ devflow mr fix-prompt 42 # issue key inferred from the branch
|
|
|
208
215
|
|
|
209
216
|
# notify the project's configured Slack channel(s)
|
|
210
217
|
devflow slack notify "Review fixes ready on {pr}" --values '{"pr":"#42"}' --project ABC
|
|
218
|
+
devflow slack channels --project ABC # configured channels (read-only, no credentials)
|
|
211
219
|
|
|
212
220
|
# force a specific profile when a key is ambiguous
|
|
213
221
|
devflow mr ready 12 --profile prof-1
|
package/package.json
CHANGED
package/src/args.js
CHANGED
package/src/cli.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { parseArgs } from "./args.js";
|
|
6
6
|
import { resolveConfig, ConfigError } from "./config.js";
|
|
7
7
|
import { gatewayRequest, GatewayError, retryHint } from "./client.js";
|
|
8
|
-
import { resolveCommand, listCommands, UsageError } from "./commands.js";
|
|
8
|
+
import { resolveCommand, listCommands, isDestructiveCommand, UsageError } from "./commands.js";
|
|
9
9
|
import {
|
|
10
10
|
createContextQuery,
|
|
11
11
|
resolveCliContext,
|
|
@@ -202,8 +202,9 @@ export async function run(argv, io = {}) {
|
|
|
202
202
|
// Destructive gate: refuse before touching the gateway unless confirmed.
|
|
203
203
|
// Deliberately independent of inference — a confirmed target is never guessed.
|
|
204
204
|
const confirmed = Boolean(flags.force || flags.yes);
|
|
205
|
+
const destructive = isDestructiveCommand(command, flags);
|
|
205
206
|
|
|
206
|
-
if (
|
|
207
|
+
if (destructive && !confirmed) {
|
|
207
208
|
throw new UsageError(
|
|
208
209
|
`"${key}" is destructive — re-run with --force (or --yes) to confirm. Usage: ${command.usage}`,
|
|
209
210
|
);
|
|
@@ -219,7 +220,7 @@ export async function run(argv, io = {}) {
|
|
|
219
220
|
const args = command.buildArgs ? command.buildArgs(rest, flags, context) : [];
|
|
220
221
|
const body = {
|
|
221
222
|
...buildRoutingBody(context),
|
|
222
|
-
...(
|
|
223
|
+
...(destructive ? { allowDestructive: true } : {}),
|
|
223
224
|
payload: {
|
|
224
225
|
projectKey: projectKeyFrom(context),
|
|
225
226
|
...(context.repo ? { repo: context.repo } : {}),
|
package/src/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// Thin HTTP client of the local gateway. Sends the Bearer token
|
|
2
|
-
// `{ ok, result, error }` envelope the
|
|
3
|
-
//
|
|
1
|
+
// Thin HTTP client of the local gateway. Sends the Bearer token only after the
|
|
2
|
+
// listener proves it holds it, parses the `{ ok, result, error }` envelope the
|
|
3
|
+
// server returns, and turns transport / envelope failures into a tagged
|
|
4
|
+
// GatewayError the CLI can print clearly.
|
|
5
|
+
|
|
6
|
+
import { UntrustedGatewayError, verifyGateway } from "./gatewayChallenge.js";
|
|
4
7
|
|
|
5
8
|
/** A gateway call failure carrying the server's error `code` (when present). */
|
|
6
9
|
export class GatewayError extends Error {
|
|
@@ -64,7 +67,7 @@ function describeTransportError(err, baseUrl) {
|
|
|
64
67
|
* @param {string} path e.g. `/actions/createBranch`
|
|
65
68
|
* @param {unknown} [body] JSON body for POST
|
|
66
69
|
* @returns {Promise<unknown>} the envelope's `result`
|
|
67
|
-
* @throws {GatewayError} on transport failure or an `{ ok: false }` envelope
|
|
70
|
+
* @throws {GatewayError} on a failed challenge, transport failure, or an `{ ok: false }` envelope
|
|
68
71
|
*/
|
|
69
72
|
export async function gatewayRequest(config, method, path, body) {
|
|
70
73
|
const headers = { Authorization: `Bearer ${config.token}` };
|
|
@@ -74,12 +77,15 @@ export async function gatewayRequest(config, method, path, body) {
|
|
|
74
77
|
let response;
|
|
75
78
|
|
|
76
79
|
try {
|
|
80
|
+
await verifyGateway(config.baseUrl, config.token);
|
|
77
81
|
response = await fetch(`${config.baseUrl}${path}`, {
|
|
78
82
|
method,
|
|
79
83
|
headers,
|
|
80
84
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
81
85
|
});
|
|
82
86
|
} catch (err) {
|
|
87
|
+
if (err instanceof UntrustedGatewayError) throw new GatewayError(err.message, err.code);
|
|
88
|
+
|
|
83
89
|
throw describeTransportError(err, config.baseUrl);
|
|
84
90
|
}
|
|
85
91
|
|
package/src/commands.js
CHANGED
|
@@ -123,7 +123,8 @@ export class UsageError extends Error {
|
|
|
123
123
|
* @property {"GET" | "POST" | "LOCAL"} method `LOCAL` runs in-process, no action call.
|
|
124
124
|
* @property {string} [path] For GET commands.
|
|
125
125
|
* @property {string} [action] Gateway action name for POST commands.
|
|
126
|
-
* @property {boolean} [destructive] Requires
|
|
126
|
+
* @property {boolean | ((flags: Record<string, unknown>) => boolean)} [destructive] Requires
|
|
127
|
+
* --force/--yes (gated by cli.js); a predicate when that depends on the flags.
|
|
127
128
|
* @property {boolean} [retryUnsafe] A retry may repeat its side effect; a TIMEOUT warns to verify first.
|
|
128
129
|
* @property {(pos: string[], flags: Record<string, unknown>, context?: object) => unknown[]} [buildArgs]
|
|
129
130
|
* @property {string} usage One-line usage string.
|
|
@@ -193,9 +194,14 @@ const COMMANDS = {
|
|
|
193
194
|
method: "POST",
|
|
194
195
|
retryUnsafe: true,
|
|
195
196
|
action: "commitByTemplate",
|
|
197
|
+
// Pushing is permanent; a local-only commit is not.
|
|
198
|
+
destructive: (flags) => !flags["no-push"],
|
|
196
199
|
usage:
|
|
197
|
-
'commit template <worktreePath> [--values \'{"key":"val"}\'] [--
|
|
198
|
-
"
|
|
200
|
+
'commit template <worktreePath> [--values \'{"key":"val"}\'] [--include-untracked]' +
|
|
201
|
+
" [--issue KEY] [--project KEY] (--no-push | --force)" +
|
|
202
|
+
" (stages every modified/deleted tracked file and pushes, refused from the base branch;" +
|
|
203
|
+
" untracked files are refused unless --include-untracked;" +
|
|
204
|
+
" --values keys fill the project's commit template, default" +
|
|
199
205
|
' "{summary}\\n\\n{changesList}\\n\\n{issueSlug}": summary, changesList (string array,' +
|
|
200
206
|
" rendered as bullets), issueSlug, prNumber; missing keys are left empty and reported" +
|
|
201
207
|
" as warnings on stderr; commits the worktree's checked-out branch, so check out the" +
|
|
@@ -206,7 +212,9 @@ const COMMANDS = {
|
|
|
206
212
|
const values = toValues("values", flags.values);
|
|
207
213
|
const push = flags["no-push"] ? false : undefined;
|
|
208
214
|
|
|
209
|
-
return [
|
|
215
|
+
return flags["include-untracked"]
|
|
216
|
+
? [worktreePath, values, push, true]
|
|
217
|
+
: [worktreePath, values, push];
|
|
210
218
|
},
|
|
211
219
|
},
|
|
212
220
|
|
|
@@ -425,8 +433,25 @@ const COMMANDS = {
|
|
|
425
433
|
return [message, channel, values];
|
|
426
434
|
},
|
|
427
435
|
},
|
|
436
|
+
"slack channels": {
|
|
437
|
+
method: "POST",
|
|
438
|
+
action: "listSlackChannels",
|
|
439
|
+
usage: "slack channels [--issue KEY] [--project KEY] (configured channels; read-only)",
|
|
440
|
+
buildArgs: () => [],
|
|
441
|
+
},
|
|
428
442
|
};
|
|
429
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Does this invocation of `command` need --force/--yes?
|
|
446
|
+
* @param {Command} command
|
|
447
|
+
* @param {Record<string, unknown>} flags
|
|
448
|
+
*/
|
|
449
|
+
export function isDestructiveCommand(command, flags) {
|
|
450
|
+
return typeof command.destructive === "function"
|
|
451
|
+
? command.destructive(flags)
|
|
452
|
+
: command.destructive === true;
|
|
453
|
+
}
|
|
454
|
+
|
|
430
455
|
/** Every command key (for help / discovery). */
|
|
431
456
|
export function listCommands() {
|
|
432
457
|
return Object.keys(COMMANDS);
|
package/src/contextResolver.js
CHANGED
|
@@ -159,7 +159,8 @@ function fill(resolved, source, origin) {
|
|
|
159
159
|
* @param {{ projectKey?: string, issueKey?: string, repo?: string, profile?: string }} [options.explicit]
|
|
160
160
|
* @param {Record<string, string | undefined>} [options.env]
|
|
161
161
|
* @param {string} [options.cwd]
|
|
162
|
-
* @param {(args: string[]) => string | null} [options.runGit]
|
|
162
|
+
* @param {((args: string[]) => string | null) | null} [options.runGit] `null` = git is not
|
|
163
|
+
* allowed in `cwd`, so only the path is matched
|
|
163
164
|
* @param {(q: { cwd?: string, remote?: string, branch?: string }) => Promise<object | null>} [options.queryContext]
|
|
164
165
|
* @returns {Promise<{
|
|
165
166
|
* projectKey?: string, issueKey?: string, repo?: string, baseBranch?: string,
|
|
@@ -190,9 +191,9 @@ export async function resolveContext({
|
|
|
190
191
|
fill(resolved, readEnvContext(env), "env");
|
|
191
192
|
|
|
192
193
|
// An explicit issue key implies its project, but only if nothing better said so.
|
|
193
|
-
const git = readGitFacts(runGit);
|
|
194
|
+
const git = runGit ? readGitFacts(runGit) : {};
|
|
194
195
|
|
|
195
|
-
if (!git.root && clean(cwd)) {
|
|
196
|
+
if (runGit && !git.root && clean(cwd)) {
|
|
196
197
|
resolved.warnings.push(`${cwd} is not inside a git repository; inferring from the path only.`);
|
|
197
198
|
}
|
|
198
199
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit.
|
|
2
|
+
// Source of truth: shared/gatewayChallenge.js. Regenerate with:
|
|
3
|
+
// node scripts/sync-context-resolver.mjs
|
|
4
|
+
|
|
5
|
+
// Mutual-auth challenge for the loopback gateway. Loopback ports aren't per-user,
|
|
6
|
+
// so before a client sends its Bearer token it makes the listener prove it already
|
|
7
|
+
// holds that token: client nonce in, HMAC(token, nonce) out.
|
|
8
|
+
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/** Unauthenticated route that answers the challenge. */
|
|
12
|
+
export const CHALLENGE_PATH = "/auth/challenge";
|
|
13
|
+
|
|
14
|
+
const PROOF_CONTEXT = "devflow-gateway-challenge-v1:";
|
|
15
|
+
const NONCE_PATTERN = /^[A-Za-z0-9_-]{32,128}$/;
|
|
16
|
+
const DEFAULT_CHALLENGE_TIMEOUT_MS = 5_000;
|
|
17
|
+
|
|
18
|
+
/** A fresh URL-safe 256-bit nonce. */
|
|
19
|
+
function createNonce() {
|
|
20
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Is `nonce` a well-formed client nonce? */
|
|
24
|
+
export function isValidNonce(nonce) {
|
|
25
|
+
return typeof nonce === "string" && NONCE_PATTERN.test(nonce);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The proof a token holder returns for `nonce`.
|
|
30
|
+
* @param {string} token
|
|
31
|
+
* @param {string} nonce
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function computeProof(token, nonce) {
|
|
35
|
+
return crypto
|
|
36
|
+
.createHmac("sha256", token)
|
|
37
|
+
.update(PROOF_CONTEXT + nonce)
|
|
38
|
+
.digest("base64url");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Constant-time string comparison; false for any non-string.
|
|
43
|
+
* @param {unknown} provided
|
|
44
|
+
* @param {string} expected
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function safeEqual(provided, expected) {
|
|
48
|
+
if (typeof provided !== "string") return false;
|
|
49
|
+
|
|
50
|
+
const a = Buffer.from(provided);
|
|
51
|
+
const b = Buffer.from(expected);
|
|
52
|
+
|
|
53
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The listener at `baseUrl` could not prove it holds the token; the token was not sent. */
|
|
57
|
+
export class UntrustedGatewayError extends Error {
|
|
58
|
+
/** @param {string} baseUrl */
|
|
59
|
+
constructor(baseUrl) {
|
|
60
|
+
super(
|
|
61
|
+
`The process listening at ${baseUrl} could not prove it holds the DevFlow gateway ` +
|
|
62
|
+
`token, so the token was not sent. Another program may own the port, the token ` +
|
|
63
|
+
`may be stale (restart the DevFlow desktop app), or the app may need updating.`,
|
|
64
|
+
);
|
|
65
|
+
this.name = "UntrustedGatewayError";
|
|
66
|
+
this.code = "GATEWAY_UNTRUSTED";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Challenge the gateway at `baseUrl` and resolve only if it proves it holds `token`.
|
|
72
|
+
* Transport failures (e.g. ECONNREFUSED) reject as-is so callers keep their own messaging.
|
|
73
|
+
* @param {string} baseUrl e.g. `http://127.0.0.1:8787`
|
|
74
|
+
* @param {string} token
|
|
75
|
+
* @param {{ timeoutMs?: number }} [options]
|
|
76
|
+
* @returns {Promise<void>}
|
|
77
|
+
* @throws {UntrustedGatewayError} when the proof is missing or wrong
|
|
78
|
+
*/
|
|
79
|
+
export async function verifyGateway(baseUrl, token, options = {}) {
|
|
80
|
+
const nonce = createNonce();
|
|
81
|
+
const response = await fetch(`${baseUrl}${CHALLENGE_PATH}?nonce=${nonce}`, {
|
|
82
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_CHALLENGE_TIMEOUT_MS),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let proof;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
proof = (await response.json())?.result?.proof;
|
|
89
|
+
} catch {
|
|
90
|
+
proof = undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!safeEqual(proof, computeProof(token, nonce))) throw new UntrustedGatewayError(baseUrl);
|
|
94
|
+
}
|
package/src/gitFacts.js
CHANGED
|
@@ -7,17 +7,50 @@
|
|
|
7
7
|
// fake runner. Generated copies ship inside cli/ and mcp/ — edit THIS file only.
|
|
8
8
|
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
10
13
|
|
|
11
14
|
/** Hard ceiling so a wedged git can never hang a CLI invocation. */
|
|
12
15
|
const GIT_TIMEOUT_MS = 2000;
|
|
13
16
|
|
|
17
|
+
/** Config that keeps a repo's own settings from running hooks, a monitor, or a transport. */
|
|
18
|
+
const SAFE_GIT_CONFIG = [
|
|
19
|
+
"-c",
|
|
20
|
+
"core.hooksPath=/dev/null",
|
|
21
|
+
"-c",
|
|
22
|
+
"core.fsmonitor=false",
|
|
23
|
+
"-c",
|
|
24
|
+
"protocol.allow=never",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Mirrors the desktop host's per-user worktree root (desktop/src/handlers/git/worktree.js). */
|
|
28
|
+
function managedWorktreeRoot() {
|
|
29
|
+
const uid = process.getuid?.() ?? null;
|
|
30
|
+
const dirName = uid == null ? "devflow-worktrees" : `devflow-worktrees-${uid}`;
|
|
31
|
+
|
|
32
|
+
return path.join(fs.realpathSync(os.tmpdir()), dirName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True when `dir` (already realpath'd) is inside a DevFlow-managed worktree.
|
|
37
|
+
* @param {string} dir
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function isManagedWorktreePath(dir) {
|
|
41
|
+
const relative = path.relative(managedWorktreeRoot(), dir);
|
|
42
|
+
|
|
43
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
44
|
+
}
|
|
45
|
+
|
|
14
46
|
/**
|
|
15
47
|
* Build a read-only, non-interactive git runner rooted at `cwd`.
|
|
16
48
|
*
|
|
17
49
|
* Every invocation is incapable of blocking on input: `--no-pager` rules out a
|
|
18
50
|
* pager, `GIT_TERMINAL_PROMPT=0` and an empty `GIT_ASKPASS`/`SSH_ASKPASS` stop a
|
|
19
|
-
* credential helper prompting, and stdin is closed.
|
|
20
|
-
*
|
|
51
|
+
* credential helper prompting, and stdin is closed. {@link SAFE_GIT_CONFIG} turns
|
|
52
|
+
* off hooks, fsmonitor and transports. A non-repo directory or any other failure
|
|
53
|
+
* yields `null` rather than throwing.
|
|
21
54
|
*
|
|
22
55
|
* @param {string} cwd
|
|
23
56
|
* @returns {(args: string[]) => string | null} stdout (trimmed), or null on failure
|
|
@@ -25,7 +58,7 @@ const GIT_TIMEOUT_MS = 2000;
|
|
|
25
58
|
export function createGitRunner(cwd) {
|
|
26
59
|
return (args) => {
|
|
27
60
|
try {
|
|
28
|
-
const stdout = execFileSync("git", ["--no-pager", ...args], {
|
|
61
|
+
const stdout = execFileSync("git", [...SAFE_GIT_CONFIG, "--no-pager", ...args], {
|
|
29
62
|
cwd: cwd || process.cwd(),
|
|
30
63
|
encoding: "utf8",
|
|
31
64
|
timeout: GIT_TIMEOUT_MS,
|