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