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