runline 0.8.0 → 0.9.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.js +4 -2
- package/dist/config/loader.d.ts +2 -4
- package/dist/config/loader.js +2 -1
- package/dist/core/engine.js +18 -13
- package/dist/index.d.ts +2 -2
- package/dist/plugin/api.d.ts +2 -9
- package/dist/plugin/api.js +1 -4
- package/dist/plugin/loader.js +41 -25
- package/dist/plugin/schema.d.ts +19 -0
- package/dist/plugin/schema.js +168 -0
- package/dist/plugin/types.d.ts +22 -8
- package/dist/plugins/gmail/src/index.js +14 -2
- package/dist/plugins/linear/src/attachments.js +87 -0
- package/dist/plugins/linear/src/comments.js +64 -0
- package/dist/plugins/linear/src/cycles.js +42 -0
- package/dist/plugins/linear/src/index.js +35 -1153
- package/dist/plugins/linear/src/initiatives.js +84 -0
- package/dist/plugins/linear/src/issues.js +267 -0
- package/dist/plugins/linear/src/labels.js +74 -0
- package/dist/plugins/linear/src/organization.js +13 -0
- package/dist/plugins/linear/src/projects.js +200 -0
- package/dist/plugins/linear/src/shared.js +234 -0
- package/dist/plugins/linear/src/states.js +41 -0
- package/dist/plugins/linear/src/teams.js +77 -0
- package/dist/plugins/linear/src/users.js +37 -0
- package/dist/plugins/linear/src/views.js +105 -0
- package/dist/plugins/linear/src/webhooks.js +61 -0
- package/dist/plugins/vercel/src/account.js +11 -0
- package/dist/plugins/vercel/src/deployments.js +79 -0
- package/dist/plugins/vercel/src/env.js +101 -0
- package/dist/plugins/vercel/src/index.js +27 -0
- package/dist/plugins/vercel/src/projects.js +29 -0
- package/dist/plugins/vercel/src/shared.js +73 -0
- package/package.json +3 -2
package/dist/commands/auth.js
CHANGED
|
@@ -5,6 +5,7 @@ import { addConnection } from "../config/loader.js";
|
|
|
5
5
|
import { OAUTH_CALLBACK_PORT, runOAuth } from "../core/oauth.js";
|
|
6
6
|
import { loadAllPlugins } from "../plugin/loader.js";
|
|
7
7
|
import { registry } from "../plugin/registry.js";
|
|
8
|
+
import { connectionFields } from "../plugin/schema.js";
|
|
8
9
|
import { printError, printJson, printSuccess } from "../utils/output.js";
|
|
9
10
|
export async function auth(plugin, options) {
|
|
10
11
|
await loadAllPlugins();
|
|
@@ -20,8 +21,9 @@ export async function auth(plugin, options) {
|
|
|
20
21
|
// Client credentials: CLI flag > env > interactive prompt.
|
|
21
22
|
// Env var names follow the plugin's own convention when declared
|
|
22
23
|
// on its connection schema; fall back to generic names otherwise.
|
|
23
|
-
const
|
|
24
|
-
const
|
|
24
|
+
const connectionSchema = connectionFields(def.connectionConfigSchema);
|
|
25
|
+
const envIdVar = connectionSchema.clientId?.env;
|
|
26
|
+
const envSecretVar = connectionSchema.clientSecret?.env;
|
|
25
27
|
const resolvedClientId = options.clientId ?? (envIdVar ? process.env[envIdVar] : undefined);
|
|
26
28
|
const resolvedClientSecret = options.clientSecret ??
|
|
27
29
|
(envSecretVar ? process.env[envSecretVar] : undefined);
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConnectionConfig } from "../plugin/types.js";
|
|
1
|
+
import type { ConnectionConfig, ConnectionSchema } from "../plugin/types.js";
|
|
2
2
|
import { type RunlineConfig } from "./types.js";
|
|
3
3
|
export declare function findConfigDir(): string | null;
|
|
4
4
|
export declare function loadConfig(): RunlineConfig;
|
|
@@ -17,6 +17,4 @@ export declare function removeConnection(name: string): boolean;
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function updateConnectionConfig(name: string, patch: Record<string, unknown>): Promise<void>;
|
|
19
19
|
export declare function getConnection(plugin: string, name?: string): ConnectionConfig | undefined;
|
|
20
|
-
export declare function applyEnvOverrides(conn: ConnectionConfig, schema?:
|
|
21
|
-
env?: string;
|
|
22
|
-
}>): ConnectionConfig;
|
|
20
|
+
export declare function applyEnvOverrides(conn: ConnectionConfig, schema?: ConnectionSchema): ConnectionConfig;
|
package/dist/config/loader.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import lockfile from "proper-lockfile";
|
|
4
|
+
import { connectionFields } from "../plugin/schema.js";
|
|
4
5
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
5
6
|
const CONFIG_DIR_NAME = ".runline";
|
|
6
7
|
const CONFIG_FILE = "config.json";
|
|
@@ -117,7 +118,7 @@ export function applyEnvOverrides(conn, schema) {
|
|
|
117
118
|
if (!schema)
|
|
118
119
|
return conn;
|
|
119
120
|
const config = { ...conn.config };
|
|
120
|
-
for (const [key, field] of Object.entries(schema)) {
|
|
121
|
+
for (const [key, field] of Object.entries(connectionFields(schema))) {
|
|
121
122
|
if (field.env && !config[key]) {
|
|
122
123
|
const envVal = process.env[field.env];
|
|
123
124
|
if (envVal)
|
package/dist/core/engine.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { getQuickJS, shouldInterruptAfterDeadline, } from "quickjs-emscripten";
|
|
3
3
|
import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
|
|
4
|
+
import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
|
|
4
5
|
export class ExecutionEngine {
|
|
5
6
|
registry;
|
|
6
7
|
config;
|
|
@@ -146,6 +147,12 @@ export class ExecutionEngine {
|
|
|
146
147
|
await updateConnectionConfig(connection.name, patch);
|
|
147
148
|
},
|
|
148
149
|
};
|
|
150
|
+
if (isTypedInputSchema(action.inputSchema)) {
|
|
151
|
+
const validation = validateTypedInput(action.inputSchema, args);
|
|
152
|
+
if (!validation.ok) {
|
|
153
|
+
throw new Error(formatValidationError(path, validation));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
149
156
|
return action.execute(args, ctx);
|
|
150
157
|
}
|
|
151
158
|
resolveConnection(plugin) {
|
|
@@ -230,14 +237,7 @@ function buildHelpData(plugins) {
|
|
|
230
237
|
data[p.name] = p.actions.map((a) => ({
|
|
231
238
|
action: a.name,
|
|
232
239
|
description: a.description,
|
|
233
|
-
inputs:
|
|
234
|
-
k,
|
|
235
|
-
{
|
|
236
|
-
type: v.type,
|
|
237
|
-
required: !!v.required,
|
|
238
|
-
description: v.description,
|
|
239
|
-
},
|
|
240
|
-
])),
|
|
240
|
+
inputs: helpInputs(a.inputSchema),
|
|
241
241
|
}));
|
|
242
242
|
}
|
|
243
243
|
return data;
|
|
@@ -291,7 +291,7 @@ const __index = (() => {
|
|
|
291
291
|
|
|
292
292
|
const __formatSignature = (plugin, entry) => {
|
|
293
293
|
const fields = Object.entries(entry.inputs || {})
|
|
294
|
-
.map(([k, v]) => k + (v.required ? '' : '?') + ': ' + v.type)
|
|
294
|
+
.map(([k, v]) => k + (v.required ? '' : '?') + ': ' + (v.displayType || v.type))
|
|
295
295
|
.join(', ');
|
|
296
296
|
return plugin + '.' + entry.action + (fields ? '({ ' + fields + ' })' : '()');
|
|
297
297
|
};
|
|
@@ -370,10 +370,15 @@ const __actionsApi = {
|
|
|
370
370
|
for (const k of Object.keys(provided)) {
|
|
371
371
|
if (!(k in inputs)) unknown.push(k);
|
|
372
372
|
else {
|
|
373
|
-
const
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
373
|
+
const spec = inputs[k];
|
|
374
|
+
const expected = spec.type;
|
|
375
|
+
const actual = Array.isArray(provided[k]) ? 'array' : provided[k] === null ? 'null' : typeof provided[k];
|
|
376
|
+
if (provided[k] !== null && provided[k] !== undefined && expected !== actual) {
|
|
377
|
+
typeErrors.push({ field: k, expected: spec.displayType || expected, actual });
|
|
378
|
+
} else if (spec.enum && !spec.enum.includes(provided[k])) {
|
|
379
|
+
typeErrors.push({ field: k, expected: spec.enum.map(String).join(' | '), actual: __fmt(provided[k]) });
|
|
380
|
+
} else if ('const' in spec && provided[k] !== spec.const) {
|
|
381
|
+
typeErrors.push({ field: k, expected: __fmt(spec.const), actual: __fmt(provided[k]) });
|
|
377
382
|
}
|
|
378
383
|
}
|
|
379
384
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,13 +5,13 @@ export type { EngineOptions, ExecuteResult } from "./core/engine.js";
|
|
|
5
5
|
export { ExecutionEngine } from "./core/engine.js";
|
|
6
6
|
export type { BuildAuthUrlOptions, ExchangeCodeOptions, OAuthTokens, PKCEPair, RunOAuthOptions, } from "./core/oauth.js";
|
|
7
7
|
export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
|
|
8
|
-
export type { ActionDefinition, PluginFunction, RunlinePluginAPI,
|
|
8
|
+
export type { ActionDefinition, PluginFunction, RunlinePluginAPI, } from "./plugin/api.js";
|
|
9
9
|
export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
|
|
10
10
|
export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
|
|
11
11
|
export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
|
|
12
12
|
export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
|
|
13
13
|
export { PluginRegistry, registry } from "./plugin/registry.js";
|
|
14
|
-
export type { ActionContext, ActionDef, ConnectionConfig, InputField, InputSchema, OAuthConfig, PluginDef, } from "./plugin/types.js";
|
|
14
|
+
export type { ActionContext, ActionDef, ConnectionConfig, ConnectionSchema, ConnectionSchemaField, InputField, InputSchema, LegacyConnectionSchema, OAuthConfig, PluginDef, } from "./plugin/types.js";
|
|
15
15
|
export type { RunlineOptions } from "./sdk.js";
|
|
16
16
|
export { Runline } from "./sdk.js";
|
|
17
17
|
export type { ExecOptions, ExecResult, OutputParser } from "./utils/cli.js";
|
package/dist/plugin/api.d.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import type { ActionDef, InputSchema, OAuthConfig, PluginDef } from "./types.js";
|
|
2
|
-
export interface SchemaField {
|
|
3
|
-
type: "string" | "number" | "boolean";
|
|
4
|
-
required?: boolean;
|
|
5
|
-
description?: string;
|
|
6
|
-
default?: unknown;
|
|
7
|
-
env?: string;
|
|
8
|
-
}
|
|
1
|
+
import type { ActionDef, ConnectionSchema, InputSchema, OAuthConfig, PluginDef } from "./types.js";
|
|
9
2
|
export interface ActionDefinition {
|
|
10
3
|
description?: string;
|
|
11
4
|
inputSchema?: InputSchema;
|
|
@@ -13,7 +6,7 @@ export interface ActionDefinition {
|
|
|
13
6
|
}
|
|
14
7
|
export interface RunlinePluginAPI {
|
|
15
8
|
registerAction(name: string, def: ActionDefinition): void;
|
|
16
|
-
setConnectionSchema(schema:
|
|
9
|
+
setConnectionSchema(schema: ConnectionSchema): void;
|
|
17
10
|
setName(name: string): void;
|
|
18
11
|
setVersion(version: string): void;
|
|
19
12
|
/**
|
package/dist/plugin/api.js
CHANGED
|
@@ -19,10 +19,7 @@ export function createPluginAPI(pluginId) {
|
|
|
19
19
|
actions.push({ name: actionName, ...def });
|
|
20
20
|
},
|
|
21
21
|
setConnectionSchema(schema) {
|
|
22
|
-
connectionConfigSchema = {};
|
|
23
|
-
for (const [key, field] of Object.entries(schema)) {
|
|
24
|
-
connectionConfigSchema[key] = { ...field };
|
|
25
|
-
}
|
|
22
|
+
connectionConfigSchema = { ...schema };
|
|
26
23
|
},
|
|
27
24
|
setOAuth(cfg) {
|
|
28
25
|
oauth = { ...cfg };
|
package/dist/plugin/loader.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createJiti } from "jiti";
|
|
6
6
|
import { findConfigDir } from "../config/loader.js";
|
|
@@ -81,6 +81,19 @@ async function loadFromDirectory(dir) {
|
|
|
81
81
|
join(fullPath, "src", "index.ts"),
|
|
82
82
|
join(fullPath, "src", "index.js"),
|
|
83
83
|
];
|
|
84
|
+
const pkgJson = join(fullPath, "package.json");
|
|
85
|
+
let packagePluginPaths = [];
|
|
86
|
+
if (existsSync(pkgJson)) {
|
|
87
|
+
try {
|
|
88
|
+
const pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
|
|
89
|
+
if (pkg.main)
|
|
90
|
+
candidates.unshift(join(fullPath, pkg.main));
|
|
91
|
+
packagePluginPaths = pkg.runline?.plugins ?? [];
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error(`[runline] Failed to parse ${pkgJson}:`, err.message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
84
97
|
const found = candidates.find((c) => existsSync(c));
|
|
85
98
|
if (found) {
|
|
86
99
|
try {
|
|
@@ -90,22 +103,15 @@ async function loadFromDirectory(dir) {
|
|
|
90
103
|
console.error(`[runline] Failed to load plugin from ${found}:`, err.message);
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
else if (packagePluginPaths.length === 0) {
|
|
107
|
+
console.error(`[runline] Failed to load plugin from ${fullPath}: No entry point found`);
|
|
108
|
+
}
|
|
109
|
+
for (const p of packagePluginPaths) {
|
|
95
110
|
try {
|
|
96
|
-
|
|
97
|
-
const pluginPaths = pkg.runline?.plugins ?? [];
|
|
98
|
-
for (const p of pluginPaths) {
|
|
99
|
-
try {
|
|
100
|
-
plugins.push(await loadPluginFromPath(join(fullPath, p)));
|
|
101
|
-
}
|
|
102
|
-
catch (err) {
|
|
103
|
-
console.error(`[runline] Failed to load plugin from ${join(fullPath, p)}:`, err.message);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
111
|
+
plugins.push(await loadPluginFromPath(join(fullPath, p)));
|
|
106
112
|
}
|
|
107
113
|
catch (err) {
|
|
108
|
-
console.error(`[runline] Failed to
|
|
114
|
+
console.error(`[runline] Failed to load plugin from ${join(fullPath, p)}:`, err.message);
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
}
|
|
@@ -120,13 +126,21 @@ export async function loadPluginsFromConfig(configDir) {
|
|
|
120
126
|
try {
|
|
121
127
|
const data = JSON.parse(readFileSync(pluginsFile, "utf-8"));
|
|
122
128
|
const entries = data.plugins ?? data;
|
|
129
|
+
if (!Array.isArray(entries)) {
|
|
130
|
+
throw new Error("Expected an array or { plugins: [...] }");
|
|
131
|
+
}
|
|
123
132
|
for (const entry of entries) {
|
|
124
|
-
const p = typeof entry === "string" ? entry : entry
|
|
133
|
+
const p = typeof entry === "string" ? entry : entry?.path;
|
|
134
|
+
if (typeof p !== "string" || p.length === 0) {
|
|
135
|
+
console.error(`[runline] Invalid plugin entry in ${pluginsFile}: expected string path or { path }`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const pluginPath = isAbsolute(p) ? p : join(configDir, p);
|
|
125
139
|
try {
|
|
126
|
-
plugins.push(await loadPluginFromPath(
|
|
140
|
+
plugins.push(await loadPluginFromPath(pluginPath));
|
|
127
141
|
}
|
|
128
142
|
catch (err) {
|
|
129
|
-
console.error(`[runline] Failed to load plugin from ${
|
|
143
|
+
console.error(`[runline] Failed to load plugin from ${pluginPath}:`, err.message);
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
}
|
|
@@ -146,32 +160,34 @@ export function defaultBuiltinDir() {
|
|
|
146
160
|
export async function discoverPlugins(configDir, options = {}) {
|
|
147
161
|
const loaded = new Set();
|
|
148
162
|
const result = [];
|
|
149
|
-
function addIfNew(plugin) {
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
163
|
+
function addIfNew(plugin, source) {
|
|
164
|
+
if (loaded.has(plugin.name)) {
|
|
165
|
+
console.error(`[runline] Skipping duplicate plugin "${plugin.name}" from ${source}: a plugin with that name is already loaded`);
|
|
166
|
+
return;
|
|
153
167
|
}
|
|
168
|
+
result.push(plugin);
|
|
169
|
+
loaded.add(plugin.name);
|
|
154
170
|
}
|
|
155
171
|
if (configDir) {
|
|
156
172
|
const projectPluginsDir = join(configDir, "plugins");
|
|
157
173
|
const projectPlugins = await loadFromDirectory(projectPluginsDir);
|
|
158
174
|
for (const p of projectPlugins)
|
|
159
|
-
addIfNew(p);
|
|
175
|
+
addIfNew(p, projectPluginsDir);
|
|
160
176
|
const configPlugins = await loadPluginsFromConfig(configDir);
|
|
161
177
|
for (const p of configPlugins)
|
|
162
|
-
addIfNew(p);
|
|
178
|
+
addIfNew(p, join(configDir, "plugins.json"));
|
|
163
179
|
}
|
|
164
180
|
const globalDir = join(homedir(), ".runline", "plugins");
|
|
165
181
|
const globalPlugins = await loadFromDirectory(globalDir);
|
|
166
182
|
for (const p of globalPlugins)
|
|
167
|
-
addIfNew(p);
|
|
183
|
+
addIfNew(p, globalDir);
|
|
168
184
|
const builtinDir = options.builtinDir ?? defaultBuiltinDir();
|
|
169
185
|
const builtinPlugins = await loadFromDirectory(builtinDir);
|
|
170
186
|
for (const p of builtinPlugins) {
|
|
171
187
|
if (options.builtinAllowlist && !options.builtinAllowlist.has(p.name)) {
|
|
172
188
|
continue;
|
|
173
189
|
}
|
|
174
|
-
addIfNew(p);
|
|
190
|
+
addIfNew(p, builtinDir);
|
|
175
191
|
}
|
|
176
192
|
return result;
|
|
177
193
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ConnectionSchema, HelpInput, InputSchema, LegacyConnectionSchema, LegacyInputSchema, TypedInputSchema } from "./types.js";
|
|
2
|
+
export interface ValidationResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
missing: string[];
|
|
5
|
+
unknown: string[];
|
|
6
|
+
typeErrors: Array<{
|
|
7
|
+
field: string;
|
|
8
|
+
expected: string;
|
|
9
|
+
actual: string;
|
|
10
|
+
}>;
|
|
11
|
+
errors: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function isTypedInputSchema(schema: InputSchema | undefined): schema is TypedInputSchema;
|
|
14
|
+
export declare function legacyFields(schema: InputSchema | undefined): LegacyInputSchema;
|
|
15
|
+
export declare function connectionFields(schema: ConnectionSchema | undefined): LegacyConnectionSchema;
|
|
16
|
+
export declare function helpInputs(schema: InputSchema | undefined): Record<string, HelpInput>;
|
|
17
|
+
export declare function validateLegacyInput(schema: LegacyInputSchema, input: unknown): ValidationResult;
|
|
18
|
+
export declare function validateTypedInput(schema: TypedInputSchema, input: unknown): ValidationResult;
|
|
19
|
+
export declare function formatValidationError(path: string, result: ValidationResult): string;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Check, Errors } from "typebox/value";
|
|
2
|
+
export function isTypedInputSchema(schema) {
|
|
3
|
+
if (!schema || typeof schema !== "object")
|
|
4
|
+
return false;
|
|
5
|
+
const candidate = schema;
|
|
6
|
+
return typeof candidate.type === "string" || Array.isArray(candidate.anyOf);
|
|
7
|
+
}
|
|
8
|
+
export function legacyFields(schema) {
|
|
9
|
+
if (!schema || isTypedInputSchema(schema))
|
|
10
|
+
return {};
|
|
11
|
+
return schema;
|
|
12
|
+
}
|
|
13
|
+
export function connectionFields(schema) {
|
|
14
|
+
if (!schema)
|
|
15
|
+
return {};
|
|
16
|
+
if (!isTypedInputSchema(schema))
|
|
17
|
+
return schema;
|
|
18
|
+
const metadata = schema;
|
|
19
|
+
if (metadata.type !== "object")
|
|
20
|
+
return {};
|
|
21
|
+
const required = new Set(metadata.required ?? []);
|
|
22
|
+
return Object.fromEntries(Object.entries(metadata.properties ?? {}).map(([key, field]) => [
|
|
23
|
+
key,
|
|
24
|
+
{
|
|
25
|
+
type: baseType(field),
|
|
26
|
+
required: required.has(key),
|
|
27
|
+
description: field.description,
|
|
28
|
+
default: field.default,
|
|
29
|
+
env: field.env,
|
|
30
|
+
},
|
|
31
|
+
]));
|
|
32
|
+
}
|
|
33
|
+
export function helpInputs(schema) {
|
|
34
|
+
if (!schema)
|
|
35
|
+
return {};
|
|
36
|
+
if (!isTypedInputSchema(schema)) {
|
|
37
|
+
const legacy = schema;
|
|
38
|
+
return Object.fromEntries(Object.entries(legacy).map(([key, field]) => [
|
|
39
|
+
key,
|
|
40
|
+
{
|
|
41
|
+
type: field.type,
|
|
42
|
+
required: !!field.required,
|
|
43
|
+
description: field.description,
|
|
44
|
+
},
|
|
45
|
+
]));
|
|
46
|
+
}
|
|
47
|
+
const metadata = schema;
|
|
48
|
+
if (metadata.type !== "object")
|
|
49
|
+
return {};
|
|
50
|
+
const required = new Set(metadata.required ?? []);
|
|
51
|
+
return Object.fromEntries(Object.entries(metadata.properties ?? {}).map(([key, field]) => [
|
|
52
|
+
key,
|
|
53
|
+
{
|
|
54
|
+
type: baseType(field),
|
|
55
|
+
displayType: displayType(field),
|
|
56
|
+
required: required.has(key),
|
|
57
|
+
description: field.description,
|
|
58
|
+
enum: enumValues(field),
|
|
59
|
+
const: field.const,
|
|
60
|
+
},
|
|
61
|
+
]));
|
|
62
|
+
}
|
|
63
|
+
export function validateLegacyInput(schema, input) {
|
|
64
|
+
const provided = input && typeof input === "object"
|
|
65
|
+
? input
|
|
66
|
+
: {};
|
|
67
|
+
const missing = [];
|
|
68
|
+
const unknown = [];
|
|
69
|
+
const typeErrors = [];
|
|
70
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
71
|
+
if (spec.required && !(key in provided))
|
|
72
|
+
missing.push(key);
|
|
73
|
+
}
|
|
74
|
+
for (const key of Object.keys(provided)) {
|
|
75
|
+
if (!(key in schema)) {
|
|
76
|
+
unknown.push(key);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const expected = schema[key].type;
|
|
80
|
+
const actual = valueType(provided[key]);
|
|
81
|
+
if (provided[key] !== null &&
|
|
82
|
+
provided[key] !== undefined &&
|
|
83
|
+
expected !== actual) {
|
|
84
|
+
typeErrors.push({ field: key, expected, actual });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return validationResult({ missing, unknown, typeErrors });
|
|
88
|
+
}
|
|
89
|
+
export function validateTypedInput(schema, input) {
|
|
90
|
+
if (Check(schema, input))
|
|
91
|
+
return validationResult({});
|
|
92
|
+
return validationResult({
|
|
93
|
+
errors: [...Errors(schema, input)].map((error) => {
|
|
94
|
+
const path = error.instancePath || "/";
|
|
95
|
+
return `${path} ${error.message}`;
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export function formatValidationError(path, result) {
|
|
100
|
+
const parts = [`Invalid input for ${path}.`];
|
|
101
|
+
if (result.missing.length > 0) {
|
|
102
|
+
parts.push(`Missing required fields: ${result.missing.join(", ")}.`);
|
|
103
|
+
}
|
|
104
|
+
if (result.unknown.length > 0) {
|
|
105
|
+
parts.push(`Unknown fields: ${result.unknown.join(", ")}.`);
|
|
106
|
+
}
|
|
107
|
+
if (result.typeErrors.length > 0) {
|
|
108
|
+
parts.push(`Type errors: ${result.typeErrors
|
|
109
|
+
.map((e) => `${e.field} expected ${e.expected}, got ${e.actual}`)
|
|
110
|
+
.join("; ")}.`);
|
|
111
|
+
}
|
|
112
|
+
if (result.errors.length > 0) {
|
|
113
|
+
parts.push(`Validation errors: ${result.errors.join("; ")}.`);
|
|
114
|
+
}
|
|
115
|
+
return parts.join(" ");
|
|
116
|
+
}
|
|
117
|
+
function validationResult(input) {
|
|
118
|
+
const missing = input.missing ?? [];
|
|
119
|
+
const unknown = input.unknown ?? [];
|
|
120
|
+
const typeErrors = input.typeErrors ?? [];
|
|
121
|
+
const errors = input.errors ?? [];
|
|
122
|
+
return {
|
|
123
|
+
ok: missing.length === 0 &&
|
|
124
|
+
unknown.length === 0 &&
|
|
125
|
+
typeErrors.length === 0 &&
|
|
126
|
+
errors.length === 0,
|
|
127
|
+
missing,
|
|
128
|
+
unknown,
|
|
129
|
+
typeErrors,
|
|
130
|
+
errors,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function displayType(schema) {
|
|
134
|
+
if (schema.anyOf?.length)
|
|
135
|
+
return schema.anyOf.map(displayType).join(" | ");
|
|
136
|
+
if (schema.enum?.length)
|
|
137
|
+
return schema.enum.map(String).join(" | ");
|
|
138
|
+
if (schema.const !== undefined)
|
|
139
|
+
return JSON.stringify(schema.const);
|
|
140
|
+
return schema.type ?? "unknown";
|
|
141
|
+
}
|
|
142
|
+
function baseType(schema) {
|
|
143
|
+
if (schema.anyOf?.length) {
|
|
144
|
+
const types = [...new Set(schema.anyOf.map(baseType))];
|
|
145
|
+
return types.length === 1 ? types[0] : types.join(" | ");
|
|
146
|
+
}
|
|
147
|
+
if (schema.type)
|
|
148
|
+
return schema.type;
|
|
149
|
+
if (schema.const !== undefined)
|
|
150
|
+
return valueType(schema.const);
|
|
151
|
+
return "unknown";
|
|
152
|
+
}
|
|
153
|
+
function enumValues(schema) {
|
|
154
|
+
if (schema.enum?.length)
|
|
155
|
+
return schema.enum;
|
|
156
|
+
if (schema.anyOf?.length &&
|
|
157
|
+
schema.anyOf.every((s) => s.const !== undefined)) {
|
|
158
|
+
return schema.anyOf.map((s) => s.const);
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
function valueType(value) {
|
|
163
|
+
if (value === null)
|
|
164
|
+
return "null";
|
|
165
|
+
if (Array.isArray(value))
|
|
166
|
+
return "array";
|
|
167
|
+
return typeof value;
|
|
168
|
+
}
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
1
2
|
export interface InputField {
|
|
2
3
|
type: "string" | "number" | "boolean" | "object" | "array";
|
|
3
4
|
description?: string;
|
|
4
5
|
required?: boolean;
|
|
5
6
|
default?: unknown;
|
|
6
7
|
}
|
|
7
|
-
export type
|
|
8
|
+
export type LegacyInputSchema = Record<string, InputField>;
|
|
9
|
+
export type TypedInputSchema = TSchema;
|
|
10
|
+
export type InputSchema = LegacyInputSchema | TypedInputSchema;
|
|
11
|
+
export interface ConnectionSchemaField {
|
|
12
|
+
type: string;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
description?: string;
|
|
15
|
+
default?: unknown;
|
|
16
|
+
env?: string;
|
|
17
|
+
}
|
|
18
|
+
export type LegacyConnectionSchema = Record<string, ConnectionSchemaField>;
|
|
19
|
+
export type ConnectionSchema = LegacyConnectionSchema | TSchema;
|
|
20
|
+
export interface HelpInput {
|
|
21
|
+
type: string;
|
|
22
|
+
displayType?: string;
|
|
23
|
+
required: boolean;
|
|
24
|
+
description?: string;
|
|
25
|
+
enum?: unknown[];
|
|
26
|
+
const?: unknown;
|
|
27
|
+
}
|
|
8
28
|
export interface ActionDef {
|
|
9
29
|
name: string;
|
|
10
30
|
description?: string;
|
|
@@ -73,13 +93,7 @@ export interface PluginDef {
|
|
|
73
93
|
name: string;
|
|
74
94
|
version: string;
|
|
75
95
|
actions: ActionDef[];
|
|
76
|
-
connectionConfigSchema?:
|
|
77
|
-
type: string;
|
|
78
|
-
required?: boolean;
|
|
79
|
-
description?: string;
|
|
80
|
-
default?: unknown;
|
|
81
|
-
env?: string;
|
|
82
|
-
}>;
|
|
96
|
+
connectionConfigSchema?: ConnectionSchema;
|
|
83
97
|
/** OAuth2 config for `runline auth <plugin>`. */
|
|
84
98
|
oauth?: OAuthConfig;
|
|
85
99
|
/** @internal */
|
|
@@ -27,6 +27,11 @@ async function accessToken(ctx) {
|
|
|
27
27
|
}
|
|
28
28
|
// ─── Request ─────────────────────────────────────────────────────
|
|
29
29
|
const API_BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
30
|
+
function sendFailureRetryHint(method, path) {
|
|
31
|
+
if (method !== "POST" || !path.endsWith("/send"))
|
|
32
|
+
return "";
|
|
33
|
+
return " Before retrying, check whether the email was already sent using gmail.message.list({ q: 'in:sent to:<recipient> subject:\"<subject>\"', maxResults: 10 }) and inspect likely matches with gmail.message.get({ id, format: 'metadata' }).";
|
|
34
|
+
}
|
|
30
35
|
async function gmailRequest(ctx, method, path, body, qs) {
|
|
31
36
|
const token = await accessToken(ctx);
|
|
32
37
|
const url = new URL(`${API_BASE}${path}`);
|
|
@@ -55,12 +60,19 @@ async function gmailRequest(ctx, method, path, body, qs) {
|
|
|
55
60
|
"application/json";
|
|
56
61
|
init.body = JSON.stringify(body);
|
|
57
62
|
}
|
|
58
|
-
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await fetch(url.toString(), init);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
69
|
+
throw new Error(`gmail: ${method} ${path} failed: ${msg}.${sendFailureRetryHint(method, path)}`);
|
|
70
|
+
}
|
|
59
71
|
if (res.status === 204)
|
|
60
72
|
return { success: true };
|
|
61
73
|
const text = await res.text();
|
|
62
74
|
if (!res.ok) {
|
|
63
|
-
throw new Error(`gmail: ${method} ${path} → ${res.status} ${text}`);
|
|
75
|
+
throw new Error(`gmail: ${method} ${path} → ${res.status} ${text}${sendFailureRetryHint(method, path)}`);
|
|
64
76
|
}
|
|
65
77
|
return text ? JSON.parse(text) : { success: true };
|
|
66
78
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { ATTACHMENT_FIELDS, assertAttachmentInScope, assertIssueInScope, gql, key, requireUnscoped } from "./shared.js";
|
|
3
|
+
export function registerAttachmentActions(rl) {
|
|
4
|
+
rl.registerAction("attachment.list", {
|
|
5
|
+
description: "List issue attachments. Disabled for scoped Linear connections.",
|
|
6
|
+
inputSchema: t.Object({ limit: t.Optional(t.Number()) }),
|
|
7
|
+
async execute(input, ctx) {
|
|
8
|
+
requireUnscoped(ctx, "attachment.list");
|
|
9
|
+
const limit = input?.limit ?? 50;
|
|
10
|
+
const data = await gql(key(ctx), `query($first: Int) { attachments(first: $first) { nodes { ${ATTACHMENT_FIELDS} } pageInfo { hasNextPage endCursor } } }`, { first: limit });
|
|
11
|
+
return data.attachments;
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
rl.registerAction("attachment.get", {
|
|
15
|
+
description: "Get an attachment by ID.",
|
|
16
|
+
inputSchema: t.Object({ id: t.String() }),
|
|
17
|
+
async execute(input, ctx) {
|
|
18
|
+
const id = input.id;
|
|
19
|
+
await assertAttachmentInScope(ctx, id);
|
|
20
|
+
const data = await gql(key(ctx), `query($id: String!) { attachment(id: $id) { ${ATTACHMENT_FIELDS} } }`, { id });
|
|
21
|
+
return data.attachment;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
rl.registerAction("attachment.create", {
|
|
25
|
+
description: "Create an attachment on an issue.",
|
|
26
|
+
inputSchema: t.Object({
|
|
27
|
+
issueId: t.String({ description: "The issue to associate the attachment with. UUID or issue identifier (e.g., 'LIN-123')" }),
|
|
28
|
+
title: t.String({ description: "The attachment title" }),
|
|
29
|
+
url: t.String({ description: "Attachment location, also used as a unique identifier. Re-creating with the same url updates the existing record" }),
|
|
30
|
+
subtitle: t.Optional(t.String({ description: "The attachment subtitle" })),
|
|
31
|
+
iconUrl: t.Optional(t.String({ description: "An icon url to display with the attachment (jpg or png, max 1MB, ideally 20x20px)" })),
|
|
32
|
+
commentBody: t.Optional(t.String({ description: "Create a linked comment with markdown body" })),
|
|
33
|
+
groupBySource: t.Optional(t.Boolean({ description: "Whether attachments for the same source application should be grouped in the Linear UI" })),
|
|
34
|
+
metadata: t.Optional(t.Object({}, { description: "Attachment metadata object with string and number values (JSONObject)" })),
|
|
35
|
+
id: t.Optional(t.String({ description: "The identifier in UUID v4 format. If none is provided, the backend will generate one" })),
|
|
36
|
+
}),
|
|
37
|
+
async execute(input, ctx) {
|
|
38
|
+
const fields = input;
|
|
39
|
+
await assertIssueInScope(ctx, String(fields.issueId));
|
|
40
|
+
const data = await gql(key(ctx), `mutation($input: AttachmentCreateInput!) { attachmentCreate(input: $input) { success attachment { ${ATTACHMENT_FIELDS} } } }`, { input: fields });
|
|
41
|
+
return data.attachmentCreate?.attachment;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
rl.registerAction("attachment.update", {
|
|
45
|
+
description: "Update an attachment. title is required.",
|
|
46
|
+
inputSchema: t.Object({
|
|
47
|
+
id: t.String({ description: "The identifier of the attachment to update" }),
|
|
48
|
+
title: t.String({ description: "The attachment title" }),
|
|
49
|
+
subtitle: t.Optional(t.String({ description: "The attachment subtitle" })),
|
|
50
|
+
iconUrl: t.Optional(t.String({ description: "An icon url to display with the attachment" })),
|
|
51
|
+
metadata: t.Optional(t.Object({}, { description: "Attachment metadata object with string and number values (JSONObject)" })),
|
|
52
|
+
}),
|
|
53
|
+
async execute(input, ctx) {
|
|
54
|
+
const { id, ...fields } = input;
|
|
55
|
+
await assertAttachmentInScope(ctx, String(id));
|
|
56
|
+
const data = await gql(key(ctx), `mutation($id: String!, $input: AttachmentUpdateInput!) { attachmentUpdate(id: $id, input: $input) { success attachment { ${ATTACHMENT_FIELDS} } } }`, { id, input: fields });
|
|
57
|
+
return data.attachmentUpdate?.attachment;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
rl.registerAction("attachment.linkURL", {
|
|
61
|
+
description: "Link any URL to an issue. If a workspace integration matches the URL (Zendesk, GitHub, Slack, etc.) a rich attachment is created; otherwise a basic one.",
|
|
62
|
+
inputSchema: t.Object({
|
|
63
|
+
issueId: t.String({ description: "The issue for which to link the url. UUID or issue identifier (e.g., 'LIN-123')" }),
|
|
64
|
+
url: t.String({ description: "The url to link" }),
|
|
65
|
+
title: t.Optional(t.String({ description: "The title to use for the attachment" })),
|
|
66
|
+
id: t.Optional(t.String({ description: "The id for the attachment (optional UUID override)" })),
|
|
67
|
+
}),
|
|
68
|
+
async execute(input, ctx) {
|
|
69
|
+
const { issueId, url, title, id } = input;
|
|
70
|
+
await assertIssueInScope(ctx, String(issueId));
|
|
71
|
+
const data = await gql(key(ctx), `mutation($issueId: String!, $url: String!, $title: String, $id: String) {
|
|
72
|
+
attachmentLinkURL(issueId: $issueId, url: $url, title: $title, id: $id) { success attachment { ${ATTACHMENT_FIELDS} } }
|
|
73
|
+
}`, { issueId, url, title: title ?? null, id: id ?? null });
|
|
74
|
+
return data.attachmentLinkURL?.attachment;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
rl.registerAction("attachment.delete", {
|
|
78
|
+
description: "Delete an attachment.",
|
|
79
|
+
inputSchema: t.Object({ id: t.String({ description: "The identifier of the attachment to delete" }) }),
|
|
80
|
+
async execute(input, ctx) {
|
|
81
|
+
const id = input.id;
|
|
82
|
+
await assertAttachmentInScope(ctx, id);
|
|
83
|
+
const data = await gql(key(ctx), `mutation($id: String!) { attachmentDelete(id: $id) { success } }`, { id });
|
|
84
|
+
return data.attachmentDelete;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|