@devflow-one/cli 0.1.12 → 0.1.14

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 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 first run, under its userData dir:
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`.
@@ -162,6 +168,7 @@ devflow mr find # issue key from the branch
162
168
  devflow branch resolve ABC-124 # read-only: branch name + whether it exists
163
169
  devflow branch create --issue ABC-124 # creates it, or reuses an existing one
164
170
  devflow branch create feat/x # a name is only a fallback when no issue key is known
171
+ devflow branch create --issue ABC-124 --from release/2026.4 # start from another remote branch
165
172
 
166
173
  # or route explicitly, overriding whatever the directory implies
167
174
  devflow branch create feat/x --project ABC
@@ -183,21 +190,25 @@ devflow mr types --project ABC # configured types: id, label, def
183
190
  # {issueTitle} needs the Jira API token set in Settings, else it renders empty.
184
191
  devflow mr create "Title" feat/x --issue ABC-123
185
192
  devflow mr create "Title" --issue ABC-123 # head branch from the branch template
186
- # the template always picks the head branch; a different one is replaced (warning on stderr)
193
+ # a head branch that exists on the remote is used as is; otherwise the template picks it
194
+ # and a different one is replaced (warning on stderr)
187
195
 
188
196
  # routed by issue key (project key = prefix before the last '-')
189
197
  devflow mr find ABC-123
190
198
  devflow pipeline status 42 deadbeef --issue ABC-9
191
199
 
192
- # commit a managed worktree with the project's commit-message template
193
- devflow commit template /wt/ABC-123 --values '{"summary":"fix login"}' --project ABC
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
194
203
  devflow commit template /wt/ABC-123 --no-push --project ABC # commit locally, skip push
195
204
 
196
205
  # read a PR/MR's title + description, then edit it: --title/--description replace,
197
- # --append-description adds text after a `---` rule (skipped if already at the end)
206
+ # --append-description adds text after a `---` rule (skipped if already at the end);
207
+ # --base retargets the PR/MR to an existing remote branch
198
208
  devflow mr get 42 --project ABC
199
209
  devflow mr update 42 --append-description "## Test plan" --project ABC
200
210
  devflow mr update 42 --title "Fix login redirect" --project ABC
211
+ devflow mr update 42 --base release/2026.4 --project ABC
201
212
 
202
213
  # assembled fix-review-comments prompt (read-only; runs no AI, makes no writes)
203
214
  devflow mr fix-prompt 42 # issue key inferred from the branch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devflow-one/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Terminal CLI wrapping the local DevFlow gateway so shell scripts can trigger repo actions.",
5
5
  "keywords": [
6
6
  "devflow",
package/src/args.js CHANGED
@@ -11,6 +11,7 @@ const BOOLEAN_FLAGS = new Set([
11
11
  "force",
12
12
  "yes",
13
13
  "no-push",
14
+ "include-untracked",
14
15
  "delete-source-branch",
15
16
  ]);
16
17
 
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 (command.destructive && !confirmed) {
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
- ...(command.destructive ? { allowDestructive: true } : {}),
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, parses the
2
- // `{ ok, result, error }` envelope the server returns, and turns transport /
3
- // envelope failures into a tagged GatewayError the CLI can print clearly.
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 --force/--yes (gated by cli.js).
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.
@@ -153,17 +154,19 @@ const COMMANDS = {
153
154
  retryUnsafe: true,
154
155
  action: "createBranch",
155
156
  usage:
156
- "branch create [branchName] [--issue KEY] [--project KEY]" +
157
+ "branch create [branchName] [--issue KEY] [--from <branch>] [--project KEY]" +
157
158
  " (the repo's branch template names the branch from the issue, incl. {parentIssueKey};" +
158
- " branchName is only a fallback; an existing branch is reused)",
159
+ " branchName is only a fallback; an existing branch is reused; --from picks the remote" +
160
+ " branch to start from instead of the repo's configured base branch)",
159
161
  buildArgs: ([branchName], flags, context = {}) => {
160
162
  const issueKey = templateIssueKey(branchName, flags, context);
163
+ const from = toBranch("from", flags.from);
161
164
 
162
165
  if (!branchName && !issueKey) {
163
166
  throw new UsageError("branch create needs an issue key (--issue) or a <branchName>.");
164
167
  }
165
168
 
166
- return [branchName, issueKey];
169
+ return from === undefined ? [branchName, issueKey] : [branchName, issueKey, from];
167
170
  },
168
171
  },
169
172
  "branch resolve": {
@@ -191,9 +194,14 @@ const COMMANDS = {
191
194
  method: "POST",
192
195
  retryUnsafe: true,
193
196
  action: "commitByTemplate",
197
+ // Pushing is permanent; a local-only commit is not.
198
+ destructive: (flags) => !flags["no-push"],
194
199
  usage:
195
- 'commit template <worktreePath> [--values \'{"key":"val"}\'] [--no-push] [--issue KEY] [--project KEY]' +
196
- " (--values keys fill the project's commit template, default" +
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" +
197
205
  ' "{summary}\\n\\n{changesList}\\n\\n{issueSlug}": summary, changesList (string array,' +
198
206
  " rendered as bullets), issueSlug, prNumber; missing keys are left empty and reported" +
199
207
  " as warnings on stderr; commits the worktree's checked-out branch, so check out the" +
@@ -204,7 +212,9 @@ const COMMANDS = {
204
212
  const values = toValues("values", flags.values);
205
213
  const push = flags["no-push"] ? false : undefined;
206
214
 
207
- return [worktreePath, values, push];
215
+ return flags["include-untracked"]
216
+ ? [worktreePath, values, push, true]
217
+ : [worktreePath, values, push];
208
218
  },
209
219
  },
210
220
 
@@ -215,8 +225,8 @@ const COMMANDS = {
215
225
  usage:
216
226
  "mr create <title> [headBranch] [--body <text>] [--base <branch>] [--type <idOrLabel>]" +
217
227
  " [--issue KEY] [--project KEY]" +
218
- " (the repo's branch template picks the head branch from the issue; headBranch is only" +
219
- " a fallback; --base defaults to the repo's configured base branch, expanding a {year}/{number} pattern;" +
228
+ " (a headBranch that exists on the remote is used as is; otherwise the repo's branch" +
229
+ " template picks the head branch from the issue; --base defaults to the repo's configured base branch, expanding a {year}/{number} pattern;" +
220
230
  " --type is auto-selected by matching PR type names against the title prefix, branch prefix," +
221
231
  " Jira issue type and title keywords, falling back to the first configured type)",
222
232
  buildArgs: ([title, headBranch], flags, context = {}) => {
@@ -328,23 +338,27 @@ const COMMANDS = {
328
338
  action: "updatePullRequest",
329
339
  usage:
330
340
  "mr update <prId> [--title <text>] [--description <text> | --append-description <text>]" +
331
- " [--issue KEY] [--project KEY]" +
341
+ " [--base <branch>] [--issue KEY] [--project KEY]" +
332
342
  " (--title/--description replace; --append-description adds text after a `---` rule" +
333
- " and is skipped if it already ends the description after that rule)",
343
+ " and is skipped if it already ends the description after that rule; --base retargets" +
344
+ " the PR/MR to an existing remote branch)",
334
345
  buildArgs: ([prId], flags) => {
335
346
  const title = toText("title", flags.title);
336
347
  const body = toRawText("description", flags.description);
337
348
  const appendBody = toRawText("append-description", flags["append-description"]);
349
+ const baseBranch = toBranch("base", flags.base);
338
350
 
339
- if (title === undefined && body === undefined && appendBody === undefined) {
340
- throw new UsageError("mr update needs --title, --description or --append-description.");
351
+ if ([title, body, appendBody, baseBranch].every((value) => value === undefined)) {
352
+ throw new UsageError(
353
+ "mr update needs --title, --description, --append-description or --base.",
354
+ );
341
355
  }
342
356
 
343
357
  if (body !== undefined && appendBody !== undefined) {
344
358
  throw new UsageError("mr update takes --description or --append-description, not both.");
345
359
  }
346
360
 
347
- return [toInt("prId", prId), { title, body, appendBody }];
361
+ return [toInt("prId", prId), { title, body, appendBody, baseBranch }];
348
362
  },
349
363
  },
350
364
  "mr types": {
@@ -421,6 +435,17 @@ const COMMANDS = {
421
435
  },
422
436
  };
423
437
 
438
+ /**
439
+ * Does this invocation of `command` need --force/--yes?
440
+ * @param {Command} command
441
+ * @param {Record<string, unknown>} flags
442
+ */
443
+ export function isDestructiveCommand(command, flags) {
444
+ return typeof command.destructive === "function"
445
+ ? command.destructive(flags)
446
+ : command.destructive === true;
447
+ }
448
+
424
449
  /** Every command key (for help / discovery). */
425
450
  export function listCommands() {
426
451
  return Object.keys(COMMANDS);
@@ -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. A non-repo directory or any
20
- * other failure yields `null` rather than throwing.
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,