@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,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));
|