@devflow-one/cli 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DevFlow One
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # devflow — CLI for the local action gateway
2
+
3
+ Trigger DevFlow repo actions (branch, PR/MR, pipeline, reviewers) from the
4
+ terminal, so shell scripts and CI-style automations can drive the same actions
5
+ the extension panel exposes.
6
+
7
+ It is a **thin HTTP client of the local gateway** the desktop app runs
8
+ (`desktop/src/gateway/server.js`). It reads the same auto-generated token file,
9
+ routes by Jira project key (or issue key), and prints the gateway's result. No
10
+ provider API logic lives here — the owning browser profile executes the action.
11
+
12
+ ## How it works
13
+
14
+ - Each command maps to one gateway action; global flags (`--issue` / `--project`
15
+ / `--profile` / `--repo`) supply routing, the command supplies its own args.
16
+ - The gateway routes the action to the connected browser profile that owns the
17
+ project key, waits for the profile's result, and returns it.
18
+ - Discovery: `list-actions` and `list-profiles` read straight from the live
19
+ gateway registry (advertised actions; connected profiles + owned keys).
20
+ - Failures come back as structured errors (`NO_PROFILE`, `AMBIGUOUS_PROFILE`,
21
+ `TIMEOUT`, `DESTRUCTIVE_BLOCKED`, or an unreachable daemon), printed with their
22
+ code (`--json` for the machine-readable envelope).
23
+
24
+ ## Prerequisites
25
+
26
+ - The **DevFlow desktop app must be running** — it hosts the gateway and writes
27
+ the token. v1 also needs the browser extension connected so a profile owns
28
+ project keys.
29
+ - Node **>= 20**.
30
+
31
+ ## Install / run
32
+
33
+ Published to npm as `@devflow-one/cli` — no clone, no build:
34
+
35
+ ```sh
36
+ npm i -g @devflow-one/cli # exposes `devflow` on your PATH
37
+ devflow --help
38
+ ```
39
+
40
+ Or run it without installing:
41
+
42
+ ```sh
43
+ npx -y @devflow-one/cli --help
44
+ ```
45
+
46
+ From a clone of the monorepo (contributors), the CLI is dependency-free and runs
47
+ straight from the repo:
48
+
49
+ ```sh
50
+ node cli/bin/devflow.js --help
51
+ cd cli && npm link # or link it onto your PATH as `devflow`
52
+ ```
53
+
54
+ ## Token & port (zero-config)
55
+
56
+ The CLI discovers the gateway automatically — no setup — by reading the token
57
+ file the desktop daemon writes on first run, under its userData dir:
58
+
59
+ | Platform | Token path |
60
+ | -------- | -------------------------------------------------------------- |
61
+ | macOS | `~/Library/Application Support/devflow/gateway-token` |
62
+ | Linux | `~/.config/devflow/gateway-token` |
63
+ | Windows | `%APPDATA%\devflow\gateway-token` |
64
+
65
+ The file is written owner-only (`0600`). Override for CI / non-standard installs:
66
+
67
+ | Env var | Purpose |
68
+ | ----------------------- | --------------------------------------------------- |
69
+ | `DEVFLOW_GATEWAY_TOKEN` | Provide the token directly (skips the file lookup). |
70
+ | `DEVFLOW_GATEWAY_PORT` | Gateway port (default `8787`). |
71
+
72
+ ## Usage
73
+
74
+ ```sh
75
+ # discovery
76
+ devflow list-actions
77
+ devflow list-profiles # connected profiles + owned keys
78
+
79
+ # routed by project key
80
+ devflow branch create feat/x --project ABC
81
+ devflow mr create "Title" feat/x --body "desc" --project ABC
82
+
83
+ # routed by issue key (project key = prefix before the last '-')
84
+ devflow mr find ABC-123 --issue ABC-123
85
+ devflow pipeline status 42 deadbeef --issue ABC-9
86
+
87
+ # commit a managed worktree with the project's commit-message template
88
+ devflow commit template /wt/ABC-123 --values '{"summary":"fix login"}' --project ABC
89
+ devflow commit template /wt/ABC-123 --no-push --project ABC # commit locally, skip push
90
+
91
+ # assembled fix-review-comments prompt (read-only; runs no AI, makes no writes)
92
+ devflow mr fix-prompt 42 --issue ABC-123
93
+
94
+ # notify the project's configured Slack channel(s)
95
+ devflow slack notify "Review fixes ready on {pr}" --values '{"pr":"#42"}' --project ABC
96
+
97
+ # force a specific profile when a key is ambiguous
98
+ devflow mr ready 12 --profile prof-1
99
+ ```
100
+
101
+ ### Destructive actions
102
+
103
+ `branch delete` and `mr close` are destructive. They are **refused unless you
104
+ confirm** with `--force` (or `--yes`); the gateway enforces the same gate
105
+ server-side, so the confirmation can't be bypassed:
106
+
107
+ ```sh
108
+ devflow branch delete old --project ABC # refused
109
+ devflow branch delete old --project ABC --force # executes
110
+ ```
111
+
112
+ ## Verify
113
+
114
+ ```sh
115
+ npm test # unit: arg parsing, command resolution, routing, destructive gate
116
+ ```
117
+
118
+ ## Releasing
119
+
120
+ Publishing this package to npm is a manual, dry-run-first process — see
121
+ [`RELEASING.md`](../RELEASING.md) at the repo root.
package/bin/devflow.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // `devflow` entry point. Delegates to the testable run() and maps its return to
3
+ // a process exit code. Unexpected (non-tagged) errors surface with a stack.
4
+
5
+ import { run } from "../src/cli.js";
6
+
7
+ run(process.argv.slice(2))
8
+ .then((code) => {
9
+ process.exitCode = code;
10
+ })
11
+ .catch((err) => {
12
+ console.error(`error: ${err?.stack ?? err}`);
13
+ process.exitCode = 1;
14
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@devflow-one/cli",
3
+ "version": "0.1.1",
4
+ "description": "Terminal CLI wrapping the local DevFlow gateway so shell scripts can trigger repo actions.",
5
+ "keywords": [
6
+ "devflow",
7
+ "jira",
8
+ "cli",
9
+ "git",
10
+ "gitlab",
11
+ "github",
12
+ "merge-request",
13
+ "pull-request",
14
+ "automation"
15
+ ],
16
+ "homepage": "https://devflow-one.com",
17
+ "bugs": {
18
+ "url": "https://gitlab.com/devflow.one/monorepo/-/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://gitlab.com/devflow.one/monorepo.git",
23
+ "directory": "cli"
24
+ },
25
+ "license": "MIT",
26
+ "author": "DevFlow One",
27
+ "type": "module",
28
+ "bin": {
29
+ "devflow": "bin/devflow.js"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "!src/tests",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "scripts": {
45
+ "test": "node --test \"src/tests/**/*.test.js\""
46
+ },
47
+ "devDependencies": {
48
+ "release-it": "^20.0.1"
49
+ }
50
+ }
package/src/args.js ADDED
@@ -0,0 +1,59 @@
1
+ // Minimal, dependency-free argv parser. Splits argv into positionals and flags.
2
+ // Supports `--flag value`, `--flag=value`, and boolean `--flag` (when the next
3
+ // token is another flag or absent). `--` ends flag parsing; the rest are
4
+ // positionals.
5
+
6
+ /** Boolean flags never consume the following token. */
7
+ const BOOLEAN_FLAGS = new Set(["json", "all", "help", "force", "yes", "no-push"]);
8
+
9
+ /**
10
+ * @param {string[]} argv the args after the node/script path
11
+ * @returns {{ positionals: string[], flags: Record<string, string | boolean> }}
12
+ */
13
+ export function parseArgs(argv) {
14
+ const positionals = [];
15
+ const flags = {};
16
+ let onlyPositionals = false;
17
+
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const token = argv[i];
20
+
21
+ if (onlyPositionals || !token.startsWith("--")) {
22
+ positionals.push(token);
23
+
24
+ continue;
25
+ }
26
+
27
+ if (token === "--") {
28
+ onlyPositionals = true;
29
+
30
+ continue;
31
+ }
32
+
33
+ const body = token.slice(2);
34
+ const eq = body.indexOf("=");
35
+
36
+ if (eq !== -1) {
37
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
38
+
39
+ continue;
40
+ }
41
+
42
+ if (BOOLEAN_FLAGS.has(body)) {
43
+ flags[body] = true;
44
+
45
+ continue;
46
+ }
47
+
48
+ const next = argv[i + 1];
49
+
50
+ if (next === undefined || next.startsWith("--")) {
51
+ flags[body] = true;
52
+ } else {
53
+ flags[body] = next;
54
+ i++;
55
+ }
56
+ }
57
+
58
+ return { positionals, flags };
59
+ }
package/src/cli.js ADDED
@@ -0,0 +1,190 @@
1
+ // CLI orchestrator: parse argv → resolve command → build the routed payload →
2
+ // call the gateway → print the result (or an actionable error). Kept free of
3
+ // process.exit so it's unit-testable; `run()` returns an exit code.
4
+
5
+ import { parseArgs } from "./args.js";
6
+ import { resolveConfig, ConfigError } from "./config.js";
7
+ import { gatewayRequest, GatewayError } from "./client.js";
8
+ import { resolveCommand, listCommands, UsageError } from "./commands.js";
9
+
10
+ const USAGE = `devflow — trigger DevFlow repo actions via the local gateway
11
+
12
+ Usage:
13
+ devflow <command> [args] [--issue KEY | --project KEY] [--profile ID] [--json]
14
+
15
+ Commands:
16
+ ${listCommands()
17
+ .map((c) => ` ${c}`)
18
+ .join("\n")}
19
+
20
+ Routing flags (one of --issue / --project required for action commands):
21
+ --issue ABC-123 Route by Jira issue key (project key = prefix before last '-').
22
+ --project ABC Route by project key directly.
23
+ --profile <id> Force a specific Chrome profile (disambiguates ambiguity).
24
+ --repo <id|url> Select a repo when the project owns several.
25
+
26
+ Destructive actions (branch delete, mr close):
27
+ --force, --yes Confirm a destructive action; required or it is refused.
28
+
29
+ Output:
30
+ --json Print machine-readable JSON (envelope) for scripts.
31
+
32
+ Environment:
33
+ DEVFLOW_GATEWAY_PORT Gateway port (default 8787).
34
+ DEVFLOW_GATEWAY_TOKEN Override the auto-read token file.`;
35
+
36
+ /** Attach the routing fields from global flags to an action request body. */
37
+ function buildRoutingBody(flags) {
38
+ const body = {};
39
+
40
+ if (flags.issue) body.issueId = String(flags.issue);
41
+ if (flags.project) body.projectKey = String(flags.project);
42
+ if (flags.profile) body.profile = String(flags.profile);
43
+
44
+ return body;
45
+ }
46
+
47
+ /**
48
+ * The project key a payload should carry (from --project, or derived from
49
+ * --issue). Uppercased to match how Jira keys are stored in the extension config
50
+ * and the gateway routing rule (`issueKeyToProjectKey`).
51
+ */
52
+ function payloadProjectKey(flags) {
53
+ if (flags.project) return String(flags.project).toUpperCase();
54
+
55
+ if (flags.issue) {
56
+ const issue = String(flags.issue);
57
+ const lastDash = issue.lastIndexOf("-");
58
+
59
+ return (lastDash === -1 ? issue : issue.slice(0, lastDash)).toUpperCase();
60
+ }
61
+
62
+ return undefined;
63
+ }
64
+
65
+ /** Print the result of a GET/POST call, honoring --json. */
66
+ function printResult(result, json, stdout) {
67
+ if (json) {
68
+ stdout(JSON.stringify({ ok: true, result }, null, 2));
69
+
70
+ return;
71
+ }
72
+
73
+ if (typeof result === "string") {
74
+ stdout(result);
75
+ } else {
76
+ stdout(JSON.stringify(result, null, 2));
77
+ }
78
+ }
79
+
80
+ /** Print an error, honoring --json, to stderr. */
81
+ function printError(err, json, stderr) {
82
+ const code = err instanceof GatewayError ? err.code : err.name;
83
+
84
+ if (json) {
85
+ stderr(JSON.stringify({ ok: false, error: { code, message: err.message } }, null, 2));
86
+
87
+ return;
88
+ }
89
+
90
+ stderr(`error${code ? ` [${code}]` : ""}: ${err.message}`);
91
+ }
92
+
93
+ /**
94
+ * Run the CLI once.
95
+ * @param {string[]} argv args after node/script (i.e. `process.argv.slice(2)`)
96
+ * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void, request?: typeof gatewayRequest }} [io]
97
+ * @returns {Promise<number>} exit code
98
+ */
99
+ export async function run(argv, io = {}) {
100
+ const stdout = io.stdout ?? ((s) => console.log(s));
101
+ const stderr = io.stderr ?? ((s) => console.error(s));
102
+ const request = io.request ?? gatewayRequest;
103
+
104
+ const { positionals, flags } = parseArgs(argv);
105
+
106
+ // Usage asked for is a success; usage printed because nothing was asked is an error.
107
+ if (flags.help || positionals.length === 0) {
108
+ stdout(USAGE);
109
+
110
+ return flags.help ? 0 : 1;
111
+ }
112
+
113
+ const match = resolveCommand(positionals);
114
+
115
+ if (!match) {
116
+ stderr(`Unknown command: "${positionals.join(" ")}". Run \`devflow --help\`.`);
117
+
118
+ return 1;
119
+ }
120
+
121
+ const { key, command, rest } = match;
122
+
123
+ let config;
124
+
125
+ try {
126
+ config = resolveConfig();
127
+ } catch (err) {
128
+ if (err instanceof ConfigError) {
129
+ printError(err, Boolean(flags.json), stderr);
130
+
131
+ return 1;
132
+ }
133
+
134
+ throw err;
135
+ }
136
+
137
+ try {
138
+ if (command.method === "GET") {
139
+ const result = await request(config, "GET", command.path);
140
+
141
+ printResult(result, Boolean(flags.json), stdout);
142
+
143
+ return 0;
144
+ }
145
+
146
+ // POST action: routing must be provided.
147
+ const projectKey = payloadProjectKey(flags);
148
+
149
+ if (!projectKey && !flags.profile) {
150
+ throw new UsageError(
151
+ `"${key}" needs a target: pass --issue, --project, or --profile. Usage: ${command.usage}`,
152
+ );
153
+ }
154
+
155
+ // Destructive gate: refuse before touching the gateway unless confirmed.
156
+ const confirmed = Boolean(flags.force || flags.yes);
157
+
158
+ if (command.destructive && !confirmed) {
159
+ throw new UsageError(
160
+ `"${key}" is destructive — re-run with --force (or --yes) to confirm. Usage: ${command.usage}`,
161
+ );
162
+ }
163
+
164
+ const action = command.resolveAction ? command.resolveAction(flags) : command.action;
165
+ const args = command.buildArgs ? command.buildArgs(rest, flags) : [];
166
+ const body = {
167
+ ...buildRoutingBody(flags),
168
+ ...(command.destructive ? { allowDestructive: true } : {}),
169
+ payload: {
170
+ projectKey,
171
+ ...(flags.repo ? { repo: String(flags.repo) } : {}),
172
+ args,
173
+ },
174
+ };
175
+
176
+ const result = await request(config, "POST", `/actions/${action}`, body);
177
+
178
+ printResult(result, Boolean(flags.json), stdout);
179
+
180
+ return 0;
181
+ } catch (err) {
182
+ if (err instanceof UsageError || err instanceof GatewayError) {
183
+ printError(err, Boolean(flags.json), stderr);
184
+
185
+ return 1;
186
+ }
187
+
188
+ throw err;
189
+ }
190
+ }
package/src/client.js ADDED
@@ -0,0 +1,77 @@
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.
4
+
5
+ /** A gateway call failure carrying the server's error `code` (when present). */
6
+ export class GatewayError extends Error {
7
+ /** @param {string} message @param {string} [code] */
8
+ constructor(message, code) {
9
+ super(message);
10
+ this.name = "GatewayError";
11
+ this.code = code;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Turn a network-level failure into an actionable message. ECONNREFUSED almost
17
+ * always means the daemon isn't running.
18
+ * @param {unknown} err
19
+ * @param {string} baseUrl
20
+ */
21
+ function describeTransportError(err, baseUrl) {
22
+ const cause = err?.cause ?? err;
23
+ const code = cause?.code;
24
+
25
+ if (code === "ECONNREFUSED") {
26
+ return new GatewayError(
27
+ `Cannot reach the DevFlow gateway at ${baseUrl}. Is the desktop app running?`,
28
+ "ECONNREFUSED",
29
+ );
30
+ }
31
+
32
+ return new GatewayError(`Gateway request failed: ${cause?.message ?? err}`, code);
33
+ }
34
+
35
+ /**
36
+ * Perform a gateway request and unwrap the response envelope.
37
+ * @param {{ baseUrl: string, token: string }} config
38
+ * @param {"GET" | "POST"} method
39
+ * @param {string} path e.g. `/actions/createBranch`
40
+ * @param {unknown} [body] JSON body for POST
41
+ * @returns {Promise<unknown>} the envelope's `result`
42
+ * @throws {GatewayError} on transport failure or an `{ ok: false }` envelope
43
+ */
44
+ export async function gatewayRequest(config, method, path, body) {
45
+ const headers = { Authorization: `Bearer ${config.token}` };
46
+
47
+ if (body !== undefined) headers["Content-Type"] = "application/json";
48
+
49
+ let response;
50
+
51
+ try {
52
+ response = await fetch(`${config.baseUrl}${path}`, {
53
+ method,
54
+ headers,
55
+ body: body !== undefined ? JSON.stringify(body) : undefined,
56
+ });
57
+ } catch (err) {
58
+ throw describeTransportError(err, config.baseUrl);
59
+ }
60
+
61
+ let envelope;
62
+
63
+ try {
64
+ envelope = await response.json();
65
+ } catch {
66
+ throw new GatewayError(
67
+ `Gateway returned a non-JSON response (HTTP ${response.status}).`,
68
+ "BAD_RESPONSE",
69
+ );
70
+ }
71
+
72
+ if (envelope?.ok) return envelope.result;
73
+
74
+ const error = envelope?.error ?? {};
75
+
76
+ throw new GatewayError(error.message ?? `Gateway error (HTTP ${response.status}).`, error.code);
77
+ }
@@ -0,0 +1,277 @@
1
+ // Command table: maps CLI verbs to gateway calls. Each command either GETs a
2
+ // discovery route or POSTs an action whose `payload.args` mirrors the positional
3
+ // signature of the matching Repo store action (see extension gatewayActions.ts).
4
+ //
5
+ // Routing (projectKey / issueKey / profile) is attached by cli.js from the global
6
+ // flags; a command only builds its `args`.
7
+
8
+ /** Parse a positional as an integer, or fail with a clear message. */
9
+ function toInt(name, value) {
10
+ const n = Number.parseInt(value, 10);
11
+
12
+ if (!Number.isInteger(n)) {
13
+ throw new UsageError(`"${name}" must be an integer, got "${value}".`);
14
+ }
15
+
16
+ return n;
17
+ }
18
+
19
+ /** Parse a `--values` JSON-object flag into a plain object, or fail clearly. */
20
+ function toValues(flag, raw) {
21
+ if (raw === undefined) return undefined;
22
+
23
+ let parsed;
24
+
25
+ try {
26
+ parsed = JSON.parse(String(raw));
27
+ } catch {
28
+ throw new UsageError(`"--${flag}" must be a JSON object, got "${raw}".`);
29
+ }
30
+
31
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
32
+ throw new UsageError(`"--${flag}" must be a JSON object (e.g. '{"key":"val"}').`);
33
+ }
34
+
35
+ return parsed;
36
+ }
37
+
38
+ /** A bad-invocation error (wrong/missing args) — printed with the command usage. */
39
+ export class UsageError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = "UsageError";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @typedef {Object} Command
48
+ * @property {"GET" | "POST"} method
49
+ * @property {string} [path] For GET commands.
50
+ * @property {string} [action] Gateway action name for POST commands.
51
+ * @property {boolean} [destructive] Requires --force/--yes (gated by cli.js).
52
+ * @property {(pos: string[], flags: Record<string, unknown>) => unknown[]} [buildArgs]
53
+ * @property {string} usage One-line usage string.
54
+ */
55
+
56
+ /** The command tree: `"group sub"` or a bare verb → {@link Command}. */
57
+ const COMMANDS = {
58
+ "list-actions": {
59
+ method: "GET",
60
+ path: "/actions",
61
+ usage: "list-actions",
62
+ },
63
+ "list-profiles": {
64
+ method: "GET",
65
+ path: "/profiles",
66
+ usage: "list-profiles",
67
+ },
68
+
69
+ "branch create": {
70
+ method: "POST",
71
+ action: "createBranch",
72
+ usage: "branch create <branchName> (--issue|--project)",
73
+ buildArgs: ([branchName]) => {
74
+ if (!branchName) throw new UsageError("branch create requires <branchName>.");
75
+
76
+ return [branchName];
77
+ },
78
+ },
79
+ "branch delete": {
80
+ method: "POST",
81
+ action: "deleteBranch",
82
+ destructive: true,
83
+ usage: "branch delete <branchName> (--issue|--project) --force",
84
+ buildArgs: ([branchName]) => {
85
+ if (!branchName) throw new UsageError("branch delete requires <branchName>.");
86
+
87
+ return [branchName];
88
+ },
89
+ },
90
+
91
+ "commit template": {
92
+ method: "POST",
93
+ action: "commitByTemplate",
94
+ usage:
95
+ 'commit template <worktreePath> [--values \'{"key":"val"}\'] [--no-push] (--issue|--project)',
96
+ buildArgs: ([worktreePath], flags) => {
97
+ if (!worktreePath) throw new UsageError("commit template requires <worktreePath>.");
98
+
99
+ const values = toValues("values", flags.values);
100
+ const push = flags["no-push"] ? false : undefined;
101
+
102
+ return [worktreePath, values, push];
103
+ },
104
+ },
105
+
106
+ "mr create": {
107
+ method: "POST",
108
+ action: "createPullRequest",
109
+ usage: "mr create <title> <headBranch> [--body <text>] (--issue|--project)",
110
+ buildArgs: ([title, headBranch], flags) => {
111
+ if (!title || !headBranch) {
112
+ throw new UsageError("mr create requires <title> and <headBranch>.");
113
+ }
114
+
115
+ return [title, flags.body ?? "", headBranch];
116
+ },
117
+ },
118
+ "mr ready": {
119
+ method: "POST",
120
+ action: "markPrReadyForReview",
121
+ usage: "mr ready <prId> (--issue|--project)",
122
+ buildArgs: ([prId]) => [toInt("prId", prId)],
123
+ },
124
+ "mr draft": {
125
+ method: "POST",
126
+ action: "markPrAsDraft",
127
+ usage: "mr draft <prId> (--issue|--project)",
128
+ buildArgs: ([prId]) => [toInt("prId", prId)],
129
+ },
130
+ "mr close": {
131
+ method: "POST",
132
+ action: "closePullRequest",
133
+ destructive: true,
134
+ usage: "mr close <prId> (--issue|--project) --force",
135
+ buildArgs: ([prId]) => [toInt("prId", prId)],
136
+ },
137
+ "mr assign": {
138
+ method: "POST",
139
+ action: "assignPr",
140
+ usage: "mr assign <prId> <assignee> (--issue|--project)",
141
+ buildArgs: ([prId, assignee]) => {
142
+ if (!assignee) throw new UsageError("mr assign requires <prId> and <assignee>.");
143
+
144
+ return [toInt("prId", prId), assignee];
145
+ },
146
+ },
147
+ "mr labels": {
148
+ method: "POST",
149
+ action: "addPrLabels",
150
+ usage: "mr labels <prId> <label...> (--issue|--project)",
151
+ buildArgs: ([prId, ...labels]) => {
152
+ if (labels.length === 0) {
153
+ throw new UsageError("mr labels requires <prId> and at least one <label>.");
154
+ }
155
+
156
+ return [toInt("prId", prId), labels];
157
+ },
158
+ },
159
+ "mr reviewers": {
160
+ method: "POST",
161
+ action: "requestPrReviewers",
162
+ usage: "mr reviewers <prId> <reviewer...> (--issue|--project)",
163
+ buildArgs: ([prId, ...reviewers]) => {
164
+ if (reviewers.length === 0) {
165
+ throw new UsageError("mr reviewers requires <prId> and at least one <reviewer>.");
166
+ }
167
+
168
+ return [toInt("prId", prId), reviewers];
169
+ },
170
+ },
171
+ "mr find": {
172
+ method: "POST",
173
+ action: "findPrByIssueKey",
174
+ usage: "mr find <issueKey> (--issue|--project)",
175
+ buildArgs: ([issueKey], flags) => {
176
+ const key = issueKey ?? flags.issue;
177
+
178
+ if (!key) throw new UsageError("mr find requires <issueKey> (or --issue).");
179
+
180
+ return [key];
181
+ },
182
+ },
183
+ "mr fix-prompt": {
184
+ method: "POST",
185
+ action: "getFixCommentsPrompt",
186
+ usage: "mr fix-prompt [prId] [--issue KEY] (--issue|--project)",
187
+ buildArgs: ([prId], flags) => {
188
+ const parsedPrId = prId === undefined ? undefined : toInt("prId", prId);
189
+ const issueKey = flags.issue ? String(flags.issue) : undefined;
190
+
191
+ return [parsedPrId, issueKey];
192
+ },
193
+ },
194
+ "pipeline status": {
195
+ method: "POST",
196
+ action: "getPipelineStatus",
197
+ usage: "pipeline status <prId> <headSha> (--issue|--project)",
198
+ buildArgs: ([prId, headSha]) => {
199
+ if (!headSha) throw new UsageError("pipeline status requires <prId> and <headSha>.");
200
+
201
+ return [{ prId: toInt("prId", prId), headSha }];
202
+ },
203
+ },
204
+ "pipeline jobs": {
205
+ method: "POST",
206
+ action: "listPipelineJobs",
207
+ usage: "pipeline jobs <prId> <headSha> (--issue|--project)",
208
+ buildArgs: ([prId, headSha]) => {
209
+ if (!headSha) throw new UsageError("pipeline jobs requires <prId> and <headSha>.");
210
+
211
+ return [{ prId: toInt("prId", prId), headSha }];
212
+ },
213
+ },
214
+ "pipeline retry": {
215
+ method: "POST",
216
+ action: "restartFailedJobs",
217
+ usage: "pipeline retry <pipelineOrRunId> [--all] [--job <jobId>] (--issue|--project)",
218
+ buildArgs: ([id], flags) => {
219
+ if (flags.job !== undefined) return [toInt("job", String(flags.job))];
220
+
221
+ const target = toInt("pipelineOrRunId", id);
222
+
223
+ return [target];
224
+ },
225
+ // `retry` maps to restartFailedJobs by default; --all → restartAllJobs, --job → restartPipelineJob.
226
+ resolveAction: (flags) => {
227
+ if (flags.job !== undefined) return "restartPipelineJob";
228
+ if (flags.all) return "restartAllJobs";
229
+
230
+ return "restartFailedJobs";
231
+ },
232
+ },
233
+
234
+ "slack notify": {
235
+ method: "POST",
236
+ action: "notifySlack",
237
+ usage: 'slack notify <message> [--channel ID] [--values \'{"key":"val"}\'] (--issue|--project)',
238
+ buildArgs: ([message], flags) => {
239
+ if (!message) throw new UsageError("slack notify requires <message>.");
240
+
241
+ const channel = flags.channel ? String(flags.channel) : undefined;
242
+ const values = toValues("values", flags.values);
243
+
244
+ return [message, channel, values];
245
+ },
246
+ },
247
+ };
248
+
249
+ /** Every command key (for help / discovery). */
250
+ export function listCommands() {
251
+ return Object.keys(COMMANDS);
252
+ }
253
+
254
+ /**
255
+ * Resolve a command from the leading positionals. Tries a two-word key
256
+ * (`group sub`) first, then a one-word key. Returns the command plus the
257
+ * remaining positionals it consumes.
258
+ * @param {string[]} positionals
259
+ * @returns {{ key: string, command: Command, rest: string[] } | null}
260
+ */
261
+ export function resolveCommand(positionals) {
262
+ if (positionals.length === 0) return null;
263
+
264
+ const two = positionals.slice(0, 2).join(" ");
265
+
266
+ if (positionals.length >= 2 && COMMANDS[two]) {
267
+ return { key: two, command: COMMANDS[two], rest: positionals.slice(2) };
268
+ }
269
+
270
+ const one = positionals[0];
271
+
272
+ if (COMMANDS[one]) {
273
+ return { key: one, command: COMMANDS[one], rest: positionals.slice(1) };
274
+ }
275
+
276
+ return null;
277
+ }
package/src/config.js ADDED
@@ -0,0 +1,94 @@
1
+ // Resolves how the CLI reaches the local gateway: base URL (port) and the Bearer
2
+ // token. The token is read from the same userData dir the desktop daemon writes it
3
+ // to (see desktop/src/gateway/token.js) so scripts "just work" with no setup. Env
4
+ // vars override for tests / non-standard installs.
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+
10
+ /** Loopback host — the gateway only ever binds 127.0.0.1. */
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";
16
+
17
+ /** A tagged config-resolution failure, so the CLI can print a clear message. */
18
+ export class ConfigError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "ConfigError";
22
+ }
23
+ }
24
+
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
+ /**
54
+ * Read the gateway token: `DEVFLOW_GATEWAY_TOKEN` wins, else the token file the
55
+ * daemon persisted. Throws {@link ConfigError} with an actionable message when
56
+ * neither is available.
57
+ * @returns {string}
58
+ */
59
+ function resolveToken() {
60
+ const fromEnv = process.env.DEVFLOW_GATEWAY_TOKEN?.trim();
61
+
62
+ if (fromEnv) return fromEnv;
63
+
64
+ const tokenPath = path.join(resolveDataDir(), TOKEN_FILE);
65
+
66
+ try {
67
+ const token = fs.readFileSync(tokenPath, "utf8").trim();
68
+
69
+ if (token) return token;
70
+
71
+ throw new ConfigError(`Gateway token file is empty: ${tokenPath}`);
72
+ } catch (err) {
73
+ if (err instanceof ConfigError) throw err;
74
+
75
+ throw new ConfigError(
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
+ }
81
+ }
82
+
83
+ /**
84
+ * Resolve the full CLI runtime config.
85
+ * @returns {{ baseUrl: string, token: string }}
86
+ */
87
+ export function resolveConfig() {
88
+ const port = resolvePort();
89
+
90
+ return {
91
+ baseUrl: `http://${HOST}:${port}`,
92
+ token: resolveToken(),
93
+ };
94
+ }