@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 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`.
@@ -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
- 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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devflow-one/cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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.
@@ -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"}\'] [--no-push] [--issue KEY] [--project KEY]' +
198
- " (--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" +
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 [worktreePath, values, push];
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);
@@ -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,