owletto 1.0.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/src/cli.ts ADDED
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Owletto SDK CLI
5
+ *
6
+ * Compile connectors and generate manifest.json:
7
+ *
8
+ * owletto-sdk compile connectors/reddit.ts connectors/github.ts
9
+ * owletto-sdk compile connectors/*.ts
10
+ * owletto-sdk compile connectors/ # discover .ts files in dir
11
+ * owletto-sdk compile --outdir dist connectors/*.ts
12
+ */
13
+
14
+ import {
15
+ copyFileSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ statSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from 'node:fs';
24
+ import { basename, dirname, join, resolve } from 'node:path';
25
+ import { pathToFileURL } from 'node:url';
26
+ import { build } from 'esbuild';
27
+
28
+ interface ManifestConnectorEntry {
29
+ key: string;
30
+ file: string;
31
+ name: string;
32
+ version: string;
33
+ description?: string;
34
+ authSchema?: Record<string, unknown>;
35
+ feeds?: Record<string, unknown>;
36
+ actions?: Record<string, unknown>;
37
+ optionsSchema?: Record<string, unknown>;
38
+ }
39
+
40
+ interface ManifestInsightEntry {
41
+ slug: string;
42
+ file: string;
43
+ name: string;
44
+ description?: string;
45
+ version: number;
46
+ source_types: {
47
+ required: string[];
48
+ recommended: string[];
49
+ };
50
+ }
51
+
52
+ interface ReleaseManifest {
53
+ connectors: ManifestConnectorEntry[];
54
+ insights: ManifestInsightEntry[];
55
+ }
56
+
57
+ // Back-compat alias
58
+ type ManifestEntry = ManifestConnectorEntry;
59
+
60
+ /**
61
+ * Resolve input paths to concrete entry points.
62
+ *
63
+ * Accepts a mix of:
64
+ * - .ts files → compile directly
65
+ * - directories → discover .ts files inside
66
+ */
67
+ function resolveEntryPoints(paths: string[]): Array<{ name: string; entryPoint: string }> {
68
+ const entries: Array<{ name: string; entryPoint: string }> = [];
69
+
70
+ for (const p of paths) {
71
+ const abs = resolve(p);
72
+ if (!existsSync(abs)) {
73
+ console.error(`Not found: ${abs}`);
74
+ process.exit(1);
75
+ }
76
+
77
+ if (statSync(abs).isFile()) {
78
+ if (!abs.endsWith('.ts')) {
79
+ console.error(`Not a TypeScript file: ${abs}`);
80
+ process.exit(1);
81
+ }
82
+ entries.push({ name: basename(abs, '.ts'), entryPoint: abs });
83
+ } else {
84
+ // Directory: discover .ts files
85
+ for (const child of readdirSync(abs, { withFileTypes: true })) {
86
+ if (!child.isFile() || !child.name.endsWith('.ts')) continue;
87
+ entries.push({
88
+ name: child.name.replace(/\.ts$/, ''),
89
+ entryPoint: join(abs, child.name),
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ return entries;
96
+ }
97
+
98
+ /**
99
+ * Extract definition metadata from a compiled bundle by dynamically importing
100
+ * it and finding the ConnectorRuntime subclass.
101
+ */
102
+ async function extractDefinitionFromBundle(
103
+ bundlePath: string
104
+ ): Promise<Omit<ManifestEntry, 'file'> | null> {
105
+ const moduleHref = `${pathToFileURL(bundlePath).href}?ts=${Date.now()}`;
106
+ const mod = (await import(moduleHref)) as Record<string, unknown>;
107
+
108
+ // Find ConnectorRuntime subclass: class with .sync() + .execute()
109
+ const candidates = Object.values(mod);
110
+ const RuntimeClass = candidates.find(
111
+ (candidate) =>
112
+ typeof candidate === 'function' &&
113
+ !!(candidate as { prototype?: { sync?: unknown; execute?: unknown } }).prototype?.sync &&
114
+ !!(candidate as { prototype?: { sync?: unknown; execute?: unknown } }).prototype?.execute
115
+ ) as (new () => { definition: unknown }) | undefined;
116
+
117
+ if (!RuntimeClass) return null;
118
+
119
+ const instance = new RuntimeClass();
120
+ const rawDef = instance.definition as Record<string, unknown> | undefined;
121
+ if (!rawDef) return null;
122
+
123
+ const key = typeof rawDef.key === 'string' ? rawDef.key : null;
124
+ const name = typeof rawDef.name === 'string' ? rawDef.name : null;
125
+ const version = typeof rawDef.version === 'string' ? rawDef.version : null;
126
+ if (!key || !name || !version) {
127
+ throw new Error('Connector definition must include key, name, and version.');
128
+ }
129
+
130
+ return {
131
+ key,
132
+ name,
133
+ version,
134
+ description: typeof rawDef.description === 'string' ? rawDef.description : undefined,
135
+ authSchema:
136
+ rawDef.authSchema && typeof rawDef.authSchema === 'object'
137
+ ? (rawDef.authSchema as Record<string, unknown>)
138
+ : undefined,
139
+ feeds:
140
+ rawDef.feeds && typeof rawDef.feeds === 'object'
141
+ ? (rawDef.feeds as Record<string, unknown>)
142
+ : undefined,
143
+ actions:
144
+ rawDef.actions && typeof rawDef.actions === 'object'
145
+ ? (rawDef.actions as Record<string, unknown>)
146
+ : undefined,
147
+ optionsSchema:
148
+ rawDef.optionsSchema && typeof rawDef.optionsSchema === 'object'
149
+ ? (rawDef.optionsSchema as Record<string, unknown>)
150
+ : undefined,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * esbuild plugin to resolve `npm:package@version` specifiers.
156
+ * Strips the `npm:` prefix and version, leaving just the bare package name
157
+ * so esbuild resolves it from node_modules.
158
+ */
159
+ function npmSpecifierPlugin(): import('esbuild').Plugin {
160
+ return {
161
+ name: 'npm-specifier',
162
+ setup(build) {
163
+ build.onResolve({ filter: /^npm:/ }, (args) => {
164
+ const specifier = args.path.slice(4); // strip "npm:"
165
+ const isScoped = specifier.startsWith('@');
166
+ const match = isScoped
167
+ ? specifier.match(/^(@[^/]+\/[^/@]+)(?:@[^/]+)?(\/.*)?$/)
168
+ : specifier.match(/^([^/@]+)(?:@[^/]+)?(\/.*)?$/);
169
+ if (!match) return { external: true, path: args.path };
170
+ const pkg = match[1];
171
+ const subpath = match[2] ?? '';
172
+ return { path: `${pkg}${subpath}`, external: true };
173
+ });
174
+ },
175
+ };
176
+ }
177
+
178
+ /**
179
+ * esbuild plugin that stubs all external runtime dependencies (npm: specifiers,
180
+ * playwright) with empty modules. Used to build a lightweight "metadata-only"
181
+ * bundle that can be imported just to read the ConnectorRuntime definition,
182
+ * without needing any npm packages installed.
183
+ */
184
+ function stubRuntimeDepsPlugin(): import('esbuild').Plugin {
185
+ return {
186
+ name: 'stub-runtime-deps',
187
+ setup(build) {
188
+ // Stub npm: specifiers
189
+ build.onResolve({ filter: /^npm:/ }, (args) => ({
190
+ path: args.path,
191
+ namespace: 'stub',
192
+ }));
193
+ // Stub playwright
194
+ build.onResolve({ filter: /^playwright/ }, (args) => ({
195
+ path: args.path,
196
+ namespace: 'stub',
197
+ }));
198
+ // Return a CJS module whose exports are a callable/constructable proxy.
199
+ // __esModule must be falsy so esbuild's __toESM sets target.default = mod,
200
+ // making default imports resolve to the proxy (which is constructable).
201
+ build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({
202
+ contents: `const stub = new Proxy(function(){}, { get: (_, k) => k === '__esModule' ? undefined : stub, apply: () => stub, construct: () => stub }); module.exports = stub;`,
203
+ loader: 'js',
204
+ }));
205
+ },
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Build a temporary "metadata-only" bundle with all runtime deps stubbed,
211
+ * extract the definition, then clean up.
212
+ */
213
+ async function extractDefinitionViaStubBuild(
214
+ entryPoint: string,
215
+ outputDir: string,
216
+ sdkEntry: string,
217
+ name: string
218
+ ): Promise<Omit<ManifestEntry, 'file'> | null> {
219
+ const stubFile = join(outputDir, `.${name}.meta.mjs`);
220
+ try {
221
+ await build({
222
+ entryPoints: [entryPoint],
223
+ bundle: true,
224
+ format: 'esm',
225
+ platform: 'node',
226
+ target: 'node20',
227
+ outfile: stubFile,
228
+ alias: { owletto: sdkEntry },
229
+ banner: {
230
+ js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`,
231
+ },
232
+ plugins: [stubRuntimeDepsPlugin()],
233
+ minify: false,
234
+ sourcemap: false,
235
+ logLevel: 'silent',
236
+ });
237
+ return await extractDefinitionFromBundle(stubFile);
238
+ } finally {
239
+ try {
240
+ unlinkSync(stubFile);
241
+ } catch {}
242
+ }
243
+ }
244
+
245
+ async function compile(inputPaths: string[], outdir: string) {
246
+ const entries = resolveEntryPoints(inputPaths);
247
+ if (entries.length === 0) {
248
+ console.error('No connector source files found.');
249
+ process.exit(1);
250
+ }
251
+
252
+ const outputDir = resolve(outdir);
253
+ mkdirSync(outputDir, { recursive: true });
254
+
255
+ // Resolve SDK directory for the owletto alias.
256
+ const sdkEntry = join(import.meta.dirname, 'index.ts');
257
+
258
+ const manifest: ManifestEntry[] = [];
259
+
260
+ for (const entry of entries) {
261
+ const outFile = join(outputDir, `${entry.name}.js`);
262
+
263
+ try {
264
+ await build({
265
+ entryPoints: [entry.entryPoint],
266
+ bundle: true,
267
+ format: 'esm',
268
+ platform: 'node',
269
+ target: 'node20',
270
+ outfile: outFile,
271
+ alias: {
272
+ owletto: sdkEntry,
273
+ },
274
+ banner: {
275
+ js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`,
276
+ },
277
+ external: ['playwright'],
278
+ plugins: [npmSpecifierPlugin()],
279
+ minify: false,
280
+ sourcemap: false,
281
+ });
282
+ console.log(`Built: ${entry.name} -> ${outFile}`);
283
+ } catch (err) {
284
+ console.error(`Failed to build ${entry.name}:`, err);
285
+ process.exit(1);
286
+ }
287
+
288
+ // Extract metadata: try the real bundle first, fall back to a stub build
289
+ // that replaces all runtime deps with empty modules (we only need to
290
+ // instantiate the class and read .definition, not run sync/execute).
291
+ let def: Omit<ManifestEntry, 'file'> | null = null;
292
+ try {
293
+ def = await extractDefinitionFromBundle(outFile);
294
+ } catch {
295
+ // Real bundle failed (missing npm deps) — try stub build
296
+ try {
297
+ def = await extractDefinitionViaStubBuild(
298
+ entry.entryPoint,
299
+ outputDir,
300
+ sdkEntry,
301
+ entry.name
302
+ );
303
+ } catch (stubErr) {
304
+ console.error(` Failed to extract metadata from ${entry.name}:`, stubErr);
305
+ process.exit(1);
306
+ }
307
+ }
308
+
309
+ if (def) {
310
+ manifest.push({ file: `${entry.name}.js`, ...def });
311
+ console.log(` Extracted metadata: ${def.key}@${def.version}`);
312
+ } else {
313
+ console.warn(
314
+ ` Warning: no ConnectorRuntime class found in ${entry.name}, skipping metadata`
315
+ );
316
+ }
317
+ }
318
+
319
+ // Discover insight template JSON files from insights/ directories
320
+ // (sibling to each connector input path)
321
+ const insightEntries: ManifestInsightEntry[] = [];
322
+ const seenInsightsDir = new Set<string>();
323
+
324
+ for (const inputPath of inputPaths) {
325
+ const abs = resolve(inputPath);
326
+ const parentDir = statSync(abs).isDirectory() ? resolve(abs, '..') : dirname(abs);
327
+ const insightsDir = join(parentDir, 'insights');
328
+
329
+ if (seenInsightsDir.has(insightsDir) || !existsSync(insightsDir)) continue;
330
+ seenInsightsDir.add(insightsDir);
331
+
332
+ for (const child of readdirSync(insightsDir, { withFileTypes: true })) {
333
+ if (!child.isFile() || !child.name.endsWith('.json')) continue;
334
+
335
+ const srcFile = join(insightsDir, child.name);
336
+ const destFile = join(outputDir, child.name);
337
+
338
+ try {
339
+ const content = JSON.parse(readFileSync(srcFile, 'utf-8')) as Record<string, unknown>;
340
+
341
+ if (!content.slug || !content.name || !content.version || !content.prompt) {
342
+ console.warn(
343
+ ` Skipping ${child.name}: missing required fields (slug, name, version, prompt)`
344
+ );
345
+ continue;
346
+ }
347
+
348
+ const sourceTypes = (content.source_types ?? {}) as {
349
+ required?: string[];
350
+ recommended?: string[];
351
+ };
352
+
353
+ insightEntries.push({
354
+ slug: content.slug as string,
355
+ file: child.name,
356
+ name: content.name as string,
357
+ description: typeof content.description === 'string' ? content.description : undefined,
358
+ version: content.version as number,
359
+ source_types: {
360
+ required: Array.isArray(sourceTypes.required) ? sourceTypes.required : [],
361
+ recommended: Array.isArray(sourceTypes.recommended) ? sourceTypes.recommended : [],
362
+ },
363
+ });
364
+
365
+ copyFileSync(srcFile, destFile);
366
+ console.log(` Insight template: ${content.slug} v${content.version}`);
367
+ } catch (err) {
368
+ console.warn(
369
+ ` Skipping ${child.name}: ${err instanceof Error ? err.message : String(err)}`
370
+ );
371
+ }
372
+ }
373
+ }
374
+
375
+ const releaseManifest: ReleaseManifest = {
376
+ connectors: manifest,
377
+ insights: insightEntries,
378
+ };
379
+
380
+ const manifestPath = join(outputDir, 'manifest.json');
381
+ writeFileSync(manifestPath, JSON.stringify(releaseManifest, null, 2));
382
+
383
+ console.log(
384
+ `\nCompiled ${manifest.length} connector(s), ${insightEntries.length} insight template(s). Manifest: ${manifestPath}`
385
+ );
386
+ }
387
+
388
+ // --- CLI entry ---
389
+
390
+ function parseArgs(argv: string[]) {
391
+ let outdir = 'dist';
392
+ const paths: string[] = [];
393
+
394
+ let i = 0;
395
+ while (i < argv.length) {
396
+ if (argv[i] === '--outdir' || argv[i] === '-o') {
397
+ outdir = argv[++i] || 'dist';
398
+ } else {
399
+ paths.push(argv[i]);
400
+ }
401
+ i++;
402
+ }
403
+
404
+ return { outdir, paths };
405
+ }
406
+
407
+ const allArgs = process.argv.slice(2);
408
+ const command = allArgs[0];
409
+
410
+ if (command === 'compile') {
411
+ const { outdir, paths } = parseArgs(allArgs.slice(1));
412
+
413
+ if (paths.length === 0) {
414
+ paths.push('.');
415
+ }
416
+
417
+ compile(paths, outdir).catch((err) => {
418
+ console.error('Compile failed:', err);
419
+ process.exit(1);
420
+ });
421
+ } else {
422
+ console.log('Usage: owletto-sdk compile [options] <files or dirs...>');
423
+ console.log('');
424
+ console.log('Commands:');
425
+ console.log(' compile Compile connector(s) and generate manifest.json');
426
+ console.log('');
427
+ console.log('Options:');
428
+ console.log(' --outdir, -o <dir> Output directory (default: "dist")');
429
+ console.log('');
430
+ console.log('Examples:');
431
+ console.log(' owletto-sdk compile connectors/reddit.ts');
432
+ console.log(' owletto-sdk compile connectors/*.ts');
433
+ console.log(' owletto-sdk compile connectors/');
434
+ console.log(' owletto-sdk compile -o build connectors/*.ts');
435
+ if (command && command !== '--help' && command !== '-h') {
436
+ process.exit(1);
437
+ }
438
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Connector Runtime
3
+ *
4
+ * Abstract base class that all connectors must implement.
5
+ * Provides the contract for sync (read) and execute (write) operations.
6
+ */
7
+
8
+ import type {
9
+ ActionContext,
10
+ ActionResult,
11
+ ConnectorDefinition,
12
+ SyncContext,
13
+ SyncResult,
14
+ } from './connector-types.js';
15
+
16
+ /**
17
+ * ConnectorRuntime is the base class for all connectors.
18
+ *
19
+ * Subclasses must:
20
+ * - Set `definition` with connector metadata
21
+ * - Implement `sync()` for feed data ingestion
22
+ * - Implement `execute()` for action execution
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * class GmailConnector extends ConnectorRuntime {
27
+ * definition = { key: 'google.gmail', name: 'Gmail', version: '1.0.0', ... };
28
+ *
29
+ * async sync(ctx: SyncContext): Promise<SyncResult> {
30
+ * // Fetch threads from Gmail API
31
+ * }
32
+ *
33
+ * async execute(ctx: ActionContext): Promise<ActionResult> {
34
+ * // Create draft, send email, etc.
35
+ * }
36
+ * }
37
+ * ```
38
+ */
39
+ export abstract class ConnectorRuntime {
40
+ /** Connector definition with metadata, feed schemas, and action schemas */
41
+ abstract readonly definition: ConnectorDefinition;
42
+
43
+ /**
44
+ * Sync data from the connected service.
45
+ *
46
+ * Called by the worker when a sync run is executed.
47
+ * Should return events to ingest and an updated checkpoint.
48
+ *
49
+ * @param ctx - Sync context with feed config, checkpoint, and credentials
50
+ * @returns Events and updated checkpoint
51
+ */
52
+ abstract sync(ctx: SyncContext): Promise<SyncResult>;
53
+
54
+ /**
55
+ * Execute an action on the connected service.
56
+ *
57
+ * Called either inline (low-risk) or by the worker (high-risk with approval).
58
+ *
59
+ * @param ctx - Action context with action key, input, and credentials
60
+ * @returns Action result with output data
61
+ */
62
+ abstract execute(ctx: ActionContext): Promise<ActionResult>;
63
+ }