@devflow-one/cli 0.1.1 → 0.1.3
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 +91 -5
- package/package.json +4 -2
- package/src/cli.js +81 -40
- package/src/client.js +13 -3
- package/src/commands.js +44 -27
- package/src/config.js +16 -56
- package/src/context.js +249 -0
- package/src/contextResolver.js +288 -0
- package/src/gatewayDataDir.js +86 -0
- package/src/gitFacts.js +47 -0
package/src/config.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
// Resolves how the CLI reaches the local gateway: base URL (port) and the Bearer
|
|
2
|
-
// token.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// token. Path/port resolution lives in the generated gatewayDataDir.js copy
|
|
3
|
+
// (authored at shared/gatewayDataDir.js) so the CLI, the MCP server and the
|
|
4
|
+
// desktop daemon can never disagree about where the token lives. Env vars
|
|
5
|
+
// override for tests / non-standard installs.
|
|
5
6
|
|
|
6
7
|
import fs from "node:fs";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import os from "node:os";
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
const HOST = "127.0.0.1";
|
|
12
|
-
/** Default gateway port (matches desktop server DEFAULT_PORT). */
|
|
13
|
-
const DEFAULT_PORT = 8787;
|
|
14
|
-
/** Token filename under the app's userData dir (matches token.js TOKEN_FILE). */
|
|
15
|
-
const TOKEN_FILE = "gateway-token";
|
|
9
|
+
import { describeMissingToken, resolveGatewayBaseUrl, resolveTokenPath } from "./gatewayDataDir.js";
|
|
16
10
|
|
|
17
11
|
/** A tagged config-resolution failure, so the CLI can print a clear message. */
|
|
18
12
|
export class ConfigError extends Error {
|
|
@@ -22,38 +16,9 @@ export class ConfigError extends Error {
|
|
|
22
16
|
}
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
/**
|
|
26
|
-
* The daemon's userData dir, matching desktop/src/gateway/token.js's fallback
|
|
27
|
-
* (the CLI runs under plain node, never Electron, so it always uses the fallback).
|
|
28
|
-
* @returns {string}
|
|
29
|
-
*/
|
|
30
|
-
function resolveDataDir() {
|
|
31
|
-
const home = os.homedir();
|
|
32
|
-
|
|
33
|
-
if (process.platform === "darwin") {
|
|
34
|
-
return path.join(home, "Library", "Application Support", "devflow");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (process.platform === "win32") {
|
|
38
|
-
const base = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
39
|
-
|
|
40
|
-
return path.join(base, "devflow");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return path.join(home, ".config", "devflow");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Resolve the gateway port from `DEVFLOW_GATEWAY_PORT`, else the default. */
|
|
47
|
-
function resolvePort() {
|
|
48
|
-
const port = Number.parseInt(process.env.DEVFLOW_GATEWAY_PORT, 10);
|
|
49
|
-
|
|
50
|
-
return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_PORT;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
19
|
/**
|
|
54
20
|
* Read the gateway token: `DEVFLOW_GATEWAY_TOKEN` wins, else the token file the
|
|
55
|
-
* daemon persisted. Throws {@link ConfigError}
|
|
56
|
-
* neither is available.
|
|
21
|
+
* daemon persisted. Throws {@link ConfigError} naming the exact path probed.
|
|
57
22
|
* @returns {string}
|
|
58
23
|
*/
|
|
59
24
|
function resolveToken() {
|
|
@@ -61,23 +26,20 @@ function resolveToken() {
|
|
|
61
26
|
|
|
62
27
|
if (fromEnv) return fromEnv;
|
|
63
28
|
|
|
64
|
-
const tokenPath =
|
|
29
|
+
const tokenPath = resolveTokenPath();
|
|
30
|
+
let contents;
|
|
65
31
|
|
|
66
32
|
try {
|
|
67
|
-
|
|
33
|
+
contents = fs.readFileSync(tokenPath, "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
throw new ConfigError(describeMissingToken(tokenPath, "missing"));
|
|
36
|
+
}
|
|
68
37
|
|
|
69
|
-
|
|
38
|
+
const token = contents.trim();
|
|
70
39
|
|
|
71
|
-
|
|
72
|
-
} catch (err) {
|
|
73
|
-
if (err instanceof ConfigError) throw err;
|
|
40
|
+
if (!token) throw new ConfigError(describeMissingToken(tokenPath, "empty"));
|
|
74
41
|
|
|
75
|
-
|
|
76
|
-
`Could not read the gateway token at ${tokenPath}. ` +
|
|
77
|
-
`Is the DevFlow desktop app running? ` +
|
|
78
|
-
`(Or set DEVFLOW_GATEWAY_TOKEN to override.)`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
42
|
+
return token;
|
|
81
43
|
}
|
|
82
44
|
|
|
83
45
|
/**
|
|
@@ -85,10 +47,8 @@ function resolveToken() {
|
|
|
85
47
|
* @returns {{ baseUrl: string, token: string }}
|
|
86
48
|
*/
|
|
87
49
|
export function resolveConfig() {
|
|
88
|
-
const port = resolvePort();
|
|
89
|
-
|
|
90
50
|
return {
|
|
91
|
-
baseUrl:
|
|
51
|
+
baseUrl: resolveGatewayBaseUrl(),
|
|
92
52
|
token: resolveToken(),
|
|
93
53
|
};
|
|
94
54
|
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Wires the shared resolver into the CLI: builds the `GET /context` caller, turns
|
|
2
|
+
// a resolved context into routing fields, and formats the two user-facing
|
|
3
|
+
// outcomes — `devflow where` and the "inference didn't get there" errors.
|
|
4
|
+
//
|
|
5
|
+
// The resolver itself (contextResolver.js) is generated from shared/ and must not
|
|
6
|
+
// be edited here; everything CLI-specific lives in this file.
|
|
7
|
+
|
|
8
|
+
import { resolveContext } from "./contextResolver.js";
|
|
9
|
+
import { createGitRunner } from "./gitFacts.js";
|
|
10
|
+
import { GatewayError } from "./client.js";
|
|
11
|
+
import { UsageError } from "./commands.js";
|
|
12
|
+
|
|
13
|
+
/** Flag that overrides each resolved field, for "pass --x" hints. */
|
|
14
|
+
const FLAG_FOR_FIELD = {
|
|
15
|
+
projectKey: "--project",
|
|
16
|
+
issueKey: "--issue",
|
|
17
|
+
repo: "--repo",
|
|
18
|
+
profile: "--profile",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Human label per precedence step, for `devflow where`. */
|
|
22
|
+
const ORIGIN_LABEL = {
|
|
23
|
+
explicit: "flag",
|
|
24
|
+
env: "environment",
|
|
25
|
+
gitRemote: "git remote",
|
|
26
|
+
localPath: "working directory",
|
|
27
|
+
branch: "branch name",
|
|
28
|
+
path: "directory name",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the `queryContext` the resolver calls.
|
|
33
|
+
*
|
|
34
|
+
* The daemon answers ambiguity with HTTP 409 `{ ok: false }`, which `gatewayRequest`
|
|
35
|
+
* raises as a GatewayError — and the envelope's `result` (holding the candidates)
|
|
36
|
+
* is dropped on that path. So AMBIGUOUS_PROFILE is translated back into the
|
|
37
|
+
* `{ ambiguous, candidates, message }` shape the resolver expects, and every other
|
|
38
|
+
* failure is re-thrown for the resolver to record as a warning.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ baseUrl: string, token: string }} config
|
|
41
|
+
* @param {typeof import("./client.js").gatewayRequest} request
|
|
42
|
+
*/
|
|
43
|
+
export function createContextQuery(config, request) {
|
|
44
|
+
return async ({ cwd = "", remote = "", branch = "" }) => {
|
|
45
|
+
const query = new URLSearchParams({ cwd, remote, branch });
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return await request(config, "GET", `/context?${query}`);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err instanceof GatewayError && err.code === "AMBIGUOUS_PROFILE") {
|
|
51
|
+
return { ambiguous: true, candidates: err.result?.candidates ?? [], message: err.message };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the routing context for this invocation: explicit flags first, then
|
|
61
|
+
* everything the environment can tell us.
|
|
62
|
+
* @param {Record<string, unknown>} flags
|
|
63
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, queryContext?: Function }} [deps]
|
|
64
|
+
*/
|
|
65
|
+
export function resolveCliContext(
|
|
66
|
+
flags,
|
|
67
|
+
{ cwd = process.cwd(), env = process.env, queryContext } = {},
|
|
68
|
+
) {
|
|
69
|
+
return resolveContext({
|
|
70
|
+
explicit: {
|
|
71
|
+
projectKey: flags.project === undefined ? undefined : String(flags.project),
|
|
72
|
+
issueKey: flags.issue === undefined ? undefined : String(flags.issue),
|
|
73
|
+
repo: flags.repo === undefined ? undefined : String(flags.repo),
|
|
74
|
+
profile: flags.profile === undefined ? undefined : String(flags.profile),
|
|
75
|
+
},
|
|
76
|
+
env,
|
|
77
|
+
cwd,
|
|
78
|
+
runGit: createGitRunner(cwd),
|
|
79
|
+
queryContext,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The project key a payload carries: explicit key, else derived from the issue key. */
|
|
84
|
+
export function projectKeyFrom(context) {
|
|
85
|
+
if (context.projectKey) return context.projectKey.toUpperCase();
|
|
86
|
+
|
|
87
|
+
if (context.issueKey) {
|
|
88
|
+
const lastDash = context.issueKey.lastIndexOf("-");
|
|
89
|
+
|
|
90
|
+
return (lastDash === -1 ? context.issueKey : context.issueKey.slice(0, lastDash)).toUpperCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Describe one ambiguity candidate as `PROJECT/repo (profile)`. */
|
|
97
|
+
function describeCandidate(candidate) {
|
|
98
|
+
const repo = candidate.repo?.name ?? candidate.repo?.id ?? candidate.repo?.remoteUrl;
|
|
99
|
+
const where = [repo, candidate.profileId].filter(Boolean).join(" via ");
|
|
100
|
+
|
|
101
|
+
return where ? `${candidate.projectKey} (${where})` : String(candidate.projectKey);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Summarise what inference DID find, so a failure says why it failed rather than
|
|
106
|
+
* only what is missing.
|
|
107
|
+
*/
|
|
108
|
+
function describeFindings(context) {
|
|
109
|
+
const found = Object.keys(FLAG_FOR_FIELD)
|
|
110
|
+
.filter((field) => context[field])
|
|
111
|
+
.map(
|
|
112
|
+
(field) =>
|
|
113
|
+
`${field}=${context[field]} (from ${ORIGIN_LABEL[context.matchedBy[field]] ?? context.matchedBy[field]})`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return found.length ? `Inferred ${found.join(", ")}.` : "Nothing could be inferred here.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A usable flag value, or undefined for anything blank/absent. */
|
|
120
|
+
function flagValue(raw) {
|
|
121
|
+
const value = raw === undefined || raw === true ? "" : String(raw).trim();
|
|
122
|
+
|
|
123
|
+
return value === "" ? undefined : value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The project key the caller named outright, via `--project` or implied by
|
|
128
|
+
* `--issue`. Mirrors `resolveProjectKey` in mcp/src/routing.ts: only the explicit
|
|
129
|
+
* inputs count, so a branch-inferred key never silently settles an ambiguity.
|
|
130
|
+
* @param {Record<string, unknown>} flags
|
|
131
|
+
* @returns {string | undefined}
|
|
132
|
+
*/
|
|
133
|
+
function explicitProjectKey(flags) {
|
|
134
|
+
const project = flagValue(flags.project);
|
|
135
|
+
|
|
136
|
+
if (project) return project;
|
|
137
|
+
|
|
138
|
+
const issue = flagValue(flags.issue);
|
|
139
|
+
|
|
140
|
+
if (!issue) return undefined;
|
|
141
|
+
|
|
142
|
+
const lastDash = issue.lastIndexOf("-");
|
|
143
|
+
|
|
144
|
+
return lastDash === -1 ? issue : issue.slice(0, lastDash);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Throw when the resolved context can't route the call. Ambiguity names every
|
|
149
|
+
* candidate; an empty result states what was inferred and which flag fixes it.
|
|
150
|
+
* @param {string} key command key, for the message
|
|
151
|
+
* @param {object} command
|
|
152
|
+
* @param {object} context resolved context
|
|
153
|
+
* @param {Record<string, unknown>} [flags] raw flags, to spot an explicit choice
|
|
154
|
+
*/
|
|
155
|
+
export function assertRoutable(key, command, context, flags = {}) {
|
|
156
|
+
// Ambiguity is never resolved by guessing — but a caller who already named a
|
|
157
|
+
// project (via --project, or --issue implying it) HAS made that choice, and the
|
|
158
|
+
// refusal below tells them to do exactly that; refusing anyway is a dead end.
|
|
159
|
+
// Same gate as `ambiguous && !explicitKey` in mcp/src/context.ts.
|
|
160
|
+
const explicitKey = explicitProjectKey(flags);
|
|
161
|
+
|
|
162
|
+
if (context.ambiguous && !explicitKey) {
|
|
163
|
+
const named = (context.candidates ?? []).map(describeCandidate).join(", ");
|
|
164
|
+
|
|
165
|
+
throw new UsageError(
|
|
166
|
+
`"${key}" could not be routed — this checkout matches several configured projects` +
|
|
167
|
+
`${named ? ` (${named})` : ""}. Pass --project (or --repo) to choose one. Usage: ${command.usage}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!projectKeyFrom(context) && !context.profile) {
|
|
172
|
+
// `--repo` narrows a project's repos; it cannot name the project itself, so
|
|
173
|
+
// on an ambiguous checkout it still needs --project alongside it. Say so,
|
|
174
|
+
// rather than reporting the generic "nothing was inferred".
|
|
175
|
+
const why = flagValue(flags.repo)
|
|
176
|
+
? `--repo only picks a repo within a project, and this checkout matches several projects` +
|
|
177
|
+
`${context.candidates?.length ? ` (${context.candidates.map(describeCandidate).join(", ")})` : ""}.`
|
|
178
|
+
: [describeFindings(context), ...context.warnings].join(" ");
|
|
179
|
+
|
|
180
|
+
throw new UsageError(
|
|
181
|
+
`"${key}" could not be routed. ${why} ` +
|
|
182
|
+
`Pass --issue ABC-123, --project ABC, or --profile <id>. Usage: ${command.usage}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The Option A offline message: when the owning profile is known but not
|
|
189
|
+
* connected, name it and say what to do, instead of a bare NO_PROFILE.
|
|
190
|
+
* Returns the error to throw, or null when the profile looks usable.
|
|
191
|
+
*/
|
|
192
|
+
export function offlineProfileError(context) {
|
|
193
|
+
if (context.profileAlive !== false || !context.profile) return null;
|
|
194
|
+
|
|
195
|
+
const owns = context.projectKey ? ` owns ${context.projectKey} but` : "";
|
|
196
|
+
|
|
197
|
+
return new GatewayError(
|
|
198
|
+
`Chrome profile "${context.profile}"${owns} is not connected, so this action cannot run. ` +
|
|
199
|
+
`Open that Chrome profile (the DevFlow extension executes the action) and retry.`,
|
|
200
|
+
"NO_PROFILE",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* How the resolved profile looks to the daemon. Shown inline on the profile row
|
|
206
|
+
* so `devflow where` answers "would a call work?" without a second lookup.
|
|
207
|
+
*/
|
|
208
|
+
function describeLiveness(context) {
|
|
209
|
+
if (context.profileAlive === true) return "connected";
|
|
210
|
+
|
|
211
|
+
if (context.profileAlive === false) {
|
|
212
|
+
return "NOT connected; open this Chrome profile before running actions";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return "liveness unknown (the daemon did not say)";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Render the `devflow where` report. */
|
|
219
|
+
export function formatWhere(context) {
|
|
220
|
+
const lines = [];
|
|
221
|
+
|
|
222
|
+
for (const [field, flag] of Object.entries(FLAG_FOR_FIELD)) {
|
|
223
|
+
const value = context[field];
|
|
224
|
+
const origin = ORIGIN_LABEL[context.matchedBy[field]] ?? context.matchedBy[field];
|
|
225
|
+
const suffix = field === "profile" && value ? ` — ${describeLiveness(context)}` : "";
|
|
226
|
+
|
|
227
|
+
lines.push(
|
|
228
|
+
value
|
|
229
|
+
? `${field.padEnd(11)} ${value} (${origin})${suffix}`
|
|
230
|
+
: `${field.padEnd(11)} — (not resolved; pass ${flag})`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (context.ambiguous) {
|
|
235
|
+
lines.push("", "Ambiguous — this checkout matches several configured projects:");
|
|
236
|
+
|
|
237
|
+
for (const candidate of context.candidates ?? []) {
|
|
238
|
+
lines.push(` - ${describeCandidate(candidate)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
lines.push("Pass --project (or --repo) to choose one.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (context.warnings.length) {
|
|
245
|
+
lines.push("", ...context.warnings.map((w) => `note: ${w}`));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit.
|
|
2
|
+
// Source of truth: shared/contextResolver.js. Regenerate with:
|
|
3
|
+
// node scripts/sync-context-resolver.mjs
|
|
4
|
+
|
|
5
|
+
// The one place routing context is inferred from the environment, consumed by
|
|
6
|
+
// both the CLI and the MCP server (each ships a generated copy inside its own
|
|
7
|
+
// package root — see scripts/sync-context-resolver.mjs; edit THIS file only).
|
|
8
|
+
//
|
|
9
|
+
// Division of labour: this module gathers environment FACTS (cwd, git remote,
|
|
10
|
+
// branch) and asks the daemon's `GET /context` to match them against the routing
|
|
11
|
+
// mirror. Matching itself — remote beats cwd, deepest nested repo wins, ties are
|
|
12
|
+
// ambiguous — lives in desktop/src/gateway/{routing,server}.js and is never
|
|
13
|
+
// re-derived here.
|
|
14
|
+
//
|
|
15
|
+
// Precedence, highest first:
|
|
16
|
+
// 1. explicit input (flag / tool argument)
|
|
17
|
+
// 2. DEVFLOW_PROJECT / DEVFLOW_ISSUE / DEVFLOW_REPO / DEVFLOW_PROFILE
|
|
18
|
+
// 3. git remote `origin`, matched against the mirror
|
|
19
|
+
// 4. cwd (walked to the git root), matched against `localPath`
|
|
20
|
+
// 5. issue key parsed from the current branch
|
|
21
|
+
// 6. Jira-key-shaped ancestor directory name
|
|
22
|
+
// A lower step never overwrites a value a higher one already supplied.
|
|
23
|
+
|
|
24
|
+
/** Env var per resolved field. */
|
|
25
|
+
const ENV_KEYS = {
|
|
26
|
+
projectKey: "DEVFLOW_PROJECT",
|
|
27
|
+
issueKey: "DEVFLOW_ISSUE",
|
|
28
|
+
repo: "DEVFLOW_REPO",
|
|
29
|
+
profile: "DEVFLOW_PROFILE",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Jira-key shape for a whole directory name: `ABC-123`. */
|
|
33
|
+
const DIR_ISSUE_KEY = /^([A-Z][A-Z0-9]+-\d+)$/;
|
|
34
|
+
|
|
35
|
+
/** Jira-key shape inside a branch name: `feature/ABC-123-thing` → `ABC-123`. */
|
|
36
|
+
const BRANCH_ISSUE_KEY = /(?:^|[^A-Z0-9])([A-Z][A-Z0-9]+-\d+)(?:[^0-9]|$)/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the issue key a branch name refers to, if any. Mirrors
|
|
40
|
+
* `issueKeyFromBranch` in desktop/src/gateway/routing.js — the daemon normally
|
|
41
|
+
* supplies this, but it is needed locally too so an offline daemon still yields
|
|
42
|
+
* a branch-derived issue key.
|
|
43
|
+
* @param {unknown} branch
|
|
44
|
+
* @returns {string | undefined}
|
|
45
|
+
*/
|
|
46
|
+
export function issueKeyFromBranch(branch) {
|
|
47
|
+
if (typeof branch !== "string" || branch.trim() === "") return undefined;
|
|
48
|
+
|
|
49
|
+
return BRANCH_ISSUE_KEY.exec(branch.trim().toUpperCase())?.[1] ?? undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Trimmed string, or `undefined` for anything unusable. */
|
|
53
|
+
function clean(value) {
|
|
54
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The project a key or issue key belongs to: split on the LAST `-` and uppercase
|
|
59
|
+
* (`abc-123` → `ABC`). Mirrors `issueKeyToProjectKey` in the daemon's routing.js.
|
|
60
|
+
* @param {string | undefined} value
|
|
61
|
+
* @returns {string | undefined}
|
|
62
|
+
*/
|
|
63
|
+
function projectOf(value) {
|
|
64
|
+
const trimmed = clean(value);
|
|
65
|
+
|
|
66
|
+
if (!trimmed) return undefined;
|
|
67
|
+
|
|
68
|
+
const lastDash = trimmed.lastIndexOf("-");
|
|
69
|
+
|
|
70
|
+
return (lastDash === -1 ? trimmed : trimmed.slice(0, lastDash)).toUpperCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The issue key an ancestor directory names, nearest first: a checkout parked in
|
|
75
|
+
* `~/work/ABC-123/repo` implies the issue even with no git metadata at all.
|
|
76
|
+
* @param {string} cwd
|
|
77
|
+
* @returns {string | undefined}
|
|
78
|
+
*/
|
|
79
|
+
export function issueKeyFromPath(cwd) {
|
|
80
|
+
const path = clean(cwd);
|
|
81
|
+
|
|
82
|
+
if (!path) return undefined;
|
|
83
|
+
|
|
84
|
+
const segments = path.replace(/\\/g, "/").split("/").filter(Boolean).reverse();
|
|
85
|
+
|
|
86
|
+
for (const segment of segments) {
|
|
87
|
+
const match = DIR_ISSUE_KEY.exec(segment.toUpperCase());
|
|
88
|
+
|
|
89
|
+
if (match) return match[1];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the four routing env vars.
|
|
97
|
+
* @param {Record<string, string | undefined>} env
|
|
98
|
+
* @returns {{ projectKey?: string, issueKey?: string, repo?: string, profile?: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function readEnvContext(env = {}) {
|
|
101
|
+
const out = {};
|
|
102
|
+
|
|
103
|
+
for (const [field, key] of Object.entries(ENV_KEYS)) {
|
|
104
|
+
const value = clean(env[key]);
|
|
105
|
+
|
|
106
|
+
if (value) out[field] = value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collect what git knows about `cwd`, tolerating a directory that is not a repo.
|
|
114
|
+
* Every command is read-only and non-interactive: `--no-pager` plus a terminal
|
|
115
|
+
* prompt of "" so a credential helper can never block on stdin.
|
|
116
|
+
* @param {(args: string[]) => string | null} runGit runner returning stdout or null
|
|
117
|
+
* @returns {{ root?: string, remote?: string, branch?: string }}
|
|
118
|
+
*/
|
|
119
|
+
export function readGitFacts(runGit) {
|
|
120
|
+
const root = clean(runGit(["rev-parse", "--show-toplevel"]));
|
|
121
|
+
|
|
122
|
+
// Not a repo — the remaining reads would fail the same way, so stop here.
|
|
123
|
+
if (!root) return {};
|
|
124
|
+
|
|
125
|
+
const branch = clean(runGit(["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
root,
|
|
129
|
+
remote: clean(runGit(["remote", "get-url", "origin"])),
|
|
130
|
+
// Detached HEAD reports "HEAD", which names no branch.
|
|
131
|
+
branch: branch && branch !== "HEAD" ? branch : undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Merge a lower-precedence source in, recording which step supplied each field.
|
|
137
|
+
* @param {object} resolved accumulator, mutated
|
|
138
|
+
* @param {Record<string, string | undefined>} source
|
|
139
|
+
* @param {string} origin precedence-step label
|
|
140
|
+
*/
|
|
141
|
+
function fill(resolved, source, origin) {
|
|
142
|
+
for (const [field, value] of Object.entries(source)) {
|
|
143
|
+
if (value === undefined || resolved[field] !== undefined) continue;
|
|
144
|
+
|
|
145
|
+
resolved[field] = value;
|
|
146
|
+
resolved.matchedBy[field] = origin;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the routing context for a call.
|
|
152
|
+
*
|
|
153
|
+
* `queryContext` is injected so this stays pure and unit-testable; the CLI and
|
|
154
|
+
* MCP both pass a `GET /context` caller. It may return `null` when the daemon is
|
|
155
|
+
* unreachable — inference then degrades to env + branch/path parsing rather than
|
|
156
|
+
* failing, because a fully explicit call needs no daemon at all.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} options
|
|
159
|
+
* @param {{ projectKey?: string, issueKey?: string, repo?: string, profile?: string }} [options.explicit]
|
|
160
|
+
* @param {Record<string, string | undefined>} [options.env]
|
|
161
|
+
* @param {string} [options.cwd]
|
|
162
|
+
* @param {(args: string[]) => string | null} [options.runGit]
|
|
163
|
+
* @param {(q: { cwd?: string, remote?: string, branch?: string }) => Promise<object | null>} [options.queryContext]
|
|
164
|
+
* @returns {Promise<{
|
|
165
|
+
* projectKey?: string, issueKey?: string, repo?: string, profile?: string,
|
|
166
|
+
* profileAlive?: boolean | null, matchedBy: Record<string, string>,
|
|
167
|
+
* candidates?: object[], ambiguous?: boolean, warnings: string[]
|
|
168
|
+
* }>}
|
|
169
|
+
*/
|
|
170
|
+
export async function resolveContext({
|
|
171
|
+
explicit = {},
|
|
172
|
+
env = {},
|
|
173
|
+
cwd = "",
|
|
174
|
+
runGit = () => null,
|
|
175
|
+
queryContext = async () => null,
|
|
176
|
+
} = {}) {
|
|
177
|
+
const resolved = { matchedBy: {}, warnings: [] };
|
|
178
|
+
|
|
179
|
+
fill(
|
|
180
|
+
resolved,
|
|
181
|
+
{
|
|
182
|
+
projectKey: clean(explicit.projectKey),
|
|
183
|
+
issueKey: clean(explicit.issueKey),
|
|
184
|
+
repo: clean(explicit.repo),
|
|
185
|
+
profile: clean(explicit.profile),
|
|
186
|
+
},
|
|
187
|
+
"explicit",
|
|
188
|
+
);
|
|
189
|
+
fill(resolved, readEnvContext(env), "env");
|
|
190
|
+
|
|
191
|
+
// An explicit issue key implies its project, but only if nothing better said so.
|
|
192
|
+
const git = readGitFacts(runGit);
|
|
193
|
+
|
|
194
|
+
if (!git.root && clean(cwd)) {
|
|
195
|
+
resolved.warnings.push(`${cwd} is not inside a git repository; inferring from the path only.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The git root is the meaningful path to match: a nested subdirectory of a
|
|
199
|
+
// checkout should resolve to the same repo as its root.
|
|
200
|
+
const matchPath = git.root ?? clean(cwd);
|
|
201
|
+
let context = null;
|
|
202
|
+
|
|
203
|
+
if (git.remote || matchPath) {
|
|
204
|
+
try {
|
|
205
|
+
context = await queryContext({
|
|
206
|
+
cwd: matchPath ?? "",
|
|
207
|
+
remote: git.remote ?? "",
|
|
208
|
+
branch: git.branch ?? "",
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
resolved.warnings.push(
|
|
212
|
+
`Could not ask the DevFlow daemon to resolve this checkout: ${err?.message ?? err}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (context?.ambiguous) {
|
|
218
|
+
resolved.ambiguous = true;
|
|
219
|
+
resolved.candidates = context.candidates ?? [];
|
|
220
|
+
resolved.warnings.push(
|
|
221
|
+
context.message ??
|
|
222
|
+
"Several configured repositories match this checkout; pass --project (or --repo) to choose one.",
|
|
223
|
+
);
|
|
224
|
+
} else if (context) {
|
|
225
|
+
// `matchedBy` from the daemon says WHICH fact matched — remote or localPath —
|
|
226
|
+
// so the reported precedence step reflects the real reason, not a guess.
|
|
227
|
+
const origin = context.matchedBy === "remote" ? "gitRemote" : "localPath";
|
|
228
|
+
|
|
229
|
+
// The daemon answered "this CHECKOUT belongs to project P". Its repo, profile,
|
|
230
|
+
// liveness and issue key all describe P — they are only meaningful together.
|
|
231
|
+
// If the caller already pinned a different project, adopting them field-by-field
|
|
232
|
+
// would aim the action at P's repository under the caller's key: silent
|
|
233
|
+
// mis-targeting, and worse than a failure because nothing looks wrong. So the
|
|
234
|
+
// whole group is discarded on disagreement. Note this compares the KEYS, not
|
|
235
|
+
// merely where they came from — a caller who names the project the checkout
|
|
236
|
+
// already matches is agreeing, and must still get its repo/profile.
|
|
237
|
+
const pinned = projectOf(resolved.projectKey) ?? projectOf(resolved.issueKey);
|
|
238
|
+
const matched = projectOf(context.projectKey);
|
|
239
|
+
const conflict = Boolean(pinned && matched && pinned !== matched);
|
|
240
|
+
|
|
241
|
+
if (conflict) {
|
|
242
|
+
const named = resolved.projectKey ? `${resolved.projectKey}` : `${resolved.issueKey}`;
|
|
243
|
+
|
|
244
|
+
resolved.warnings.push(
|
|
245
|
+
`This checkout belongs to ${matched}, but ${named} was given explicitly; ` +
|
|
246
|
+
`ignoring the repository and profile inferred for ${matched}. ` +
|
|
247
|
+
`Pass --repo / --profile to target ${pinned} exactly.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fill(
|
|
252
|
+
resolved,
|
|
253
|
+
{
|
|
254
|
+
projectKey: clean(context.projectKey),
|
|
255
|
+
...(conflict
|
|
256
|
+
? {}
|
|
257
|
+
: {
|
|
258
|
+
repo:
|
|
259
|
+
clean(context.repo?.id) ??
|
|
260
|
+
clean(context.repo?.name) ??
|
|
261
|
+
clean(context.repo?.remoteUrl),
|
|
262
|
+
profile: clean(context.profileId),
|
|
263
|
+
}),
|
|
264
|
+
},
|
|
265
|
+
origin,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (context.candidates?.length) resolved.candidates = context.candidates;
|
|
269
|
+
|
|
270
|
+
// Liveness is only meaningful for the profile we actually resolved to; it lets
|
|
271
|
+
// consumers name an offline profile instead of printing a bare NO_PROFILE.
|
|
272
|
+
if (!conflict && resolved.profile && resolved.profile === clean(context.profileId)) {
|
|
273
|
+
resolved.profileAlive = context.profileAlive ?? null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The daemon's issue key describes its matched project too, so it is dropped on
|
|
277
|
+
// conflict — but the branch/path fallbacks below still apply, since those are
|
|
278
|
+
// read from the local checkout rather than from the project match.
|
|
279
|
+
if (!conflict) fill(resolved, { issueKey: clean(context.issueKey) }, "branch");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Branch-derived issue key ranks below every repo match but above the path.
|
|
283
|
+
// Parsed locally when the daemon didn't answer, so this survives it being down.
|
|
284
|
+
fill(resolved, { issueKey: issueKeyFromBranch(git.branch) }, "branch");
|
|
285
|
+
fill(resolved, { issueKey: issueKeyFromPath(matchPath) }, "path");
|
|
286
|
+
|
|
287
|
+
return resolved;
|
|
288
|
+
}
|