@syntax-syllogism/flow-delta 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ FlowDelta:
2
+ image: node:24-alpine
3
+ rules:
4
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
5
+ variables:
6
+ GIT_DEPTH: 0
7
+ before_script:
8
+ - apk add --no-cache git
9
+ script:
10
+ - |
11
+ # see /docs/ci.md for details on how this works
12
+ npx --yes -p @syntax-syllogism/flow-delta@latest flow-delta \
13
+ --repo . \
14
+ --from "$CI_MERGE_REQUEST_DIFF_BASE_SHA" \
15
+ --to "${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA:-$CI_COMMIT_SHA}" \
16
+ --path 'force-app/**/*.flow-meta.xml' \
17
+ --changed-only \
18
+ --out flow-delta-out --json
19
+ - npx --yes -p @syntax-syllogism/flow-delta@latest flow-delta-gitlab --in flow-delta-out
20
+ artifacts:
21
+ paths: [flow-delta-out]
22
+ expire_in: 30 days
23
+ allow_failure: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/flow-delta",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Semantic visual diff for Salesforce Flows",
6
6
  "license": "MIT",
@@ -27,14 +27,15 @@
27
27
  "scripts": {
28
28
  "build": "node scripts/build.mjs",
29
29
  "smoke:gitlab": "node --import tsx scripts/smoke-gitlab.ts",
30
- "test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/gitlab-report.test.ts test/smoke-gitlab.test.ts",
30
+ "smoke:github": "node --import tsx scripts/smoke-github.ts",
31
+ "test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/report-core.test.ts test/gitlab-report.test.ts test/github-report.test.ts test/smoke-gitlab.test.ts test/r2-publish.test.ts test/cloudflare-worker.test.ts",
31
32
  "test:parser": "node --import tsx --test test/parser.test.ts",
32
33
  "prepublishOnly": "npm run build && npm test",
33
34
  "render:fixtures": "bash bin/render-fixtures.sh"
34
35
  },
35
36
  "devDependencies": {
36
37
  "esbuild": "^0.28.0",
37
- "@types/node": "^25.9.2",
38
+ "@types/node": "^26.1.0",
38
39
  "@types/xml2js": "^0.4.14",
39
40
  "tsx": "^4.19.0"
40
41
  },
@@ -44,10 +45,13 @@
44
45
  },
45
46
  "bin": {
46
47
  "flow-delta": "dist/cli.js",
47
- "flow-delta-gitlab": "dist/gitlab-report.js"
48
+ "flow-delta-gitlab": "dist/gitlab-report.js",
49
+ "flow-delta-github": "dist/github-report.js"
48
50
  },
49
51
  "files": [
50
52
  "dist",
53
+ "examples",
54
+ "scripts/r2-publish.mjs",
51
55
  "NOTICE",
52
56
  "LICENSE",
53
57
  "LICENSE-APACHE",
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ import { createHmac } from "node:crypto";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
9
+ const [outDir = "flow-delta-out", keyPrefix = defaultKeyPrefix()] = process.argv.slice(2);
10
+
11
+ try {
12
+ const urls = publishDirectory(outDir, keyPrefix);
13
+ process.stdout.write(`${JSON.stringify(urls, null, 2)}\n`);
14
+ } catch (error) {
15
+ console.error(error instanceof Error ? error.message : String(error));
16
+ process.exitCode = 1;
17
+ }
18
+ }
19
+
20
+ export function publishDirectory(outDir, keyPrefix, env = process.env) {
21
+ const bucket = required(env.R2_BUCKET, "R2_BUCKET");
22
+ const endpoint = r2Endpoint(env);
23
+ const htmlFiles = readdirSync(outDir).filter((file) => file.endsWith(".html")).sort();
24
+ const urls = {};
25
+
26
+ for (const file of htmlFiles) {
27
+ const key = joinKey(keyPrefix, file);
28
+ uploadHtml(join(outDir, file), bucket, key, endpoint);
29
+ urls[file] = resolveUrl(bucket, key, endpoint, env);
30
+ }
31
+
32
+ return urls;
33
+ }
34
+
35
+ export function signedWorkerUrl(baseUrl, key, secret, exp) {
36
+ const base = baseUrl.replace(/\/+$/, "");
37
+ const encodedKey = key.split("/").map(encodeURIComponent).join("/");
38
+ const sig = hmacHex(secret, `${key}:${exp}`);
39
+ return `${base}/${encodedKey}?exp=${exp}&sig=${sig}`;
40
+ }
41
+
42
+ export function hmacHex(secret, message) {
43
+ return createHmac("sha256", secret).update(message).digest("hex");
44
+ }
45
+
46
+ function resolveUrl(bucket, key, endpoint, env) {
47
+ if (env.ARTIFACT_BASE_URL) {
48
+ const secret = required(env.ARTIFACT_HMAC_KEY, "ARTIFACT_HMAC_KEY");
49
+ const exp = String(Math.floor(Date.now() / 1000) + expirySeconds(env));
50
+ return signedWorkerUrl(env.ARTIFACT_BASE_URL, key, secret, exp);
51
+ }
52
+
53
+ return presignUrl(bucket, key, endpoint, expirySeconds(env));
54
+ }
55
+
56
+ function uploadHtml(path, bucket, key, endpoint) {
57
+ runAws([
58
+ "s3",
59
+ "cp",
60
+ path,
61
+ `s3://${bucket}/${key}`,
62
+ "--endpoint-url",
63
+ endpoint,
64
+ "--content-type",
65
+ "text/html",
66
+ ]);
67
+ }
68
+
69
+ function presignUrl(bucket, key, endpoint, expiresIn) {
70
+ return runAws([
71
+ "s3",
72
+ "presign",
73
+ `s3://${bucket}/${key}`,
74
+ "--endpoint-url",
75
+ endpoint,
76
+ "--expires-in",
77
+ String(expiresIn),
78
+ ]);
79
+ }
80
+
81
+ function runAws(args) {
82
+ const result = spawnSync("aws", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
83
+ if (result.status !== 0) {
84
+ throw new Error(result.stderr.trim() || `aws ${args.join(" ")} failed`);
85
+ }
86
+ return result.stdout.trim();
87
+ }
88
+
89
+ function r2Endpoint(env) {
90
+ const accountId = required(env.R2_ACCOUNT_ID, "R2_ACCOUNT_ID");
91
+ return `https://${accountId}.r2.cloudflarestorage.com`;
92
+ }
93
+
94
+ function expirySeconds(env) {
95
+ const raw = env.ARTIFACT_EXPIRES_IN_SECONDS ?? "86400";
96
+ const value = Number(raw);
97
+ if (!Number.isInteger(value) || value <= 0) {
98
+ throw new Error("ARTIFACT_EXPIRES_IN_SECONDS must be a positive integer");
99
+ }
100
+ return Math.min(value, 604800);
101
+ }
102
+
103
+ function joinKey(prefix, file) {
104
+ return [prefix.replace(/^\/+|\/+$/g, ""), basename(file)].filter(Boolean).join("/");
105
+ }
106
+
107
+ function defaultKeyPrefix() {
108
+ const repo = required(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY");
109
+ const sha = required(process.env.GITHUB_SHA, "GITHUB_SHA");
110
+ const pr = process.env.GITHUB_PR_NUMBER ?? process.env.PR_NUMBER ?? readGitHubEventPrNumber() ?? "unknown-pr";
111
+ return `${repo}/${pr}/${sha}`;
112
+ }
113
+
114
+ function readGitHubEventPrNumber() {
115
+ const eventPath = process.env.GITHUB_EVENT_PATH;
116
+ if (!eventPath) {
117
+ return undefined;
118
+ }
119
+ try {
120
+ const event = JSON.parse(readFileSync(eventPath, "utf8"));
121
+ return typeof event?.number === "number" ? String(event.number) : undefined;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ function required(value, name) {
128
+ if (!value) {
129
+ throw new Error(`Missing required value: ${name}`);
130
+ }
131
+ return value;
132
+ }