runline 0.2.2 → 0.3.0
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 +46 -0
- package/dist/core/oauth.js +145 -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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic OAuth2 authorization-code flow.
|
|
3
|
+
*
|
|
4
|
+
* Runs the browser login dance for any plugin that declares
|
|
5
|
+
* `setOAuth(config)`: opens the provider's consent URL, catches
|
|
6
|
+
* the redirect on a local port, exchanges the code for tokens,
|
|
7
|
+
* and returns `{accessToken, refreshToken, expiresAt, scope}`.
|
|
8
|
+
*
|
|
9
|
+
* Provider-agnostic — the caller (the `auth` CLI command) supplies
|
|
10
|
+
* the plugin's `OAuthConfig` along with the client credentials.
|
|
11
|
+
*/
|
|
12
|
+
import type { OAuthConfig } from "../plugin/types.js";
|
|
13
|
+
/**
|
|
14
|
+
* Fixed callback port for OAuth redirects. Pinned so users can
|
|
15
|
+
* register `http://127.0.0.1:<PORT>/callback` once with the
|
|
16
|
+
* provider and have it keep working across every `runline auth`
|
|
17
|
+
* invocation. Override via `RUNLINE_OAUTH_CALLBACK_PORT` if you
|
|
18
|
+
* need a different port (you'll have to re-register the redirect
|
|
19
|
+
* URI with the provider after changing it).
|
|
20
|
+
*/
|
|
21
|
+
export declare const OAUTH_CALLBACK_PORT: number;
|
|
22
|
+
export interface OAuthTokens {
|
|
23
|
+
accessToken: string;
|
|
24
|
+
refreshToken: string;
|
|
25
|
+
/** Milliseconds since epoch. */
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
scope?: string;
|
|
28
|
+
tokenType?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface RunOAuthOptions {
|
|
31
|
+
clientId: string;
|
|
32
|
+
clientSecret: string;
|
|
33
|
+
/**
|
|
34
|
+
* Called with the consent URL before the browser is opened.
|
|
35
|
+
* Lets the CLI print a clickable link in case auto-open fails.
|
|
36
|
+
*/
|
|
37
|
+
onAuthUrl?: (url: string) => void;
|
|
38
|
+
/** Override the browser launcher. Defaults to OS-appropriate command. */
|
|
39
|
+
openBrowser?: (url: string) => void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run the full OAuth2 authorization-code flow end-to-end.
|
|
43
|
+
* Resolves with the exchanged tokens once the user completes the
|
|
44
|
+
* browser consent and the token endpoint returns a refresh token.
|
|
45
|
+
*/
|
|
46
|
+
export declare function runOAuth(config: OAuthConfig, options: RunOAuthOptions): Promise<OAuthTokens>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic OAuth2 authorization-code flow.
|
|
3
|
+
*
|
|
4
|
+
* Runs the browser login dance for any plugin that declares
|
|
5
|
+
* `setOAuth(config)`: opens the provider's consent URL, catches
|
|
6
|
+
* the redirect on a local port, exchanges the code for tokens,
|
|
7
|
+
* and returns `{accessToken, refreshToken, expiresAt, scope}`.
|
|
8
|
+
*
|
|
9
|
+
* Provider-agnostic — the caller (the `auth` CLI command) supplies
|
|
10
|
+
* the plugin's `OAuthConfig` along with the client credentials.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { createServer, } from "node:http";
|
|
14
|
+
/**
|
|
15
|
+
* Fixed callback port for OAuth redirects. Pinned so users can
|
|
16
|
+
* register `http://127.0.0.1:<PORT>/callback` once with the
|
|
17
|
+
* provider and have it keep working across every `runline auth`
|
|
18
|
+
* invocation. Override via `RUNLINE_OAUTH_CALLBACK_PORT` if you
|
|
19
|
+
* need a different port (you'll have to re-register the redirect
|
|
20
|
+
* URI with the provider after changing it).
|
|
21
|
+
*/
|
|
22
|
+
export const OAUTH_CALLBACK_PORT = (() => {
|
|
23
|
+
const raw = process.env.RUNLINE_OAUTH_CALLBACK_PORT;
|
|
24
|
+
if (raw) {
|
|
25
|
+
const n = Number(raw);
|
|
26
|
+
if (Number.isInteger(n) && n > 0 && n < 65536)
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
return 47823;
|
|
30
|
+
})();
|
|
31
|
+
/**
|
|
32
|
+
* Run the full OAuth2 authorization-code flow end-to-end.
|
|
33
|
+
* Resolves with the exchanged tokens once the user completes the
|
|
34
|
+
* browser consent and the token endpoint returns a refresh token.
|
|
35
|
+
*/
|
|
36
|
+
export async function runOAuth(config, options) {
|
|
37
|
+
const port = OAUTH_CALLBACK_PORT;
|
|
38
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
39
|
+
const state = randomState();
|
|
40
|
+
const authUrl = new URL(config.authUrl);
|
|
41
|
+
authUrl.searchParams.set("client_id", options.clientId);
|
|
42
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
43
|
+
authUrl.searchParams.set("response_type", "code");
|
|
44
|
+
authUrl.searchParams.set("scope", config.scopes.join(" "));
|
|
45
|
+
authUrl.searchParams.set("state", state);
|
|
46
|
+
for (const [k, v] of Object.entries(config.authParams ?? {})) {
|
|
47
|
+
authUrl.searchParams.set(k, v);
|
|
48
|
+
}
|
|
49
|
+
options.onAuthUrl?.(authUrl.toString());
|
|
50
|
+
const open = options.openBrowser ?? defaultOpenBrowser;
|
|
51
|
+
open(authUrl.toString());
|
|
52
|
+
const { code } = await captureCode(port, state);
|
|
53
|
+
const tokens = await exchangeCode(config.tokenUrl, code, options.clientId, options.clientSecret, redirectUri);
|
|
54
|
+
if (!tokens.refresh_token) {
|
|
55
|
+
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' }.");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
accessToken: tokens.access_token,
|
|
59
|
+
refreshToken: tokens.refresh_token,
|
|
60
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
61
|
+
scope: tokens.scope,
|
|
62
|
+
tokenType: tokens.token_type,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function captureCode(port, expectedState) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const server = createServer((req, res) => {
|
|
68
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
69
|
+
if (url.pathname !== "/callback") {
|
|
70
|
+
res.statusCode = 404;
|
|
71
|
+
res.end("Not found");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const code = url.searchParams.get("code");
|
|
75
|
+
const state = url.searchParams.get("state");
|
|
76
|
+
const error = url.searchParams.get("error");
|
|
77
|
+
if (error) {
|
|
78
|
+
res.statusCode = 400;
|
|
79
|
+
res.end(`Authorization failed: ${error}. You can close this tab.`);
|
|
80
|
+
server.close();
|
|
81
|
+
reject(new Error(`OAuth: ${error}`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!code) {
|
|
85
|
+
res.statusCode = 400;
|
|
86
|
+
res.end("Missing code");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (state !== expectedState) {
|
|
90
|
+
res.statusCode = 400;
|
|
91
|
+
res.end("State mismatch — possible CSRF. Aborting.");
|
|
92
|
+
server.close();
|
|
93
|
+
reject(new Error("OAuth: state mismatch"));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
res.statusCode = 200;
|
|
97
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
98
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Connected</title>` +
|
|
99
|
+
`<body style="font-family:system-ui;padding:2rem">` +
|
|
100
|
+
`<h1 style="margin:0 0 0.5rem">Connected \u2713</h1>` +
|
|
101
|
+
`<p style="color:#666">You can close this tab and return to your terminal.</p>`);
|
|
102
|
+
server.close();
|
|
103
|
+
resolve({ code });
|
|
104
|
+
});
|
|
105
|
+
server.once("error", reject);
|
|
106
|
+
server.listen(port, "127.0.0.1");
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function exchangeCode(tokenUrl, code, clientId, clientSecret, redirectUri) {
|
|
110
|
+
const body = new URLSearchParams({
|
|
111
|
+
code,
|
|
112
|
+
client_id: clientId,
|
|
113
|
+
client_secret: clientSecret,
|
|
114
|
+
redirect_uri: redirectUri,
|
|
115
|
+
grant_type: "authorization_code",
|
|
116
|
+
});
|
|
117
|
+
const res = await fetch(tokenUrl, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
120
|
+
body: body.toString(),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
throw new Error(`OAuth: token exchange failed (${res.status}): ${await res.text()}`);
|
|
124
|
+
}
|
|
125
|
+
return (await res.json());
|
|
126
|
+
}
|
|
127
|
+
function randomState() {
|
|
128
|
+
return (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
|
129
|
+
}
|
|
130
|
+
function defaultOpenBrowser(url) {
|
|
131
|
+
const cmd = process.platform === "darwin"
|
|
132
|
+
? "open"
|
|
133
|
+
: process.platform === "win32"
|
|
134
|
+
? "start"
|
|
135
|
+
: "xdg-open";
|
|
136
|
+
const proc = spawn(cmd, [url], {
|
|
137
|
+
stdio: "ignore",
|
|
138
|
+
detached: true,
|
|
139
|
+
});
|
|
140
|
+
proc.on("error", () => {
|
|
141
|
+
// Browser failed to open — caller's onAuthUrl prints the URL
|
|
142
|
+
// so the user can paste it manually.
|
|
143
|
+
});
|
|
144
|
+
proc.unref();
|
|
145
|
+
}
|
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 { OAuthTokens, RunOAuthOptions } from "./core/oauth.js";
|
|
7
|
+
export { OAUTH_CALLBACK_PORT, 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 { OAUTH_CALLBACK_PORT, 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
|
}
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -23,6 +23,51 @@ export interface ActionContext {
|
|
|
23
23
|
warn(msg: string): void;
|
|
24
24
|
error(msg: string): void;
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Merge a partial config patch into the current connection,
|
|
28
|
+
* persisting it atomically to `.runline/config.json`.
|
|
29
|
+
*
|
|
30
|
+
* Intended for plugins that refresh credentials at runtime
|
|
31
|
+
* (OAuth access tokens, rotating API keys). The write is
|
|
32
|
+
* guarded by a file lock, so concurrent `runline exec`
|
|
33
|
+
* processes refreshing the same connection won't race.
|
|
34
|
+
*
|
|
35
|
+
* In-memory `ctx.connection.config` is also mutated so the
|
|
36
|
+
* rest of the current action sees the new values.
|
|
37
|
+
*/
|
|
38
|
+
updateConnection(patch: Record<string, unknown>): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* OAuth2 authorization-code configuration declared by a plugin.
|
|
42
|
+
* Consumed by the generic `runline auth <plugin>` flow, which
|
|
43
|
+
* handles the browser redirect, code exchange, and persistence
|
|
44
|
+
* of `clientId`, `clientSecret`, `refreshToken`, `accessToken`,
|
|
45
|
+
* and `accessTokenExpiresAt` into the plugin's connection.
|
|
46
|
+
*/
|
|
47
|
+
export interface OAuthConfig {
|
|
48
|
+
/** Authorization endpoint, e.g. https://accounts.google.com/o/oauth2/v2/auth */
|
|
49
|
+
authUrl: string;
|
|
50
|
+
/** Token endpoint, e.g. https://oauth2.googleapis.com/token */
|
|
51
|
+
tokenUrl: string;
|
|
52
|
+
/** Scopes to request on the consent screen. */
|
|
53
|
+
scopes: string[];
|
|
54
|
+
/**
|
|
55
|
+
* Extra query parameters on the auth URL. Used for provider-
|
|
56
|
+
* specific knobs like Google's `access_type=offline` and
|
|
57
|
+
* `prompt=consent` (both required to get a refresh token back).
|
|
58
|
+
*/
|
|
59
|
+
authParams?: Record<string, string>;
|
|
60
|
+
/**
|
|
61
|
+
* Printed by `runline auth <plugin>` before credentials are
|
|
62
|
+
* requested. Each array entry is a line. The token
|
|
63
|
+
* `{{redirectUri}}` is substituted with the actual callback URL
|
|
64
|
+
* the plugin will use, so users can register it verbatim with
|
|
65
|
+
* the provider (e.g. in Google Cloud Console).
|
|
66
|
+
*
|
|
67
|
+
* Omit for plugins where client credentials come from the
|
|
68
|
+
* provider's partner program and no user setup is needed.
|
|
69
|
+
*/
|
|
70
|
+
setupHelp?: string[];
|
|
26
71
|
}
|
|
27
72
|
export interface PluginDef {
|
|
28
73
|
name: string;
|
|
@@ -35,6 +80,8 @@ export interface PluginDef {
|
|
|
35
80
|
default?: unknown;
|
|
36
81
|
env?: string;
|
|
37
82
|
}>;
|
|
83
|
+
/** OAuth2 config for `runline auth <plugin>`. */
|
|
84
|
+
oauth?: OAuthConfig;
|
|
38
85
|
/** @internal */
|
|
39
86
|
initHooks?: Array<(config: Record<string, unknown>) => void>;
|
|
40
87
|
}
|