@invect/version-control 0.0.1 → 0.0.2

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 (52) hide show
  1. package/README.md +78 -0
  2. package/dist/backend/flow-serializer.d.ts.map +1 -1
  3. package/dist/backend/index.cjs +209 -48
  4. package/dist/backend/index.cjs.map +1 -1
  5. package/dist/backend/index.d.cts +8 -3
  6. package/dist/backend/index.d.cts.map +1 -1
  7. package/dist/backend/index.d.mts +8 -3
  8. package/dist/backend/index.d.mts.map +1 -1
  9. package/dist/backend/index.mjs +208 -47
  10. package/dist/backend/index.mjs.map +1 -1
  11. package/dist/backend/plugin.d.ts +2 -2
  12. package/dist/backend/plugin.d.ts.map +1 -1
  13. package/dist/backend/sync-service.d.ts.map +1 -1
  14. package/dist/backend/types.d.ts +5 -0
  15. package/dist/backend/types.d.ts.map +1 -1
  16. package/dist/backend/validation.d.ts +19 -0
  17. package/dist/backend/validation.d.ts.map +1 -0
  18. package/dist/frontend/components/ConnectFlowForm.d.ts +10 -0
  19. package/dist/frontend/components/ConnectFlowForm.d.ts.map +1 -0
  20. package/dist/frontend/components/VcHeaderButton.d.ts +8 -0
  21. package/dist/frontend/components/VcHeaderButton.d.ts.map +1 -0
  22. package/dist/frontend/components/VcSyncPanel.d.ts +10 -0
  23. package/dist/frontend/components/VcSyncPanel.d.ts.map +1 -0
  24. package/dist/frontend/hooks/useFlowSync.d.ts +37 -0
  25. package/dist/frontend/hooks/useFlowSync.d.ts.map +1 -0
  26. package/dist/frontend/index.cjs +717 -0
  27. package/dist/frontend/index.cjs.map +1 -0
  28. package/dist/frontend/index.d.cts +43 -2
  29. package/dist/frontend/index.d.cts.map +1 -0
  30. package/dist/frontend/index.d.mts +43 -2
  31. package/dist/frontend/index.d.mts.map +1 -0
  32. package/dist/frontend/index.d.ts +9 -0
  33. package/dist/frontend/index.d.ts.map +1 -1
  34. package/dist/frontend/index.mjs +705 -1
  35. package/dist/frontend/index.mjs.map +1 -0
  36. package/dist/providers/github.d.cts +1 -1
  37. package/dist/providers/github.d.mts +1 -1
  38. package/dist/shared/types.cjs +19 -0
  39. package/dist/shared/types.cjs.map +1 -0
  40. package/dist/shared/types.d.cts +2 -2
  41. package/dist/shared/types.d.mts +2 -2
  42. package/dist/shared/types.d.ts +4 -2
  43. package/dist/shared/types.d.ts.map +1 -1
  44. package/dist/shared/types.mjs +17 -1
  45. package/dist/shared/types.mjs.map +1 -0
  46. package/dist/{types-B32wGtx7.d.cts → types-DACJdSjJ.d.mts} +6 -4
  47. package/dist/types-DACJdSjJ.d.mts.map +1 -0
  48. package/dist/{types-B7fFBAOX.d.mts → types-DDMnbS1q.d.cts} +6 -4
  49. package/dist/types-DDMnbS1q.d.cts.map +1 -0
  50. package/package.json +31 -4
  51. package/dist/types-B32wGtx7.d.cts.map +0 -1
  52. package/dist/types-B7fFBAOX.d.mts.map +0 -1
@@ -1,4 +1,6 @@
1
+ import { VC_SYNC_DIRECTIONS, VC_SYNC_MODES } from "../shared/types.mjs";
1
2
  import { randomUUID } from "node:crypto";
3
+ import { z } from "zod/v4";
2
4
  //#region src/backend/schema.ts
3
5
  const SYNC_MODES = [
4
6
  "direct-commit",
@@ -207,6 +209,10 @@ function serializeFlowToTs(definition, metadata) {
207
209
  lines.push(" ],");
208
210
  lines.push("});");
209
211
  lines.push("");
212
+ lines.push("/* @invect-definition");
213
+ lines.push(JSON.stringify(definition));
214
+ lines.push("*/");
215
+ lines.push("");
210
216
  return lines.join("\n");
211
217
  }
212
218
  /** Map action IDs to SDK helper function names */
@@ -344,6 +350,11 @@ var VcSyncService = class {
344
350
  }
345
351
  async pushFlow(db, flowId, identity) {
346
352
  const config = await this.requireConfig(db, flowId);
353
+ if (config.syncDirection === "pull") return {
354
+ success: false,
355
+ error: "Push is not allowed — this flow is configured for pull-only sync.",
356
+ action: "push"
357
+ };
347
358
  const { content, version } = await this.exportFlow(db, flowId);
348
359
  try {
349
360
  if (config.mode === "direct-commit") return await this.directCommit(db, config, content, version, identity);
@@ -389,6 +400,11 @@ var VcSyncService = class {
389
400
  }
390
401
  async pullFlow(db, flowId, identity) {
391
402
  const config = await this.requireConfig(db, flowId);
403
+ if (config.syncDirection === "push") return {
404
+ success: false,
405
+ error: "Pull is not allowed — this flow is configured for push-only sync.",
406
+ action: "pull"
407
+ };
392
408
  const remote = await this.provider.getFileContent(config.repo, config.filePath, config.branch);
393
409
  if (!remote) return {
394
410
  success: false,
@@ -623,7 +639,7 @@ var VcSyncService = class {
623
639
  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
640
  if (versions.length === 0) throw new Error(`No versions found for flow: ${flowId}`);
625
641
  const fv = versions[0];
626
- const definition = typeof fv.invectDefinition === "string" ? JSON.parse(fv.invectDefinition) : fv.invectDefinition;
642
+ const definition = typeof fv.invect_definition === "string" ? JSON.parse(fv.invect_definition) : fv.invect_definition;
627
643
  let tags;
628
644
  if (flow.tags) try {
629
645
  tags = typeof flow.tags === "string" ? JSON.parse(flow.tags) : flow.tags;
@@ -640,43 +656,27 @@ var VcSyncService = class {
640
656
  };
641
657
  }
642
658
  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
- }
659
+ const definition = parseFlowTsContent(content);
660
+ if (!definition || typeof definition !== "object" || !Array.isArray(definition.nodes) || !Array.isArray(definition.edges)) throw new Error("Imported .flow.ts file did not produce a valid InvectDefinition. Expected an object with \"nodes\" and \"edges\" arrays.");
661
+ const nextVersion = ((await db.query("SELECT MAX(version) as version FROM flow_versions WHERE flow_id = ?", [flowId]))[0]?.version ?? 0) + 1;
662
+ const defJson = JSON.stringify(definition);
663
+ await db.execute(`INSERT INTO flow_versions (flow_id, version, invect_definition, created_at, created_by)
664
+ VALUES (?, ?, ?, ?, ?)`, [
665
+ flowId,
666
+ nextVersion,
667
+ defJson,
668
+ (/* @__PURE__ */ new Date()).toISOString(),
669
+ identity ?? null
670
+ ]);
671
+ await db.execute("UPDATE flows SET live_version_number = ?, updated_at = ? WHERE id = ?", [
672
+ nextVersion,
673
+ (/* @__PURE__ */ new Date()).toISOString(),
674
+ flowId
675
+ ]);
676
+ this.logger.info("Flow imported from remote", {
677
+ flowId,
678
+ version: nextVersion
679
+ });
680
680
  }
681
681
  buildFilePath(flowName) {
682
682
  return `${this.options.path ?? "workflows/"}${flowName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}.flow.ts`;
@@ -756,6 +756,153 @@ function mapHistoryRow(r) {
756
756
  createdBy: r.created_by
757
757
  };
758
758
  }
759
+ /**
760
+ * Parse a .flow.ts file content to extract the InvectDefinition.
761
+ *
762
+ * This is a static parser that does NOT evaluate the TypeScript file.
763
+ * It works by extracting the `defineFlow({ ... })` call's argument as a
764
+ * JS object literal string and parsing it with a safe JSON5-like approach.
765
+ *
766
+ * Falls back to extracting raw `nodes` and `edges` arrays if defineFlow
767
+ * wrapper is not found.
768
+ */
769
+ function parseFlowTsContent(content) {
770
+ const jsonCommentMatch = content.match(/\/\*\s*@invect-definition\s+([\s\S]*?)\s*\*\//);
771
+ if (jsonCommentMatch) try {
772
+ return JSON.parse(jsonCommentMatch[1]);
773
+ } catch {}
774
+ const defineFlowMatch = content.match(/defineFlow\s*\(\s*\{/);
775
+ if (defineFlowMatch && defineFlowMatch.index !== void 0) {
776
+ const objStr = extractBalancedBraces(content, defineFlowMatch.index + defineFlowMatch[0].length - 1);
777
+ if (objStr) try {
778
+ const parsed = parseObjectLiteral(objStr);
779
+ if (parsed && Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) return {
780
+ nodes: parsed.nodes,
781
+ edges: parsed.edges
782
+ };
783
+ } catch {}
784
+ }
785
+ return null;
786
+ }
787
+ /** Extract a balanced {} block from a string starting at the given { index */
788
+ function extractBalancedBraces(str, startIdx) {
789
+ let depth = 0;
790
+ let inString = false;
791
+ let escaped = false;
792
+ for (let i = startIdx; i < str.length; i++) {
793
+ const ch = str[i];
794
+ if (escaped) {
795
+ escaped = false;
796
+ continue;
797
+ }
798
+ if (ch === "\\") {
799
+ escaped = true;
800
+ continue;
801
+ }
802
+ if (inString) {
803
+ if (ch === inString) inString = false;
804
+ continue;
805
+ }
806
+ if (ch === "\"" || ch === "'" || ch === "`") {
807
+ inString = ch;
808
+ continue;
809
+ }
810
+ if (ch === "{" || ch === "[") depth++;
811
+ else if (ch === "}" || ch === "]") {
812
+ depth--;
813
+ if (depth === 0) return str.slice(startIdx, i + 1);
814
+ }
815
+ }
816
+ return null;
817
+ }
818
+ /**
819
+ * Parse a JS object literal string into a JSON-compatible value.
820
+ *
821
+ * Handles: unquoted keys, single-quoted strings, trailing commas,
822
+ * template literals (simplified), and function calls by converting
823
+ * them to strings.
824
+ */
825
+ function parseObjectLiteral(objStr) {
826
+ try {
827
+ let normalized = objStr.replace(/\/\/.*$/gm, "");
828
+ normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, "");
829
+ normalized = replaceQuotes(normalized);
830
+ normalized = normalized.replace(/(?<=[{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)(?=\s*:)/g, "\"$1\"");
831
+ normalized = normalized.replace(/,\s*([}\]])/g, "$1");
832
+ normalized = replaceFunctionCalls(normalized);
833
+ return JSON.parse(normalized);
834
+ } catch {
835
+ return null;
836
+ }
837
+ }
838
+ /** Replace single-quoted strings with double-quoted */
839
+ function replaceQuotes(str) {
840
+ let result = "";
841
+ let inDouble = false;
842
+ let inSingle = false;
843
+ let escaped = false;
844
+ for (let i = 0; i < str.length; i++) {
845
+ const ch = str[i];
846
+ if (escaped) {
847
+ result += ch;
848
+ escaped = false;
849
+ continue;
850
+ }
851
+ if (ch === "\\") {
852
+ result += ch;
853
+ escaped = true;
854
+ continue;
855
+ }
856
+ if (!inSingle && ch === "\"") {
857
+ inDouble = !inDouble;
858
+ result += ch;
859
+ } else if (!inDouble && ch === "'") {
860
+ inSingle = !inSingle;
861
+ result += "\"";
862
+ } else result += ch;
863
+ }
864
+ return result;
865
+ }
866
+ /**
867
+ * Replace function calls like `input("ref", { ... })` with a JSON object
868
+ * that captures the node structure. This handles the SDK helper calls
869
+ * in the serialized .flow.ts nodes array.
870
+ *
871
+ * Pattern: `helperName("refId", { params })` → `{ "type": "helperName", "referenceId": "refId", "params": { ... } }`
872
+ * Also handles namespaced: `ns.helperName("refId", { ... })`
873
+ */
874
+ function replaceFunctionCalls(str) {
875
+ const callPattern = /([a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*|[a-zA-Z_$][\w$]*)\s*\(\s*"([^"]*)"\s*,\s*(\{)/g;
876
+ let result = str;
877
+ let match;
878
+ let offset = 0;
879
+ callPattern.lastIndex = 0;
880
+ while ((match = callPattern.exec(str)) !== null) {
881
+ const fnName = match[1];
882
+ const refId = match[2];
883
+ const braceStart = match.index + match[0].length - 1;
884
+ const paramsBlock = extractBalancedBraces(str, braceStart);
885
+ if (!paramsBlock) continue;
886
+ let closeParen = braceStart + paramsBlock.length;
887
+ while (closeParen < str.length && str[closeParen] !== ")") closeParen++;
888
+ const fullCall = str.slice(match.index, closeParen + 1);
889
+ const replacement = `{ "__type": "${fnName}", "referenceId": "${refId}", "params": ${paramsBlock} }`;
890
+ result = result.slice(0, match.index + offset) + replacement + result.slice(match.index + offset + fullCall.length);
891
+ offset += replacement.length - fullCall.length;
892
+ }
893
+ return result;
894
+ }
895
+ //#endregion
896
+ //#region src/backend/validation.ts
897
+ const configureSyncInputSchema = z.object({
898
+ repo: z.string().regex(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/, "Invalid repo format. Expected \"owner/name\".").optional(),
899
+ branch: z.string().max(256).regex(/^[a-zA-Z0-9._/-]+$/, "Invalid branch name.").optional(),
900
+ filePath: z.string().max(1024).regex(/^[a-zA-Z0-9._/-]+\.flow\.ts$/, "File path must end with .flow.ts").optional(),
901
+ mode: z.enum(VC_SYNC_MODES).optional(),
902
+ syncDirection: z.enum(VC_SYNC_DIRECTIONS).optional(),
903
+ enabled: z.boolean().optional()
904
+ });
905
+ const historyLimitSchema = z.coerce.number().int().min(1).max(100).default(20);
759
906
  //#endregion
760
907
  //#region src/backend/plugin.ts
761
908
  /**
@@ -779,6 +926,15 @@ function mapHistoryRow(r) {
779
926
  * ```
780
927
  */
781
928
  function versionControl(options) {
929
+ const { frontend, ...backendOptions } = options;
930
+ return {
931
+ id: "version-control",
932
+ name: "Version Control",
933
+ backend: _vcBackendPlugin(backendOptions),
934
+ frontend
935
+ };
936
+ }
937
+ function _vcBackendPlugin(options) {
782
938
  let syncService;
783
939
  let pluginLogger = console;
784
940
  return {
@@ -797,10 +953,17 @@ function versionControl(options) {
797
953
  path: "/vc/flows/:flowId/configure",
798
954
  handler: async (ctx) => {
799
955
  const { flowId } = ctx.params;
800
- const input = ctx.body;
956
+ const parsed = configureSyncInputSchema.safeParse(ctx.body);
957
+ if (!parsed.success) return {
958
+ status: 400,
959
+ body: {
960
+ error: "Invalid input",
961
+ details: parsed.error.issues
962
+ }
963
+ };
801
964
  return {
802
965
  status: 200,
803
- body: await syncService.configureSyncForFlow(ctx.database, flowId, input)
966
+ body: await syncService.configureSyncForFlow(ctx.database, flowId, parsed.data)
804
967
  };
805
968
  }
806
969
  },
@@ -997,7 +1160,7 @@ function versionControl(options) {
997
1160
  path: "/vc/flows/:flowId/history",
998
1161
  handler: async (ctx) => {
999
1162
  const { flowId } = ctx.params;
1000
- const limit = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 20;
1163
+ const limit = historyLimitSchema.parse(ctx.query.limit);
1001
1164
  return {
1002
1165
  status: 200,
1003
1166
  body: {
@@ -1011,12 +1174,14 @@ function versionControl(options) {
1011
1174
  hooks: {}
1012
1175
  };
1013
1176
  async function handlePrMerged(db, prNumber) {
1014
- const rows = await db.query("SELECT flow_id FROM vc_sync_config WHERE active_pr_number = ?", [prNumber]);
1177
+ const rows = await db.query("SELECT flow_id, draft_branch, repo FROM vc_sync_config WHERE active_pr_number = ?", [prNumber]);
1015
1178
  for (const row of rows) {
1179
+ if (row.draft_branch) try {
1180
+ await options.provider.deleteBranch(row.repo, row.draft_branch);
1181
+ } catch {}
1016
1182
  await db.execute(`UPDATE vc_sync_config
1017
1183
  SET active_pr_number = NULL, active_pr_url = NULL, draft_branch = NULL, updated_at = ?
1018
1184
  WHERE flow_id = ?`, [(/* @__PURE__ */ new Date()).toISOString(), row.flow_id]);
1019
- const { randomUUID } = await import("node:crypto");
1020
1185
  await db.execute(`INSERT INTO vc_sync_history (id, flow_id, action, pr_number, message, created_at)
1021
1186
  VALUES (?, ?, ?, ?, ?, ?)`, [
1022
1187
  randomUUID(),
@@ -1026,10 +1191,6 @@ function versionControl(options) {
1026
1191
  `PR #${prNumber} merged`,
1027
1192
  (/* @__PURE__ */ new Date()).toISOString()
1028
1193
  ]);
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
1194
  pluginLogger.info("PR merged — sync updated", {
1034
1195
  flowId: row.flow_id,
1035
1196
  prNumber