runline 0.7.7 → 0.8.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.
Files changed (41) hide show
  1. package/dist/commands/auth.js +4 -2
  2. package/dist/config/loader.d.ts +2 -4
  3. package/dist/config/loader.js +2 -1
  4. package/dist/core/engine.js +18 -13
  5. package/dist/index.d.ts +2 -2
  6. package/dist/plugin/api.d.ts +2 -9
  7. package/dist/plugin/api.js +1 -4
  8. package/dist/plugin/schema.d.ts +19 -0
  9. package/dist/plugin/schema.js +168 -0
  10. package/dist/plugin/types.d.ts +22 -8
  11. package/dist/plugins/_shared/imageFile.js +40 -0
  12. package/dist/plugins/_shared/microsoftAuth.js +100 -0
  13. package/dist/plugins/gmail/src/index.js +27 -3
  14. package/dist/plugins/googleAppsScript/src/index.js +203 -0
  15. package/dist/plugins/googleImage/src/index.js +30 -11
  16. package/dist/plugins/linear/src/attachments.js +65 -0
  17. package/dist/plugins/linear/src/comments.js +50 -0
  18. package/dist/plugins/linear/src/cycles.js +40 -0
  19. package/dist/plugins/linear/src/index.js +31 -1153
  20. package/dist/plugins/linear/src/initiatives.js +79 -0
  21. package/dist/plugins/linear/src/issues.js +245 -0
  22. package/dist/plugins/linear/src/labels.js +69 -0
  23. package/dist/plugins/linear/src/organization.js +12 -0
  24. package/dist/plugins/linear/src/projects.js +189 -0
  25. package/dist/plugins/linear/src/shared.js +131 -0
  26. package/dist/plugins/linear/src/states.js +39 -0
  27. package/dist/plugins/linear/src/teams.js +74 -0
  28. package/dist/plugins/linear/src/users.js +36 -0
  29. package/dist/plugins/linear/src/views.js +96 -0
  30. package/dist/plugins/linear/src/webhooks.js +57 -0
  31. package/dist/plugins/microsoftCalendar/src/index.js +46 -0
  32. package/dist/plugins/microsoftFiles/src/index.js +127 -0
  33. package/dist/plugins/microsoftMail/src/index.js +91 -0
  34. package/dist/plugins/openai/src/index.js +45 -20
  35. package/dist/plugins/parallel/src/index.js +100 -0
  36. package/dist/plugins/recraft/src/index.js +10 -6
  37. package/dist/plugins/replicate/src/index.js +17 -3
  38. package/dist/plugins/steel/src/index.js +378 -0
  39. package/dist/plugins/together/src/index.js +10 -6
  40. package/dist/plugins/xai/src/index.js +11 -5
  41. package/package.json +3 -2
@@ -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 envIdVar = def.connectionConfigSchema?.clientId?.env;
24
- const envSecretVar = def.connectionConfigSchema?.clientSecret?.env;
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);
@@ -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?: Record<string, {
21
- env?: string;
22
- }>): ConnectionConfig;
20
+ export declare function applyEnvOverrides(conn: ConnectionConfig, schema?: ConnectionSchema): ConnectionConfig;
@@ -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)
@@ -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: Object.fromEntries(Object.entries(a.inputSchema ?? {}).map(([k, v]) => [
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 expected = inputs[k].type;
374
- const actual = Array.isArray(provided[k]) ? 'array' : typeof provided[k];
375
- if (expected !== actual && !(provided[k] === null || provided[k] === undefined)) {
376
- typeErrors.push({ field: k, expected, actual });
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, SchemaField, } from "./plugin/api.js";
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";
@@ -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: Record<string, SchemaField>): void;
9
+ setConnectionSchema(schema: ConnectionSchema): void;
17
10
  setName(name: string): void;
18
11
  setVersion(version: string): void;
19
12
  /**
@@ -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 };
@@ -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
+ }
@@ -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 InputSchema = Record<string, InputField>;
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?: Record<string, {
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 */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared helper for image-generation plugins: write generated bytes to a
3
+ * file and return its path, instead of returning raw base64.
4
+ *
5
+ * Base64 image payloads in an action result bloat the agent context and are
6
+ * stripped by many hosts before reaching the model, so the agent can never
7
+ * actually deliver the image. A file `path` hands straight to a host
8
+ * send_file/attachment tool. Plugins load in-process via jiti, so node:fs is
9
+ * available (same pattern as googleDrive/googleSlides).
10
+ */
11
+ import { writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ const MIME_EXT = {
15
+ "image/png": "png",
16
+ "image/jpeg": "jpg",
17
+ "image/jpg": "jpg",
18
+ "image/webp": "webp",
19
+ "image/gif": "gif",
20
+ "image/svg+xml": "svg",
21
+ };
22
+ export function extForMime(mimeType) {
23
+ if (!mimeType)
24
+ return "png";
25
+ return MIME_EXT[mimeType] ?? mimeType.split("/")[1]?.split("+")[0] ?? "png";
26
+ }
27
+ /**
28
+ * Decode base64 image bytes and write them to `saveDir` (or the OS temp dir),
29
+ * named `<provider>-<stamp>-<index>.<ext>`. Returns the file path + size.
30
+ */
31
+ export function writeImageFile(opts) {
32
+ const mimeType = opts.mimeType ?? "image/png";
33
+ const dir = (typeof opts.saveDir === "string" && opts.saveDir.trim()) || tmpdir();
34
+ const bytes = Buffer.from(opts.base64 ?? "", "base64");
35
+ const stamp = opts.stamp ?? Date.now();
36
+ const path = join(dir, `${opts.provider}-${stamp}-${opts.index}.${extForMime(mimeType)}`);
37
+ writeFileSync(path, bytes);
38
+ return { path, mimeType, byteLength: bytes.length };
39
+ }
40
+ export const SEND_FILE_NOTE = "Image(s) written to disk. Deliver each to the user with send_file using its `path`.";
@@ -0,0 +1,100 @@
1
+ const REFRESH_SKEW_MS = 60_000;
2
+ function authority(cfg) {
3
+ return `https://login.microsoftonline.com/${cfg.tenantId || "common"}/oauth2/v2.0/token`;
4
+ }
5
+ export function isAppOnly(cfg) {
6
+ return !cfg.refreshToken && !!(cfg.tenantId && cfg.clientId && cfg.clientSecret);
7
+ }
8
+ /** Graph path prefix for the acting principal: /me (delegated) or /users/{upn} (app-only). */
9
+ export function userBase(ctx) {
10
+ const cfg = ctx.connection.config;
11
+ if (cfg.refreshToken)
12
+ return "/me";
13
+ if (cfg.userUpn)
14
+ return `/users/${encodeURIComponent(cfg.userUpn)}`;
15
+ throw new Error("microsoft: app-only mode requires userUpn (target mailbox/drive). Set MS_GRAPH_USER_UPN, or connect via OAuth.");
16
+ }
17
+ export async function microsoftAccessToken(ctx, pluginName, scopes) {
18
+ const cfg = ctx.connection.config;
19
+ if (cfg.accessToken &&
20
+ typeof cfg.accessTokenExpiresAt === "number" &&
21
+ Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
22
+ return cfg.accessToken;
23
+ }
24
+ let body;
25
+ if (cfg.refreshToken) {
26
+ if (!cfg.clientId || !cfg.clientSecret) {
27
+ throw new Error(`${pluginName}: missing clientId/clientSecret for OAuth refresh.`);
28
+ }
29
+ body = new URLSearchParams({
30
+ client_id: cfg.clientId,
31
+ client_secret: cfg.clientSecret,
32
+ refresh_token: cfg.refreshToken,
33
+ grant_type: "refresh_token",
34
+ scope: [...scopes, "offline_access"].join(" "),
35
+ });
36
+ }
37
+ else if (isAppOnly(cfg)) {
38
+ body = new URLSearchParams({
39
+ client_id: cfg.clientId,
40
+ client_secret: cfg.clientSecret,
41
+ grant_type: "client_credentials",
42
+ scope: "https://graph.microsoft.com/.default",
43
+ });
44
+ }
45
+ else {
46
+ throw new Error(`${pluginName}: no credentials. Connect via OAuth, or set tenantId/clientId/clientSecret (app-only).`);
47
+ }
48
+ const res = await fetch(authority(cfg), {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
51
+ body: body.toString(),
52
+ });
53
+ if (!res.ok) {
54
+ throw new Error(`${pluginName}: token request failed (${res.status}): ${await res.text()}`);
55
+ }
56
+ const data = (await res.json());
57
+ const patch = {
58
+ accessToken: data.access_token,
59
+ accessTokenExpiresAt: Date.now() + data.expires_in * 1000,
60
+ };
61
+ // Microsoft rotates refresh tokens — persist the new one when present.
62
+ if (data.refresh_token)
63
+ patch.refreshToken = data.refresh_token;
64
+ await ctx.updateConnection(patch);
65
+ return data.access_token;
66
+ }
67
+ /** Authenticated Graph v1.0 request. Returns parsed JSON ({success:true} for 204). */
68
+ export async function graphRequest(ctx, pluginName, scopes, method, path, body) {
69
+ const token = await microsoftAccessToken(ctx, pluginName, scopes);
70
+ const init = {
71
+ method,
72
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
73
+ };
74
+ if (body !== undefined) {
75
+ init.headers["Content-Type"] = "application/json";
76
+ init.body = JSON.stringify(body);
77
+ }
78
+ const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, init);
79
+ if (res.status === 204)
80
+ return { success: true };
81
+ const text = await res.text();
82
+ if (!res.ok)
83
+ throw new Error(`${pluginName}: ${method} ${path} → ${res.status} ${text}`);
84
+ return text ? JSON.parse(text) : { success: true };
85
+ }
86
+ /** Setup help shown by the OAuth wizard for all Microsoft plugins. */
87
+ export function microsoftSetupHelp(apiName) {
88
+ return [
89
+ `You need a Microsoft Entra (Azure AD) app registration. One-time, ~5 minutes.`,
90
+ "",
91
+ "1. Register an app: https://entra.microsoft.com → App registrations → New registration.",
92
+ " Supported account types: your org (single tenant) is fine.",
93
+ "2. Add a Web redirect URI (Authentication → Add platform → Web):",
94
+ " {{redirectUri}}",
95
+ "3. Certificates & secrets → New client secret → copy the VALUE (not the Secret ID).",
96
+ `4. API permissions → Add → Microsoft Graph → Delegated → add the ${apiName} scopes,`,
97
+ " then 'Grant admin consent'.",
98
+ "5. Paste the Application (client) ID and the client secret VALUE below.",
99
+ ];
100
+ }
@@ -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
- const res = await fetch(url.toString(), init);
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
  }
@@ -108,6 +120,18 @@ function header(name, value) {
108
120
  function foldedBase64ByteLength(length) {
109
121
  return length === 0 ? 0 : length + Math.floor((length - 1) / 76) * CRLF.length;
110
122
  }
123
+ function normalizeMimeBase64(value, index) {
124
+ const compact = value.replace(/\s+/g, "");
125
+ if (!/^[A-Za-z0-9+/=_-]*$/.test(compact)) {
126
+ throw new Error(`gmail: attachment ${index} contentBase64 contains invalid base64 characters`);
127
+ }
128
+ const standard = compact.replace(/-/g, "+").replace(/_/g, "/");
129
+ const remainder = standard.length % 4;
130
+ if (remainder === 1) {
131
+ throw new Error(`gmail: attachment ${index} contentBase64 has invalid base64 length`);
132
+ }
133
+ return remainder === 0 ? standard : `${standard}${"=".repeat(4 - remainder)}`;
134
+ }
111
135
  function foldBase64(encoded) {
112
136
  let folded = "";
113
137
  for (let i = 0; i < encoded.length; i += 76) {
@@ -147,7 +171,7 @@ function normalizeAttachment(input, index) {
147
171
  return {
148
172
  name: input.name ?? input.filename ?? `attachment-${index + 1}`,
149
173
  mimeType: input.mimeType ?? "application/octet-stream",
150
- contentBase64,
174
+ contentBase64: normalizeMimeBase64(contentBase64, index),
151
175
  };
152
176
  }
153
177
  function attachmentPart(att) {