@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,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
|
+
};
|