@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,119 @@
1
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
2
+ import { LIMITS, credentialProvider, readOrgId, type Credential } from '../config.ts';
3
+ import { exists } from '../util/fsx.ts';
4
+ import { UserError, debug } from '../util/log.ts';
5
+ import { PipefyClient } from '../pipefy/client.ts';
6
+ import { InternalApi } from '../pipefy/internal.ts';
7
+ import { SnapshotClient } from '../pipefy/snapshot.ts';
8
+ import { Reconstructor } from '../pipefy/reconstruct.ts';
9
+ import { IpaasClient } from '../pipefy/ipaas.ts';
10
+ import { detect, type Capabilities } from '../pipefy/capability.ts';
11
+ import { paths } from '../workspace/layout.ts';
12
+ import { readLock, type Lock, type LockRepo } from '../workspace/lock.ts';
13
+ import type { Parsed } from '../cli/args.ts';
14
+ import { str } from '../cli/args.ts';
15
+
16
+ /** Shared plumbing for every command that talks to Pipefy or to a workspace. */
17
+
18
+ export type Net = {
19
+ client: PipefyClient;
20
+ internal: InternalApi;
21
+ snapshots: SnapshotClient;
22
+ reconstructor: Reconstructor;
23
+ /**
24
+ * A pipe's iPaaS workspace. Built from the internal API adapter, because the
25
+ * pipe-scoped token it needs is minted there — no second credential.
26
+ */
27
+ ipaas: IpaasClient;
28
+ organizationId: string | null;
29
+ /** Which credential won, for `pipe doctor` and the verbose log. */
30
+ credential: Credential;
31
+ capabilities: () => Promise<Capabilities>;
32
+ };
33
+
34
+ export const connect = async (p: Parsed): Promise<Net> => {
35
+ /**
36
+ * One credential, shared by the public client and the internal adapter, and
37
+ * expressed as a provider so a short-lived toolkit token can be refreshed
38
+ * mid-run rather than failing at step 90 of an apply.
39
+ */
40
+ const { provider, initial } = await credentialProvider();
41
+ debug(`credential source: ${initial.source}${initial.detail ? ` (${initial.detail})` : ''}`);
42
+
43
+ const client = new PipefyClient({ token: provider });
44
+ const internal = new InternalApi(provider);
45
+ const snapshots = new SnapshotClient(client);
46
+ const reconstructor = new Reconstructor(client, internal);
47
+ const ipaas = new IpaasClient(internal);
48
+ const organizationId = str(p, 'org') ?? (await readOrgId());
49
+
50
+ let caps: Capabilities | null = null;
51
+ return {
52
+ client,
53
+ internal,
54
+ snapshots,
55
+ reconstructor,
56
+ ipaas,
57
+ organizationId,
58
+ credential: initial,
59
+ capabilities: async () => {
60
+ caps ??= await detect(client, internal);
61
+ return caps;
62
+ },
63
+ };
64
+ };
65
+
66
+ /** Walk up from a directory looking for a workspace. */
67
+ export const findWorkspace = async (start: string): Promise<string | null> => {
68
+ let dir = resolve(start);
69
+ for (;;) {
70
+ if (await exists(paths(dir).workspaceFile)) return dir;
71
+ const parent = dirname(dir);
72
+ if (parent === dir) return null;
73
+ dir = parent;
74
+ }
75
+ };
76
+
77
+ export const resolveWorkspace = async (p: Parsed, positionalIndex = 0): Promise<string> => {
78
+ const explicit = p.positionals[positionalIndex] ?? str(p, 'workspace');
79
+ const start = explicit ? (isAbsolute(explicit) ? explicit : resolve(process.cwd(), explicit)) : process.cwd();
80
+ const found = await findWorkspace(start);
81
+ if (!found) {
82
+ throw new UserError(
83
+ `no pipe workspace found at or above ${start}`,
84
+ 'run `pipe pull <pipeId> --out <dir>` first, or pass the workspace directory',
85
+ );
86
+ }
87
+ debug(`workspace: ${found}`);
88
+ return found;
89
+ };
90
+
91
+ export type WorkspaceContext = {
92
+ root: string;
93
+ lock: Lock;
94
+ /** The repo a command acts on: --repo, or the root repo. */
95
+ repo: LockRepo;
96
+ repoDir: string;
97
+ };
98
+
99
+ export const openWorkspace = async (p: Parsed, positionalIndex = 0): Promise<WorkspaceContext> => {
100
+ const root = await resolveWorkspace(p, positionalIndex);
101
+ const lock = await readLock(root);
102
+ if (!lock) throw new UserError(`${root} has no .ppc/lock.json`, 'the workspace is incomplete — re-run `pipe pull`');
103
+
104
+ const wanted = str(p, 'repo');
105
+ const repo = wanted
106
+ ? lock.repos.find((r) => String(r.id) === wanted || r.apiId === wanted || (r.name ?? '').toLowerCase() === wanted.toLowerCase())
107
+ : lock.repos.find((r) => r.id === lock.rootRepoId);
108
+
109
+ if (!repo) {
110
+ throw new UserError(
111
+ wanted ? `no repo "${wanted}" in this workspace` : 'the workspace lock names no root repo',
112
+ `available: ${lock.repos.map((r) => `${r.id} (${r.name})`).join(', ')}`,
113
+ );
114
+ }
115
+
116
+ return { root, lock, repo, repoDir: join(root, repo.dir) };
117
+ };
118
+
119
+ export const readConcurrency = () => LIMITS.readConcurrency;
@@ -0,0 +1,79 @@
1
+ import { requirePositional, str, type Parsed } from '../cli/args.ts';
2
+ import { bold, dim, info, ok, out, UserError, yellow } from '../util/log.ts';
3
+ import { readOrgId } from '../config.ts';
4
+ import { connect } from './context.ts';
5
+
6
+ export const help = `pipe create <name> [options]
7
+
8
+ --org <id> organization to create the pipe in (default: the stored org)
9
+ --icon <name> pipe icon, e.g. rocket, target, chat
10
+ --out <dir> workspace directory for the new pipe
11
+ --no-pull just create it; do not build a workspace
12
+
13
+ Creates an empty pipe and pulls it, so the next step is editing files. The
14
+ structure — phases, fields, labels, automations — is then authored in the
15
+ workspace and applied like any other change, which is the point: a pipe built
16
+ this way is described by files from its first day rather than reverse-engineered
17
+ into them later.`;
18
+
19
+ export const run = async (p: Parsed) => {
20
+ const name = requirePositional(p, 0, 'a name for the new pipe');
21
+ const net = await connect(p);
22
+ const organizationId = str(p, 'org') ?? (await readOrgId());
23
+ if (!organizationId) {
24
+ throw new UserError(
25
+ 'no organization id',
26
+ 'pass --org <id>, or run `pipe login` again so the organization is stored',
27
+ );
28
+ }
29
+
30
+ const icon = str(p, 'icon');
31
+
32
+ info('');
33
+ info(bold(`pipe create ${JSON.stringify(name)}`));
34
+ info(dim(`organization ${organizationId}${icon ? ` · icon ${icon}` : ''}`));
35
+
36
+ /**
37
+ * Only name, organization and icon are sent, even though `CreatePipeInput`
38
+ * also takes phases, labels, start_form_fields and preferences.
39
+ *
40
+ * That is deliberate. Everything this tool can build, it should build through
41
+ * the same compile-and-apply path as any other change, so that the pipe's
42
+ * structure has a diff, a plan, and a run report behind it. Passing the whole
43
+ * structure to `createPipe` would produce a pipe nothing in the workspace can
44
+ * account for.
45
+ */
46
+ const res = await net.client.raw<{ createPipe: { pipe: { id: string; name: string } } }>(
47
+ `mutation createPipe($input: CreatePipeInput!) {
48
+ createPipe(input: $input) { pipe { id name } }
49
+ }`,
50
+ { input: { name, organization_id: organizationId, ...(icon ? { icon } : {}) } },
51
+ 'createPipe',
52
+ );
53
+
54
+ const pipe = res?.createPipe?.pipe;
55
+ if (!pipe?.id) throw new UserError('createPipe returned no pipe', 'the endpoint accepted the call but answered with nothing');
56
+
57
+ ok(`pipe ${pipe.id} ${bold(pipe.name)} created`);
58
+
59
+ if (str(p, 'no-pull') !== undefined || p.flags['no-pull'] === true) {
60
+ out('');
61
+ out(`${yellow('next')} pipe pull ${pipe.id} --out ./<dir>`);
62
+ return 0;
63
+ }
64
+
65
+ /**
66
+ * Pull it immediately. A new pipe is not empty — Pipefy gives it a start form
67
+ * and default phases — so the only honest starting point for editing is a read
68
+ * of what was actually created, not an assumption about it.
69
+ */
70
+ const pull = await import('./pull.ts');
71
+ await pull.run({
72
+ ...p,
73
+ command: 'pull',
74
+ positionals: [pipe.id],
75
+ flags: { ...p.flags, org: organizationId },
76
+ });
77
+
78
+ return 0;
79
+ };
@@ -0,0 +1,314 @@
1
+ import { resolve } from 'node:path';
2
+ import { spawn } from 'node:child_process';
3
+ import { bool, str, type Parsed } from '../cli/args.ts';
4
+ import { readJson, writeFile } from '../util/fsx.ts';
5
+ import { UserError, bold, dim, info, ok, out, warn } from '../util/log.ts';
6
+ import type { PayloadEnvelope } from '../model/payload.ts';
7
+ import { unpack } from '../codec/unpack.ts';
8
+ import { readTree } from '../workspace/read.ts';
9
+ import { baselineFile, repoDir } from '../workspace/layout.ts';
10
+ import { diffTrees, type ChangeSet } from '../diff/diff.ts';
11
+ import { renderChangeSet, toJson } from '../diff/render.ts';
12
+ import { renderHtml } from '../diff/html.ts';
13
+ import { connect, openWorkspace } from './context.ts';
14
+ import { diffFlowsFor } from './flows.ts';
15
+ import { renderFlowChanges } from '../diff/flow.ts';
16
+ import { diffAgentsFor } from './agents.ts';
17
+ import type { RepoRef } from '../pipefy/discovery.ts';
18
+ import type { Plan } from '../apply/types.ts';
19
+
20
+ export const help = `pipe diff [workspace] [options]
21
+
22
+ --repo <id> which repo in the workspace (default: the root pipe)
23
+ --against <target> what to compare the edited tree against:
24
+ baseline (default) the payload captured by the last pull
25
+ live re-read the pipe now
26
+ <pipeId> another pipe — a cross-repo migration diff
27
+ --html <file> write a standalone HTML diff view
28
+ --open open the HTML view in the browser
29
+ --json machine-readable output
30
+ --verbose-deltas show every changed field, not the first six`;
31
+
32
+ /**
33
+ * `pipe diff` — what changed, and against what.
34
+ *
35
+ * `--against <pipeId>` is the recipes' actual use case: comparing a dev pipe to
36
+ * a prod pipe and producing the change list to migrate. That runs in cross-repo
37
+ * mode, where matching falls back to the natural key (phase name, phase+label)
38
+ * exactly as the recipes match, and the resulting id map is what lets embedded
39
+ * field references be rewritten on apply.
40
+ */
41
+ export const run = async (p: Parsed) => {
42
+ const { root, lock, repo, repoDir: dir } = await openWorkspace(p);
43
+ const against = str(p, 'against') ?? 'baseline';
44
+
45
+ const edited = await readTree(dir);
46
+ let baseline = await loadBaseline(root, repo.id);
47
+ let mode: ChangeSet['mode'] = 'same-repo';
48
+
49
+ if (against === 'live') {
50
+ const net = await connect(p);
51
+ const caps = await net.capabilities();
52
+ info(dim(`re-reading pipe ${repo.id} (${caps.snapshots ? 'snapshot' : 'GraphQL reconstruction'})`));
53
+ const env = caps.snapshots
54
+ ? await liveSnapshot(net, repo.id)
55
+ : await net.reconstructor.reconstruct(repo.id, { organizationId: lock.organizationId });
56
+ baseline = unpack(env);
57
+ } else if (against !== 'baseline') {
58
+ const otherId = Number(against);
59
+ if (!Number.isFinite(otherId)) {
60
+ throw new UserError(`--against must be baseline, live, or a numeric pipe id (got "${against}")`);
61
+ }
62
+ mode = 'cross-repo';
63
+ const inWorkspace = lock.repos.find((r) => r.id === otherId);
64
+ if (inWorkspace) {
65
+ baseline = await readTree(resolve(root, inWorkspace.dir));
66
+ info(dim(`comparing against ${inWorkspace.name} (${otherId}) from this workspace`));
67
+ } else {
68
+ const net = await connect(p);
69
+ const caps = await net.capabilities();
70
+ info(dim(`reading pipe ${otherId} live for a cross-repo diff`));
71
+ const env = caps.snapshots ? await liveSnapshot(net, otherId) : await net.reconstructor.reconstruct(otherId, { organizationId: lock.organizationId });
72
+ baseline = unpack(env);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * In cross-repo mode the *target* is the pipe named by --against and the
78
+ * source is the workspace: "make that pipe look like this one". Same-repo mode
79
+ * is the ordinary edit loop: baseline -> edited.
80
+ */
81
+ const changeSet =
82
+ mode === 'cross-repo' ? diffTrees(baseline, edited, { mode }) : diffTrees(baseline, edited, { mode });
83
+
84
+ if (bool(p, 'json')) {
85
+ out(JSON.stringify(toJson(changeSet), null, 2));
86
+ } else {
87
+ out(renderChangeSet(changeSet, { verbose: bool(p, 'verbose-deltas') }));
88
+ }
89
+
90
+ /**
91
+ * The flow half.
92
+ *
93
+ * Reported in the same command because a pipe can diff clean while its actual
94
+ * behaviour changed entirely — the change was in a flow. A diff that stays
95
+ * silent about that is telling the user something false.
96
+ *
97
+ * Needs the network, unlike the pipe diff, since the flow baseline is the live
98
+ * workspace rather than a stored envelope. Skipped for --json and when there is
99
+ * no flows/ directory at all.
100
+ */
101
+ let flowExit = 0;
102
+ if (!bool(p, 'json') && !bool(p, 'no-flows') && mode === 'same-repo') {
103
+ const { readFlows } = await import('../workspace/flows.ts');
104
+ if (await readFlows(dir)) {
105
+ try {
106
+ // Only `--against live` needs the network; the baseline diff is offline.
107
+ const wantLive = against === 'live';
108
+ const net = wantLive ? await connect(p) : null;
109
+ const { changeSet: flowChanges } = await diffFlowsFor(net?.ipaas ?? null, repo.id, dir, {
110
+ against: wantLive ? 'live' : 'baseline',
111
+ workspaceRoot: root,
112
+ });
113
+ const rendered = renderFlowChanges(flowChanges);
114
+ if (rendered.trim()) {
115
+ out('');
116
+ out(bold('flows'));
117
+ out(rendered);
118
+ }
119
+ flowExit = flowChanges.changes.length;
120
+ } catch (e) {
121
+ info(dim(` flows not compared: ${(e as Error).message}`));
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The agent half. Offline, same as the pipe half — the agent baseline is a
128
+ * recorded file (workspace/agents.ts), not a live read, so `--against live`
129
+ * does not change anything here yet.
130
+ */
131
+ let agentExit = 0;
132
+ if (!bool(p, 'json') && mode === 'same-repo') {
133
+ try {
134
+ const { renderAgentChanges } = await import('../diff/agents.ts');
135
+ const agentChanges = await diffAgentsFor(root, repo.id, dir, { tree: edited });
136
+ const rendered = renderAgentChanges(agentChanges);
137
+ if (rendered.trim()) {
138
+ out('');
139
+ out(bold('agents'));
140
+ out(rendered);
141
+ }
142
+ agentExit = agentChanges.changes.length;
143
+ } catch (e) {
144
+ info(dim(` agents not compared: ${(e as Error).message}`));
145
+ }
146
+ }
147
+
148
+ const htmlPath = str(p, 'html') ?? (bool(p, 'open') ? resolve(root, '.ppc', 'diff.html') : undefined);
149
+ if (htmlPath) {
150
+ await writeHtml(htmlPath, changeSet, root, null);
151
+ info('');
152
+ ok(`HTML diff written to ${htmlPath}`);
153
+ if (bool(p, 'open')) await openViewer(htmlPath);
154
+ }
155
+
156
+ /**
157
+ * The rest of the workspace, when no `--repo` narrowed it.
158
+ *
159
+ * `pipe diff` diffs one repo — the root, unless told otherwise — and a
160
+ * workspace holds several. That was a quiet trap even before databases: edit a
161
+ * connected pipe, run `pipe diff`, read "nothing to do". Databases make it
162
+ * routine, because a database is never the root: it is reached through a
163
+ * connector field or a relation, so every database edit lands in a repo this
164
+ * command was not looking at.
165
+ *
166
+ * Reported the way flows are: a repo with changes prints its **actual diff**
167
+ * under a heading, and the clean ones collapse to one line. A count plus a
168
+ * "now run this" was the first attempt and it was worse — you cannot review a
169
+ * change you have to run a second command to see, and the whole point of this
170
+ * command is to be the thing you read before applying.
171
+ *
172
+ * Offline: a tree read and a diff per repo, no network. Their changes count
173
+ * towards the exit code, because a workspace with pending edits is not clean
174
+ * just because the root happens to be.
175
+ */
176
+ let othersPending = 0;
177
+ if (!str(p, 'repo') && against === 'baseline' && lock.repos.length > 1) {
178
+ const clean: string[] = [];
179
+ const failed: string[] = [];
180
+ const changed: Array<{ label: string; rendered: string; count: number }> = [];
181
+
182
+ for (const other of lock.repos.filter((r) => r.id !== repo.id)) {
183
+ try {
184
+ const otherChanges = diffTrees(
185
+ await loadBaseline(root, other.id),
186
+ await readTree(resolve(root, other.dir)),
187
+ { mode: 'same-repo' },
188
+ );
189
+ if (otherChanges.changes.length) {
190
+ othersPending += otherChanges.changes.length;
191
+ changed.push({
192
+ /**
193
+ * `renderChangeSet` already names the repo and its numeric id, so this
194
+ * adds only what it cannot know: which kind of repo it is, and the api
195
+ * id you would pass to `--repo`.
196
+ */
197
+ label: `${other.kind}${other.apiId !== String(other.id) ? ` · ${other.apiId}` : ''}`,
198
+ rendered: renderChangeSet(otherChanges, { verbose: bool(p, 'verbose-deltas') }),
199
+ count: otherChanges.changes.length,
200
+ });
201
+ } else {
202
+ clean.push(String(other.name ?? other.id));
203
+ }
204
+ } catch (e) {
205
+ failed.push(`${other.name ?? other.id}: ${(e as Error).message}`);
206
+ }
207
+ }
208
+
209
+ for (const c of changed) {
210
+ out('');
211
+ out(bold(c.label));
212
+ out(c.rendered);
213
+ }
214
+ if (clean.length) {
215
+ out('');
216
+ out(dim(`${clean.length} other repo(s) clean: ${clean.join(', ')}`));
217
+ }
218
+ for (const f of failed) info(dim(` not compared — ${f}`));
219
+ if (othersPending) {
220
+ out('');
221
+ info(dim(' each of those applies on its own: pipe apply --repo <id>'));
222
+ }
223
+ }
224
+
225
+ return changeSet.changes.length === 0 && flowExit === 0 && agentExit === 0 && othersPending === 0 ? 0 : 1;
226
+ };
227
+
228
+ /**
229
+ * The baseline as the envelope it is stored as, coverage and read path intact.
230
+ *
231
+ * `loadBaseline` unpacks to a tree, which is what the diff wants and what a run
232
+ * report does not: a report keeps the payload verbatim so the run folder is a
233
+ * usable copy of the pipe, not a reshaped one.
234
+ */
235
+ export const loadBaselineEnvelope = async (root: string, repoId: number): Promise<PayloadEnvelope | null> => {
236
+ try {
237
+ return await readJson<PayloadEnvelope>(baselineFile(root, repoId));
238
+ } catch {
239
+ return null;
240
+ }
241
+ };
242
+
243
+ export const loadBaseline = async (root: string, repoId: number) => {
244
+ const path = baselineFile(root, repoId);
245
+ try {
246
+ const env = await readJson<PayloadEnvelope>(path);
247
+ return unpack(env);
248
+ } catch {
249
+ throw new UserError(
250
+ `no baseline for repo ${repoId} at ${path}`,
251
+ 'the baseline is written by `pipe pull` — re-pull the workspace',
252
+ );
253
+ }
254
+ };
255
+
256
+ /** The baseline, or null, for callers where its absence is not an error. */
257
+ export const loadBaselineQuiet = async (root: string, repoId: number) => {
258
+ try {
259
+ return await loadBaseline(root, repoId);
260
+ } catch {
261
+ return null;
262
+ }
263
+ };
264
+
265
+ const liveSnapshot = async (net: Awaited<ReturnType<typeof connect>>, repoId: number): Promise<PayloadEnvelope> => {
266
+ const { payload, versionId } = await net.snapshots.pull(repoId);
267
+ const { fullCoverage } = await import('../model/payload.ts');
268
+ return {
269
+ source: 'snapshot',
270
+ versionId,
271
+ readAt: new Date().toISOString(),
272
+ repoKind: 'pipe',
273
+ coverage: fullCoverage('complete'),
274
+ notes: [],
275
+ payload,
276
+ };
277
+ };
278
+
279
+ export const writeHtml = async (path: string, changeSet: ChangeSet, workspace: string, plan: Plan | null) => {
280
+ const html = renderHtml({ changeSet, plan, workspace, generatedAt: new Date().toISOString() });
281
+ await writeFile(path, html);
282
+ return path;
283
+ };
284
+
285
+ /**
286
+ * Open the HTML view in the OS browser, which needs no install at all.
287
+ *
288
+ * There was an Electron branch here that ran the page in a desktop shell when the
289
+ * dependency happened to be present. It went: this is a CLI, the browser renders
290
+ * the same file, and a GUI shell is a second product to keep working — including
291
+ * the only heavy devDependency in a repo whose whole point is having none.
292
+ */
293
+ export const openViewer = async (htmlPath: string) => {
294
+ const cmd =
295
+ process.platform === 'win32' ? ['cmd', '/c', 'start', '', htmlPath] : process.platform === 'darwin' ? ['open', htmlPath] : ['xdg-open', htmlPath];
296
+ try {
297
+ spawn(cmd[0] as string, cmd.slice(1), { detached: true, stdio: 'ignore' }).unref();
298
+ } catch (e) {
299
+ warn(`could not open a viewer: ${(e as Error).message}`);
300
+ info(dim(`open this file manually: ${htmlPath}`));
301
+ }
302
+ };
303
+
304
+ export const refFor = (repo: { kind: 'pipe' | 'table'; id: number; apiId: string; name: string | null }): RepoRef => ({
305
+ kind: repo.kind,
306
+ id: repo.id,
307
+ apiId: repo.apiId,
308
+ name: repo.name,
309
+ uuid: null,
310
+ via: [],
311
+ depth: 0,
312
+ });
313
+
314
+ export const dirFor = (root: string, repo: Parameters<typeof refFor>[0]) => repoDir(root, refFor(repo));