@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.
Files changed (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. package/src/workspace/write.ts +225 -0
@@ -0,0 +1,137 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn } from 'node:child_process';
5
+ import type { Parsed } from '../cli/args.ts';
6
+ import { exists } from '../util/fsx.ts';
7
+ import { UserError, bold, dim, out, info } from '../util/log.ts';
8
+
9
+ export const help = `pipe skills [list|install] [options]
10
+
11
+ list the agent skills this install ships with (default). Local,
12
+ offline, no network
13
+
14
+ install hand off to \`npx skills add\` (vercel-labs/skills), which
15
+ installs these skills for whichever agents you have —
16
+ Claude Code, Cursor, Codex, and 75+ others — choosing each
17
+ agent's own directory and, by default, symlinking rather than
18
+ copying. Needs network (fetches the \`skills\` package via npx
19
+ on first run) and Node's npx on PATH. Every flag after
20
+ \`install\` is forwarded to it verbatim; see
21
+ https://github.com/vercel-labs/skills or \`npx skills add --help\`.
22
+ Common ones:
23
+ -g, --global install to the home directory, not the
24
+ project
25
+ -a, --agent <...> target specific agents (e.g. claude-code)
26
+ -s, --skill <...> install specific skills, not all four
27
+ -y, --yes skip confirmation prompts
28
+ --copy copy instead of symlinking`;
29
+
30
+ /**
31
+ * These skills live at \`.agents/skills/\` in this repo — one of the standard
32
+ * discovery locations \`npx skills add\` already walks — so they only ever
33
+ * triggered for an agent session opened *inside* this repo until installed
34
+ * somewhere every session reads from. A pulled workspace (PLAN.md, README
35
+ * "Where `pipe` can run") is meant to live anywhere — a client's own folder,
36
+ * a completely different repo — so the guidance on how to use `pipe` needs
37
+ * to travel with the binary, not stay pinned to one checkout.
38
+ *
39
+ * `install` used to copy the folders and manage a `~/.claude/skills`
40
+ * compatibility link by hand — this repo's own version of the exact problem
41
+ * vercel-labs/skills already solves generically, for 75+ agents, not just
42
+ * Claude Code. Delegating to it is less code and more correct than
43
+ * reinventing it, at the cost of the one thing this CLI otherwise has none
44
+ * of: a runtime dependency (network + npx) for this one subcommand only.
45
+ */
46
+
47
+ /** Resolved from this file's own URL — survives `npm link` and `npm i -g .`
48
+ * the same way `pipe --version` locates package.json (cli.ts). */
49
+ const packageRoot = (): string => {
50
+ const here = dirname(fileURLToPath(import.meta.url));
51
+ return join(here, '..', '..');
52
+ };
53
+
54
+ const sourceDir = (): string => join(packageRoot(), '.agents', 'skills');
55
+
56
+ const skillDirs = async (src: string): Promise<string[]> => {
57
+ let entries;
58
+ try {
59
+ entries = await readdir(src, { withFileTypes: true });
60
+ } catch (e) {
61
+ throw new UserError(`could not read ${src}`, (e as Error).message);
62
+ }
63
+ const names: string[] = [];
64
+ for (const e of entries) {
65
+ if (e.isDirectory() && (await exists(join(src, e.name, 'SKILL.md')))) names.push(e.name);
66
+ }
67
+ return names.sort();
68
+ };
69
+
70
+ const readSkillMeta = async (dir: string): Promise<{ description?: string }> => {
71
+ try {
72
+ const md = await readFile(join(dir, 'SKILL.md'), 'utf8');
73
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)?.[1] ?? '';
74
+ const description = /^description:\s*(.+)$/m.exec(frontmatter)?.[1]?.trim().replace(/\r$/, '');
75
+ return { description };
76
+ } catch {
77
+ return {};
78
+ }
79
+ };
80
+
81
+ const list = async (): Promise<number> => {
82
+ const src = sourceDir();
83
+ const names = await skillDirs(src);
84
+ if (!names.length) throw new UserError(`no skills found under ${src}`, 'the install may be incomplete — re-clone or re-install');
85
+
86
+ out(bold(`${names.length} skill(s) ship with this install`));
87
+ for (const name of names) {
88
+ const meta = await readSkillMeta(join(src, name));
89
+ out(` ${name}`);
90
+ if (meta.description) out(dim(` ${meta.description}`));
91
+ }
92
+ info('');
93
+ info(dim(' `pipe skills install` hands off to `npx skills add`, which installs'));
94
+ info(dim(' these for whichever agents you have — Claude Code, Cursor, Codex and'));
95
+ info(dim(' 75+ others — not only a session opened inside this repo.'));
96
+ return 0;
97
+ };
98
+
99
+ /**
100
+ * Everything typed after the `install`/`list` subcommand, forwarded to
101
+ * `npx skills add` byte for byte — reconstructing it from the parsed flags
102
+ * would round-trip lossily (repeated `-a`, bare `-y`, `--skill '*'`, …).
103
+ * `argv` is `process.argv` (node, script, `skills`, `install`, …flags).
104
+ */
105
+ export const npxInstallArgs = (argv: string[], root: string): string[] => {
106
+ const raw = argv.slice(3);
107
+ const forwarded = raw[0] === 'install' || raw[0] === 'list' ? raw.slice(1) : raw;
108
+ return ['skills', 'add', root, ...forwarded];
109
+ };
110
+
111
+ const install = async (): Promise<number> => {
112
+ const src = sourceDir();
113
+ const names = await skillDirs(src);
114
+ if (!names.length) throw new UserError(`no skills found under ${src}`, 'the install may be incomplete — re-clone or re-install');
115
+
116
+ const args = npxInstallArgs(process.argv, packageRoot());
117
+ out(dim(`+ npx ${args.join(' ')}`));
118
+
119
+ // `shell: true` on Windows: `npx` resolves to `npx.cmd`, a batch file, and
120
+ // Node refuses to spawn `.cmd` directly without a shell (the CVE-2024-27980
121
+ // fix) — spawning `npx.cmd` outright fails with EINVAL. Node warns that the
122
+ // args array is then shell-concatenated unescaped, which matters for
123
+ // untrusted input; there is none here — `args` is this CLI's own resolved
124
+ // package path plus whatever the person invoking `pipe` typed themselves.
125
+ return new Promise<number>((resolve, reject) => {
126
+ const child = spawn('npx', args, { stdio: 'inherit', shell: process.platform === 'win32' });
127
+ child.on('error', (e) => reject(new UserError('could not run `npx skills add`', (e as Error).message)));
128
+ child.on('exit', (code) => resolve(code ?? 1));
129
+ });
130
+ };
131
+
132
+ export const run = async (p: Parsed): Promise<number> => {
133
+ const sub = p.positionals[0] ?? 'list';
134
+ if (sub === 'list') return list();
135
+ if (sub === 'install') return install();
136
+ throw new UserError(`unknown \`pipe skills ${sub}\``, 'use `pipe skills list` or `pipe skills install`');
137
+ };
@@ -0,0 +1,253 @@
1
+ import { bool, type Parsed } from '../cli/args.ts';
2
+ import { writeJson } from '../util/fsx.ts';
3
+ import { dim, info, ok, out, stripAnsi, warn } from '../util/log.ts';
4
+ import { fullCoverage, type PayloadEnvelope } from '../model/payload.ts';
5
+ import { unpack } from '../codec/unpack.ts';
6
+ import { readTree } from '../workspace/read.ts';
7
+ import { baselineFile } from '../workspace/layout.ts';
8
+ import { diffTrees } from '../diff/diff.ts';
9
+ import { renderChangeSet } from '../diff/render.ts';
10
+ import { connect, openWorkspace } from './context.ts';
11
+ import type { Lock, LockRepo } from '../workspace/lock.ts';
12
+ import type { IdMap } from '../apply/idmap.ts';
13
+ import { RunRecorder, invocation } from '../report/run.ts';
14
+
15
+ export const help = `pipe verify [workspace] [options]
16
+
17
+ --repo <id> which repo in the workspace (default: the root pipe)
18
+ --update adopt the live state wholesale, once it matches the workspace
19
+ --adopt adopt only the server-assigned ids of entities the workspace
20
+ still marks as new, keeping every unapplied edit. This is the
21
+ repair for a partial apply: without it a re-plan would create
22
+ those entities a second time.
23
+ --json machine-readable output
24
+ --report write a run report into runs/ (payload copy included)`;
25
+
26
+ /**
27
+ * `pipe verify` — read the pipe back and diff it against what the workspace asks
28
+ * for. PLAN.md §5 step 6: "Empty diff = provably correct."
29
+ *
30
+ * This is the same routine the apply runs at the end, and the same one a draft
31
+ * rehearsal would run first. That symmetry is the safety model, and it survives
32
+ * the Route B applier swap untouched.
33
+ */
34
+ export const run = async (p: Parsed) => {
35
+ const { root, lock, repo, repoDir: dir } = await openWorkspace(p);
36
+ const net = await connect(p);
37
+
38
+ /**
39
+ * `--adopt` runs before any comparison, because its whole purpose is to fix a
40
+ * workspace that cannot verify: entities that exist on the pipe but still read
41
+ * `"id": null` in the files they were authored in.
42
+ */
43
+ if (bool(p, 'adopt')) {
44
+ const { stampCreated, renderStamp } = await import('../workspace/stamp.ts');
45
+ const { writeTree } = await import('../workspace/write.ts');
46
+ const caps = await net.capabilities();
47
+ info(dim(`reading pipe ${repo.id} to match against (${caps.snapshots ? 'snapshot' : 'GraphQL reconstruction'})`));
48
+ const envelope =
49
+ caps.snapshots
50
+ ? await (async () => {
51
+ const { payload, versionId } = await net.snapshots.pull(repo.id);
52
+ return {
53
+ source: 'snapshot' as const,
54
+ versionId,
55
+ readAt: new Date().toISOString(),
56
+ repoKind: repo.kind,
57
+ coverage: fullCoverage('complete'),
58
+ notes: [],
59
+ payload,
60
+ };
61
+ })()
62
+ : await net.reconstructor.reconstruct(repo.id, { organizationId: lock.organizationId });
63
+
64
+ const authored = await readTree(dir);
65
+ const { loadBaselineEnvelope } = await import('./diff.ts');
66
+ const previous = await loadBaselineEnvelope(root, repo.id);
67
+ const stamp = stampCreated(authored, unpack(envelope), previous ? unpack(previous) : null);
68
+ if (!stamp.changed && !stamp.unmatched.length) {
69
+ ok('nothing to adopt — the workspace marks no entity as new');
70
+ return 0;
71
+ }
72
+ if (stamp.changed) await writeTree(dir, authored);
73
+ // The baseline is updated regardless: the read just done is newer than it,
74
+ // and leaving it stale is what makes a plan re-create what already exists.
75
+
76
+ /**
77
+ * The baseline has to learn about them too.
78
+ *
79
+ * A diff is baseline versus workspace. Stamping the ids into the files while
80
+ * leaving the baseline at its pre-apply state means the baseline has never
81
+ * heard of those entities, so the diff goes on proposing to create them —
82
+ * which is the very loop this is meant to break. The read that was just done
83
+ * is exactly the right new baseline.
84
+ */
85
+ await writeJson(baselineFile(root, repo.id), envelope);
86
+ const adoptRec = recorderFor(p, root, repo, 'verify');
87
+ adoptRec?.after(envelope);
88
+ for (const line of renderStamp(stamp)) adoptRec?.note(stripAnsi(line).trim());
89
+ adoptRec?.note(`adopted ${stamp.stamped.length} server id(s); ${stamp.unmatched.length} still unmatched`);
90
+ await write(adoptRec, stamp.changed ? 'ok' : 'no-op', `--adopt: ${stamp.stamped.length} id(s) adopted into the workspace`);
91
+ for (const line of renderStamp(stamp)) info(line);
92
+ info(dim(' the baseline now reflects the pipe, so the next diff shows only what is still unapplied'));
93
+ return 0;
94
+ }
95
+
96
+ const rec = recorderFor(p, root, repo, 'verify');
97
+ const result = await verifyRepo({ net, root, lock, repo, expectedTreeDir: dir });
98
+ rec?.after(result.envelope);
99
+ rec?.verified(result.ok, result.ok ? undefined : `${result.changeCount} difference(s) remain`);
100
+
101
+ if (bool(p, 'json')) {
102
+ out(JSON.stringify({ ok: result.ok, changes: result.changeCount }, null, 2));
103
+ } else {
104
+ out(result.render);
105
+ }
106
+
107
+ if (result.ok && bool(p, 'update')) {
108
+ /**
109
+ * Adopt the live state as both the files and the baseline. Updating only the
110
+ * baseline would leave a created entity still reading `"id": null` in the
111
+ * file it came from, so the next diff would propose creating it again.
112
+ */
113
+ const { writeTree } = await import('../workspace/write.ts');
114
+ const { upsertRepo, writeLock } = await import('../workspace/lock.ts');
115
+ const { refFor } = await import('./diff.ts');
116
+ const { tagBehaviors, behaviorDirMapOnDisk } = await import('../workspace/agents.ts');
117
+ const live = unpack(result.envelope);
118
+ // See the identical comment in apply.ts's success path: a live read-back
119
+ // does not know which automations are behaviors on its own.
120
+ live.automations = tagBehaviors(live.automations, await behaviorDirMapOnDisk(dir));
121
+ await writeTree(dir, live);
122
+ await writeJson(baselineFile(root, repo.id), result.envelope);
123
+ upsertRepo(lock, refFor(repo), root, live);
124
+ await writeLock(root, lock);
125
+ ok('workspace and baseline updated to the live state');
126
+ rec?.note('--update: workspace and baseline adopted the live state');
127
+ }
128
+
129
+ await write(
130
+ rec,
131
+ result.ok ? 'ok' : 'failed',
132
+ result.ok ? 'the pipe satisfies the workspace intent' : `${result.changeCount} difference(s) between the pipe and the workspace`,
133
+ );
134
+
135
+ return result.ok ? 0 : 1;
136
+ };
137
+
138
+ /**
139
+ * A verify is a run too — it is the read that says whether the pipe is what the
140
+ * workspace claims, and the payload it read is the evidence. Written only on
141
+ * `--report`, and never for `--json`, where the caller is a script that wants
142
+ * one line of output.
143
+ */
144
+ const recorderFor = (p: Parsed, root: string, repo: LockRepo, kind: 'verify') =>
145
+ !bool(p, 'report') || bool(p, 'json')
146
+ ? null
147
+ : new RunRecorder(kind, {
148
+ workspace: root,
149
+ repo: { id: repo.id, uuid: repo.uuid, name: repo.name ?? String(repo.id), kind: repo.kind },
150
+ command: invocation(),
151
+ });
152
+
153
+ const write = async (rec: RunRecorder | null, outcome: 'ok' | 'failed' | 'no-op', summary: string) => {
154
+ if (!rec) return;
155
+ const res = await rec.write(outcome, summary);
156
+ if (res?.error) warn(`could not write the run report: ${res.error}`);
157
+ else if (res) info(dim(` run recorded in runs/${rec.id}/`));
158
+ };
159
+
160
+ export type VerifyArgs = {
161
+ net: Awaited<ReturnType<typeof connect>>;
162
+ root: string;
163
+ lock: Lock;
164
+ repo: LockRepo;
165
+ expectedTreeDir: string;
166
+ /**
167
+ * The id map from the apply that just ran. With it, the intent is resolved
168
+ * before comparison — `"phase_id": "%{_new:<uuid>}"` becomes the phase that was
169
+ * created — so verification compares what was actually requested. Without it
170
+ * (a standalone `pipe verify`), placeholder values are treated as satisfied.
171
+ */
172
+ idMap?: IdMap;
173
+ /**
174
+ * Read via GraphQL/internal-API reconstruction even when this endpoint has
175
+ * snapshots. `SnapshotClient.pull()` always creates a brand-new snapshot
176
+ * (snapshot.ts) — that is what makes a pre-apply rollback snapshot stop
177
+ * being restorable the moment this runs. Set when the caller has decided
178
+ * not to spend a snapshot on this verification, trading full coverage for
179
+ * leaving the rollback point alone.
180
+ */
181
+ preferReconstruct?: boolean;
182
+ };
183
+
184
+ export const verifyRepo = async (args: VerifyArgs) => {
185
+ const { net, lock, repo, expectedTreeDir, idMap, preferReconstruct } = args;
186
+ const caps = await net.capabilities();
187
+ const useSnapshot = caps.snapshots && !preferReconstruct;
188
+
189
+ const authored = await readTree(expectedTreeDir);
190
+ const expected = idMap ? (idMap.rewrite(authored) as typeof authored) : authored;
191
+
192
+ info(dim(`reading pipe ${repo.id} back (${useSnapshot ? 'snapshot' : 'GraphQL reconstruction'})`));
193
+ let envelope: PayloadEnvelope;
194
+ /**
195
+ * A database reads back through the snapshot path too.
196
+ *
197
+ * It used to fall through to reconstruction, which is a *weaker* check: no
198
+ * reconstructed group is ever 'complete', so coverage suppresses part of the
199
+ * comparison and a verified-clean apply proves less than it appears to.
200
+ * `preferReconstruct` trades that away deliberately — see its doc comment.
201
+ */
202
+ if (useSnapshot) {
203
+ const { payload, versionId } = await net.snapshots.pull(repo.id, `post-apply/${new Date().toISOString()}`);
204
+ envelope = {
205
+ source: 'snapshot',
206
+ versionId,
207
+ readAt: new Date().toISOString(),
208
+ repoKind: repo.kind,
209
+ coverage: fullCoverage('complete'),
210
+ notes: [],
211
+ payload,
212
+ };
213
+ } else {
214
+ envelope = await net.reconstructor.reconstruct(repo.id, { organizationId: lock.organizationId });
215
+ }
216
+
217
+ const live = unpack(envelope);
218
+
219
+ /**
220
+ * Direction matters: live -> expected. A change in this diff means "the pipe
221
+ * still needs this done", which is exactly what verification should report.
222
+ */
223
+ const createdIds = idMap
224
+ ? Object.values(idMap.snapshot().placeholders).map(Number).filter((n) => Number.isFinite(n))
225
+ : [];
226
+
227
+ if (!idMap && JSON.stringify(authored).includes('%{_new:')) {
228
+ warn(
229
+ 'this workspace still names entities by placeholder, and there is no apply to resolve them against — ' +
230
+ 'those values are reported as satisfied rather than checked. Run pipe apply, which verifies with its own id map.',
231
+ );
232
+ }
233
+
234
+ const changeSet = diffTrees(live, expected, { intentOnly: true, createdIds });
235
+
236
+ if (changeSet.changes.length === 0) {
237
+ return { ok: true, changeCount: 0, render: 'verified — the pipe matches the workspace', envelope, changeSet };
238
+ }
239
+
240
+ const residual = changeSet.changes.filter((c) => !c.flags.includes('readonly-edit'));
241
+ if (residual.length === 0) {
242
+ warn('the only differences are in read-only entities, which were never going to be applied');
243
+ return { ok: true, changeCount: 0, render: renderChangeSet(changeSet), envelope, changeSet };
244
+ }
245
+
246
+ return {
247
+ ok: false,
248
+ changeCount: residual.length,
249
+ render: renderChangeSet(changeSet),
250
+ envelope,
251
+ changeSet,
252
+ };
253
+ };
package/src/config.ts ADDED
@@ -0,0 +1,168 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { promises as fs } from 'node:fs';
4
+ import { exists, readJson, writeJson } from './util/fsx.ts';
5
+ import { UserError, debug } from './util/log.ts';
6
+ import { readBearer, INSTALL_HINT } from './pipefy/toolkit.ts';
7
+
8
+ /**
9
+ * Endpoints. `internal` is the unversioned app endpoint the web UI uses; both
10
+ * accept the same personal API token, which is why this CLI stays token-only
11
+ * with no login webview or session store (PLAN.md §2.1).
12
+ */
13
+ export const ENDPOINTS = {
14
+ graphql: process.env.PPC_GRAPHQL_URL ?? 'https://api.pipefy.com/graphql',
15
+ internal: process.env.PPC_INTERNAL_URL ?? 'https://app.pipefy.com/internal_api',
16
+ internalSettings: process.env.PPC_INTERNAL_SETTINGS_URL ?? 'https://app.pipefy.com/internal_api/settings',
17
+ } as const;
18
+
19
+ export type CredentialStore = { token?: string; organizationId?: string };
20
+
21
+ const configDir = () => process.env.PPC_CONFIG_DIR ?? join(homedir(), '.ppc');
22
+ const credPath = () => join(configDir(), 'credentials.json');
23
+
24
+ export type CredentialSource =
25
+ /** PIPEFY_TOKEN / PPC_TOKEN in the environment. */
26
+ | 'env'
27
+ /** Resolved by the official ai-toolkit: `pipefy auth login`, or its own env vars. */
28
+ | 'toolkit:stored-session'
29
+ | 'toolkit:static-token'
30
+ | 'toolkit:service-account'
31
+ /** This tool's own file store, from `pipe login --token`. */
32
+ | 'file';
33
+
34
+ export type Credential = {
35
+ token: string;
36
+ source: CredentialSource;
37
+ /** Human-readable note for `pipe doctor`. */
38
+ detail?: string;
39
+ };
40
+
41
+ /**
42
+ * Credential resolution, in order:
43
+ *
44
+ * 1. `PIPEFY_TOKEN` / `PPC_TOKEN` — an explicit override, and the only path
45
+ * that needs nothing installed. CI uses this.
46
+ * 2. The **Pipefy AI toolkit** — `pipefy auth login` stores an OAuth session in
47
+ * the OS keychain, and the toolkit resolves its own precedence (static
48
+ * token, service account, stored session) on top of it. This is the
49
+ * intended path: one login shared with the MCP server and the `pipefy` CLI.
50
+ * 3. `~/.ppc/credentials.json` — `pipe login --token`, for machines without
51
+ * the toolkit.
52
+ *
53
+ * The toolkit's bearer is short-lived, so nothing caches it to disk and callers
54
+ * take a provider that can re-resolve. See src/pipefy/toolkit.ts.
55
+ */
56
+ export const resolveCredential = async (): Promise<Credential | null> => {
57
+ const env = process.env.PIPEFY_TOKEN ?? process.env.PPC_TOKEN;
58
+ if (env && env.trim()) {
59
+ return { token: env.trim(), source: 'env', detail: 'PIPEFY_TOKEN in the environment' };
60
+ }
61
+
62
+ const toolkit = await readBearer();
63
+ if (toolkit?.ok) {
64
+ return {
65
+ token: toolkit.token,
66
+ source: `toolkit:${toolkit.source}` as CredentialSource,
67
+ detail:
68
+ toolkit.source === 'stored-session'
69
+ ? `ai-toolkit session${toolkit.issuer ? ` (${toolkit.issuer})` : ''}`
70
+ : `ai-toolkit ${toolkit.source}`,
71
+ };
72
+ }
73
+
74
+ if (await exists(credPath())) {
75
+ const c = await readJson<CredentialStore>(credPath());
76
+ if (c.token) return { token: c.token, source: 'file', detail: credPath() };
77
+ }
78
+
79
+ if (toolkit && !toolkit.ok) {
80
+ debug(`toolkit holds no credential: ${toolkit.reason}`);
81
+ }
82
+ return null;
83
+ };
84
+
85
+ /** Legacy shape, kept for callers that only need a token. */
86
+ export const readToken = async (): Promise<string | null> => (await resolveCredential())?.token ?? null;
87
+
88
+ export const requireCredential = async (): Promise<Credential> => {
89
+ const c = await resolveCredential();
90
+ if (!c) {
91
+ const toolkit = await readBearer();
92
+ throw new UserError(
93
+ 'No Pipefy credential found.',
94
+ toolkit === null
95
+ ? `${INSTALL_HINT}. Or run \`pipe login --token <token>\`, or set PIPEFY_TOKEN.`
96
+ : 'run `pipe login` (browser sign-in through the Pipefy AI toolkit), ' +
97
+ '`pipe login --token <token>`, or set PIPEFY_TOKEN.',
98
+ );
99
+ }
100
+ return c;
101
+ };
102
+
103
+ export const requireToken = async (): Promise<string> => (await requireCredential()).token;
104
+
105
+ /**
106
+ * A token provider rather than a token.
107
+ *
108
+ * The toolkit hands out short-lived OAuth access tokens, and an apply or a
109
+ * snapshot pull can outlive one — a snapshot alone takes ~63s. So the client
110
+ * holds this, caches the value, and calls again after a 401 instead of failing
111
+ * at step 90 of a plan.
112
+ */
113
+ export type TokenProvider = (opts?: { forceRefresh?: boolean }) => Promise<string>;
114
+
115
+ export const credentialProvider = async (): Promise<{ provider: TokenProvider; initial: Credential }> => {
116
+ const initial = await requireCredential();
117
+ let cached: Credential = initial;
118
+
119
+ const provider: TokenProvider = async (opts) => {
120
+ if (!opts?.forceRefresh) return cached.token;
121
+ // Only the toolkit can mint a fresh token; a static token that stopped
122
+ // working will not become valid by asking again.
123
+ if (!cached.source.startsWith('toolkit:')) return cached.token;
124
+ const next = await resolveCredential();
125
+ if (next) {
126
+ cached = next;
127
+ debug(`credential re-resolved from ${next.source}`);
128
+ }
129
+ return cached.token;
130
+ };
131
+
132
+ return { provider, initial };
133
+ };
134
+
135
+ export const writeToken = async (token: string, organizationId?: string) => {
136
+ const p = credPath();
137
+ const current = (await exists(p)) ? await readJson<CredentialStore>(p) : {};
138
+ await writeJson(p, { ...current, token, ...(organizationId ? { organizationId } : {}) });
139
+ try {
140
+ await fs.chmod(p, 0o600);
141
+ } catch {
142
+ /* best effort — Windows ACLs are not chmod */
143
+ }
144
+ return p;
145
+ };
146
+
147
+ export const readOrgId = async (): Promise<string | null> => {
148
+ const env = process.env.PIPEFY_ORG_ID ?? process.env.PPC_ORG_ID;
149
+ if (env) return env;
150
+ if (await exists(credPath())) {
151
+ const c = await readJson<CredentialStore>(credPath());
152
+ if (c.organizationId) return String(c.organizationId);
153
+ }
154
+ return null;
155
+ };
156
+
157
+ export const credentialsPath = credPath;
158
+
159
+ /** Tunables that matter when a real client pipe is 50x the test pipe. */
160
+ export const LIMITS = {
161
+ /** Parallel GraphQL/internal reads. Conservative: rate limits are unmeasured. */
162
+ readConcurrency: Number(process.env.PPC_READ_CONCURRENCY ?? 4),
163
+ /** Snapshot generation measured at ~63s (PLAN.md §1.1). */
164
+ snapshotPollMs: Number(process.env.PPC_SNAPSHOT_POLL_MS ?? 5_000),
165
+ snapshotTimeoutMs: Number(process.env.PPC_SNAPSHOT_TIMEOUT_MS ?? 300_000),
166
+ /** Writes are serial by default: ordering is the correctness argument. */
167
+ writeConcurrency: 1,
168
+ } as const;
@@ -0,0 +1,122 @@
1
+ import { canonicalString } from '../util/json.ts';
2
+ import { agentDirName, type AgentsSection } from '../workspace/agents.ts';
3
+ import type { AgentRow } from '../pipefy/agents.ts';
4
+ import type { Row } from '../model/payload.ts';
5
+
6
+ /**
7
+ * The agent diff: baseline against the workspace's own files.
8
+ *
9
+ * Deliberately coarse compared to the pipe diff — an agent change is always
10
+ * applied as "send the whole thing" (`createAiAgent` / `updateAiAgent` replace
11
+ * the entire behaviors list, PLAN.md-style granular field deltas do not apply
12
+ * here), so there is nothing to gain from reporting which specific behavior
13
+ * field moved. What matters is *whether* anything did.
14
+ */
15
+
16
+ export type AgentChangeOp = 'create' | 'update' | 'delete';
17
+
18
+ export type AgentChange = {
19
+ key: string;
20
+ op: AgentChangeOp;
21
+ label: string;
22
+ before?: AgentRow;
23
+ after?: AgentRow;
24
+ /** The complete behaviors list to send — a create/update always sends all of it. */
25
+ behaviors: Row[];
26
+ destructive?: boolean;
27
+ };
28
+
29
+ export type AgentChangeSet = { changes: AgentChange[] };
30
+
31
+ const isNewAgent = (a: AgentRow): boolean => !a['uuid'] || a['_new'] === true;
32
+
33
+ /** Strip the tool's own routing tag before comparing — never part of the real row. */
34
+ const forCompare = (b: Row): Row => {
35
+ const { _agent_dir, ...rest } = b as Row & { _agent_dir?: string };
36
+ return rest;
37
+ };
38
+
39
+ const sameBehaviors = (a: Row[], b: Row[]): boolean => {
40
+ const norm = (list: Row[]) => list.map((x) => canonicalString(forCompare(x))).sort();
41
+ return canonicalString(norm(a)) === canonicalString(norm(b));
42
+ };
43
+
44
+ const sameAgentFields = (before: AgentRow, after: AgentRow): boolean =>
45
+ String(before['name'] ?? '') === String(after['name'] ?? '') &&
46
+ String(before['instruction'] ?? '') === String(after['instruction'] ?? '') &&
47
+ canonicalString(before['dataSourceIds'] ?? []) === canonicalString(after['dataSourceIds'] ?? []) &&
48
+ canonicalString(before['disabledAt'] ?? null) === canonicalString(after['disabledAt'] ?? null);
49
+
50
+ /**
51
+ * @param authoredBehaviorsByDir The workspace's current behaviors for each
52
+ * agent, grouped by `agentDirName` — from `tree.automations`, filtered to the
53
+ * ones `_agent_dir` tags, and grouped by that tag. `readAgents` alone cannot
54
+ * answer this: `agent.json` never carries `behaviors` (workspace/agents.ts).
55
+ */
56
+ export const diffAgents = (
57
+ before: AgentsSection | null,
58
+ after: AgentsSection | null,
59
+ authoredBehaviorsByDir: Map<string, Row[]>,
60
+ ): AgentChangeSet => {
61
+ const changes: AgentChange[] = [];
62
+ const beforeByUuid = new Map(
63
+ (before?.agents ?? []).filter((a) => a['uuid']).map((a) => [String(a['uuid']), a] as const),
64
+ );
65
+ const matched = new Set<string>();
66
+
67
+ for (const a of after?.agents ?? []) {
68
+ const uuid = a['uuid'] ? String(a['uuid']) : null;
69
+ const dir = agentDirName(a);
70
+ const behaviors = authoredBehaviorsByDir.get(dir) ?? [];
71
+ const label = String(a['name'] ?? uuid ?? dir);
72
+ const b = uuid ? beforeByUuid.get(uuid) : undefined;
73
+
74
+ if (!b || isNewAgent(a)) {
75
+ changes.push({ key: uuid ? `uuid:${uuid}` : `new:${dir}`, op: 'create', label, after: a, behaviors });
76
+ continue;
77
+ }
78
+ matched.add(uuid as string);
79
+ if (!sameAgentFields(b, a) || !sameBehaviors(b.behaviors ?? [], behaviors)) {
80
+ changes.push({ key: `uuid:${uuid}`, op: 'update', label, before: b, after: a, behaviors });
81
+ }
82
+ }
83
+
84
+ for (const [uuid, b] of beforeByUuid) {
85
+ if (matched.has(uuid)) continue;
86
+ changes.push({
87
+ key: `uuid:${uuid}`,
88
+ op: 'delete',
89
+ label: String(b['name'] ?? uuid),
90
+ before: b,
91
+ behaviors: [],
92
+ destructive: true,
93
+ });
94
+ }
95
+
96
+ return { changes };
97
+ };
98
+
99
+ export const renderAgentChanges = (cs: AgentChangeSet): string => {
100
+ if (!cs.changes.length) return '';
101
+ const lines: string[] = [];
102
+ for (const c of cs.changes) {
103
+ const mark = c.op === 'create' ? '+' : c.op === 'delete' ? '-' : '~';
104
+ const tag = c.destructive ? ' [destructive]' : '';
105
+ const behaviorNote = c.op === 'delete' ? '' : ` (${c.behaviors.length} behavior${c.behaviors.length === 1 ? '' : 's'})`;
106
+ lines.push(` ${mark} agents ${c.label}${tag}${behaviorNote}`);
107
+ }
108
+ return lines.join('\n');
109
+ };
110
+
111
+ /** This workspace's automations, grouped by which agent's `behaviors/` they came from. */
112
+ export const groupBehaviorsByAgentDir = (automations: Row[]): Map<string, Row[]> => {
113
+ const byDir = new Map<string, Row[]>();
114
+ for (const a of automations) {
115
+ const dir = a['_agent_dir'] as string | undefined;
116
+ if (!dir) continue;
117
+ const arr = byDir.get(dir) ?? [];
118
+ arr.push(a);
119
+ byDir.set(dir, arr);
120
+ }
121
+ return byDir;
122
+ };