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.
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +96 -0
- package/dist/config/loader.d.ts +11 -0
- package/dist/config/loader.js +46 -0
- package/dist/core/engine.js +7 -1
- package/dist/core/oauth.d.ts +102 -0
- package/dist/core/oauth.js +225 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/main.js +28 -0
- package/dist/plugin/api.d.ts +7 -1
- package/dist/plugin/api.js +7 -0
- package/dist/plugin/types.d.ts +47 -0
- package/dist/plugins/gmail/src/index.js +1075 -0
- package/package.json +3 -1
|
@@ -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
|
+
}
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/loader.js
CHANGED
|
@@ -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)
|
package/dist/core/engine.js
CHANGED
|
@@ -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")
|
package/dist/plugin/api.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugin/api.js
CHANGED
|
@@ -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
|
}
|