@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.
- package/LICENSE +21 -0
- package/dist/backend/flow-serializer.d.ts +38 -0
- package/dist/backend/flow-serializer.d.ts.map +1 -0
- package/dist/backend/git-provider.d.ts +77 -0
- package/dist/backend/git-provider.d.ts.map +1 -0
- package/dist/backend/github-provider.d.ts +14 -0
- package/dist/backend/github-provider.d.ts.map +1 -0
- package/dist/backend/index.cjs +1045 -0
- package/dist/backend/index.cjs.map +1 -0
- package/dist/backend/index.d.cts +51 -0
- package/dist/backend/index.d.cts.map +1 -0
- package/dist/backend/index.d.mts +51 -0
- package/dist/backend/index.d.mts.map +1 -0
- package/dist/backend/index.d.ts +5 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.mjs +1043 -0
- package/dist/backend/index.mjs.map +1 -0
- package/dist/backend/plugin.d.ts +24 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/schema.d.ts +3 -0
- package/dist/backend/schema.d.ts.map +1 -0
- package/dist/backend/sync-service.d.ts +47 -0
- package/dist/backend/sync-service.d.ts.map +1 -0
- package/dist/backend/types.d.ts +20 -0
- package/dist/backend/types.d.ts.map +1 -0
- package/dist/frontend/index.cjs +0 -0
- package/dist/frontend/index.d.cts +2 -0
- package/dist/frontend/index.d.mts +2 -0
- package/dist/frontend/index.d.ts +2 -0
- package/dist/frontend/index.d.ts.map +1 -0
- package/dist/frontend/index.mjs +1 -0
- package/dist/git-provider-BD8MMEXB.d.mts +80 -0
- package/dist/git-provider-BD8MMEXB.d.mts.map +1 -0
- package/dist/git-provider-CjMtpb86.d.cts +80 -0
- package/dist/git-provider-CjMtpb86.d.cts.map +1 -0
- package/dist/providers/github.cjs +191 -0
- package/dist/providers/github.cjs.map +1 -0
- package/dist/providers/github.d.cts +17 -0
- package/dist/providers/github.d.cts.map +1 -0
- package/dist/providers/github.d.mts +17 -0
- package/dist/providers/github.d.mts.map +1 -0
- package/dist/providers/github.d.ts +2 -0
- package/dist/providers/github.d.ts.map +1 -0
- package/dist/providers/github.mjs +190 -0
- package/dist/providers/github.mjs.map +1 -0
- package/dist/shared/types.cjs +0 -0
- package/dist/shared/types.d.cts +2 -0
- package/dist/shared/types.d.mts +2 -0
- package/dist/shared/types.d.ts +77 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.mjs +1 -0
- package/dist/types-B32wGtx7.d.cts +80 -0
- package/dist/types-B32wGtx7.d.cts.map +1 -0
- package/dist/types-B7fFBAOX.d.mts +80 -0
- package/dist/types-B7fFBAOX.d.mts.map +1 -0
- 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
|