@pipefy/pipefy-process-coder 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
  19. package/AGENTS.md +465 -456
  20. package/README.md +825 -807
  21. package/bin/pipe.js +25 -4
  22. package/dist/apply/adopt.js +128 -0
  23. package/dist/apply/agentops.js +42 -0
  24. package/dist/apply/compile.js +722 -0
  25. package/dist/apply/execute.js +277 -0
  26. package/dist/apply/flowops.js +293 -0
  27. package/dist/apply/idmap.js +233 -0
  28. package/dist/apply/mutations.js +704 -0
  29. package/dist/apply/registry.js +348 -0
  30. package/dist/apply/types.js +18 -0
  31. package/dist/cli/args.js +79 -0
  32. package/dist/cli.js +187 -0
  33. package/dist/codec/flow.js +164 -0
  34. package/dist/codec/pack.js +88 -0
  35. package/dist/codec/roundtrip.js +86 -0
  36. package/dist/codec/unpack.js +181 -0
  37. package/dist/commands/agents.js +145 -0
  38. package/dist/commands/apply.js +982 -0
  39. package/dist/commands/context.js +79 -0
  40. package/dist/commands/create.js +63 -0
  41. package/dist/commands/diff.js +294 -0
  42. package/dist/commands/flows.js +348 -0
  43. package/dist/commands/misc.js +528 -0
  44. package/dist/commands/plan.js +301 -0
  45. package/dist/commands/pull.js +511 -0
  46. package/dist/commands/runs.js +74 -0
  47. package/dist/commands/skills.js +131 -0
  48. package/dist/commands/verify.js +205 -0
  49. package/dist/config.js +125 -0
  50. package/dist/diff/agents.js +80 -0
  51. package/dist/diff/diff.js +983 -0
  52. package/dist/diff/flow.js +258 -0
  53. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  54. package/dist/diff/render.js +90 -0
  55. package/dist/model/payload.js +84 -0
  56. package/dist/model/tree.js +7 -0
  57. package/dist/model/volatile.js +54 -0
  58. package/dist/pipefy/agents.js +122 -0
  59. package/dist/pipefy/automations.js +155 -0
  60. package/dist/pipefy/capability.js +84 -0
  61. package/dist/pipefy/client.js +204 -0
  62. package/dist/pipefy/discovery.js +148 -0
  63. package/dist/pipefy/internal.js +315 -0
  64. package/dist/pipefy/ipaas.js +269 -0
  65. package/dist/pipefy/reconstruct.js +674 -0
  66. package/dist/pipefy/reference.js +172 -0
  67. package/dist/pipefy/snapshot.js +185 -0
  68. package/dist/pipefy/toolkit.js +158 -0
  69. package/dist/report/integrations.js +165 -0
  70. package/dist/report/run.js +355 -0
  71. package/dist/util/fsx.js +50 -0
  72. package/dist/util/git.js +25 -0
  73. package/dist/util/json.js +56 -0
  74. package/dist/util/log.js +69 -0
  75. package/dist/util/pool.js +42 -0
  76. package/dist/util/slug.js +23 -0
  77. package/dist/util/tui.js +287 -0
  78. package/dist/validate/index.js +86 -0
  79. package/dist/validate/integrity.js +370 -0
  80. package/dist/validate/reference.js +120 -0
  81. package/dist/validate/schema.js +278 -0
  82. package/dist/workspace/agents.js +245 -0
  83. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  84. package/dist/workspace/flows.js +149 -0
  85. package/dist/workspace/layout.js +146 -0
  86. package/dist/workspace/lock.js +83 -0
  87. package/dist/workspace/read.js +168 -0
  88. package/dist/workspace/reference.js +24 -0
  89. package/dist/workspace/stamp.js +234 -0
  90. package/dist/workspace/write.js +194 -0
  91. package/package.json +37 -35
  92. package/src/apply/adopt.ts +0 -150
  93. package/src/apply/agentops.ts +0 -71
  94. package/src/apply/compile.ts +0 -875
  95. package/src/apply/execute.ts +0 -336
  96. package/src/apply/flowops.ts +0 -399
  97. package/src/apply/idmap.ts +0 -241
  98. package/src/apply/mutations.ts +0 -955
  99. package/src/apply/registry.ts +0 -430
  100. package/src/apply/types.ts +0 -134
  101. package/src/cli/args.ts +0 -88
  102. package/src/cli.ts +0 -211
  103. package/src/codec/flow.ts +0 -199
  104. package/src/codec/pack.ts +0 -103
  105. package/src/codec/roundtrip.ts +0 -94
  106. package/src/codec/unpack.ts +0 -198
  107. package/src/commands/agents.ts +0 -184
  108. package/src/commands/apply.ts +0 -1144
  109. package/src/commands/context.ts +0 -119
  110. package/src/commands/create.ts +0 -79
  111. package/src/commands/diff.ts +0 -314
  112. package/src/commands/flows.ts +0 -414
  113. package/src/commands/misc.ts +0 -644
  114. package/src/commands/plan.ts +0 -331
  115. package/src/commands/pull.ts +0 -567
  116. package/src/commands/runs.ts +0 -83
  117. package/src/commands/skills.ts +0 -137
  118. package/src/commands/verify.ts +0 -253
  119. package/src/config.ts +0 -168
  120. package/src/diff/agents.ts +0 -122
  121. package/src/diff/diff.ts +0 -1130
  122. package/src/diff/flow.ts +0 -318
  123. package/src/diff/render.ts +0 -101
  124. package/src/model/payload.ts +0 -154
  125. package/src/model/tree.ts +0 -99
  126. package/src/model/volatile.ts +0 -55
  127. package/src/pipefy/agents.ts +0 -165
  128. package/src/pipefy/automations.ts +0 -219
  129. package/src/pipefy/capability.ts +0 -119
  130. package/src/pipefy/client.ts +0 -267
  131. package/src/pipefy/discovery.ts +0 -209
  132. package/src/pipefy/internal.ts +0 -380
  133. package/src/pipefy/ipaas.ts +0 -365
  134. package/src/pipefy/reconstruct.ts +0 -775
  135. package/src/pipefy/reference.ts +0 -251
  136. package/src/pipefy/snapshot.ts +0 -245
  137. package/src/pipefy/toolkit.ts +0 -200
  138. package/src/report/integrations.ts +0 -231
  139. package/src/report/run.ts +0 -475
  140. package/src/util/fsx.ts +0 -45
  141. package/src/util/git.ts +0 -32
  142. package/src/util/json.ts +0 -55
  143. package/src/util/log.ts +0 -76
  144. package/src/util/pool.ts +0 -48
  145. package/src/util/slug.ts +0 -26
  146. package/src/util/tui.ts +0 -335
  147. package/src/validate/index.ts +0 -123
  148. package/src/validate/integrity.ts +0 -387
  149. package/src/validate/reference.ts +0 -136
  150. package/src/validate/schema.ts +0 -328
  151. package/src/workspace/agents.ts +0 -290
  152. package/src/workspace/flows.ts +0 -191
  153. package/src/workspace/layout.ts +0 -165
  154. package/src/workspace/lock.ts +0 -148
  155. package/src/workspace/read.ts +0 -165
  156. package/src/workspace/reference.ts +0 -24
  157. package/src/workspace/stamp.ts +0 -301
  158. package/src/workspace/write.ts +0 -225
  159. /package/{src → dist}/pipefy/toolkit_bearer.py +0 -0
@@ -0,0 +1,528 @@
1
+ import { resolve } from 'node:path';
2
+ import { loadReference } from "../workspace/reference.js";
3
+ import { bool, int, requirePositional, str } from "../cli/args.js";
4
+ import { ENDPOINTS, writeToken } from "../config.js";
5
+ import { readJson, writeFile, writeJson } from "../util/fsx.js";
6
+ import { UserError, bold, dim, green, info, ok, out, red, step, warn, yellow } from "../util/log.js";
7
+ import { ENTITY_NAMES } from "../model/payload.js";
8
+ import { unpack } from "../codec/unpack.js";
9
+ import { pack } from "../codec/pack.js";
10
+ import { assertRoundTrip, compare } from "../codec/roundtrip.js";
11
+ import { missingMutations } from "../pipefy/capability.js";
12
+ import { RECONSTRUCTION_GAPS } from "../pipefy/reconstruct.js";
13
+ import { readTree } from "../workspace/read.js";
14
+ import { registryRows } from "../apply/registry.js";
15
+ import { validateTree, renderFindings, blockingErrors } from "../validate/index.js";
16
+ import { connect, openWorkspace } from "./context.js";
17
+ // ── login ────────────────────────────────────────────────────────────────────
18
+ export const loginHelp = `pipe login [options]
19
+
20
+ With no options: runs the Pipefy browser sign-in through the official AI
21
+ toolkit (github.com/pipefy/ai-toolkit), which stores the session in your OS
22
+ keychain. That one session is then shared by \`pipe\`, the \`pipefy\` CLI and the
23
+ Pipefy MCP server — this tool never holds a long-lived credential of its own.
24
+
25
+ --token <token> store a personal API token in ~/.ppc/credentials.json
26
+ instead. For machines without the toolkit, and for CI.
27
+ --org <id> pin the organization used by the internal automations query
28
+ --device pass --device to the toolkit flow (headless machines)
29
+ --status just report which credential would be used
30
+
31
+ Resolution order: PIPEFY_TOKEN in the environment, then the toolkit, then
32
+ ~/.ppc/credentials.json.`;
33
+ export const login = async (p) => {
34
+ if (bool(p, 'status'))
35
+ return loginStatus();
36
+ const token = str(p, 'token');
37
+ // ── the intended path: delegate to the toolkit's OAuth flow ───────────────
38
+ if (!token) {
39
+ const { inspectToolkit, runToolkitLogin, INSTALL_HINT } = await import("../pipefy/toolkit.js");
40
+ const toolkit = await inspectToolkit();
41
+ if (!toolkit.installed) {
42
+ throw new UserError('the Pipefy AI toolkit is not installed, so there is no browser login to run', `${INSTALL_HINT} — or use \`pipe login --token <token>\` for a personal API token`);
43
+ }
44
+ step('running `pipefy auth login` (browser sign-in)');
45
+ const code = await runToolkitLogin(bool(p, 'device') ? ['--device'] : []);
46
+ if (code !== 0) {
47
+ throw new UserError(`the toolkit login exited with code ${code}`, 'run `pipefy auth login` directly to see its output');
48
+ }
49
+ return loginStatus();
50
+ }
51
+ // ── the fallback: this tool's own token store ─────────────────────────────
52
+ const { PipefyClient } = await import("../pipefy/client.js");
53
+ const client = new PipefyClient({ token });
54
+ const me = await client.tryRaw('query { me { name email } }', undefined, 'me');
55
+ if (!me?.me)
56
+ throw new UserError('that token was rejected by ' + ENDPOINTS.graphql);
57
+ const orgs = await client.tryRaw('query { organizations { id name } }', undefined, 'organizations');
58
+ const org = str(p, 'org') ?? orgs?.organizations?.[0]?.id;
59
+ const path = await writeToken(token, org);
60
+ ok(`token stored for ${me.me.name ?? me.me.email ?? 'this account'} — ${dim(path)}`);
61
+ if (orgs?.organizations?.length) {
62
+ info(dim('organizations: ' + orgs.organizations.map((o) => `${o.id} (${o.name})`).join(', ')));
63
+ if (org)
64
+ info(dim(`using organization ${org} for internal automation queries`));
65
+ }
66
+ warn('a personal API token outranks nothing: PIPEFY_TOKEN and the toolkit session are both checked first');
67
+ return 0;
68
+ };
69
+ /** Which credential would be used, and who it belongs to. */
70
+ const loginStatus = async () => {
71
+ const { resolveCredential } = await import("../config.js");
72
+ const credential = await resolveCredential();
73
+ if (!credential) {
74
+ warn('no credential found');
75
+ info(dim(' run `pipe login`, `pipe login --token <token>`, or set PIPEFY_TOKEN'));
76
+ return 1;
77
+ }
78
+ const { PipefyClient } = await import("../pipefy/client.js");
79
+ const client = new PipefyClient({ token: credential.token });
80
+ const me = await client.tryRaw('query { me { name email } }', undefined, 'me');
81
+ ok(`signed in as ${me?.me?.name ?? me?.me?.email ?? 'an unknown identity'} ` +
82
+ dim(`via ${credential.source}${credential.detail ? ` — ${credential.detail}` : ''}`));
83
+ if (!me?.me)
84
+ warn('the credential resolved but the API did not accept it');
85
+ return me?.me ? 0 : 1;
86
+ };
87
+ // ── doctor ───────────────────────────────────────────────────────────────────
88
+ export const doctorHelp = `pipe doctor [--repo <pipeId>]
89
+
90
+ Reports what this endpoint can actually do, before you start work.
91
+ With --repo it also probes that pipe's read paths.`;
92
+ export const doctor = async (p) => {
93
+ const { resolveCredential } = await import("../config.js");
94
+ const { inspectToolkit, toolkitAuthStatus, INSTALL_HINT } = await import("../pipefy/toolkit.js");
95
+ out(bold('endpoints'));
96
+ out(` graphql ${ENDPOINTS.graphql}`);
97
+ out(` internal ${ENDPOINTS.internal}`);
98
+ out('');
99
+ out(bold('credential'));
100
+ const toolkit = await inspectToolkit();
101
+ const credential = await resolveCredential();
102
+ out(` toolkit ${toolkit.installed ? green('installed') : red('not installed')}` +
103
+ (toolkit.installed ? dim(` ${toolkit.cliPath ?? toolkit.interpreter}`) : dim(` ${INSTALL_HINT}`)));
104
+ if (toolkit.installed) {
105
+ const status = await toolkitAuthStatus();
106
+ if (status) {
107
+ const identity = status['identity'];
108
+ out(` session ${status['signed_in'] ? green('signed in') : yellow('not signed in')}` +
109
+ dim(` ${identity?.name ?? identity?.email ?? ''}` +
110
+ `${status['auth_source'] ? ` via ${String(status['auth_source'])}` : ''}` +
111
+ `${status['access_expires_at'] ? `, access expires ${String(status['access_expires_at'])}` : ''}`));
112
+ }
113
+ else if (!toolkit.credential.ok) {
114
+ out(` session ${yellow('not signed in')} ${dim(toolkit.credential.message)}`);
115
+ }
116
+ }
117
+ out(` in use ${credential ? green(credential.source) : red('none')}` +
118
+ dim(credential?.detail ? ` ${credential.detail}` : ''));
119
+ if (!credential) {
120
+ info('');
121
+ throw new UserError('no credential', toolkit.installed ? 'run `pipe login` for the browser flow' : `${INSTALL_HINT}, or \`pipe login --token <token>\``);
122
+ }
123
+ const net = await connect(p);
124
+ const caps = await net.capabilities();
125
+ out('');
126
+ out(bold('capabilities'));
127
+ const row = (label, present, consequence) => out(` ${present ? green('yes') : red(' no')} ${label.padEnd(24)} ${dim(consequence)}`);
128
+ row('snapshots', caps.snapshots, caps.snapshots ? 'pulls use the snapshot payload' : 'pulls reconstruct over GraphQL');
129
+ /**
130
+ * "atomic rollback available" was the old text and it would now be a lie in the
131
+ * other direction: the mutation exists, and it only accepts the newest
132
+ * snapshot, so it cannot undo an apply whose own read-back has since become
133
+ * the newest. The row names the mutation and the constraint instead.
134
+ */
135
+ row(caps.restoreMutation ?? 'restore', caps.restoreRepoSnapshot, caps.restoreRepoSnapshot
136
+ ? caps.restoreLatestOnly
137
+ ? 'restores the LATEST snapshot only — cannot undo a completed apply'
138
+ : 'atomic rollback available'
139
+ : 'no one-command rollback');
140
+ row('createRepoDraft', caps.createRepoDraft, caps.createRepoDraft ? 'draft rehearsal possible' : 'no free rehearsal environment');
141
+ row('importRepoSnapshot', caps.importRepoSnapshot, caps.importRepoSnapshot ? 'Route B is available' : 'writes go through the mutation compiler');
142
+ const missing = missingMutations(caps);
143
+ out('');
144
+ out(bold('mutations'));
145
+ out(` ${Object.keys(caps.mutations).length - missing.length} of ${Object.keys(caps.mutations).length} present`);
146
+ if (missing.length) {
147
+ warn(`missing: ${missing.join(', ')}`);
148
+ info(dim(' changes needing these will be refused by `pipe plan` rather than attempted'));
149
+ }
150
+ const repoId = str(p, 'repo');
151
+ if (repoId) {
152
+ out('');
153
+ out(bold(`probing repo ${repoId}`));
154
+ const jumps = await net.internal.getPhaseSettings(await firstPhaseId(net, repoId));
155
+ out(` internal settings endpoint ${jumps ? green('answers') : red('no answer')}`);
156
+ const autos = net.organizationId ? await net.internal.listAutomations(net.organizationId, repoId) : null;
157
+ out(` internal automations query ${autos ? green(`answers (${autos.length} automations)`) : net.organizationId ? red('no answer') : yellow('needs --org')}`);
158
+ }
159
+ out('');
160
+ out(bold('internal API'));
161
+ out(` status ${net.internal.status === 'ok' ? green('ok') : net.internal.status === 'unavailable' ? red('unavailable') : dim('unknown')}`);
162
+ info(dim(' Unversioned, no compatibility contract (PLAN.md §2.1). When it breaks, automation\n' +
163
+ ' detail and phase-jump editing degrade to unknown rather than failing an apply.'));
164
+ if (!caps.snapshots) {
165
+ out('');
166
+ out(bold('what a reconstructed pull cannot see'));
167
+ for (const g of RECONSTRUCTION_GAPS)
168
+ out(` ${dim('-')} ${g}`);
169
+ }
170
+ return 0;
171
+ };
172
+ const firstPhaseId = async (net, repoId) => {
173
+ const data = await net.client.tryRaw('query($id: ID!) { pipe(id: $id) { phases { id } } }', { id: repoId }, 'first phase');
174
+ return data?.pipe?.phases?.[0]?.id ?? '0';
175
+ };
176
+ // ── export ───────────────────────────────────────────────────────────────────
177
+ export const exportHelp = `pipe export <pipeId> [options]
178
+
179
+ Writes one repo's structure as a single snapshot-shaped JSON payload, with no
180
+ workspace and no tree. This is the standalone exporter: it works whether or not
181
+ the organization has snapshots, because the reconstruction path produces the
182
+ same shape.
183
+
184
+ --out <file> write here instead of stdout
185
+ --no-snapshot force GraphQL reconstruction
186
+ --no-internal skip the internal API
187
+ --envelope include the coverage/provenance envelope, not just the payload
188
+ --pretty indent the JSON`;
189
+ export const exportCmd = async (p) => {
190
+ const repoId = Number(requirePositional(p, 0, 'a pipe id'));
191
+ const net = await connect(p);
192
+ const caps = await net.capabilities();
193
+ const useSnapshot = caps.snapshots && !bool(p, 'no-snapshot');
194
+ let env;
195
+ if (useSnapshot) {
196
+ const { payload, versionId } = await net.snapshots.pull(repoId);
197
+ const { fullCoverage } = await import("../model/payload.js");
198
+ env = {
199
+ source: 'snapshot',
200
+ versionId,
201
+ readAt: new Date().toISOString(),
202
+ repoKind: 'pipe',
203
+ coverage: fullCoverage('complete'),
204
+ notes: [],
205
+ payload,
206
+ };
207
+ }
208
+ else {
209
+ env = await net.reconstructor.reconstruct(repoId, {
210
+ organizationId: net.organizationId,
211
+ noInternal: bool(p, 'no-internal'),
212
+ });
213
+ }
214
+ /**
215
+ * A bare payload has no provenance, and for a reconstructed read that matters.
216
+ *
217
+ * `pull --from-file` cannot tell a payload that was fully read from one where
218
+ * webhooks or automations were never readable, so it has to assume the former —
219
+ * and absence then means deletion. Exporting a reconstruction without the
220
+ * envelope silently converts "I could not see this" into "this does not exist".
221
+ */
222
+ if (!bool(p, 'envelope')) {
223
+ const gaps = Object.entries(env.coverage).filter(([, c]) => c !== 'complete');
224
+ if (gaps.length) {
225
+ warn(`${gaps.length} entity group(s) were not fully read, and a bare payload cannot carry that: ` +
226
+ `${gaps.map(([g]) => g).join(', ')}`);
227
+ info(dim(' re-run with --envelope so `pipe pull --from-file` knows what was unreadable,'));
228
+ info(dim(' otherwise it will treat every absence as a deletion.'));
229
+ }
230
+ }
231
+ const body = bool(p, 'envelope') ? env : env.payload;
232
+ const json = bool(p, 'pretty') ? JSON.stringify(body, null, 2) : JSON.stringify(body);
233
+ const outFile = str(p, 'out');
234
+ if (outFile) {
235
+ await writeFile(resolve(outFile), json + '\n');
236
+ ok(`wrote ${outFile}`);
237
+ const counts = ENTITY_NAMES.map((n) => `${n} ${env.payload.relations[n]?.length ?? 0}`).join(', ');
238
+ info(dim(` ${env.source} · ${counts}`));
239
+ }
240
+ else {
241
+ out(json);
242
+ }
243
+ return 0;
244
+ };
245
+ // ── roundtrip ────────────────────────────────────────────────────────────────
246
+ export const roundtripHelp = `pipe roundtrip <payload.json | workspace>
247
+
248
+ The Phase 0 exit criterion: pack(unpack(P)) must equal P under a canonical
249
+ form. Run it on any exported payload, or on a workspace to check every repo's
250
+ baseline. A failure means the codec would lose data, and planning is refused.`;
251
+ export const roundtrip = async (p) => {
252
+ const target = requirePositional(p, 0, 'a payload file or workspace directory');
253
+ const path = resolve(target);
254
+ const { stat } = await import('node:fs/promises');
255
+ const s = await stat(path).catch(() => null);
256
+ if (!s)
257
+ throw new UserError(`${path} does not exist`);
258
+ if (s.isFile()) {
259
+ const raw = await readJson(path);
260
+ const env = 'payload' in raw && raw.payload
261
+ ? raw
262
+ : {
263
+ source: 'snapshot',
264
+ versionId: null,
265
+ readAt: new Date().toISOString(),
266
+ repoKind: 'pipe',
267
+ coverage: (await import("../model/payload.js")).fullCoverage('complete'),
268
+ notes: [],
269
+ payload: raw,
270
+ };
271
+ const result = assertRoundTrip(env);
272
+ reportRoundTrip(path, result);
273
+ return result.ok ? 0 : 1;
274
+ }
275
+ const { root, lock } = await openWorkspace(p);
276
+ let failures = 0;
277
+ for (const repo of lock.repos) {
278
+ const env = await readJson(resolve(root, '.ppc', 'baseline', `${repo.id}.json`));
279
+ const result = assertRoundTrip(env);
280
+ reportRoundTrip(`${repo.name} (${repo.id})`, result);
281
+ if (!result.ok)
282
+ failures++;
283
+ // Also check that the files on disk still round-trip through the writer and
284
+ // reader, which is what an edit loop actually depends on.
285
+ const tree = await readTree(resolve(root, repo.dir));
286
+ const viaDisk = compare(env.payload, pack(tree));
287
+ if (!viaDisk.ok) {
288
+ const editedKeys = viaDisk.differences.length;
289
+ info(dim(` ${editedKeys} difference(s) between the baseline payload and the files on disk (expected if you have edited them)`));
290
+ }
291
+ }
292
+ return failures ? 1 : 0;
293
+ };
294
+ const reportRoundTrip = (label, result) => {
295
+ if (result.ok) {
296
+ ok(`${label}: round trip clean`);
297
+ return;
298
+ }
299
+ warn(`${label}: round trip lost or invented data`);
300
+ for (const d of result.differences.slice(0, 15))
301
+ out(` ${d.where}: ${dim(d.detail)}`);
302
+ if (result.differences.length > 15)
303
+ out(dim(` …and ${result.differences.length - 15} more`));
304
+ };
305
+ // ── validate ─────────────────────────────────────────────────────────────────
306
+ export const validateHelp = `pipe validate [workspace] [--repo <id>] [--json]
307
+
308
+ JSON shape, then referential integrity. Offline and instant — this is the loop
309
+ Claude should iterate against before proposing anything.`;
310
+ export const validate = async (p) => {
311
+ const { lock, root } = await openWorkspace(p);
312
+ const wanted = str(p, 'repo');
313
+ const repos = wanted ? lock.repos.filter((r) => String(r.id) === wanted || r.apiId === wanted) : lock.repos;
314
+ const { loadBaselineQuiet } = await import("./diff.js");
315
+ const reference = await loadReference(root);
316
+ let errors = 0;
317
+ const all = [];
318
+ for (const repo of repos) {
319
+ const tree = await readTree(resolve(root, repo.dir));
320
+ const { readAgents } = await import("../workspace/agents.js");
321
+ const findings = validateTree(tree, {
322
+ knownRepoIds: lock.repos.map((r) => r.id),
323
+ baseline: await loadBaselineQuiet(root, repo.id),
324
+ reference,
325
+ agents: await readAgents(resolve(root, repo.dir)),
326
+ });
327
+ all.push({ repo: `${repo.name} (${repo.id})`, findings });
328
+ errors += blockingErrors(findings).length;
329
+ }
330
+ if (bool(p, 'json')) {
331
+ out(JSON.stringify(all, null, 2));
332
+ }
333
+ else {
334
+ for (const { repo, findings } of all) {
335
+ out(renderFindings(findings, repo));
336
+ out('');
337
+ }
338
+ /**
339
+ * One verdict for the workspace, after the per-repo blocks.
340
+ *
341
+ * Each block ends with its own "0 errors" line, so in a multi-repo workspace
342
+ * the last block's summary reads like the answer — and a real error two
343
+ * blocks up goes unread. It did, once.
344
+ */
345
+ if (all.length > 1) {
346
+ const line = `${all.length} repos · ${errors} blocking error${errors === 1 ? '' : 's'}`;
347
+ out(errors ? red(`x ${line}`) : green(`+ ${line}`));
348
+ if (errors) {
349
+ for (const { repo, findings } of all) {
350
+ const n = blockingErrors(findings).length;
351
+ if (n)
352
+ out(dim(` ${repo}: ${n}`));
353
+ }
354
+ }
355
+ out('');
356
+ }
357
+ }
358
+ return errors ? 1 : 0;
359
+ };
360
+ // ── status ───────────────────────────────────────────────────────────────────
361
+ export const statusHelp = `pipe status [workspace]
362
+
363
+ What is in this workspace, how it was read, and what is pending.`;
364
+ export const status = async (p) => {
365
+ const { root, lock } = await openWorkspace(p);
366
+ const { readWorkspace } = await import("../workspace/lock.js");
367
+ const ws = await readWorkspace(root);
368
+ out(bold(root));
369
+ out(dim(`pulled ${ws?.createdAt ?? '?'} · depth ${lock.depth} · org ${lock.organizationId ?? 'unknown'}`));
370
+ out('');
371
+ for (const repo of lock.repos) {
372
+ const unknown = Object.entries(repo.coverage).filter(([, c]) => c === 'unknown').map(([e]) => e);
373
+ const partial = Object.entries(repo.coverage).filter(([, c]) => c === 'partial').map(([e]) => e);
374
+ out(`${repo.id === lock.rootRepoId ? green('*') : ' '} ${bold(String(repo.name))} ${dim(`${repo.kind} ${repo.id}` + (repo.apiId !== String(repo.id) ? ` (api id ${repo.apiId})` : ''))}`);
375
+ out(` read from ${repo.source}${repo.versionId ? dim(` ${repo.versionId.slice(0, 8)}`) : ''} at ${dim(repo.readAt)}`);
376
+ out(` ${repo.dir}`);
377
+ if (repo.via.length)
378
+ out(dim(` found via ${repo.via.join(', ')}`));
379
+ if (partial.length)
380
+ out(yellow(` partial: ${partial.join(', ')}`));
381
+ if (unknown.length)
382
+ out(yellow(` unknown: ${unknown.join(', ')}`));
383
+ }
384
+ const { listJson } = await import("../util/fsx.js");
385
+ const plans = await listJson(resolve(root, '.ppc', 'plans'));
386
+ if (plans.length) {
387
+ out('');
388
+ out(bold('plans'));
389
+ for (const path of plans.slice(-8)) {
390
+ const plan = await readJson(path);
391
+ const done = plan.steps.filter((s) => s.status === 'done').length;
392
+ const mark = plan.status === 'applied' ? green('applied') : plan.status === 'failed' ? red('failed') : plan.status === 'partially-applied' ? yellow('partial') : dim(plan.status);
393
+ out(` ${mark.padEnd(20)} ${plan.id} ${dim(`${done}/${plan.steps.length} steps`)}`);
394
+ }
395
+ }
396
+ return 0;
397
+ };
398
+ // ── capabilities ─────────────────────────────────────────────────────────────
399
+ export const capsHelp = `pipe capabilities [--json]
400
+
401
+ The write-coverage registry: what pipe can change, how, and what it refuses.`;
402
+ export const capabilities = async (p) => {
403
+ const rows = registryRows().sort((a, b) => a.key.localeCompare(b.key));
404
+ if (bool(p, 'json')) {
405
+ out(JSON.stringify(rows, null, 2));
406
+ return 0;
407
+ }
408
+ const width = Math.max(...rows.map((r) => r.key.length)) + 2;
409
+ for (const r of rows) {
410
+ const mark = r.status === 'SUPPORTED' ? green('yes ') : r.status === 'PARTIAL' ? yellow('partly') : red('no ');
411
+ out(`${mark} ${r.key.padEnd(width)} ${dim(r.via ?? '')}`);
412
+ if (r.status !== 'SUPPORTED' && r.reason)
413
+ out(` ${dim(r.reason)}${r.ask ? dim(` (ask ${r.ask})`) : ''}`);
414
+ }
415
+ return 0;
416
+ };
417
+ // ── rollback ─────────────────────────────────────────────────────────────────
418
+ export const rollbackHelp = `pipe rollback --repo <pipeId> --version <versionId>
419
+
420
+ Restores a repo to a snapshot. The endpoint only accepts the **latest**
421
+ snapshot, so this usually cannot undo an apply — see \`pipe doctor\`.
422
+
423
+ Scoped to pipe structure — phases, fields, automations, field conditions,
424
+ labels and the like, everything \`createRepoSnapshot\` reads. It does not
425
+ touch iPaaS flows or connections (a separate system entirely), and it
426
+ cannot un-send an email or un-fire a webhook an automation already
427
+ triggered. If a flow was applied after this snapshot and references a
428
+ field or phase the restore is about to change, the flow is not rewound
429
+ with it.`;
430
+ /**
431
+ * `pipe rollback` — and the reason it is narrower than it sounds.
432
+ *
433
+ * This used to probe for `restoreRepoSnapshot` and refuse, because that mutation
434
+ * does not exist here. The endpoint has **`restoreRepoToSnapshot`**, which it
435
+ * never looked for, so the command refused a restore that was available and every
436
+ * apply printed "no one-command rollback" beside a usable snapshot.
437
+ *
438
+ * Fixed, and then measured, which is where the real limit turned up: a restore
439
+ * only accepts the **latest** uploaded snapshot. An older versionId is refused
440
+ * with "Version_id must reference the latest uploaded snapshot of the Pipe". So a
441
+ * pre-apply snapshot stops being restorable as soon as anything else snapshots
442
+ * the repo — and `pipe apply` reads the repo back to verify, which does exactly
443
+ * that. The check below therefore runs *before* the mutation, so the answer names
444
+ * the snapshot that would be restored instead of coming back as a bare error.
445
+ *
446
+ * The restore itself is asynchronous change management, reported through
447
+ * `operations`, so this polls rather than claiming success on the mutation
448
+ * returning.
449
+ */
450
+ export const rollback = async (p) => {
451
+ const repoId = str(p, 'repo');
452
+ const versionId = str(p, 'version');
453
+ if (!repoId || !versionId)
454
+ throw new UserError('--repo and --version are both required');
455
+ const net = await connect(p);
456
+ const caps = await net.capabilities();
457
+ if (!caps.restoreMutation) {
458
+ throw new UserError('no restore mutation exists on this endpoint', 'the pre-apply snapshot is still a record of the previous state, but restoring it must be done by Pipefy');
459
+ }
460
+ /**
461
+ * Refuse before sending when the target is not the latest, because the
462
+ * endpoint's own error names neither the snapshot asked for nor the one it
463
+ * wanted.
464
+ */
465
+ if (caps.restoreLatestOnly) {
466
+ const latest = (await net.snapshots.list(repoId, 1))[0];
467
+ if (latest && latest.versionId !== versionId) {
468
+ throw new UserError(`this endpoint restores only the latest snapshot, and the latest is ${latest.versionId}`, `${versionId} is older, so it cannot be restored. A pre-apply snapshot usually loses this race to the ` +
469
+ 'read-back that `pipe apply` takes to verify. Recover forward instead: `pipe pull` to re-read the live ' +
470
+ 'state, or re-apply the workspace, whose files still hold the intent.');
471
+ }
472
+ }
473
+ return restoreToVersion(net, repoId, versionId, caps);
474
+ };
475
+ /**
476
+ * The mutation-and-poll half of a rollback, shared with `pipe apply`'s own
477
+ * post-failure prompt (apply.ts) so there is exactly one place that sends
478
+ * `restoreRepoToSnapshot`/`restoreRepoSnapshot` and waits on the RESTORE
479
+ * operation. `pipe rollback` wraps this with argument parsing and the
480
+ * latest-snapshot pre-check above; a caller that has already done its own
481
+ * capability/latest check (as apply.ts does) can call this directly.
482
+ */
483
+ export const restoreToVersion = async (net, repoId, versionId, caps) => {
484
+ const mutation = caps.restoreMutation;
485
+ if (!mutation) {
486
+ warn('no restore mutation exists on this endpoint');
487
+ return 1;
488
+ }
489
+ const inputType = mutation === 'restoreRepoToSnapshot' ? 'RestoreRepoToSnapshotInput' : 'RestoreRepoSnapshotInput';
490
+ await net.client.raw(`mutation($input: ${inputType}!) { ${mutation}(input: $input) { clientMutationId } }`, { input: { repoId: String(repoId), versionId } }, mutation);
491
+ ok(`restore requested via ${mutation} — ${versionId}`);
492
+ /**
493
+ * A restore reports through `operations`, not through the mutation, so waiting
494
+ * on it is the only way to know it landed. Bounded: a restore that is still
495
+ * running when this gives up is still running, and saying so beats a spinner
496
+ * that never ends.
497
+ */
498
+ info(dim(' waiting for the RESTORE operation…'));
499
+ for (let i = 0; i < 20; i++) {
500
+ const r = await net.client
501
+ .tryRaw(`query ($id: ID!) { pipe(id: $id) { operations { kind status error } } }`, { id: String(repoId) }, 'restore operations')
502
+ .catch(() => null);
503
+ const op = r?.pipe?.operations?.find((o) => o.kind === 'RESTORE');
504
+ if (op && !['PENDING', 'RUNNING'].includes(op.status)) {
505
+ if (op.error) {
506
+ warn(`the restore finished as ${op.status}: ${op.error}`);
507
+ return 1;
508
+ }
509
+ ok(`restore ${op.status.toLowerCase()}`);
510
+ info(dim(' run `pipe pull` to bring the restored structure back to disk'));
511
+ return 0;
512
+ }
513
+ await new Promise((r2) => setTimeout(r2, 3000));
514
+ }
515
+ warn('the restore is still running — check `pipe status` or the Pipefy UI, then `pipe pull`');
516
+ return 0;
517
+ };
518
+ // ── viewer ───────────────────────────────────────────────────────────────────
519
+ export const viewerHelp = `pipe viewer [workspace] [--repo <id>] [--against <target>]
520
+
521
+ Build the HTML diff view and open it in the OS browser.`;
522
+ export const viewer = async (p) => {
523
+ const { run: runDiff } = await import("./diff.js");
524
+ p.flags['open'] = true;
525
+ return runDiff(p);
526
+ };
527
+ export const writeViewModel = (path, model) => writeJson(path, model);
528
+ export const unusedInt = int;