runline 0.2.2 → 0.3.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.
@@ -0,0 +1,7 @@
1
+ export declare function auth(plugin: string, options: {
2
+ name?: string;
3
+ clientId?: string;
4
+ clientSecret?: string;
5
+ json?: boolean;
6
+ quiet?: boolean;
7
+ }): Promise<void>;
@@ -0,0 +1,96 @@
1
+ import { stdin, stdout } from "node:process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import chalk from "chalk";
4
+ import { addConnection } from "../config/loader.js";
5
+ import { OAUTH_CALLBACK_PORT, runOAuth } from "../core/oauth.js";
6
+ import { loadAllPlugins } from "../plugin/loader.js";
7
+ import { registry } from "../plugin/registry.js";
8
+ import { printError, printJson, printSuccess } from "../utils/output.js";
9
+ export async function auth(plugin, options) {
10
+ await loadAllPlugins();
11
+ const def = registry.getPlugin(plugin);
12
+ if (!def) {
13
+ printError(`Plugin "${plugin}" not found. Run \`runline plugin list\`.`);
14
+ process.exit(1);
15
+ }
16
+ if (!def.oauth) {
17
+ printError(`Plugin "${plugin}" does not declare OAuth config.`);
18
+ process.exit(1);
19
+ }
20
+ // Client credentials: CLI flag > env > interactive prompt.
21
+ // Env var names follow the plugin's own convention when declared
22
+ // on its connection schema; fall back to generic names otherwise.
23
+ const envIdVar = def.connectionConfigSchema?.clientId?.env;
24
+ const envSecretVar = def.connectionConfigSchema?.clientSecret?.env;
25
+ const resolvedClientId = options.clientId ?? (envIdVar ? process.env[envIdVar] : undefined);
26
+ const resolvedClientSecret = options.clientSecret ??
27
+ (envSecretVar ? process.env[envSecretVar] : undefined);
28
+ // If we're about to prompt and the plugin published setup help,
29
+ // print it once so the user knows what to paste. Suppressed
30
+ // under --json and --quiet.
31
+ const willPrompt = !resolvedClientId || !resolvedClientSecret;
32
+ if (willPrompt &&
33
+ def.oauth.setupHelp &&
34
+ def.oauth.setupHelp.length > 0 &&
35
+ !options.json &&
36
+ !options.quiet) {
37
+ const redirectUri = `http://127.0.0.1:${OAUTH_CALLBACK_PORT}/callback`;
38
+ console.log();
39
+ console.log(chalk.bold(`Setting up ${plugin} OAuth`));
40
+ console.log();
41
+ for (const line of def.oauth.setupHelp) {
42
+ console.log(chalk.dim(" ") +
43
+ line.replace(/\{\{redirectUri\}\}/g, chalk.cyan(redirectUri)));
44
+ }
45
+ console.log();
46
+ }
47
+ const clientId = resolvedClientId ?? (await prompt(`${plugin} OAuth client ID: `));
48
+ const clientSecret = resolvedClientSecret ?? (await prompt(`${plugin} OAuth client secret: `));
49
+ if (!clientId || !clientSecret) {
50
+ printError("Both client ID and client secret are required.");
51
+ process.exit(1);
52
+ }
53
+ const connectionName = options.name ?? plugin;
54
+ if (!options.quiet && !options.json) {
55
+ console.log(`\nOpening browser to authorize ${chalk.bold(plugin)}\u2026`);
56
+ }
57
+ try {
58
+ const tokens = await runOAuth(def.oauth, {
59
+ clientId,
60
+ clientSecret,
61
+ onAuthUrl: (url) => {
62
+ if (!options.quiet && !options.json) {
63
+ console.log(chalk.dim(`If it doesn't open automatically, visit:\n ${url}\n`));
64
+ }
65
+ },
66
+ });
67
+ addConnection(connectionName, plugin, {
68
+ clientId,
69
+ clientSecret,
70
+ refreshToken: tokens.refreshToken,
71
+ accessToken: tokens.accessToken,
72
+ accessTokenExpiresAt: tokens.expiresAt,
73
+ });
74
+ if (options.json) {
75
+ printJson({ ok: true, name: connectionName, plugin });
76
+ }
77
+ else {
78
+ printSuccess(`Connection ${chalk.bold(connectionName)} saved (plugin: ${plugin})`);
79
+ }
80
+ }
81
+ catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ printError(msg);
84
+ process.exit(1);
85
+ }
86
+ }
87
+ async function prompt(q) {
88
+ const rl = createInterface({ input: stdin, output: stdout });
89
+ try {
90
+ const answer = await rl.question(q);
91
+ return answer.trim();
92
+ }
93
+ finally {
94
+ rl.close();
95
+ }
96
+ }
@@ -5,6 +5,17 @@ export declare function loadConfig(): RunlineConfig;
5
5
  export declare function saveConfig(config: RunlineConfig): void;
6
6
  export declare function addConnection(name: string, plugin: string, configValues: Record<string, unknown>): void;
7
7
  export declare function removeConnection(name: string): boolean;
8
+ /**
9
+ * Merge a partial config patch into an existing connection, atomically.
10
+ *
11
+ * Used by plugins that need to persist refreshed OAuth tokens (or any
12
+ * other runtime-mutated credential) back to disk. The whole read-
13
+ * modify-write is guarded by a file lock so two concurrent `runline
14
+ * exec` processes refreshing the same token don't stomp each other.
15
+ *
16
+ * If the connection doesn't exist the call is a no-op.
17
+ */
18
+ export declare function updateConnectionConfig(name: string, patch: Record<string, unknown>): Promise<void>;
8
19
  export declare function getConnection(plugin: string, name?: string): ConnectionConfig | undefined;
9
20
  export declare function applyEnvOverrides(conn: ConnectionConfig, schema?: Record<string, {
10
21
  env?: string;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import lockfile from "proper-lockfile";
3
4
  import { DEFAULT_CONFIG } from "./types.js";
4
5
  const CONFIG_DIR_NAME = ".runline";
5
6
  const CONFIG_FILE = "config.json";
@@ -61,6 +62,51 @@ export function removeConnection(name) {
61
62
  saveConfig(config);
62
63
  return true;
63
64
  }
65
+ /**
66
+ * Merge a partial config patch into an existing connection, atomically.
67
+ *
68
+ * Used by plugins that need to persist refreshed OAuth tokens (or any
69
+ * other runtime-mutated credential) back to disk. The whole read-
70
+ * modify-write is guarded by a file lock so two concurrent `runline
71
+ * exec` processes refreshing the same token don't stomp each other.
72
+ *
73
+ * If the connection doesn't exist the call is a no-op.
74
+ */
75
+ export async function updateConnectionConfig(name, patch) {
76
+ const configDir = findConfigDir() ?? join(process.cwd(), CONFIG_DIR_NAME);
77
+ mkdirSync(configDir, { recursive: true });
78
+ const configPath = join(configDir, CONFIG_FILE);
79
+ if (!existsSync(configPath))
80
+ writeFileSync(configPath, "{}\n");
81
+ const release = await lockfile.lock(configPath, {
82
+ retries: { retries: 10, factor: 2, minTimeout: 50, maxTimeout: 2_000 },
83
+ stale: 30_000,
84
+ realpath: false,
85
+ });
86
+ try {
87
+ let raw;
88
+ try {
89
+ raw = {
90
+ ...DEFAULT_CONFIG,
91
+ ...JSON.parse(readFileSync(configPath, "utf-8")),
92
+ };
93
+ }
94
+ catch {
95
+ raw = { ...DEFAULT_CONFIG };
96
+ }
97
+ const idx = raw.connections.findIndex((c) => c.name === name);
98
+ if (idx < 0)
99
+ return;
100
+ raw.connections[idx] = {
101
+ ...raw.connections[idx],
102
+ config: { ...raw.connections[idx].config, ...patch },
103
+ };
104
+ writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`);
105
+ }
106
+ finally {
107
+ await release();
108
+ }
109
+ }
64
110
  export function getConnection(plugin, name) {
65
111
  const config = loadConfig();
66
112
  if (name)
@@ -1,5 +1,5 @@
1
1
  import { getQuickJS, shouldInterruptAfterDeadline, } from "quickjs-emscripten";
2
- import { applyEnvOverrides } from "../config/loader.js";
2
+ import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
3
3
  export class ExecutionEngine {
4
4
  registry;
5
5
  config;
@@ -138,6 +138,12 @@ export class ExecutionEngine {
138
138
  warn: (msg) => console.warn(`[${plugin.name}] ${msg}`),
139
139
  error: (msg) => console.error(`[${plugin.name}] ${msg}`),
140
140
  },
141
+ updateConnection: async (patch) => {
142
+ // Mutate the in-memory copy so the rest of this action
143
+ // sees the new values without re-reading disk.
144
+ Object.assign(connection.config, patch);
145
+ await updateConnectionConfig(connection.name, patch);
146
+ },
141
147
  };
142
148
  return action.execute(args, ctx);
143
149
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Generic OAuth2 authorization-code flow.
3
+ *
4
+ * Two surfaces:
5
+ *
6
+ * 1. `runOAuth(config, {clientId, clientSecret})` — end-to-end CLI
7
+ * flow. Opens the browser, captures the redirect on a pinned
8
+ * localhost port, exchanges the code, returns tokens. Used by
9
+ * `runline auth <plugin>`.
10
+ *
11
+ * 2. Primitives for callers that can't run a local callback server
12
+ * (hosted apps, GUIs, anywhere the user's browser doesn't talk
13
+ * back to the process driving the flow):
14
+ *
15
+ * - `generatePKCE()` — S256 verifier + challenge
16
+ * - `buildAuthUrl(config, opts)` — assemble the consent URL
17
+ * - `exchangeAuthCode(config, opts)` — POST to the token
18
+ * endpoint, get back tokens
19
+ *
20
+ * The caller orchestrates: generates state + PKCE, builds the
21
+ * URL, shows it to the user, receives the code back however it
22
+ * wants (redirect-URI paste, browser popup with postMessage,
23
+ * public HTTPS callback, …), calls exchangeAuthCode.
24
+ */
25
+ import type { OAuthConfig } from "../plugin/types.js";
26
+ /**
27
+ * Fixed callback port for OAuth redirects. Pinned so users can
28
+ * register `http://127.0.0.1:<PORT>/callback` once with the
29
+ * provider and have it keep working across every `runline auth`
30
+ * invocation. Override via `RUNLINE_OAUTH_CALLBACK_PORT` if you
31
+ * need a different port (you'll have to re-register the redirect
32
+ * URI with the provider after changing it).
33
+ */
34
+ export declare const OAUTH_CALLBACK_PORT: number;
35
+ /** Canonical localhost redirect URI for CLI-based flows. */
36
+ export declare const OAUTH_CALLBACK_URI: string;
37
+ export interface OAuthTokens {
38
+ accessToken: string;
39
+ refreshToken: string;
40
+ /** Milliseconds since epoch. */
41
+ expiresAt: number;
42
+ scope?: string;
43
+ tokenType?: string;
44
+ }
45
+ export interface RunOAuthOptions {
46
+ clientId: string;
47
+ clientSecret: string;
48
+ /**
49
+ * Called with the consent URL before the browser is opened.
50
+ * Lets the CLI print a clickable link in case auto-open fails.
51
+ */
52
+ onAuthUrl?: (url: string) => void;
53
+ /** Override the browser launcher. Defaults to OS-appropriate command. */
54
+ openBrowser?: (url: string) => void;
55
+ }
56
+ export interface BuildAuthUrlOptions {
57
+ clientId: string;
58
+ redirectUri: string;
59
+ state: string;
60
+ /** S256 code challenge. Pass alongside a verifier kept by the driver. */
61
+ pkceChallenge?: string;
62
+ }
63
+ export interface ExchangeCodeOptions {
64
+ clientId: string;
65
+ clientSecret: string;
66
+ code: string;
67
+ redirectUri: string;
68
+ /** PKCE verifier matching the challenge sent on the auth URL. */
69
+ codeVerifier?: string;
70
+ }
71
+ export interface PKCEPair {
72
+ verifier: string;
73
+ /** Base64url SHA-256 of the verifier, for `code_challenge_method=S256`. */
74
+ challenge: string;
75
+ }
76
+ /**
77
+ * Generate a PKCE verifier and its SHA-256 challenge. The verifier
78
+ * stays with the driver until the token exchange; the challenge
79
+ * goes on the auth URL.
80
+ */
81
+ export declare function generatePKCE(): PKCEPair;
82
+ /**
83
+ * Assemble the authorization URL for a plugin's OAuth config.
84
+ * Pass `pkceChallenge` to include PKCE; omit for plain auth-code.
85
+ */
86
+ export declare function buildAuthUrl(config: OAuthConfig, opts: BuildAuthUrlOptions): string;
87
+ /**
88
+ * Exchange an authorization code for tokens. Throws if the
89
+ * provider doesn't return a refresh_token — that's almost always a
90
+ * misconfiguration (e.g. missing `access_type=offline` for Google).
91
+ */
92
+ export declare function exchangeAuthCode(config: OAuthConfig, opts: ExchangeCodeOptions): Promise<OAuthTokens>;
93
+ /**
94
+ * Run the full OAuth2 authorization-code flow end-to-end.
95
+ * Resolves with the exchanged tokens once the user completes the
96
+ * browser consent and the token endpoint returns a refresh token.
97
+ *
98
+ * Uses the pinned localhost callback port. If you can't run a
99
+ * local callback server, drive the flow yourself with
100
+ * `buildAuthUrl` + `exchangeAuthCode`.
101
+ */
102
+ export declare function runOAuth(config: OAuthConfig, options: RunOAuthOptions): Promise<OAuthTokens>;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Generic OAuth2 authorization-code flow.
3
+ *
4
+ * Two surfaces:
5
+ *
6
+ * 1. `runOAuth(config, {clientId, clientSecret})` — end-to-end CLI
7
+ * flow. Opens the browser, captures the redirect on a pinned
8
+ * localhost port, exchanges the code, returns tokens. Used by
9
+ * `runline auth <plugin>`.
10
+ *
11
+ * 2. Primitives for callers that can't run a local callback server
12
+ * (hosted apps, GUIs, anywhere the user's browser doesn't talk
13
+ * back to the process driving the flow):
14
+ *
15
+ * - `generatePKCE()` — S256 verifier + challenge
16
+ * - `buildAuthUrl(config, opts)` — assemble the consent URL
17
+ * - `exchangeAuthCode(config, opts)` — POST to the token
18
+ * endpoint, get back tokens
19
+ *
20
+ * The caller orchestrates: generates state + PKCE, builds the
21
+ * URL, shows it to the user, receives the code back however it
22
+ * wants (redirect-URI paste, browser popup with postMessage,
23
+ * public HTTPS callback, …), calls exchangeAuthCode.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+ import { createHash, randomBytes } from "node:crypto";
27
+ import { createServer, } from "node:http";
28
+ /**
29
+ * Fixed callback port for OAuth redirects. Pinned so users can
30
+ * register `http://127.0.0.1:<PORT>/callback` once with the
31
+ * provider and have it keep working across every `runline auth`
32
+ * invocation. Override via `RUNLINE_OAUTH_CALLBACK_PORT` if you
33
+ * need a different port (you'll have to re-register the redirect
34
+ * URI with the provider after changing it).
35
+ */
36
+ export const OAUTH_CALLBACK_PORT = (() => {
37
+ const raw = process.env.RUNLINE_OAUTH_CALLBACK_PORT;
38
+ if (raw) {
39
+ const n = Number(raw);
40
+ if (Number.isInteger(n) && n > 0 && n < 65536)
41
+ return n;
42
+ }
43
+ return 47823;
44
+ })();
45
+ /** Canonical localhost redirect URI for CLI-based flows. */
46
+ export const OAUTH_CALLBACK_URI = `http://127.0.0.1:${OAUTH_CALLBACK_PORT}/callback`;
47
+ // ─── Primitives ──────────────────────────────────────────────────
48
+ /**
49
+ * Generate a PKCE verifier and its SHA-256 challenge. The verifier
50
+ * stays with the driver until the token exchange; the challenge
51
+ * goes on the auth URL.
52
+ */
53
+ export function generatePKCE() {
54
+ const verifier = base64urlEncode(randomBytes(32));
55
+ const challenge = base64urlEncode(createHash("sha256").update(verifier).digest());
56
+ return { verifier, challenge };
57
+ }
58
+ /**
59
+ * Assemble the authorization URL for a plugin's OAuth config.
60
+ * Pass `pkceChallenge` to include PKCE; omit for plain auth-code.
61
+ */
62
+ export function buildAuthUrl(config, opts) {
63
+ const url = new URL(config.authUrl);
64
+ url.searchParams.set("client_id", opts.clientId);
65
+ url.searchParams.set("redirect_uri", opts.redirectUri);
66
+ url.searchParams.set("response_type", "code");
67
+ url.searchParams.set("scope", config.scopes.join(" "));
68
+ url.searchParams.set("state", opts.state);
69
+ if (opts.pkceChallenge) {
70
+ url.searchParams.set("code_challenge", opts.pkceChallenge);
71
+ url.searchParams.set("code_challenge_method", "S256");
72
+ }
73
+ for (const [k, v] of Object.entries(config.authParams ?? {})) {
74
+ url.searchParams.set(k, v);
75
+ }
76
+ return url.toString();
77
+ }
78
+ /**
79
+ * Exchange an authorization code for tokens. Throws if the
80
+ * provider doesn't return a refresh_token — that's almost always a
81
+ * misconfiguration (e.g. missing `access_type=offline` for Google).
82
+ */
83
+ export async function exchangeAuthCode(config, opts) {
84
+ const body = new URLSearchParams({
85
+ code: opts.code,
86
+ client_id: opts.clientId,
87
+ client_secret: opts.clientSecret,
88
+ redirect_uri: opts.redirectUri,
89
+ grant_type: "authorization_code",
90
+ });
91
+ if (opts.codeVerifier)
92
+ body.set("code_verifier", opts.codeVerifier);
93
+ const res = await fetch(config.tokenUrl, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
96
+ body: body.toString(),
97
+ });
98
+ if (!res.ok) {
99
+ throw new Error(`OAuth: token exchange failed (${res.status}): ${await res.text()}`);
100
+ }
101
+ const data = (await res.json());
102
+ if (!data.refresh_token) {
103
+ throw new Error("OAuth: provider did not return a refresh token. This usually means a prior consent is cached — revoke access with the provider and retry. For Google, set authParams: { access_type: 'offline', prompt: 'consent' }.");
104
+ }
105
+ return {
106
+ accessToken: data.access_token,
107
+ refreshToken: data.refresh_token,
108
+ expiresAt: Date.now() + data.expires_in * 1000,
109
+ scope: data.scope,
110
+ tokenType: data.token_type,
111
+ };
112
+ }
113
+ // ─── End-to-end CLI flow ─────────────────────────────────────────
114
+ /**
115
+ * Run the full OAuth2 authorization-code flow end-to-end.
116
+ * Resolves with the exchanged tokens once the user completes the
117
+ * browser consent and the token endpoint returns a refresh token.
118
+ *
119
+ * Uses the pinned localhost callback port. If you can't run a
120
+ * local callback server, drive the flow yourself with
121
+ * `buildAuthUrl` + `exchangeAuthCode`.
122
+ */
123
+ export async function runOAuth(config, options) {
124
+ const redirectUri = OAUTH_CALLBACK_URI;
125
+ const state = randomState();
126
+ const { verifier, challenge } = generatePKCE();
127
+ const authUrl = buildAuthUrl(config, {
128
+ clientId: options.clientId,
129
+ redirectUri,
130
+ state,
131
+ pkceChallenge: challenge,
132
+ });
133
+ // Always make the URL visible. `onAuthUrl` is the preferred
134
+ // channel (CLI formats it inline); if absent, fall back to
135
+ // stderr so headless invocations (SSH, CI) aren't left waiting
136
+ // on a browser that never opens.
137
+ if (options.onAuthUrl) {
138
+ options.onAuthUrl(authUrl);
139
+ }
140
+ else {
141
+ console.error(`Open this URL to authorize:\n ${authUrl}`);
142
+ }
143
+ const open = options.openBrowser ?? defaultOpenBrowser;
144
+ open(authUrl);
145
+ const { code } = await captureCode(OAUTH_CALLBACK_PORT, state);
146
+ return exchangeAuthCode(config, {
147
+ clientId: options.clientId,
148
+ clientSecret: options.clientSecret,
149
+ code,
150
+ redirectUri,
151
+ codeVerifier: verifier,
152
+ });
153
+ }
154
+ // ─── Local callback server ───────────────────────────────────────
155
+ function captureCode(port, expectedState) {
156
+ return new Promise((resolve, reject) => {
157
+ const server = createServer((req, res) => {
158
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
159
+ if (url.pathname !== "/callback") {
160
+ res.statusCode = 404;
161
+ res.end("Not found");
162
+ return;
163
+ }
164
+ const code = url.searchParams.get("code");
165
+ const state = url.searchParams.get("state");
166
+ const error = url.searchParams.get("error");
167
+ if (error) {
168
+ res.statusCode = 400;
169
+ res.end(`Authorization failed: ${error}. You can close this tab.`);
170
+ server.close();
171
+ reject(new Error(`OAuth: ${error}`));
172
+ return;
173
+ }
174
+ if (!code) {
175
+ res.statusCode = 400;
176
+ res.end("Missing code");
177
+ return;
178
+ }
179
+ if (state !== expectedState) {
180
+ res.statusCode = 400;
181
+ res.end("State mismatch — possible CSRF. Aborting.");
182
+ server.close();
183
+ reject(new Error("OAuth: state mismatch"));
184
+ return;
185
+ }
186
+ res.statusCode = 200;
187
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
188
+ res.end(`<!doctype html><meta charset="utf-8"><title>Connected</title>` +
189
+ `<body style="font-family:system-ui;padding:2rem">` +
190
+ `<h1 style="margin:0 0 0.5rem">Connected \u2713</h1>` +
191
+ `<p style="color:#666">You can close this tab and return to your terminal.</p>`);
192
+ server.close();
193
+ resolve({ code });
194
+ });
195
+ server.once("error", reject);
196
+ server.listen(port, "127.0.0.1");
197
+ });
198
+ }
199
+ // ─── Helpers ─────────────────────────────────────────────────────
200
+ function randomState() {
201
+ return base64urlEncode(randomBytes(16));
202
+ }
203
+ function base64urlEncode(buf) {
204
+ return buf
205
+ .toString("base64")
206
+ .replace(/\+/g, "-")
207
+ .replace(/\//g, "_")
208
+ .replace(/=+$/, "");
209
+ }
210
+ function defaultOpenBrowser(url) {
211
+ const cmd = process.platform === "darwin"
212
+ ? "open"
213
+ : process.platform === "win32"
214
+ ? "start"
215
+ : "xdg-open";
216
+ const proc = spawn(cmd, [url], {
217
+ stdio: "ignore",
218
+ detached: true,
219
+ });
220
+ proc.on("error", () => {
221
+ // Browser failed to open — caller's onAuthUrl prints the URL
222
+ // so the user can paste it manually.
223
+ });
224
+ proc.unref();
225
+ }
package/dist/index.d.ts CHANGED
@@ -1,15 +1,17 @@
1
- export { addConnection, findConfigDir, getConnection, loadConfig, removeConnection, saveConfig, } from "./config/loader.js";
1
+ export { addConnection, findConfigDir, getConnection, loadConfig, removeConnection, saveConfig, updateConnectionConfig, } from "./config/loader.js";
2
2
  export type { RunlineConfig } from "./config/types.js";
3
3
  export { DEFAULT_CONFIG } from "./config/types.js";
4
4
  export type { EngineOptions, ExecuteResult } from "./core/engine.js";
5
5
  export { ExecutionEngine } from "./core/engine.js";
6
+ export type { BuildAuthUrlOptions, ExchangeCodeOptions, OAuthTokens, PKCEPair, RunOAuthOptions, } from "./core/oauth.js";
7
+ export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
6
8
  export type { ActionDefinition, PluginFunction, RunlinePluginAPI, SchemaField, } from "./plugin/api.js";
7
9
  export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
8
10
  export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
9
11
  export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
10
12
  export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
11
13
  export { PluginRegistry, registry } from "./plugin/registry.js";
12
- export type { ActionContext, ActionDef, ConnectionConfig, InputField, InputSchema, PluginDef, } from "./plugin/types.js";
14
+ export type { ActionContext, ActionDef, ConnectionConfig, InputField, InputSchema, OAuthConfig, PluginDef, } from "./plugin/types.js";
13
15
  export type { RunlineOptions } from "./sdk.js";
14
16
  export { Runline } from "./sdk.js";
15
17
  export type { ExecOptions, ExecResult, OutputParser } from "./utils/cli.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- export { addConnection, findConfigDir, getConnection, loadConfig, removeConnection, saveConfig, } from "./config/loader.js";
1
+ export { addConnection, findConfigDir, getConnection, loadConfig, removeConnection, saveConfig, updateConnectionConfig, } from "./config/loader.js";
2
2
  export { DEFAULT_CONFIG } from "./config/types.js";
3
3
  export { ExecutionEngine } from "./core/engine.js";
4
+ export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
4
5
  export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
5
6
  export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
6
7
  export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
package/dist/main.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { Command } from "commander";
4
4
  import { actions } from "./commands/actions.js";
5
+ import { auth } from "./commands/auth.js";
5
6
  import { connectionAdd, connectionList, connectionRemove, } from "./commands/connection.js";
6
7
  import { exec } from "./commands/exec.js";
7
8
  import { init } from "./commands/init.js";
@@ -27,6 +28,7 @@ Examples:
27
28
  $ runline exec -f ./scripts/deploy.js
28
29
  $ runline actions
29
30
  $ runline connection add gh --plugin github --set token=ghp_xxx
31
+ $ runline auth gmail
30
32
 
31
33
  https://github.com/Michaelliv/runline`);
32
34
  program
@@ -114,6 +116,32 @@ pluginCmd
114
116
  await pluginList({ json: globals.json });
115
117
  });
116
118
  // ── init ────────────────────────────────────────────────
119
+ program
120
+ .command("auth <plugin>")
121
+ .description("Run the OAuth login flow for a plugin and save the connection")
122
+ .option("-n, --name <name>", "Connection name (default: plugin name)")
123
+ .option("--client-id <id>", "OAuth client ID (falls back to env or prompt)")
124
+ .option("--client-secret <secret>", "OAuth client secret (falls back to env or prompt)")
125
+ .addHelpText("after", `
126
+ The plugin must declare OAuth config via setOAuth(). The flow
127
+ opens your browser to the provider's consent screen, catches the
128
+ redirect on a localhost port, exchanges the code for tokens, and
129
+ writes a connection into .runline/config.json.
130
+
131
+ Examples:
132
+ $ runline auth gmail
133
+ $ runline auth gmail --name gmail-work
134
+ $ runline auth gmail --client-id $CLIENT --client-secret $SECRET`)
135
+ .action(async (plugin, opts, cmd) => {
136
+ const globals = cmd.optsWithGlobals();
137
+ await auth(plugin, {
138
+ name: opts.name,
139
+ clientId: opts.clientId,
140
+ clientSecret: opts.clientSecret,
141
+ json: globals.json,
142
+ quiet: globals.quiet,
143
+ });
144
+ });
117
145
  program
118
146
  .command("init")
119
147
  .description("Create .runline/ in current directory")
@@ -1,4 +1,4 @@
1
- import type { ActionDef, InputSchema, PluginDef } from "./types.js";
1
+ import type { ActionDef, InputSchema, OAuthConfig, PluginDef } from "./types.js";
2
2
  export interface SchemaField {
3
3
  type: "string" | "number" | "boolean";
4
4
  required?: boolean;
@@ -16,6 +16,12 @@ export interface RunlinePluginAPI {
16
16
  setConnectionSchema(schema: Record<string, SchemaField>): void;
17
17
  setName(name: string): void;
18
18
  setVersion(version: string): void;
19
+ /**
20
+ * Declare OAuth2 configuration so `runline auth <plugin>` can
21
+ * run the browser login flow and seed this plugin's connection
22
+ * with refreshable tokens.
23
+ */
24
+ setOAuth(oauth: OAuthConfig): void;
19
25
  onInit(fn: (config: Record<string, unknown>) => void): void;
20
26
  log: {
21
27
  info(msg: string): void;
@@ -3,6 +3,7 @@ export function createPluginAPI(pluginId) {
3
3
  let version = "0.0.0";
4
4
  const actions = [];
5
5
  let connectionConfigSchema;
6
+ let oauth;
6
7
  const initHooks = [];
7
8
  const api = {
8
9
  setName(n) {
@@ -23,6 +24,9 @@ export function createPluginAPI(pluginId) {
23
24
  connectionConfigSchema[key] = { ...field };
24
25
  }
25
26
  },
27
+ setOAuth(cfg) {
28
+ oauth = { ...cfg };
29
+ },
26
30
  onInit(fn) {
27
31
  initHooks.push(fn);
28
32
  },
@@ -45,6 +49,9 @@ export function createPluginAPI(pluginId) {
45
49
  actions,
46
50
  connectionConfigSchema,
47
51
  };
52
+ if (oauth) {
53
+ plugin.oauth = oauth;
54
+ }
48
55
  if (initHooks.length > 0) {
49
56
  plugin.initHooks = initHooks;
50
57
  }