@invect/version-control 0.0.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/dist/backend/flow-serializer.d.ts +38 -0
  3. package/dist/backend/flow-serializer.d.ts.map +1 -0
  4. package/dist/backend/git-provider.d.ts +77 -0
  5. package/dist/backend/git-provider.d.ts.map +1 -0
  6. package/dist/backend/github-provider.d.ts +14 -0
  7. package/dist/backend/github-provider.d.ts.map +1 -0
  8. package/dist/backend/index.cjs +1045 -0
  9. package/dist/backend/index.cjs.map +1 -0
  10. package/dist/backend/index.d.cts +51 -0
  11. package/dist/backend/index.d.cts.map +1 -0
  12. package/dist/backend/index.d.mts +51 -0
  13. package/dist/backend/index.d.mts.map +1 -0
  14. package/dist/backend/index.d.ts +5 -0
  15. package/dist/backend/index.d.ts.map +1 -0
  16. package/dist/backend/index.mjs +1043 -0
  17. package/dist/backend/index.mjs.map +1 -0
  18. package/dist/backend/plugin.d.ts +24 -0
  19. package/dist/backend/plugin.d.ts.map +1 -0
  20. package/dist/backend/schema.d.ts +3 -0
  21. package/dist/backend/schema.d.ts.map +1 -0
  22. package/dist/backend/sync-service.d.ts +47 -0
  23. package/dist/backend/sync-service.d.ts.map +1 -0
  24. package/dist/backend/types.d.ts +20 -0
  25. package/dist/backend/types.d.ts.map +1 -0
  26. package/dist/frontend/index.cjs +0 -0
  27. package/dist/frontend/index.d.cts +2 -0
  28. package/dist/frontend/index.d.mts +2 -0
  29. package/dist/frontend/index.d.ts +2 -0
  30. package/dist/frontend/index.d.ts.map +1 -0
  31. package/dist/frontend/index.mjs +1 -0
  32. package/dist/git-provider-BD8MMEXB.d.mts +80 -0
  33. package/dist/git-provider-BD8MMEXB.d.mts.map +1 -0
  34. package/dist/git-provider-CjMtpb86.d.cts +80 -0
  35. package/dist/git-provider-CjMtpb86.d.cts.map +1 -0
  36. package/dist/providers/github.cjs +191 -0
  37. package/dist/providers/github.cjs.map +1 -0
  38. package/dist/providers/github.d.cts +17 -0
  39. package/dist/providers/github.d.cts.map +1 -0
  40. package/dist/providers/github.d.mts +17 -0
  41. package/dist/providers/github.d.mts.map +1 -0
  42. package/dist/providers/github.d.ts +2 -0
  43. package/dist/providers/github.d.ts.map +1 -0
  44. package/dist/providers/github.mjs +190 -0
  45. package/dist/providers/github.mjs.map +1 -0
  46. package/dist/shared/types.cjs +0 -0
  47. package/dist/shared/types.d.cts +2 -0
  48. package/dist/shared/types.d.mts +2 -0
  49. package/dist/shared/types.d.ts +77 -0
  50. package/dist/shared/types.d.ts.map +1 -0
  51. package/dist/shared/types.mjs +1 -0
  52. package/dist/types-B32wGtx7.d.cts +80 -0
  53. package/dist/types-B32wGtx7.d.cts.map +1 -0
  54. package/dist/types-B7fFBAOX.d.mts +80 -0
  55. package/dist/types-B7fFBAOX.d.mts.map +1 -0
  56. package/package.json +53 -0
@@ -0,0 +1,1043 @@
1
+ import { randomUUID } from "node:crypto";
2
+ //#region src/backend/schema.ts
3
+ const SYNC_MODES = [
4
+ "direct-commit",
5
+ "pr-per-save",
6
+ "pr-per-publish"
7
+ ];
8
+ const SYNC_DIRECTIONS = [
9
+ "push",
10
+ "pull",
11
+ "bidirectional"
12
+ ];
13
+ const SYNC_ACTIONS = [
14
+ "push",
15
+ "pull",
16
+ "pr-created",
17
+ "pr-merged",
18
+ "conflict"
19
+ ];
20
+ const VC_SCHEMA = {
21
+ vcSyncConfig: {
22
+ tableName: "vc_sync_config",
23
+ order: 10,
24
+ fields: {
25
+ id: {
26
+ type: "string",
27
+ primaryKey: true
28
+ },
29
+ flowId: {
30
+ type: "string",
31
+ required: true,
32
+ unique: true,
33
+ references: {
34
+ table: "flows",
35
+ field: "id",
36
+ onDelete: "cascade"
37
+ },
38
+ index: true
39
+ },
40
+ provider: {
41
+ type: "string",
42
+ required: true
43
+ },
44
+ repo: {
45
+ type: "string",
46
+ required: true
47
+ },
48
+ branch: {
49
+ type: "string",
50
+ required: true
51
+ },
52
+ filePath: {
53
+ type: "string",
54
+ required: true
55
+ },
56
+ mode: {
57
+ type: [...SYNC_MODES],
58
+ required: true
59
+ },
60
+ syncDirection: {
61
+ type: [...SYNC_DIRECTIONS],
62
+ required: true,
63
+ defaultValue: "push"
64
+ },
65
+ lastSyncedAt: {
66
+ type: "date",
67
+ required: false
68
+ },
69
+ lastCommitSha: {
70
+ type: "string",
71
+ required: false
72
+ },
73
+ lastSyncedVersion: {
74
+ type: "number",
75
+ required: false
76
+ },
77
+ draftBranch: {
78
+ type: "string",
79
+ required: false
80
+ },
81
+ activePrNumber: {
82
+ type: "number",
83
+ required: false
84
+ },
85
+ activePrUrl: {
86
+ type: "string",
87
+ required: false
88
+ },
89
+ enabled: {
90
+ type: "boolean",
91
+ required: true,
92
+ defaultValue: true
93
+ },
94
+ createdAt: {
95
+ type: "date",
96
+ required: true,
97
+ defaultValue: "now()"
98
+ },
99
+ updatedAt: {
100
+ type: "date",
101
+ required: true,
102
+ defaultValue: "now()"
103
+ }
104
+ }
105
+ },
106
+ vcSyncHistory: {
107
+ tableName: "vc_sync_history",
108
+ order: 20,
109
+ fields: {
110
+ id: {
111
+ type: "uuid",
112
+ primaryKey: true,
113
+ defaultValue: "uuid()"
114
+ },
115
+ flowId: {
116
+ type: "string",
117
+ required: true,
118
+ references: {
119
+ table: "flows",
120
+ field: "id",
121
+ onDelete: "cascade"
122
+ },
123
+ index: true
124
+ },
125
+ action: {
126
+ type: [...SYNC_ACTIONS],
127
+ required: true
128
+ },
129
+ commitSha: {
130
+ type: "string",
131
+ required: false
132
+ },
133
+ prNumber: {
134
+ type: "number",
135
+ required: false
136
+ },
137
+ version: {
138
+ type: "number",
139
+ required: false
140
+ },
141
+ message: {
142
+ type: "string",
143
+ required: false
144
+ },
145
+ createdAt: {
146
+ type: "date",
147
+ required: true,
148
+ defaultValue: "now()"
149
+ },
150
+ createdBy: {
151
+ type: "string",
152
+ required: false
153
+ }
154
+ }
155
+ }
156
+ };
157
+ //#endregion
158
+ //#region src/backend/flow-serializer.ts
159
+ /**
160
+ * Serializes an InvectDefinition to a readable .flow.ts file.
161
+ *
162
+ * The output uses the Option A (declarative) format from the SDK plan:
163
+ * defineFlow({ name, nodes: [...], edges: [...] })
164
+ *
165
+ * NOTE: This is a standalone serializer — it doesn't depend on the SDK being
166
+ * implemented yet. It generates the .flow.ts text directly from the definition JSON.
167
+ * When the SDK ships, this will import the actual helpers instead.
168
+ */
169
+ function serializeFlowToTs(definition, metadata) {
170
+ const lines = [];
171
+ const helpers = /* @__PURE__ */ new Set();
172
+ const providerImports = /* @__PURE__ */ new Map();
173
+ for (const node of definition.nodes) {
174
+ const { helperName, providerNs } = resolveHelper(node.type);
175
+ if (providerNs) {
176
+ if (!providerImports.has(providerNs)) providerImports.set(providerNs, /* @__PURE__ */ new Set());
177
+ providerImports.get(providerNs)?.add(helperName);
178
+ } else helpers.add(helperName);
179
+ }
180
+ helpers.add("defineFlow");
181
+ const coreHelpers = [...helpers].sort();
182
+ lines.push(`import { ${coreHelpers.join(", ")} } from '@invect/core/sdk';`);
183
+ for (const [ns, _methods] of providerImports) lines.push(`import { ${ns} } from '@invect/core/sdk/providers';`);
184
+ lines.push("");
185
+ lines.push("export default defineFlow({");
186
+ lines.push(` name: ${JSON.stringify(metadata.name)},`);
187
+ if (metadata.description) lines.push(` description: ${JSON.stringify(metadata.description)},`);
188
+ if (metadata.tags && metadata.tags.length > 0) lines.push(` tags: ${JSON.stringify(metadata.tags)},`);
189
+ lines.push("");
190
+ lines.push(" nodes: [");
191
+ for (const node of definition.nodes) {
192
+ const ref = node.referenceId || node.id;
193
+ const { helperCall } = resolveHelper(node.type);
194
+ const params = serializeParams(node.params);
195
+ lines.push(` ${helperCall}(${JSON.stringify(ref)}, ${params}),`);
196
+ lines.push("");
197
+ }
198
+ lines.push(" ],");
199
+ lines.push("");
200
+ lines.push(" edges: [");
201
+ for (const edge of definition.edges) {
202
+ const source = resolveNodeRef(edge.source, definition.nodes);
203
+ const target = resolveNodeRef(edge.target, definition.nodes);
204
+ if (edge.sourceHandle) lines.push(` [${JSON.stringify(source)}, ${JSON.stringify(target)}, ${JSON.stringify(edge.sourceHandle)}],`);
205
+ else lines.push(` [${JSON.stringify(source)}, ${JSON.stringify(target)}],`);
206
+ }
207
+ lines.push(" ],");
208
+ lines.push("});");
209
+ lines.push("");
210
+ return lines.join("\n");
211
+ }
212
+ /** Map action IDs to SDK helper function names */
213
+ const ACTION_TO_HELPER = {
214
+ "core.input": { helperName: "input" },
215
+ "core.output": { helperName: "output" },
216
+ "core.model": { helperName: "model" },
217
+ "core.jq": { helperName: "jq" },
218
+ "core.if_else": { helperName: "ifElse" },
219
+ "core.template_string": { helperName: "template" },
220
+ "core.javascript": { helperName: "javascript" },
221
+ "core.loop": { helperName: "loop" },
222
+ "http.request": { helperName: "httpRequest" },
223
+ AGENT: { helperName: "agent" }
224
+ };
225
+ function resolveHelper(nodeType) {
226
+ const known = ACTION_TO_HELPER[nodeType];
227
+ if (known) return {
228
+ helperName: known.helperName,
229
+ helperCall: known.providerNs ? `${known.providerNs}.${known.helperName}` : known.helperName,
230
+ providerNs: known.providerNs
231
+ };
232
+ const dotIdx = nodeType.indexOf(".");
233
+ if (dotIdx > 0) {
234
+ const ns = nodeType.substring(0, dotIdx);
235
+ const camel = snakeToCamel(nodeType.substring(dotIdx + 1));
236
+ return {
237
+ helperName: camel,
238
+ helperCall: `${ns}.${camel}`,
239
+ providerNs: ns
240
+ };
241
+ }
242
+ return {
243
+ helperName: "node",
244
+ helperCall: "node"
245
+ };
246
+ }
247
+ function snakeToCamel(s) {
248
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
249
+ }
250
+ /** Resolve a node ID (e.g. "node-classify") back to its referenceId ("classify") */
251
+ function resolveNodeRef(nodeId, nodes) {
252
+ const node = nodes.find((n) => n.id === nodeId);
253
+ if (node?.referenceId) return node.referenceId;
254
+ if (nodeId.startsWith("node-")) return nodeId.substring(5);
255
+ return nodeId;
256
+ }
257
+ /** Serialize params object to formatted string, filtering out credentials by ID */
258
+ function serializeParams(params) {
259
+ const cleaned = { ...params };
260
+ if (typeof cleaned.credentialId === "string" && !cleaned.credentialId.startsWith("{{")) cleaned.credentialId = `{{env.${toEnvName(cleaned.credentialId)}}}`;
261
+ return formatObject(cleaned, 4);
262
+ }
263
+ function toEnvName(credentialId) {
264
+ const name = credentialId.replace(/^cred[_-]?/i, "").replace(/[_-]?\d+$/g, "").toUpperCase();
265
+ return name ? `${name}_CREDENTIAL` : "CREDENTIAL";
266
+ }
267
+ /** Format a JS value as readable code (not JSON — no quoting keys where unnecessary) */
268
+ function formatObject(value, indent) {
269
+ if (value === null || value === void 0) return "null";
270
+ if (typeof value === "string") return JSON.stringify(value);
271
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
272
+ if (Array.isArray(value)) {
273
+ if (value.length === 0) return "[]";
274
+ if (value.every((v) => typeof v === "string" || typeof v === "number")) return `[${value.map((v) => JSON.stringify(v)).join(", ")}]`;
275
+ return `[\n${value.map((v) => `${" ".repeat(indent + 2)}${formatObject(v, indent + 2)}`).join(",\n")}\n${" ".repeat(indent)}]`;
276
+ }
277
+ if (typeof value === "object") {
278
+ const obj = value;
279
+ const entries = Object.entries(obj).filter(([, v]) => v !== void 0);
280
+ if (entries.length === 0) return "{}";
281
+ return `{\n${entries.map(([key, val]) => {
282
+ const k = isValidIdentifier(key) ? key : JSON.stringify(key);
283
+ return `${" ".repeat(indent + 2)}${k}: ${formatObject(val, indent + 2)}`;
284
+ }).join(",\n")}\n${" ".repeat(indent)}}`;
285
+ }
286
+ return JSON.stringify(value);
287
+ }
288
+ function isValidIdentifier(s) {
289
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(s);
290
+ }
291
+ //#endregion
292
+ //#region src/backend/sync-service.ts
293
+ var VcSyncService = class {
294
+ constructor(provider, options, logger) {
295
+ this.provider = provider;
296
+ this.options = options;
297
+ this.logger = logger;
298
+ }
299
+ async configureSyncForFlow(db, flowId, input) {
300
+ const flows = await db.query("SELECT id, name FROM flows WHERE id = ?", [flowId]);
301
+ if (flows.length === 0) throw new Error(`Flow not found: ${flowId}`);
302
+ const flow = flows[0];
303
+ const id = randomUUID();
304
+ const now = (/* @__PURE__ */ new Date()).toISOString();
305
+ const repo = input.repo ?? this.options.repo;
306
+ const branch = input.branch ?? this.options.defaultBranch ?? "main";
307
+ const mode = input.mode ?? this.options.mode ?? "direct-commit";
308
+ const syncDirection = input.syncDirection ?? this.options.syncDirection ?? "push";
309
+ const filePath = input.filePath ?? this.buildFilePath(flow.name);
310
+ await db.execute("DELETE FROM vc_sync_config WHERE flow_id = ?", [flowId]);
311
+ await db.execute(`INSERT INTO vc_sync_config (id, flow_id, provider, repo, branch, file_path, mode, sync_direction, enabled, created_at, updated_at)
312
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
313
+ id,
314
+ flowId,
315
+ this.provider.id,
316
+ repo,
317
+ branch,
318
+ filePath,
319
+ mode,
320
+ syncDirection,
321
+ true,
322
+ now,
323
+ now
324
+ ]);
325
+ return this.getSyncConfig(db, flowId);
326
+ }
327
+ async getSyncConfig(db, flowId) {
328
+ const rows = await db.query("SELECT * FROM vc_sync_config WHERE flow_id = ?", [flowId]);
329
+ if (rows.length === 0) return null;
330
+ return mapSyncConfigRow(rows[0]);
331
+ }
332
+ async disconnectFlow(db, flowId) {
333
+ const config = await this.getSyncConfig(db, flowId);
334
+ if (!config) return;
335
+ if (config.activePrNumber) try {
336
+ await this.provider.closePullRequest(config.repo, config.activePrNumber, "Sync disconnected — flow unlinked from version control.");
337
+ } catch (err) {
338
+ this.logger.warn("Failed to close PR on disconnect", { error: err.message });
339
+ }
340
+ if (config.draftBranch) try {
341
+ await this.provider.deleteBranch(config.repo, config.draftBranch);
342
+ } catch {}
343
+ await db.execute("DELETE FROM vc_sync_config WHERE flow_id = ?", [flowId]);
344
+ }
345
+ async pushFlow(db, flowId, identity) {
346
+ const config = await this.requireConfig(db, flowId);
347
+ const { content, version } = await this.exportFlow(db, flowId);
348
+ try {
349
+ if (config.mode === "direct-commit") return await this.directCommit(db, config, content, version, identity);
350
+ else if (config.mode === "pr-per-save") return await this.commitToPrBranch(db, config, content, version, identity, true);
351
+ else return await this.commitToDraftBranch(db, config, content, version, identity);
352
+ } catch (err) {
353
+ const message = err.message;
354
+ if (message.includes("409") || message.includes("sha")) {
355
+ await this.recordHistory(db, flowId, "conflict", {
356
+ version,
357
+ message,
358
+ createdBy: identity
359
+ });
360
+ return {
361
+ success: false,
362
+ error: "Conflict: remote file has changed. Use force-push or force-pull.",
363
+ action: "conflict"
364
+ };
365
+ }
366
+ throw err;
367
+ }
368
+ }
369
+ async forcePushFlow(db, flowId, identity) {
370
+ const config = await this.requireConfig(db, flowId);
371
+ const { content, version } = await this.exportFlow(db, flowId);
372
+ const sha = (await this.provider.getFileContent(config.repo, config.filePath, config.branch))?.sha;
373
+ const result = await this.provider.createOrUpdateFile(config.repo, config.filePath, content, `chore(flow): force-push ${this.flowFileName(config.filePath)} v${version}`, {
374
+ branch: config.branch,
375
+ sha
376
+ });
377
+ await this.updateConfigAfterSync(db, flowId, result.commitSha, version);
378
+ await this.recordHistory(db, flowId, "push", {
379
+ commitSha: result.commitSha,
380
+ version,
381
+ message: "Force push (local wins)",
382
+ createdBy: identity
383
+ });
384
+ return {
385
+ success: true,
386
+ commitSha: result.commitSha,
387
+ action: "push"
388
+ };
389
+ }
390
+ async pullFlow(db, flowId, identity) {
391
+ const config = await this.requireConfig(db, flowId);
392
+ const remote = await this.provider.getFileContent(config.repo, config.filePath, config.branch);
393
+ if (!remote) return {
394
+ success: false,
395
+ error: "Remote file not found",
396
+ action: "pull"
397
+ };
398
+ if (config.lastCommitSha && remote.sha === config.lastCommitSha) return {
399
+ success: true,
400
+ action: "pull"
401
+ };
402
+ await this.importFlowContent(db, flowId, remote.content, identity);
403
+ await this.updateConfigAfterSync(db, flowId, remote.sha, null);
404
+ await this.recordHistory(db, flowId, "pull", {
405
+ commitSha: remote.sha,
406
+ message: "Pulled from remote",
407
+ createdBy: identity
408
+ });
409
+ return {
410
+ success: true,
411
+ commitSha: remote.sha,
412
+ action: "pull"
413
+ };
414
+ }
415
+ async forcePullFlow(db, flowId, identity) {
416
+ const config = await this.requireConfig(db, flowId);
417
+ const remote = await this.provider.getFileContent(config.repo, config.filePath, config.branch);
418
+ if (!remote) return {
419
+ success: false,
420
+ error: "Remote file not found",
421
+ action: "pull"
422
+ };
423
+ await this.importFlowContent(db, flowId, remote.content, identity);
424
+ await this.updateConfigAfterSync(db, flowId, remote.sha, null);
425
+ await this.recordHistory(db, flowId, "pull", {
426
+ commitSha: remote.sha,
427
+ message: "Force pull (remote wins)",
428
+ createdBy: identity
429
+ });
430
+ return {
431
+ success: true,
432
+ commitSha: remote.sha,
433
+ action: "pull"
434
+ };
435
+ }
436
+ async publishFlow(db, flowId, identity) {
437
+ const config = await this.requireConfig(db, flowId);
438
+ if (config.mode !== "pr-per-publish") return {
439
+ success: false,
440
+ error: "Publish is only available in pr-per-publish mode",
441
+ action: "pr-created"
442
+ };
443
+ if (!config.draftBranch) return {
444
+ success: false,
445
+ error: "No draft branch found — push changes first",
446
+ action: "pr-created"
447
+ };
448
+ if (config.activePrNumber) {
449
+ if ((await this.provider.getPullRequest(config.repo, config.activePrNumber)).state === "open") return {
450
+ success: true,
451
+ prNumber: config.activePrNumber,
452
+ prUrl: config.activePrUrl ?? void 0,
453
+ action: "pr-created"
454
+ };
455
+ }
456
+ const fileName = this.flowFileName(config.filePath);
457
+ const pr = await this.provider.createPullRequest(config.repo, {
458
+ title: `feat(flow): publish ${fileName}`,
459
+ body: `Automated PR from Invect — publishing flow changes for \`${fileName}\`.`,
460
+ head: config.draftBranch,
461
+ base: config.branch
462
+ });
463
+ await db.execute("UPDATE vc_sync_config SET active_pr_number = ?, active_pr_url = ?, updated_at = ? WHERE flow_id = ?", [
464
+ pr.number,
465
+ pr.url,
466
+ (/* @__PURE__ */ new Date()).toISOString(),
467
+ flowId
468
+ ]);
469
+ await this.recordHistory(db, flowId, "pr-created", {
470
+ prNumber: pr.number,
471
+ message: `PR #${pr.number} created`,
472
+ createdBy: identity
473
+ });
474
+ return {
475
+ success: true,
476
+ prNumber: pr.number,
477
+ prUrl: pr.url,
478
+ action: "pr-created"
479
+ };
480
+ }
481
+ async getFlowSyncStatus(db, flowId) {
482
+ const config = await this.getSyncConfig(db, flowId);
483
+ if (!config) return {
484
+ status: "not-connected",
485
+ config: null,
486
+ lastSync: null
487
+ };
488
+ const history = await db.query("SELECT * FROM vc_sync_history WHERE flow_id = ? ORDER BY created_at DESC LIMIT 1", [flowId]);
489
+ const lastSync = history.length > 0 ? mapHistoryRow(history[0]) : null;
490
+ let status = "synced";
491
+ if (!config.enabled) status = "not-connected";
492
+ else if (lastSync?.action === "conflict") status = "conflict";
493
+ else if (!config.lastSyncedAt) status = "pending";
494
+ else {
495
+ const latestVersion = (await db.query("SELECT MAX(version) as version FROM flow_versions WHERE flow_id = ?", [flowId]))[0]?.version;
496
+ if (latestVersion && config.lastSyncedVersion && latestVersion > config.lastSyncedVersion) status = "pending";
497
+ }
498
+ return {
499
+ status,
500
+ config,
501
+ lastSync
502
+ };
503
+ }
504
+ async getSyncHistory(db, flowId, limit = 20) {
505
+ return (await db.query("SELECT * FROM vc_sync_history WHERE flow_id = ? ORDER BY created_at DESC LIMIT ?", [flowId, limit])).map(mapHistoryRow);
506
+ }
507
+ async listSyncedFlows(db) {
508
+ return (await db.query(`SELECT vc_sync_config.*, flows.name as flow_name
509
+ FROM vc_sync_config
510
+ JOIN flows ON flows.id = vc_sync_config.flow_id
511
+ ORDER BY vc_sync_config.updated_at DESC`)).map((r) => ({
512
+ ...mapSyncConfigRow(r),
513
+ flowName: r.flow_name
514
+ }));
515
+ }
516
+ async onFlowDeleted(db, flowId) {
517
+ const config = await this.getSyncConfig(db, flowId);
518
+ if (!config) return;
519
+ try {
520
+ const remote = await this.provider.getFileContent(config.repo, config.filePath, config.branch);
521
+ if (remote) {
522
+ await this.provider.deleteFile(config.repo, config.filePath, `chore(flow): delete ${this.flowFileName(config.filePath)}`, {
523
+ branch: config.branch,
524
+ sha: remote.sha
525
+ });
526
+ this.logger.info("Deleted flow file from remote", {
527
+ flowId,
528
+ filePath: config.filePath
529
+ });
530
+ }
531
+ } catch (err) {
532
+ this.logger.warn("Failed to delete flow file from remote", {
533
+ flowId,
534
+ error: err.message
535
+ });
536
+ }
537
+ if (config.activePrNumber) try {
538
+ await this.provider.closePullRequest(config.repo, config.activePrNumber, "Flow deleted — closing PR.");
539
+ } catch {}
540
+ if (config.draftBranch) try {
541
+ await this.provider.deleteBranch(config.repo, config.draftBranch);
542
+ } catch {}
543
+ }
544
+ async directCommit(db, config, content, version, identity) {
545
+ let remoteSha = config.lastCommitSha ?? void 0;
546
+ if (!remoteSha) remoteSha = (await this.provider.getFileContent(config.repo, config.filePath, config.branch))?.sha;
547
+ const result = await this.provider.createOrUpdateFile(config.repo, config.filePath, content, `chore(flow): update ${this.flowFileName(config.filePath)} v${version}`, {
548
+ branch: config.branch,
549
+ sha: remoteSha
550
+ });
551
+ await this.updateConfigAfterSync(db, config.flowId, result.commitSha, version);
552
+ await this.recordHistory(db, config.flowId, "push", {
553
+ commitSha: result.commitSha,
554
+ version,
555
+ message: `Direct commit v${version}`,
556
+ createdBy: identity
557
+ });
558
+ return {
559
+ success: true,
560
+ commitSha: result.commitSha,
561
+ action: "push"
562
+ };
563
+ }
564
+ async commitToPrBranch(db, config, content, version, identity, openPr = true) {
565
+ const branchName = config.draftBranch ?? `invect/flow/${this.flowSlug(config.filePath)}`;
566
+ if (!await this.provider.getBranch(config.repo, branchName)) await this.provider.createBranch(config.repo, branchName, config.branch);
567
+ const remote = await this.provider.getFileContent(config.repo, config.filePath, branchName);
568
+ const result = await this.provider.createOrUpdateFile(config.repo, config.filePath, content, `chore(flow): update ${this.flowFileName(config.filePath)} v${version}`, {
569
+ branch: branchName,
570
+ sha: remote?.sha
571
+ });
572
+ await db.execute("UPDATE vc_sync_config SET draft_branch = ?, updated_at = ? WHERE flow_id = ?", [
573
+ branchName,
574
+ (/* @__PURE__ */ new Date()).toISOString(),
575
+ config.flowId
576
+ ]);
577
+ let prNumber = config.activePrNumber ?? void 0;
578
+ let prUrl = config.activePrUrl ?? void 0;
579
+ if (openPr && !prNumber) {
580
+ const pr = await this.provider.createPullRequest(config.repo, {
581
+ title: `feat(flow): update ${this.flowFileName(config.filePath)}`,
582
+ body: `Automated PR from Invect — flow changes for \`${this.flowFileName(config.filePath)}\`.`,
583
+ head: branchName,
584
+ base: config.branch
585
+ });
586
+ prNumber = pr.number;
587
+ prUrl = pr.url;
588
+ await db.execute("UPDATE vc_sync_config SET active_pr_number = ?, active_pr_url = ?, updated_at = ? WHERE flow_id = ?", [
589
+ prNumber,
590
+ prUrl,
591
+ (/* @__PURE__ */ new Date()).toISOString(),
592
+ config.flowId
593
+ ]);
594
+ await this.recordHistory(db, config.flowId, "pr-created", {
595
+ commitSha: result.commitSha,
596
+ prNumber,
597
+ version,
598
+ message: `PR #${prNumber} created`,
599
+ createdBy: identity
600
+ });
601
+ } else await this.recordHistory(db, config.flowId, "push", {
602
+ commitSha: result.commitSha,
603
+ version,
604
+ message: `Updated PR branch v${version}`,
605
+ createdBy: identity
606
+ });
607
+ await this.updateConfigAfterSync(db, config.flowId, result.commitSha, version);
608
+ return {
609
+ success: true,
610
+ commitSha: result.commitSha,
611
+ prNumber,
612
+ prUrl,
613
+ action: prNumber ? "pr-created" : "push"
614
+ };
615
+ }
616
+ async commitToDraftBranch(db, config, content, version, identity) {
617
+ return this.commitToPrBranch(db, config, content, version, identity, false);
618
+ }
619
+ async exportFlow(db, flowId) {
620
+ const flows = await db.query("SELECT id, name, description, tags FROM flows WHERE id = ?", [flowId]);
621
+ if (flows.length === 0) throw new Error(`Flow not found: ${flowId}`);
622
+ const flow = flows[0];
623
+ const versions = await db.query("SELECT flow_id, version, invect_definition FROM flow_versions WHERE flow_id = ? ORDER BY version DESC LIMIT 1", [flowId]);
624
+ if (versions.length === 0) throw new Error(`No versions found for flow: ${flowId}`);
625
+ const fv = versions[0];
626
+ const definition = typeof fv.invectDefinition === "string" ? JSON.parse(fv.invectDefinition) : fv.invectDefinition;
627
+ let tags;
628
+ if (flow.tags) try {
629
+ tags = typeof flow.tags === "string" ? JSON.parse(flow.tags) : flow.tags;
630
+ } catch {
631
+ tags = void 0;
632
+ }
633
+ return {
634
+ content: serializeFlowToTs(definition, {
635
+ name: flow.name,
636
+ description: flow.description ?? void 0,
637
+ tags
638
+ }),
639
+ version: fv.version
640
+ };
641
+ }
642
+ async importFlowContent(db, flowId, content, identity) {
643
+ const { writeFileSync, unlinkSync, mkdtempSync } = await import("node:fs");
644
+ const { join } = await import("node:path");
645
+ const { tmpdir } = await import("node:os");
646
+ const tmpDir = mkdtempSync(join(tmpdir(), "invect-vc-"));
647
+ const tmpFile = join(tmpDir, "import.flow.ts");
648
+ try {
649
+ writeFileSync(tmpFile, content, "utf-8");
650
+ const { createJiti } = await import("jiti");
651
+ const result = await createJiti(import.meta.url, { interopDefault: true }).import(tmpFile);
652
+ const definition = result.default ?? result;
653
+ if (!definition || typeof definition !== "object" || !("nodes" in definition) || !("edges" in definition)) throw new Error("Imported .flow.ts file did not produce a valid InvectDefinition. Expected an object with \"nodes\" and \"edges\" arrays.");
654
+ const nextVersion = ((await db.query("SELECT MAX(version) as version FROM flow_versions WHERE flow_id = ?", [flowId]))[0]?.version ?? 0) + 1;
655
+ const defJson = typeof definition === "string" ? definition : JSON.stringify(definition);
656
+ await db.execute(`INSERT INTO flow_versions (flow_id, version, invect_definition, created_at, created_by)
657
+ VALUES (?, ?, ?, ?, ?)`, [
658
+ flowId,
659
+ nextVersion,
660
+ defJson,
661
+ (/* @__PURE__ */ new Date()).toISOString(),
662
+ identity ?? null
663
+ ]);
664
+ await db.execute("UPDATE flows SET live_version_number = ?, updated_at = ? WHERE id = ?", [
665
+ nextVersion,
666
+ (/* @__PURE__ */ new Date()).toISOString(),
667
+ flowId
668
+ ]);
669
+ this.logger.info("Flow imported from remote", {
670
+ flowId,
671
+ version: nextVersion
672
+ });
673
+ } finally {
674
+ try {
675
+ unlinkSync(tmpFile);
676
+ const { rmdirSync } = await import("node:fs");
677
+ rmdirSync(tmpDir);
678
+ } catch {}
679
+ }
680
+ }
681
+ buildFilePath(flowName) {
682
+ return `${this.options.path ?? "workflows/"}${flowName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}.flow.ts`;
683
+ }
684
+ flowFileName(filePath) {
685
+ return filePath.split("/").pop() ?? filePath;
686
+ }
687
+ flowSlug(filePath) {
688
+ return this.flowFileName(filePath).replace(/\.flow\.ts$/, "");
689
+ }
690
+ async requireConfig(db, flowId) {
691
+ const config = await this.getSyncConfig(db, flowId);
692
+ if (!config) throw new Error(`Flow ${flowId} is not connected to version control`);
693
+ if (!config.enabled) throw new Error(`Version control sync is disabled for flow ${flowId}`);
694
+ return config;
695
+ }
696
+ async updateConfigAfterSync(db, flowId, commitSha, version) {
697
+ const now = (/* @__PURE__ */ new Date()).toISOString();
698
+ if (version !== null) await db.execute("UPDATE vc_sync_config SET last_synced_at = ?, last_commit_sha = ?, last_synced_version = ?, updated_at = ? WHERE flow_id = ?", [
699
+ now,
700
+ commitSha,
701
+ version,
702
+ now,
703
+ flowId
704
+ ]);
705
+ else await db.execute("UPDATE vc_sync_config SET last_synced_at = ?, last_commit_sha = ?, updated_at = ? WHERE flow_id = ?", [
706
+ now,
707
+ commitSha,
708
+ now,
709
+ flowId
710
+ ]);
711
+ }
712
+ async recordHistory(db, flowId, action, opts) {
713
+ await db.execute(`INSERT INTO vc_sync_history (id, flow_id, action, commit_sha, pr_number, version, message, created_at, created_by)
714
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
715
+ randomUUID(),
716
+ flowId,
717
+ action,
718
+ opts.commitSha ?? null,
719
+ opts.prNumber ?? null,
720
+ opts.version ?? null,
721
+ opts.message ?? null,
722
+ (/* @__PURE__ */ new Date()).toISOString(),
723
+ opts.createdBy ?? null
724
+ ]);
725
+ }
726
+ };
727
+ function mapSyncConfigRow(r) {
728
+ return {
729
+ id: r.id,
730
+ flowId: r.flow_id,
731
+ provider: r.provider,
732
+ repo: r.repo,
733
+ branch: r.branch,
734
+ filePath: r.file_path,
735
+ mode: r.mode,
736
+ syncDirection: r.sync_direction,
737
+ lastSyncedAt: r.last_synced_at,
738
+ lastCommitSha: r.last_commit_sha,
739
+ lastSyncedVersion: r.last_synced_version,
740
+ draftBranch: r.draft_branch,
741
+ activePrNumber: r.active_pr_number,
742
+ activePrUrl: r.active_pr_url,
743
+ enabled: r.enabled === true || r.enabled === 1
744
+ };
745
+ }
746
+ function mapHistoryRow(r) {
747
+ return {
748
+ id: r.id,
749
+ flowId: r.flow_id,
750
+ action: r.action,
751
+ commitSha: r.commit_sha,
752
+ prNumber: r.pr_number,
753
+ version: r.version,
754
+ message: r.message,
755
+ createdAt: r.created_at,
756
+ createdBy: r.created_by
757
+ };
758
+ }
759
+ //#endregion
760
+ //#region src/backend/plugin.ts
761
+ /**
762
+ * Create the Version Control plugin.
763
+ *
764
+ * Syncs Invect flows to a Git remote as readable `.flow.ts` files.
765
+ *
766
+ * ```ts
767
+ * import { versionControl } from '@invect/version-control';
768
+ * import { githubProvider } from '@invect/version-control/providers/github';
769
+ *
770
+ * new Invect({
771
+ * plugins: [
772
+ * versionControl({
773
+ * provider: githubProvider({ auth: { type: 'token', token: process.env.GITHUB_TOKEN! } }),
774
+ * repo: 'acme/workflows',
775
+ * mode: 'pr-per-publish',
776
+ * }),
777
+ * ],
778
+ * });
779
+ * ```
780
+ */
781
+ function versionControl(options) {
782
+ let syncService;
783
+ let pluginLogger = console;
784
+ return {
785
+ id: "version-control",
786
+ name: "Version Control",
787
+ schema: VC_SCHEMA,
788
+ setupInstructions: "Run `npx invect-cli generate` then `npx invect-cli migrate` to create the vc_sync_config and vc_sync_history tables.",
789
+ init: async (ctx) => {
790
+ pluginLogger = ctx.logger;
791
+ syncService = new VcSyncService(options.provider, options, ctx.logger);
792
+ ctx.logger.info(`Version control plugin initialized (provider: ${options.provider.id}, repo: ${options.repo})`);
793
+ },
794
+ endpoints: [
795
+ {
796
+ method: "POST",
797
+ path: "/vc/flows/:flowId/configure",
798
+ handler: async (ctx) => {
799
+ const { flowId } = ctx.params;
800
+ const input = ctx.body;
801
+ return {
802
+ status: 200,
803
+ body: await syncService.configureSyncForFlow(ctx.database, flowId, input)
804
+ };
805
+ }
806
+ },
807
+ {
808
+ method: "GET",
809
+ path: "/vc/flows/:flowId/status",
810
+ handler: async (ctx) => {
811
+ const { flowId } = ctx.params;
812
+ return {
813
+ status: 200,
814
+ body: {
815
+ flowId,
816
+ ...await syncService.getFlowSyncStatus(ctx.database, flowId)
817
+ }
818
+ };
819
+ }
820
+ },
821
+ {
822
+ method: "DELETE",
823
+ path: "/vc/flows/:flowId/disconnect",
824
+ handler: async (ctx) => {
825
+ const { flowId } = ctx.params;
826
+ await syncService.disconnectFlow(ctx.database, flowId);
827
+ return {
828
+ status: 200,
829
+ body: { success: true }
830
+ };
831
+ }
832
+ },
833
+ {
834
+ method: "POST",
835
+ path: "/vc/flows/:flowId/push",
836
+ handler: async (ctx) => {
837
+ const { flowId } = ctx.params;
838
+ const identity = ctx.identity?.id;
839
+ const result = await syncService.pushFlow(ctx.database, flowId, identity);
840
+ return {
841
+ status: result.success ? 200 : 409,
842
+ body: result
843
+ };
844
+ }
845
+ },
846
+ {
847
+ method: "POST",
848
+ path: "/vc/flows/:flowId/pull",
849
+ handler: async (ctx) => {
850
+ const { flowId } = ctx.params;
851
+ const identity = ctx.identity?.id;
852
+ const result = await syncService.pullFlow(ctx.database, flowId, identity);
853
+ return {
854
+ status: result.success ? 200 : 404,
855
+ body: result
856
+ };
857
+ }
858
+ },
859
+ {
860
+ method: "POST",
861
+ path: "/vc/flows/:flowId/publish",
862
+ handler: async (ctx) => {
863
+ const { flowId } = ctx.params;
864
+ const identity = ctx.identity?.id;
865
+ const result = await syncService.publishFlow(ctx.database, flowId, identity);
866
+ return {
867
+ status: result.success ? 200 : 400,
868
+ body: result
869
+ };
870
+ }
871
+ },
872
+ {
873
+ method: "POST",
874
+ path: "/vc/flows/:flowId/force-push",
875
+ handler: async (ctx) => {
876
+ const { flowId } = ctx.params;
877
+ const identity = ctx.identity?.id;
878
+ return {
879
+ status: 200,
880
+ body: await syncService.forcePushFlow(ctx.database, flowId, identity)
881
+ };
882
+ }
883
+ },
884
+ {
885
+ method: "POST",
886
+ path: "/vc/flows/:flowId/force-pull",
887
+ handler: async (ctx) => {
888
+ const { flowId } = ctx.params;
889
+ const identity = ctx.identity?.id;
890
+ const result = await syncService.forcePullFlow(ctx.database, flowId, identity);
891
+ return {
892
+ status: result.success ? 200 : 404,
893
+ body: result
894
+ };
895
+ }
896
+ },
897
+ {
898
+ method: "POST",
899
+ path: "/vc/push-all",
900
+ handler: async (ctx) => {
901
+ const configs = await syncService.listSyncedFlows(ctx.database);
902
+ const identity = ctx.identity?.id;
903
+ const results = [];
904
+ for (const config of configs) {
905
+ if (!config.enabled) continue;
906
+ try {
907
+ const result = await syncService.pushFlow(ctx.database, config.flowId, identity);
908
+ results.push({
909
+ flowId: config.flowId,
910
+ flowName: config.flowName,
911
+ ...result
912
+ });
913
+ } catch (err) {
914
+ results.push({
915
+ flowId: config.flowId,
916
+ flowName: config.flowName,
917
+ success: false,
918
+ error: err.message,
919
+ action: "push"
920
+ });
921
+ }
922
+ }
923
+ return {
924
+ status: 200,
925
+ body: { results }
926
+ };
927
+ }
928
+ },
929
+ {
930
+ method: "POST",
931
+ path: "/vc/pull-all",
932
+ handler: async (ctx) => {
933
+ const configs = await syncService.listSyncedFlows(ctx.database);
934
+ const identity = ctx.identity?.id;
935
+ const results = [];
936
+ for (const config of configs) {
937
+ if (!config.enabled) continue;
938
+ try {
939
+ const result = await syncService.pullFlow(ctx.database, config.flowId, identity);
940
+ results.push({
941
+ flowId: config.flowId,
942
+ flowName: config.flowName,
943
+ ...result
944
+ });
945
+ } catch (err) {
946
+ results.push({
947
+ flowId: config.flowId,
948
+ flowName: config.flowName,
949
+ success: false,
950
+ error: err.message,
951
+ action: "pull"
952
+ });
953
+ }
954
+ }
955
+ return {
956
+ status: 200,
957
+ body: { results }
958
+ };
959
+ }
960
+ },
961
+ {
962
+ method: "POST",
963
+ path: "/vc/webhook",
964
+ isPublic: true,
965
+ handler: async (ctx) => {
966
+ if (!options.webhookSecret) return {
967
+ status: 400,
968
+ body: { error: "Webhook secret not configured" }
969
+ };
970
+ const signature = ctx.headers["x-hub-signature-256"] ?? "";
971
+ const body = JSON.stringify(ctx.body);
972
+ if (!options.provider.verifyWebhookSignature(body, signature, options.webhookSecret)) return {
973
+ status: 401,
974
+ body: { error: "Invalid webhook signature" }
975
+ };
976
+ const action = ctx.body.action;
977
+ const pullRequest = ctx.body.pull_request;
978
+ if (action === "closed" && pullRequest?.merged) await handlePrMerged(ctx.database, pullRequest.number);
979
+ return {
980
+ status: 200,
981
+ body: { received: true }
982
+ };
983
+ }
984
+ },
985
+ {
986
+ method: "GET",
987
+ path: "/vc/flows",
988
+ handler: async (ctx) => {
989
+ return {
990
+ status: 200,
991
+ body: { flows: await syncService.listSyncedFlows(ctx.database) }
992
+ };
993
+ }
994
+ },
995
+ {
996
+ method: "GET",
997
+ path: "/vc/flows/:flowId/history",
998
+ handler: async (ctx) => {
999
+ const { flowId } = ctx.params;
1000
+ const limit = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 20;
1001
+ return {
1002
+ status: 200,
1003
+ body: {
1004
+ flowId,
1005
+ history: await syncService.getSyncHistory(ctx.database, flowId, limit)
1006
+ }
1007
+ };
1008
+ }
1009
+ }
1010
+ ],
1011
+ hooks: {}
1012
+ };
1013
+ async function handlePrMerged(db, prNumber) {
1014
+ const rows = await db.query("SELECT flow_id FROM vc_sync_config WHERE active_pr_number = ?", [prNumber]);
1015
+ for (const row of rows) {
1016
+ await db.execute(`UPDATE vc_sync_config
1017
+ SET active_pr_number = NULL, active_pr_url = NULL, draft_branch = NULL, updated_at = ?
1018
+ WHERE flow_id = ?`, [(/* @__PURE__ */ new Date()).toISOString(), row.flow_id]);
1019
+ const { randomUUID } = await import("node:crypto");
1020
+ await db.execute(`INSERT INTO vc_sync_history (id, flow_id, action, pr_number, message, created_at)
1021
+ VALUES (?, ?, ?, ?, ?, ?)`, [
1022
+ randomUUID(),
1023
+ row.flow_id,
1024
+ "pr-merged",
1025
+ prNumber,
1026
+ `PR #${prNumber} merged`,
1027
+ (/* @__PURE__ */ new Date()).toISOString()
1028
+ ]);
1029
+ const configs = await db.query("SELECT draft_branch, repo FROM vc_sync_config WHERE flow_id = ?", [row.flow_id]);
1030
+ if (configs[0]?.draft_branch) try {
1031
+ await options.provider.deleteBranch(configs[0].repo, configs[0].draft_branch);
1032
+ } catch {}
1033
+ pluginLogger.info("PR merged — sync updated", {
1034
+ flowId: row.flow_id,
1035
+ prNumber
1036
+ });
1037
+ }
1038
+ }
1039
+ }
1040
+ //#endregion
1041
+ export { VC_SCHEMA, versionControl };
1042
+
1043
+ //# sourceMappingURL=index.mjs.map