@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 +16 -5
- 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 +39 -14
- 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`.
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
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.
|
|
@@ -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"}\'] [--
|
|
196
|
-
"
|
|
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 [
|
|
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
|
-
" (
|
|
219
|
-
"
|
|
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
|
|
340
|
-
throw new UsageError(
|
|
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);
|
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,
|