@pipefy/pipefy-process-coder 0.1.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.
- package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
- package/AGENTS.md +456 -0
- package/README.md +808 -0
- package/bin/pipe.js +13 -0
- package/package.json +35 -0
- package/src/apply/adopt.ts +150 -0
- package/src/apply/agentops.ts +71 -0
- package/src/apply/compile.ts +875 -0
- package/src/apply/execute.ts +336 -0
- package/src/apply/flowops.ts +399 -0
- package/src/apply/idmap.ts +241 -0
- package/src/apply/mutations.ts +955 -0
- package/src/apply/registry.ts +430 -0
- package/src/apply/types.ts +134 -0
- package/src/cli/args.ts +88 -0
- package/src/cli.ts +211 -0
- package/src/codec/flow.ts +199 -0
- package/src/codec/pack.ts +103 -0
- package/src/codec/roundtrip.ts +94 -0
- package/src/codec/unpack.ts +198 -0
- package/src/commands/agents.ts +184 -0
- package/src/commands/apply.ts +1144 -0
- package/src/commands/context.ts +119 -0
- package/src/commands/create.ts +79 -0
- package/src/commands/diff.ts +314 -0
- package/src/commands/flows.ts +414 -0
- package/src/commands/misc.ts +644 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/pull.ts +567 -0
- package/src/commands/runs.ts +83 -0
- package/src/commands/skills.ts +137 -0
- package/src/commands/verify.ts +253 -0
- package/src/config.ts +168 -0
- package/src/diff/agents.ts +122 -0
- package/src/diff/diff.ts +1130 -0
- package/src/diff/flow.ts +318 -0
- package/src/diff/html.ts +322 -0
- package/src/diff/render.ts +101 -0
- package/src/model/payload.ts +154 -0
- package/src/model/tree.ts +99 -0
- package/src/model/volatile.ts +55 -0
- package/src/pipefy/agents.ts +165 -0
- package/src/pipefy/automations.ts +219 -0
- package/src/pipefy/capability.ts +119 -0
- package/src/pipefy/client.ts +267 -0
- package/src/pipefy/discovery.ts +209 -0
- package/src/pipefy/internal.ts +380 -0
- package/src/pipefy/ipaas.ts +365 -0
- package/src/pipefy/reconstruct.ts +775 -0
- package/src/pipefy/reference.ts +251 -0
- package/src/pipefy/snapshot.ts +245 -0
- package/src/pipefy/toolkit.ts +200 -0
- package/src/pipefy/toolkit_bearer.py +137 -0
- package/src/report/integrations.ts +231 -0
- package/src/report/run.ts +475 -0
- package/src/util/fsx.ts +45 -0
- package/src/util/git.ts +32 -0
- package/src/util/json.ts +55 -0
- package/src/util/log.ts +76 -0
- package/src/util/pool.ts +48 -0
- package/src/util/slug.ts +26 -0
- package/src/util/tui.ts +335 -0
- package/src/validate/index.ts +123 -0
- package/src/validate/integrity.ts +387 -0
- package/src/validate/reference.ts +136 -0
- package/src/validate/schema.ts +328 -0
- package/src/workspace/agents.ts +290 -0
- package/src/workspace/docs.ts +407 -0
- package/src/workspace/flows.ts +191 -0
- package/src/workspace/layout.ts +165 -0
- package/src/workspace/lock.ts +148 -0
- package/src/workspace/read.ts +165 -0
- package/src/workspace/reference.ts +24 -0
- package/src/workspace/stamp.ts +301 -0
- package/src/workspace/write.ts +225 -0
package/src/diff/flow.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { canonicalString } from '../util/json.ts';
|
|
2
|
+
import type { FlowTree, FlowNode } from '../codec/flow.ts';
|
|
3
|
+
import type { FlowsSection } from '../workspace/flows.ts';
|
|
4
|
+
import { flowIdOf, isNewFlow } from '../workspace/flows.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Diffing flows.
|
|
8
|
+
*
|
|
9
|
+
* Node-by-node for the human, because that is how a person thinks about a flow and
|
|
10
|
+
* how the compiler will send it — the granular operations are per node. The
|
|
11
|
+
* whole-tree replace the API also offers is not used (see `IpaasClient.operate`),
|
|
12
|
+
* so there is no reason to diff whole-tree either.
|
|
13
|
+
*
|
|
14
|
+
* Coverage is respected exactly as it is for pipe entities: a workspace whose
|
|
15
|
+
* `flows` group is `unknown` is not diffed at all. Comparing against a group the
|
|
16
|
+
* read path could not see would propose deleting every flow in it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type FlowChangeOp =
|
|
20
|
+
| 'flow.create'
|
|
21
|
+
| 'flow.delete'
|
|
22
|
+
| 'flow.rename'
|
|
23
|
+
| 'flow.status'
|
|
24
|
+
| 'node.create'
|
|
25
|
+
| 'node.update'
|
|
26
|
+
| 'node.delete'
|
|
27
|
+
| 'trigger.update';
|
|
28
|
+
|
|
29
|
+
export type FlowChange = {
|
|
30
|
+
op: FlowChangeOp;
|
|
31
|
+
/** Stable key: the flow, plus the node when the change is a node's. */
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
flowId: string | null;
|
|
35
|
+
/** The flow's display name, for the label when there is no id yet. */
|
|
36
|
+
flowName: string;
|
|
37
|
+
nodeName?: string;
|
|
38
|
+
before?: FlowNode | null;
|
|
39
|
+
after?: FlowNode | null;
|
|
40
|
+
/** For flow-level changes. */
|
|
41
|
+
beforeFlow?: FlowTree | null;
|
|
42
|
+
afterFlow?: FlowTree | null;
|
|
43
|
+
deltas: Array<{ key: string; before: unknown; after: unknown }>;
|
|
44
|
+
flags: string[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type FlowChangeSet = {
|
|
48
|
+
changes: FlowChange[];
|
|
49
|
+
skipped: Array<{ reason: string }>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keys that never carry intent and must not produce a change.
|
|
54
|
+
*
|
|
55
|
+
* `sampleData` is generated by testing a step, `lastUpdatedDate` moves on every
|
|
56
|
+
* write, and the server-minted ids move whenever a version is created. Comparing
|
|
57
|
+
* them would make every flow permanently dirty.
|
|
58
|
+
*/
|
|
59
|
+
const VOLATILE_NODE_KEYS = new Set(['sampleData', 'lastUpdatedDate']);
|
|
60
|
+
const VOLATILE_FLOW_KEYS = new Set([
|
|
61
|
+
'created',
|
|
62
|
+
'updated',
|
|
63
|
+
'publishedVersionId',
|
|
64
|
+
'operationStatus',
|
|
65
|
+
'timeSavedPerRun',
|
|
66
|
+
'createdBy',
|
|
67
|
+
'updatedBy',
|
|
68
|
+
'ownerId',
|
|
69
|
+
'externalId',
|
|
70
|
+
'id',
|
|
71
|
+
'flowId',
|
|
72
|
+
'projectId',
|
|
73
|
+
'templateId',
|
|
74
|
+
'backupFiles',
|
|
75
|
+
/**
|
|
76
|
+
* Derived by the API from the inline `{{connections[...]}}` references rather
|
|
77
|
+
* than supplied, so it is an echo of the nodes and never an intent of its own.
|
|
78
|
+
*/
|
|
79
|
+
'connectionIds',
|
|
80
|
+
'state',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const comparableNode = (n: FlowNode): Record<string, unknown> => {
|
|
84
|
+
const out: Record<string, unknown> = {};
|
|
85
|
+
for (const [k, v] of Object.entries(n)) {
|
|
86
|
+
if (VOLATILE_NODE_KEYS.has(k)) continue;
|
|
87
|
+
if (k.startsWith('_')) continue; // link metadata is compared separately, as structure
|
|
88
|
+
if (k === 'settings' && v && typeof v === 'object') {
|
|
89
|
+
const s: Record<string, unknown> = {};
|
|
90
|
+
for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
|
|
91
|
+
if (VOLATILE_NODE_KEYS.has(sk)) continue;
|
|
92
|
+
s[sk] = sv;
|
|
93
|
+
}
|
|
94
|
+
out[k] = s;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
out[k] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const nodeDeltas = (before: FlowNode, after: FlowNode) => {
|
|
103
|
+
const a = comparableNode(before);
|
|
104
|
+
const b = comparableNode(after);
|
|
105
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
106
|
+
const out: Array<{ key: string; before: unknown; after: unknown }> = [];
|
|
107
|
+
for (const k of keys) {
|
|
108
|
+
if (canonicalString(a[k]) !== canonicalString(b[k])) out.push({ key: k, before: a[k], after: b[k] });
|
|
109
|
+
}
|
|
110
|
+
// Structure: a node whose place in the chain moved is a change even when its
|
|
111
|
+
// settings are identical.
|
|
112
|
+
for (const link of ['_nextAction', '_firstLoopAction', '_children'] as const) {
|
|
113
|
+
if (canonicalString(before[link]) !== canonicalString(after[link])) {
|
|
114
|
+
out.push({ key: link, before: before[link], after: after[link] });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const flowLabel = (tree: FlowTree) => String(tree.version['displayName'] ?? flowIdOf(tree) ?? 'flow');
|
|
121
|
+
|
|
122
|
+
/** Match flows by id, then by display name for one the workspace is creating. */
|
|
123
|
+
const matchFlows = (before: FlowTree[], after: FlowTree[]) => {
|
|
124
|
+
const pairs: Array<[FlowTree, FlowTree]> = [];
|
|
125
|
+
const added: FlowTree[] = [];
|
|
126
|
+
const removed: FlowTree[] = [...before];
|
|
127
|
+
|
|
128
|
+
for (const a of after) {
|
|
129
|
+
const id = flowIdOf(a);
|
|
130
|
+
let hitAt = id ? removed.findIndex((b) => flowIdOf(b) === id) : -1;
|
|
131
|
+
if (hitAt < 0 && isNewFlow(a)) {
|
|
132
|
+
hitAt = removed.findIndex((b) => flowLabel(b) === flowLabel(a));
|
|
133
|
+
}
|
|
134
|
+
if (hitAt >= 0) {
|
|
135
|
+
pairs.push([removed[hitAt] as FlowTree, a]);
|
|
136
|
+
removed.splice(hitAt, 1);
|
|
137
|
+
} else {
|
|
138
|
+
added.push(a);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { pairs, added, removed };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const diffFlows = (before: FlowsSection | null, after: FlowsSection | null): FlowChangeSet => {
|
|
145
|
+
const changes: FlowChange[] = [];
|
|
146
|
+
const skipped: Array<{ reason: string }> = [];
|
|
147
|
+
|
|
148
|
+
if (!before || !after) {
|
|
149
|
+
return { changes, skipped: [{ reason: 'one side has no flows section, so flows are not diffed' }] };
|
|
150
|
+
}
|
|
151
|
+
if (before.meta.coverage !== 'complete' || after.meta.coverage !== 'complete') {
|
|
152
|
+
/**
|
|
153
|
+
* The whole reason coverage exists. A pipe whose iPaaS could not be reached
|
|
154
|
+
* reports zero flows, and diffing that against a workspace that has some would
|
|
155
|
+
* propose deleting every one of them.
|
|
156
|
+
*/
|
|
157
|
+
return {
|
|
158
|
+
changes,
|
|
159
|
+
skipped: [
|
|
160
|
+
{
|
|
161
|
+
reason:
|
|
162
|
+
`flows coverage is ${before.meta.coverage}/${after.meta.coverage}, not complete — ` +
|
|
163
|
+
'not diffed, because absence here is not evidence of absence',
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { pairs, added, removed } = matchFlows(before.flows, after.flows);
|
|
170
|
+
|
|
171
|
+
for (const tree of added) {
|
|
172
|
+
changes.push({
|
|
173
|
+
op: 'flow.create',
|
|
174
|
+
key: `flow:new:${flowLabel(tree)}`,
|
|
175
|
+
label: `create flow "${flowLabel(tree)}" (${tree.nodes.length} nodes)`,
|
|
176
|
+
flowId: null,
|
|
177
|
+
flowName: flowLabel(tree),
|
|
178
|
+
afterFlow: tree,
|
|
179
|
+
deltas: [],
|
|
180
|
+
flags: [],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const tree of removed) {
|
|
185
|
+
changes.push({
|
|
186
|
+
op: 'flow.delete',
|
|
187
|
+
key: `flow:${flowIdOf(tree)}`,
|
|
188
|
+
label: `delete flow "${flowLabel(tree)}"`,
|
|
189
|
+
flowId: flowIdOf(tree),
|
|
190
|
+
flowName: flowLabel(tree),
|
|
191
|
+
beforeFlow: tree,
|
|
192
|
+
deltas: [],
|
|
193
|
+
flags: ['destructive'],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const [b, a] of pairs) {
|
|
198
|
+
const flowId = flowIdOf(a) ?? flowIdOf(b);
|
|
199
|
+
const name = flowLabel(a);
|
|
200
|
+
|
|
201
|
+
if (flowLabel(b) !== flowLabel(a)) {
|
|
202
|
+
changes.push({
|
|
203
|
+
op: 'flow.rename',
|
|
204
|
+
key: `flow:${flowId}:name`,
|
|
205
|
+
label: `rename flow "${flowLabel(b)}" → "${flowLabel(a)}"`,
|
|
206
|
+
flowId,
|
|
207
|
+
flowName: name,
|
|
208
|
+
deltas: [{ key: 'displayName', before: flowLabel(b), after: flowLabel(a) }],
|
|
209
|
+
flags: [],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (String(b.flow['status'] ?? '') !== String(a.flow['status'] ?? '')) {
|
|
214
|
+
changes.push({
|
|
215
|
+
op: 'flow.status',
|
|
216
|
+
key: `flow:${flowId}:status`,
|
|
217
|
+
label: `flow "${name}" ${b.flow['status']} → ${a.flow['status']}`,
|
|
218
|
+
flowId,
|
|
219
|
+
flowName: name,
|
|
220
|
+
deltas: [{ key: 'status', before: b.flow['status'], after: a.flow['status'] }],
|
|
221
|
+
// Enabling a flow arms it against live traffic. That is never incidental.
|
|
222
|
+
flags: String(a.flow['status']) === 'ENABLED' ? ['arms-flow'] : [],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const beforeNodes = new Map(b.nodes.map((n) => [n.name, n]));
|
|
227
|
+
const afterNodes = new Map(a.nodes.map((n) => [n.name, n]));
|
|
228
|
+
|
|
229
|
+
for (const [nodeName, node] of afterNodes) {
|
|
230
|
+
const prior = beforeNodes.get(nodeName);
|
|
231
|
+
if (!prior) {
|
|
232
|
+
changes.push({
|
|
233
|
+
op: 'node.create',
|
|
234
|
+
key: `flow:${flowId}:node:${nodeName}`,
|
|
235
|
+
label: `flow "${name}": add ${nodeName} [${node.type}] ${String(node['displayName'] ?? '')}`.trim(),
|
|
236
|
+
flowId,
|
|
237
|
+
flowName: name,
|
|
238
|
+
nodeName,
|
|
239
|
+
after: node,
|
|
240
|
+
/**
|
|
241
|
+
* The whole intended tree, not just the node: `ADD_ACTION` needs the
|
|
242
|
+
* parent this node hangs from, and that is a property of where `node`
|
|
243
|
+
* sits in the chain — found by scanning every node's `_nextAction` /
|
|
244
|
+
* `_firstLoopAction` / `_children` for `nodeName` (apply/flowops.ts
|
|
245
|
+
* `parentOf`), the same lookup a brand-new flow's build already uses.
|
|
246
|
+
*/
|
|
247
|
+
afterFlow: a,
|
|
248
|
+
deltas: [],
|
|
249
|
+
flags: [],
|
|
250
|
+
});
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const deltas = nodeDeltas(prior, node);
|
|
254
|
+
if (!deltas.length) continue;
|
|
255
|
+
const isTrigger = nodeName === 'trigger';
|
|
256
|
+
changes.push({
|
|
257
|
+
op: isTrigger ? 'trigger.update' : 'node.update',
|
|
258
|
+
key: `flow:${flowId}:node:${nodeName}`,
|
|
259
|
+
label: `flow "${name}": ${isTrigger ? 'trigger' : nodeName} — ${deltas.map((d) => d.key).join(', ')}`,
|
|
260
|
+
flowId,
|
|
261
|
+
flowName: name,
|
|
262
|
+
nodeName,
|
|
263
|
+
before: prior,
|
|
264
|
+
after: node,
|
|
265
|
+
deltas,
|
|
266
|
+
flags: [],
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const [nodeName, node] of beforeNodes) {
|
|
271
|
+
if (afterNodes.has(nodeName)) continue;
|
|
272
|
+
changes.push({
|
|
273
|
+
op: 'node.delete',
|
|
274
|
+
key: `flow:${flowId}:node:${nodeName}`,
|
|
275
|
+
label: `flow "${name}": delete ${nodeName} [${node.type}]`,
|
|
276
|
+
flowId,
|
|
277
|
+
flowName: name,
|
|
278
|
+
nodeName,
|
|
279
|
+
before: node,
|
|
280
|
+
deltas: [],
|
|
281
|
+
flags: ['destructive'],
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { changes, skipped };
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export const renderFlowChanges = (cs: FlowChangeSet): string => {
|
|
290
|
+
const lines: string[] = [];
|
|
291
|
+
for (const s of cs.skipped) lines.push(` flows not diffed: ${s.reason}`);
|
|
292
|
+
if (!cs.changes.length) {
|
|
293
|
+
if (!cs.skipped.length) lines.push(' flows: no changes');
|
|
294
|
+
return lines.join('\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const byFlow = new Map<string, FlowChange[]>();
|
|
298
|
+
for (const c of cs.changes) {
|
|
299
|
+
const k = c.flowId ?? `new:${c.flowName}`;
|
|
300
|
+
byFlow.set(k, [...(byFlow.get(k) ?? []), c]);
|
|
301
|
+
}
|
|
302
|
+
for (const [, group] of byFlow) {
|
|
303
|
+
lines.push('');
|
|
304
|
+
lines.push(` ${group[0]?.flowName ?? 'flow'}`);
|
|
305
|
+
for (const c of group) {
|
|
306
|
+
const mark = c.flags.includes('destructive') ? '-' : c.op.endsWith('create') ? '+' : '~';
|
|
307
|
+
lines.push(` ${mark} ${c.label}`);
|
|
308
|
+
for (const d of c.deltas.slice(0, 6)) {
|
|
309
|
+
const short = (v: unknown) => {
|
|
310
|
+
const s = canonicalString(v);
|
|
311
|
+
return s.length > 90 ? `${s.slice(0, 90)}…` : s;
|
|
312
|
+
};
|
|
313
|
+
lines.push(` ${d.key}: ${short(d.before)} -> ${short(d.after)}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return lines.join('\n');
|
|
318
|
+
};
|
package/src/diff/html.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { ChangeSet } from './diff.ts';
|
|
2
|
+
import type { Plan } from '../apply/types.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A self-contained HTML diff view.
|
|
6
|
+
*
|
|
7
|
+
* The one thing a GUI genuinely buys is *seeing* a diff, so that is what this is:
|
|
8
|
+
* one file, no dependencies, no build, opens in any browser. It is the reason the
|
|
9
|
+
* desktop shell of PLAN.md phase 3 was dropped rather than built.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ViewModel = {
|
|
13
|
+
changeSet: ChangeSet;
|
|
14
|
+
plan?: Plan | null;
|
|
15
|
+
workspace?: string;
|
|
16
|
+
generatedAt: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const escapeHtml = (s: string) =>
|
|
20
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
21
|
+
|
|
22
|
+
const STYLE = `
|
|
23
|
+
:root {
|
|
24
|
+
color-scheme: light dark;
|
|
25
|
+
--bg: #fbfbfa; --panel: #ffffff; --border: #e4e2dd; --text: #1c1b19;
|
|
26
|
+
--muted: #6f6b64; --add: #1a7f4b; --add-bg: #e8f5ee; --del: #b3261e;
|
|
27
|
+
--del-bg: #fdeceb; --upd: #8a6100; --upd-bg: #fdf3e2; --move: #6b4ea8;
|
|
28
|
+
--move-bg: #f1ecfa; --accent: #1f6feb; --code: #f4f3f0;
|
|
29
|
+
}
|
|
30
|
+
@media (prefers-color-scheme: dark) {
|
|
31
|
+
:root {
|
|
32
|
+
--bg: #171716; --panel: #1f1f1e; --border: #33322f; --text: #ecebe8;
|
|
33
|
+
--muted: #9b968d; --add: #56d38a; --add-bg: #14301f; --del: #ff8a80;
|
|
34
|
+
--del-bg: #331a18; --upd: #e5b567; --upd-bg: #32270f; --move: #b39ddb;
|
|
35
|
+
--move-bg: #241d33; --accent: #6ea8ff; --code: #262624;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
* { box-sizing: border-box; }
|
|
39
|
+
body {
|
|
40
|
+
margin: 0; background: var(--bg); color: var(--text);
|
|
41
|
+
font: 14px/1.5 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
42
|
+
}
|
|
43
|
+
code, pre, .mono { font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; }
|
|
44
|
+
header {
|
|
45
|
+
position: sticky; top: 0; z-index: 5; background: var(--panel);
|
|
46
|
+
border-bottom: 1px solid var(--border); padding: 14px 20px;
|
|
47
|
+
}
|
|
48
|
+
h1 { font-size: 16px; margin: 0 0 4px; font-weight: 650; }
|
|
49
|
+
.sub { color: var(--muted); font-size: 12.5px; }
|
|
50
|
+
.counts { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
|
51
|
+
.pill {
|
|
52
|
+
border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px;
|
|
53
|
+
font-size: 12px; background: var(--bg); cursor: pointer; color: var(--text);
|
|
54
|
+
}
|
|
55
|
+
.pill[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
56
|
+
.pill.create { color: var(--add); } .pill.delete { color: var(--del); }
|
|
57
|
+
.pill.update { color: var(--upd); } .pill.reorder { color: var(--move); }
|
|
58
|
+
main { padding: 18px 20px 60px; max-width: 1100px; margin: 0 auto; }
|
|
59
|
+
input[type=search] {
|
|
60
|
+
width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px;
|
|
61
|
+
background: var(--bg); color: var(--text); margin-top: 10px; font-size: 13px;
|
|
62
|
+
}
|
|
63
|
+
.notice {
|
|
64
|
+
border: 1px solid var(--border); border-left: 3px solid var(--upd); background: var(--upd-bg);
|
|
65
|
+
padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 13px;
|
|
66
|
+
}
|
|
67
|
+
.notice.block { border-left-color: var(--del); background: var(--del-bg); }
|
|
68
|
+
.notice h3 { margin: 0 0 6px; font-size: 13px; }
|
|
69
|
+
.notice ul { margin: 4px 0 0 18px; padding: 0; }
|
|
70
|
+
.entity { margin: 22px 0 8px; font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
|
71
|
+
.change {
|
|
72
|
+
border: 1px solid var(--border); border-radius: 10px; background: var(--panel);
|
|
73
|
+
margin-bottom: 8px; overflow: hidden;
|
|
74
|
+
}
|
|
75
|
+
.change > summary {
|
|
76
|
+
padding: 10px 12px; cursor: pointer; display: flex; gap: 10px; align-items: baseline;
|
|
77
|
+
list-style: none;
|
|
78
|
+
}
|
|
79
|
+
.change > summary::-webkit-details-marker { display: none; }
|
|
80
|
+
.op {
|
|
81
|
+
flex: none; width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center;
|
|
82
|
+
font-weight: 700; font-size: 13px;
|
|
83
|
+
}
|
|
84
|
+
.op.create { color: var(--add); background: var(--add-bg); }
|
|
85
|
+
.op.delete { color: var(--del); background: var(--del-bg); }
|
|
86
|
+
.op.update, .op.archive, .op.unarchive { color: var(--upd); background: var(--upd-bg); }
|
|
87
|
+
.op.reorder { color: var(--move); background: var(--move-bg); }
|
|
88
|
+
.title { font-weight: 550; }
|
|
89
|
+
.flags { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
90
|
+
.flag {
|
|
91
|
+
font-size: 11px; border: 1px solid var(--border); border-radius: 999px;
|
|
92
|
+
padding: 1px 8px; color: var(--muted);
|
|
93
|
+
}
|
|
94
|
+
.flag.warn { color: var(--del); border-color: var(--del); }
|
|
95
|
+
.body { border-top: 1px solid var(--border); padding: 10px 12px; }
|
|
96
|
+
table.deltas { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
|
97
|
+
table.deltas td { vertical-align: top; padding: 5px 6px; border-top: 1px solid var(--border); }
|
|
98
|
+
table.deltas tr:first-child td { border-top: 0; }
|
|
99
|
+
td.k { width: 22%; color: var(--muted); }
|
|
100
|
+
td.b, td.a { width: 39%; }
|
|
101
|
+
.val { display: block; padding: 3px 6px; border-radius: 5px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
|
102
|
+
.val.before { background: var(--del-bg); color: var(--del); }
|
|
103
|
+
.val.after { background: var(--add-bg); color: var(--add); }
|
|
104
|
+
pre.json {
|
|
105
|
+
background: var(--code); border-radius: 8px; padding: 10px; overflow-x: auto;
|
|
106
|
+
font-size: 12px; max-height: 340px; margin: 8px 0 0;
|
|
107
|
+
}
|
|
108
|
+
.step { font-size: 12.5px; color: var(--muted); margin-top: 8px; }
|
|
109
|
+
.step b { color: var(--text); }
|
|
110
|
+
.empty { color: var(--muted); padding: 40px 0; text-align: center; }
|
|
111
|
+
footer { color: var(--muted); font-size: 12px; padding: 0 20px 30px; max-width: 1100px; margin: 0 auto; }
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const CLIENT = `
|
|
115
|
+
const state = { ops: new Set(), entities: new Set(), q: '' };
|
|
116
|
+
function apply() {
|
|
117
|
+
const q = state.q.toLowerCase();
|
|
118
|
+
let shown = 0;
|
|
119
|
+
document.querySelectorAll('.change').forEach(function (el) {
|
|
120
|
+
const op = el.dataset.op, ent = el.dataset.entity, hay = el.dataset.search;
|
|
121
|
+
let ok = true;
|
|
122
|
+
if (state.ops.size && !state.ops.has(op)) ok = false;
|
|
123
|
+
if (ok && state.entities.size && !state.entities.has(ent)) ok = false;
|
|
124
|
+
if (ok && q && hay.indexOf(q) === -1) ok = false;
|
|
125
|
+
el.hidden = !ok;
|
|
126
|
+
if (ok) shown++;
|
|
127
|
+
});
|
|
128
|
+
document.querySelectorAll('.entity').forEach(function (h) {
|
|
129
|
+
const any = document.querySelectorAll('.change[data-entity="' + h.dataset.entity + '"]:not([hidden])').length;
|
|
130
|
+
h.hidden = any === 0;
|
|
131
|
+
});
|
|
132
|
+
const empty = document.getElementById('empty');
|
|
133
|
+
if (empty) empty.hidden = shown !== 0;
|
|
134
|
+
}
|
|
135
|
+
document.querySelectorAll('.pill[data-op]').forEach(function (b) {
|
|
136
|
+
b.addEventListener('click', function () {
|
|
137
|
+
const op = b.dataset.op;
|
|
138
|
+
if (state.ops.has(op)) { state.ops.delete(op); b.setAttribute('aria-pressed', 'false'); }
|
|
139
|
+
else { state.ops.add(op); b.setAttribute('aria-pressed', 'true'); }
|
|
140
|
+
apply();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
document.querySelectorAll('.pill[data-entity]').forEach(function (b) {
|
|
144
|
+
b.addEventListener('click', function () {
|
|
145
|
+
const e = b.dataset.entity;
|
|
146
|
+
if (state.entities.has(e)) { state.entities.delete(e); b.setAttribute('aria-pressed', 'false'); }
|
|
147
|
+
else { state.entities.add(e); b.setAttribute('aria-pressed', 'true'); }
|
|
148
|
+
apply();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
const search = document.getElementById('q');
|
|
152
|
+
if (search) search.addEventListener('input', function () { state.q = search.value; apply(); });
|
|
153
|
+
document.addEventListener('keydown', function (e) {
|
|
154
|
+
if (e.key === 'e' && (e.metaKey || e.ctrlKey)) {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
const open = document.querySelectorAll('.change[open]').length === 0;
|
|
157
|
+
document.querySelectorAll('.change').forEach(function (d) { d.open = open; });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
const val = (v: unknown): string => {
|
|
163
|
+
if (v === null || v === undefined) return 'null';
|
|
164
|
+
if (typeof v === 'string') return v;
|
|
165
|
+
return JSON.stringify(v, null, 2);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const renderHtml = (vm: ViewModel): string => {
|
|
169
|
+
const cs = vm.changeSet;
|
|
170
|
+
const plan = vm.plan ?? null;
|
|
171
|
+
|
|
172
|
+
const stepFor = (key: string) => plan?.steps.filter((s) => s.changeKey === key) ?? [];
|
|
173
|
+
const supportFor = (key: string) => plan?.classifications.find((c) => c.changeKey === key);
|
|
174
|
+
|
|
175
|
+
const opCounts = cs.changes.reduce<Record<string, number>>((a, c) => {
|
|
176
|
+
a[c.op] = (a[c.op] ?? 0) + 1;
|
|
177
|
+
return a;
|
|
178
|
+
}, {});
|
|
179
|
+
const entities = [...new Set(cs.changes.map((c) => c.entity))].sort();
|
|
180
|
+
|
|
181
|
+
const blocked = plan?.classifications.filter((c) => c.status === 'UNSUPPORTED') ?? [];
|
|
182
|
+
|
|
183
|
+
const byEntity = new Map<string, typeof cs.changes>();
|
|
184
|
+
for (const c of cs.changes) {
|
|
185
|
+
const arr = byEntity.get(c.entity) ?? [];
|
|
186
|
+
arr.push(c);
|
|
187
|
+
byEntity.set(c.entity, arr);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const changeHtml = [...byEntity.entries()]
|
|
191
|
+
.map(([entity, group]) => {
|
|
192
|
+
const rows = group
|
|
193
|
+
.map((c) => {
|
|
194
|
+
const sup = supportFor(c.key);
|
|
195
|
+
const steps = stepFor(c.key);
|
|
196
|
+
const search = [c.entity, c.label, c.op, ...c.flags, ...c.deltas.map((d) => d.key)]
|
|
197
|
+
.join(' ')
|
|
198
|
+
.toLowerCase();
|
|
199
|
+
|
|
200
|
+
const deltaRows = c.deltas
|
|
201
|
+
.map(
|
|
202
|
+
(d) => `<tr>
|
|
203
|
+
<td class="k mono">${escapeHtml(d.key)}</td>
|
|
204
|
+
<td class="b"><span class="val before mono">${escapeHtml(val(d.before))}</span></td>
|
|
205
|
+
<td class="a"><span class="val after mono">${escapeHtml(val(d.after))}</span></td>
|
|
206
|
+
</tr>`,
|
|
207
|
+
)
|
|
208
|
+
.join('');
|
|
209
|
+
|
|
210
|
+
const full =
|
|
211
|
+
c.op === 'create'
|
|
212
|
+
? `<pre class="json mono">${escapeHtml(JSON.stringify(c.after, null, 2))}</pre>`
|
|
213
|
+
: c.op === 'delete'
|
|
214
|
+
? `<pre class="json mono">${escapeHtml(JSON.stringify(c.before, null, 2))}</pre>`
|
|
215
|
+
: '';
|
|
216
|
+
|
|
217
|
+
const stepHtml = steps.length
|
|
218
|
+
? `<div class="step">${steps
|
|
219
|
+
.map((s) => `<b>${escapeHtml(s.kind)}</b> ${escapeHtml(s.description)}`)
|
|
220
|
+
.join('<br>')}</div>`
|
|
221
|
+
: '';
|
|
222
|
+
|
|
223
|
+
const supHtml = sup
|
|
224
|
+
? `<div class="step"><b>${sup.status}</b>${sup.via ? ` via <span class="mono">${escapeHtml(sup.via)}</span>` : ''}${
|
|
225
|
+
sup.reason ? ` — ${escapeHtml(sup.reason)}` : ''
|
|
226
|
+
}</div>`
|
|
227
|
+
: '';
|
|
228
|
+
|
|
229
|
+
const flagHtml = [
|
|
230
|
+
...c.flags.map(
|
|
231
|
+
(f) =>
|
|
232
|
+
`<span class="flag${/destructive|readonly-edit|field-type-change|field-phase-move|phase-reorder|start-form/.test(f) ? ' warn' : ''}">${escapeHtml(f)}</span>`,
|
|
233
|
+
),
|
|
234
|
+
sup && sup.status !== 'SUPPORTED' ? `<span class="flag warn">${sup.status}</span>` : '',
|
|
235
|
+
]
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.join('');
|
|
238
|
+
|
|
239
|
+
return `<details class="change" data-op="${c.op}" data-entity="${escapeHtml(c.entity)}" data-search="${escapeHtml(search)}">
|
|
240
|
+
<summary>
|
|
241
|
+
<span class="op ${c.op}">${c.op === 'create' ? '+' : c.op === 'delete' ? '−' : c.op === 'reorder' ? '↕' : '~'}</span>
|
|
242
|
+
<span class="title">${escapeHtml(c.label)}</span>
|
|
243
|
+
<span class="flags">${flagHtml}</span>
|
|
244
|
+
</summary>
|
|
245
|
+
<div class="body">
|
|
246
|
+
${deltaRows ? `<table class="deltas">${deltaRows}</table>` : ''}
|
|
247
|
+
${full}
|
|
248
|
+
${supHtml}
|
|
249
|
+
${stepHtml}
|
|
250
|
+
</div>
|
|
251
|
+
</details>`;
|
|
252
|
+
})
|
|
253
|
+
.join('\n');
|
|
254
|
+
return `<h2 class="entity" data-entity="${escapeHtml(entity)}">${escapeHtml(entity)} · ${group.length}</h2>\n${rows}`;
|
|
255
|
+
})
|
|
256
|
+
.join('\n');
|
|
257
|
+
|
|
258
|
+
const noticeBlocked = blocked.length
|
|
259
|
+
? `<div class="notice block">
|
|
260
|
+
<h3>${blocked.length} change${blocked.length === 1 ? '' : 's'} cannot be applied</h3>
|
|
261
|
+
<ul>${blocked
|
|
262
|
+
.map(
|
|
263
|
+
(b) => `<li><span class="mono">${escapeHtml(b.entity)}.${escapeHtml(b.op)}</span> — ${escapeHtml(b.reason ?? 'no write path')}</li>`,
|
|
264
|
+
)
|
|
265
|
+
.join('')}</ul>
|
|
266
|
+
</div>`
|
|
267
|
+
: '';
|
|
268
|
+
|
|
269
|
+
const noticeSkipped = cs.skipped.length
|
|
270
|
+
? `<div class="notice">
|
|
271
|
+
<h3>${cs.skipped.length} entity group${cs.skipped.length === 1 ? '' : 's'} not diffed</h3>
|
|
272
|
+
<ul>${cs.skipped
|
|
273
|
+
.map((s) => `<li><span class="mono">${escapeHtml(s.entity)}</span> — ${escapeHtml(s.reason)}</li>`)
|
|
274
|
+
.join('')}</ul>
|
|
275
|
+
</div>`
|
|
276
|
+
: '';
|
|
277
|
+
|
|
278
|
+
const opPills = Object.entries(opCounts)
|
|
279
|
+
.map(([op, n]) => `<button class="pill ${op}" data-op="${op}" aria-pressed="false">${op} ${n}</button>`)
|
|
280
|
+
.join('');
|
|
281
|
+
const entityPills = entities
|
|
282
|
+
.map((e) => `<button class="pill" data-entity="${escapeHtml(e)}" aria-pressed="false">${escapeHtml(e)}</button>`)
|
|
283
|
+
.join('');
|
|
284
|
+
|
|
285
|
+
const header =
|
|
286
|
+
cs.mode === 'cross-repo'
|
|
287
|
+
? `${escapeHtml(cs.from.name)} <span class="mono">${cs.from.repoId}</span> → ${escapeHtml(cs.to.name)} <span class="mono">${cs.to.repoId}</span>`
|
|
288
|
+
: `${escapeHtml(cs.to.name)} <span class="mono">${cs.to.repoId}</span>`;
|
|
289
|
+
|
|
290
|
+
return `<!doctype html>
|
|
291
|
+
<html lang="en">
|
|
292
|
+
<head>
|
|
293
|
+
<meta charset="utf-8">
|
|
294
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
295
|
+
<title>pipe diff — ${escapeHtml(cs.to.name)}</title>
|
|
296
|
+
<style>${STYLE}</style>
|
|
297
|
+
</head>
|
|
298
|
+
<body>
|
|
299
|
+
<header>
|
|
300
|
+
<h1>${header}</h1>
|
|
301
|
+
<div class="sub">
|
|
302
|
+
${cs.mode} diff · baseline from ${escapeHtml(cs.from.source)} · compared tree from ${escapeHtml(cs.to.source)}
|
|
303
|
+
${vm.workspace ? ` · <span class="mono">${escapeHtml(vm.workspace)}</span>` : ''}
|
|
304
|
+
</div>
|
|
305
|
+
<div class="counts">${opPills}${entityPills}</div>
|
|
306
|
+
<input id="q" type="search" placeholder="filter by name, field or flag — cmd/ctrl+E toggles all">
|
|
307
|
+
</header>
|
|
308
|
+
<main>
|
|
309
|
+
${noticeBlocked}
|
|
310
|
+
${noticeSkipped}
|
|
311
|
+
${cs.changes.length ? changeHtml : '<div class="empty">No changes. The edited workspace matches the baseline.</div>'}
|
|
312
|
+
<div class="empty" id="empty" hidden>Nothing matches this filter.</div>
|
|
313
|
+
</main>
|
|
314
|
+
<footer>
|
|
315
|
+
Generated ${escapeHtml(vm.generatedAt)} by <span class="mono">pipe diff --html</span>.
|
|
316
|
+
Webhook URLs and automation credentials can appear in these values — this file is as sensitive as the workspace.
|
|
317
|
+
</footer>
|
|
318
|
+
<script>${CLIENT}</script>
|
|
319
|
+
</body>
|
|
320
|
+
</html>
|
|
321
|
+
`;
|
|
322
|
+
};
|