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