@pipefy/pipefy-process-coder 0.1.0 → 0.1.2

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 (162) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
  19. package/AGENTS.md +476 -456
  20. package/LICENSE +202 -0
  21. package/NOTICE +11 -0
  22. package/README.md +830 -808
  23. package/bin/pipe.js +25 -4
  24. package/dist/apply/adopt.js +128 -0
  25. package/dist/apply/agentops.js +42 -0
  26. package/dist/apply/compile.js +722 -0
  27. package/dist/apply/execute.js +277 -0
  28. package/dist/apply/flowops.js +293 -0
  29. package/dist/apply/idmap.js +233 -0
  30. package/dist/apply/mutations.js +704 -0
  31. package/dist/apply/registry.js +348 -0
  32. package/dist/apply/types.js +18 -0
  33. package/dist/cli/args.js +79 -0
  34. package/dist/cli.js +196 -0
  35. package/dist/codec/flow.js +164 -0
  36. package/dist/codec/pack.js +88 -0
  37. package/dist/codec/roundtrip.js +86 -0
  38. package/dist/codec/unpack.js +181 -0
  39. package/dist/commands/agents.js +145 -0
  40. package/dist/commands/apply.js +982 -0
  41. package/dist/commands/context.js +79 -0
  42. package/dist/commands/create.js +63 -0
  43. package/dist/commands/diff.js +294 -0
  44. package/dist/commands/flows.js +348 -0
  45. package/dist/commands/misc.js +562 -0
  46. package/dist/commands/plan.js +301 -0
  47. package/dist/commands/pull.js +549 -0
  48. package/dist/commands/runs.js +74 -0
  49. package/dist/commands/skills.js +131 -0
  50. package/dist/commands/verify.js +205 -0
  51. package/dist/config.js +125 -0
  52. package/dist/diff/agents.js +80 -0
  53. package/dist/diff/diff.js +983 -0
  54. package/dist/diff/flow.js +258 -0
  55. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  56. package/dist/diff/render.js +90 -0
  57. package/dist/model/payload.js +84 -0
  58. package/dist/model/tree.js +7 -0
  59. package/dist/model/volatile.js +54 -0
  60. package/dist/pipefy/agents.js +131 -0
  61. package/dist/pipefy/automations.js +155 -0
  62. package/dist/pipefy/capability.js +84 -0
  63. package/dist/pipefy/client.js +204 -0
  64. package/dist/pipefy/discovery.js +148 -0
  65. package/dist/pipefy/internal.js +406 -0
  66. package/dist/pipefy/ipaas.js +269 -0
  67. package/dist/pipefy/reconstruct.js +676 -0
  68. package/dist/pipefy/reference.js +172 -0
  69. package/dist/pipefy/snapshot.js +185 -0
  70. package/dist/pipefy/toolkit.js +174 -0
  71. package/{src → dist}/pipefy/toolkit_bearer.py +60 -14
  72. package/dist/report/integrations.js +165 -0
  73. package/dist/report/run.js +356 -0
  74. package/dist/util/fsx.js +50 -0
  75. package/dist/util/git.js +25 -0
  76. package/dist/util/json.js +56 -0
  77. package/dist/util/log.js +69 -0
  78. package/dist/util/pool.js +42 -0
  79. package/dist/util/slug.js +23 -0
  80. package/dist/util/tui.js +287 -0
  81. package/dist/validate/index.js +86 -0
  82. package/dist/validate/integrity.js +370 -0
  83. package/dist/validate/reference.js +120 -0
  84. package/dist/validate/schema.js +278 -0
  85. package/dist/version.js +18 -0
  86. package/dist/workspace/agents.js +245 -0
  87. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  88. package/dist/workspace/flows.js +149 -0
  89. package/dist/workspace/layout.js +146 -0
  90. package/dist/workspace/lock.js +83 -0
  91. package/dist/workspace/read.js +168 -0
  92. package/dist/workspace/reference.js +24 -0
  93. package/dist/workspace/stamp.js +234 -0
  94. package/dist/workspace/write.js +194 -0
  95. package/package.json +38 -35
  96. package/src/apply/adopt.ts +0 -150
  97. package/src/apply/agentops.ts +0 -71
  98. package/src/apply/compile.ts +0 -875
  99. package/src/apply/execute.ts +0 -336
  100. package/src/apply/flowops.ts +0 -399
  101. package/src/apply/idmap.ts +0 -241
  102. package/src/apply/mutations.ts +0 -955
  103. package/src/apply/registry.ts +0 -430
  104. package/src/apply/types.ts +0 -134
  105. package/src/cli/args.ts +0 -88
  106. package/src/cli.ts +0 -211
  107. package/src/codec/flow.ts +0 -199
  108. package/src/codec/pack.ts +0 -103
  109. package/src/codec/roundtrip.ts +0 -94
  110. package/src/codec/unpack.ts +0 -198
  111. package/src/commands/agents.ts +0 -184
  112. package/src/commands/apply.ts +0 -1144
  113. package/src/commands/context.ts +0 -119
  114. package/src/commands/create.ts +0 -79
  115. package/src/commands/diff.ts +0 -314
  116. package/src/commands/flows.ts +0 -414
  117. package/src/commands/misc.ts +0 -644
  118. package/src/commands/plan.ts +0 -331
  119. package/src/commands/pull.ts +0 -567
  120. package/src/commands/runs.ts +0 -83
  121. package/src/commands/skills.ts +0 -137
  122. package/src/commands/verify.ts +0 -253
  123. package/src/config.ts +0 -168
  124. package/src/diff/agents.ts +0 -122
  125. package/src/diff/diff.ts +0 -1130
  126. package/src/diff/flow.ts +0 -318
  127. package/src/diff/render.ts +0 -101
  128. package/src/model/payload.ts +0 -154
  129. package/src/model/tree.ts +0 -99
  130. package/src/model/volatile.ts +0 -55
  131. package/src/pipefy/agents.ts +0 -165
  132. package/src/pipefy/automations.ts +0 -219
  133. package/src/pipefy/capability.ts +0 -119
  134. package/src/pipefy/client.ts +0 -267
  135. package/src/pipefy/discovery.ts +0 -209
  136. package/src/pipefy/internal.ts +0 -380
  137. package/src/pipefy/ipaas.ts +0 -365
  138. package/src/pipefy/reconstruct.ts +0 -775
  139. package/src/pipefy/reference.ts +0 -251
  140. package/src/pipefy/snapshot.ts +0 -245
  141. package/src/pipefy/toolkit.ts +0 -200
  142. package/src/report/integrations.ts +0 -231
  143. package/src/report/run.ts +0 -475
  144. package/src/util/fsx.ts +0 -45
  145. package/src/util/git.ts +0 -32
  146. package/src/util/json.ts +0 -55
  147. package/src/util/log.ts +0 -76
  148. package/src/util/pool.ts +0 -48
  149. package/src/util/slug.ts +0 -26
  150. package/src/util/tui.ts +0 -335
  151. package/src/validate/index.ts +0 -123
  152. package/src/validate/integrity.ts +0 -387
  153. package/src/validate/reference.ts +0 -136
  154. package/src/validate/schema.ts +0 -328
  155. package/src/workspace/agents.ts +0 -290
  156. package/src/workspace/flows.ts +0 -191
  157. package/src/workspace/layout.ts +0 -165
  158. package/src/workspace/lock.ts +0 -148
  159. package/src/workspace/read.ts +0 -165
  160. package/src/workspace/reference.ts +0 -24
  161. package/src/workspace/stamp.ts +0 -301
  162. package/src/workspace/write.ts +0 -225
@@ -0,0 +1,172 @@
1
+ import { debug } from "../util/log.js";
2
+ /**
3
+ * The closed sets — every place an authored value must come from a fixed list.
4
+ *
5
+ * This exists because of a structural gap, not a convenience. The schema
6
+ * publishes the lists:
7
+ *
8
+ * AutomationsEvents 10 values card_moved, field_updated, …
9
+ * AutomationsActions 15 values move_single_card, update_card_field, …
10
+ * FieldTypeId 24 values short_text, connector, …
11
+ *
12
+ * and then types the input fields that consume them as `ID` and `String`:
13
+ *
14
+ * CreateAutomationInput.event_id ID
15
+ * CreateAutomationInput.action_id ID
16
+ * CreatePhaseFieldInput.type_id String
17
+ *
18
+ * So GraphQL validates none of them. A wrong value passes query validation,
19
+ * reaches a runtime check, and comes back as the two words `is invalid` — no
20
+ * parameter name, no field name, no list of what would have been accepted. The
21
+ * list was always public; nothing was consulting it.
22
+ *
23
+ * Two consumers, and the split matters:
24
+ *
25
+ * - `pipe validate`, `plan` and `apply` read the cached copy and refuse
26
+ * offline, naming the value and what belongs there.
27
+ * - `REFERENCE.md` puts the same lists in the workspace, so whoever is editing
28
+ * the JSON — a person or an agent — reads what the API accepts instead of
29
+ * guessing and waiting for `is invalid`.
30
+ *
31
+ * Read once per pull, cached in `.ppc/reference.json`. Never assumed: an
32
+ * absent reference means the checks are skipped, not that a value is wrong.
33
+ */
34
+ /** Enums whose values get authored into workspace JSON by hand. */
35
+ const TRACKED_ENUMS = [
36
+ 'FieldTypeId',
37
+ 'AutomationsEvents',
38
+ 'AutomationsActions',
39
+ 'Colors',
40
+ 'FieldConditionFilter',
41
+ 'AutomationHttpAuthentication',
42
+ 'HttpRequestMethodTypeGQLEnum',
43
+ 'HttpRequestAddAuthenticationTo',
44
+ 'UpdateFieldValuesOperators',
45
+ 'DistributeAssignmentAutomationStrategyGQLEnum',
46
+ 'CardTypeEnum',
47
+ ];
48
+ const ENUMS_QUERY = `query ClosedSets {
49
+ __schema { types { name kind enumValues { name } } }
50
+ }`;
51
+ const CATALOGUE_QUERY = `query AutomationCatalogue($repoId: ID!) {
52
+ automationEvents { id acceptedParameters actionsBlacklist }
53
+ automationActions(repoId: $repoId) { id acceptedParameters eventsBlacklist }
54
+ }`;
55
+ /**
56
+ * Read every closed set for one pipe.
57
+ *
58
+ * Returns null rather than throwing: a token that cannot introspect, or a pipe
59
+ * whose action catalogue is refused, must degrade to "no reference" — which
60
+ * skips the checks — and never to a guess that would report valid work as broken.
61
+ */
62
+ export const readReference = async (client, repoId) => {
63
+ try {
64
+ const [schema, cat] = await Promise.all([
65
+ client.raw(ENUMS_QUERY),
66
+ client.raw(CATALOGUE_QUERY, { repoId: String(repoId) }),
67
+ ]);
68
+ const byName = new Map((schema?.__schema?.types ?? [])
69
+ .filter((t) => t.kind === 'ENUM')
70
+ .map((t) => [t.name, (t.enumValues ?? []).map((v) => v.name)]));
71
+ const enums = {};
72
+ for (const name of TRACKED_ENUMS) {
73
+ const values = byName.get(name);
74
+ /**
75
+ * An enum the endpoint no longer publishes is omitted, not recorded empty.
76
+ * An empty list would read as "nothing is valid here" and refuse every
77
+ * value in that position.
78
+ */
79
+ if (values?.length)
80
+ enums[name] = values;
81
+ }
82
+ const ref = {
83
+ readAt: new Date().toISOString(),
84
+ repoId: String(repoId),
85
+ enums,
86
+ events: (cat?.automationEvents ?? []).map((e) => ({
87
+ id: e.id,
88
+ acceptedParameters: e.acceptedParameters ?? [],
89
+ actionsBlacklist: e.actionsBlacklist ?? [],
90
+ })),
91
+ actions: (cat?.automationActions ?? []).map((a) => ({
92
+ id: a.id,
93
+ acceptedParameters: a.acceptedParameters ?? [],
94
+ eventsBlacklist: a.eventsBlacklist ?? [],
95
+ })),
96
+ };
97
+ debug(`reference: ${Object.keys(enums).length} enums, ${ref.events.length} events, ` +
98
+ `${ref.actions.length} actions for repo ${repoId}`);
99
+ return ref;
100
+ }
101
+ catch (e) {
102
+ debug(`reference unavailable: ${e.message}`);
103
+ return null;
104
+ }
105
+ };
106
+ /** The cached reference, in the shape the automation param check already takes. */
107
+ export const toCatalogue = (ref) => ({
108
+ events: new Map(ref.events.map((e) => [e.id, e])),
109
+ actions: new Map(ref.actions.map((a) => [a.id, a])),
110
+ });
111
+ export const enumValues = (ref, name) => {
112
+ const v = ref?.enums[name];
113
+ return v?.length ? v : null;
114
+ };
115
+ const table = (rows, head) => {
116
+ const w = Math.max(head[0].length, ...rows.map(([a]) => a.length));
117
+ return [
118
+ `| ${head[0].padEnd(w)} | ${head[1]} |`,
119
+ `| ${'-'.repeat(w)} | ${'-'.repeat(Math.max(3, head[1].length))} |`,
120
+ ...rows.map(([a, b]) => `| ${a.padEnd(w)} | ${b} |`),
121
+ ];
122
+ };
123
+ /**
124
+ * REFERENCE.md — the closed sets, on disk, next to the JSON being edited.
125
+ *
126
+ * Deliberately generated rather than written by hand. A hand-written list of
127
+ * what an API accepts is wrong the first time the API changes, and nothing tells
128
+ * you it went wrong; this one is re-read on every pull and stamped with when.
129
+ */
130
+ export const renderReferenceMd = (ref) => {
131
+ const lines = ['# What the API accepts', ''];
132
+ if (!ref) {
133
+ lines.push('The closed sets could not be read on the last pull, so this file is empty and', '`pipe validate` skips the checks that depend on it. That is not a claim that', 'any value is valid — it is the absence of a claim. Re-run `pipe pull` to fill it.', '');
134
+ return lines.join('\n');
135
+ }
136
+ lines.push(`Read from the API on ${ref.readAt} for pipe ${ref.repoId}. Regenerated by every`, '`pipe pull` — edit the workspace JSON, never this file.', '', 'These are the values the endpoint accepts in each position. They matter because', 'the schema publishes the lists but types the inputs that consume them as `ID`', 'and `String`, so a wrong value is not caught by GraphQL: it reaches a runtime', 'check and comes back as `is invalid`, naming nothing. `pipe validate` checks', 'these offline so a bad value is refused with its name before anything is sent.', '', '## Automation events', '', 'Each event accepts **only** the parameters listed. A parameter that belongs to a', 'different event is still just `is invalid`.', '');
137
+ for (const e of [...ref.events].sort((a, b) => a.id.localeCompare(b.id))) {
138
+ lines.push(`### \`${e.id}\``);
139
+ lines.push('');
140
+ lines.push(e.acceptedParameters.length
141
+ ? `\`event_params\`: ${e.acceptedParameters.map((p) => `\`${p}\``).join(', ')}`
142
+ : '`event_params`: none — this event takes no parameters.');
143
+ if (e.actionsBlacklist.length) {
144
+ lines.push('');
145
+ lines.push(`Cannot be paired with: ${e.actionsBlacklist.map((a) => `\`${a}\``).join(', ')}`);
146
+ }
147
+ lines.push('');
148
+ }
149
+ lines.push('## Automation actions', '', 'Actions are per-pipe: a pipe without AI enabled offers fewer than this list if', 'it was pulled from a different pipe. `field_map` is accepted on every action', 'that writes a field and is not listed below — the catalogue names', '`fields_map_order` beside it and stops.', '');
150
+ for (const a of [...ref.actions].sort((x, y) => x.id.localeCompare(y.id))) {
151
+ lines.push(`### \`${a.id}\``);
152
+ lines.push('');
153
+ lines.push(a.acceptedParameters.length
154
+ ? `\`action_params\`: ${a.acceptedParameters.map((p) => `\`${p}\``).join(', ')}`
155
+ : '`action_params`: none — this action takes no parameters.');
156
+ if (a.eventsBlacklist.length) {
157
+ lines.push('');
158
+ lines.push(`Cannot be triggered by: ${a.eventsBlacklist.map((e) => `\`${e}\``).join(', ')}`);
159
+ }
160
+ lines.push('');
161
+ }
162
+ const enumRows = Object.entries(ref.enums)
163
+ .sort(([a], [b]) => a.localeCompare(b))
164
+ .map(([name, values]) => [`\`${name}\``, values.map((v) => `\`${v}\``).join(', ')]);
165
+ if (enumRows.length) {
166
+ lines.push('## Other closed sets', '');
167
+ lines.push('Where these are used: `FieldTypeId` is a field\'s `type_id`; `Colors` is a', 'phase or pipe `color`; `FieldConditionFilter` is a field condition\'s', '`filter`; the `Http*` sets belong to `send_http_request` action params.', '');
168
+ lines.push(...table(enumRows, ['Set', 'Values']));
169
+ lines.push('');
170
+ }
171
+ return lines.join('\n');
172
+ };
@@ -0,0 +1,185 @@
1
+ import { gunzipSync } from 'node:zlib';
2
+ import { LIMITS } from "../config.js";
3
+ import { debug, progress, progressDone, step } from "../util/log.js";
4
+ import { retry, sleep } from "../util/pool.js";
5
+ /**
6
+ * `RepoSnapshotStatus` is `PENDING | UPLOADED | FAILED` — verified by
7
+ * introspection against api.pipefy.com. **UPLOADED is the success state**: the
8
+ * job has written the payload to S3 and `signedUrl` is available. The wider set
9
+ * is kept because this is an undocumented enum with no `schema_version`
10
+ * (ROUTE-B-V2.md ask 9), so a deployment that renames it degrades to a slow
11
+ * poll rather than a wrong answer.
12
+ */
13
+ const DONE = new Set(['uploaded', 'done', 'succeeded', 'success', 'completed', 'finished', 'ready']);
14
+ const FAILED = new Set(['failed', 'error', 'errored', 'cancelled', 'canceled']);
15
+ export const isDone = (s) => DONE.has(String(s ?? '').toLowerCase());
16
+ export const isFailed = (s) => FAILED.has(String(s ?? '').toLowerCase());
17
+ export class SnapshotClient {
18
+ client;
19
+ /** Whether this endpoint has snapshots at all — decided once, cached. */
20
+ available = null;
21
+ constructor(client) {
22
+ this.client = client;
23
+ }
24
+ /**
25
+ * Where poll progress goes. The snapshot wait is the longest thing this tool
26
+ * does (~63s), so whoever owns the screen renders it, rather than this client
27
+ * scribbling its own line into someone else's frame.
28
+ */
29
+ onProgress = null;
30
+ report(text) {
31
+ if (this.onProgress)
32
+ this.onProgress(text);
33
+ else
34
+ progress(text);
35
+ }
36
+ /**
37
+ * Snapshots are an entitlement, not a given. The whole point of the
38
+ * reconstruction path is that this can be false — so it is answered by
39
+ * introspection, up front, and never by a failed pull halfway through.
40
+ */
41
+ async isAvailable() {
42
+ if (this.available !== null)
43
+ return this.available;
44
+ const [mut, pipeFields] = await Promise.all([
45
+ this.client.hasMutation('createRepoSnapshot'),
46
+ this.client.typeFields('Pipe'),
47
+ ]);
48
+ this.available = mut && Boolean(pipeFields?.has('snapshot') || pipeFields?.has('snapshots'));
49
+ debug(`snapshot capability: createRepoSnapshot=${mut} pipe.snapshots=${pipeFields?.has('snapshots')}`);
50
+ return this.available;
51
+ }
52
+ /**
53
+ * `Pipe.snapshots` is a Relay `RepoSnapshotConnection`, not the flat array
54
+ * PLAN.md §1.1 recorded — so the rows come from `nodes`.
55
+ *
56
+ * `snapshots(last: N)` is not the most recent N — measured live, on a pipe
57
+ * with 86 snapshots: `last: 1` returned `sequenceIndex: 1`, the *oldest* one
58
+ * on record, while `first: 1` correctly returned `sequenceIndex: 86`. The
59
+ * connection is apparently ordered newest-first internally, the opposite of
60
+ * the usual Relay convention, which inverts what `first`/`last` each mean.
61
+ *
62
+ * This is not cosmetic: `pipe rollback`'s only-the-latest check, and
63
+ * `pipe apply`'s post-failure rollback offer, both call this to decide
64
+ * whether a version is still restorable. With `last`, that check compared
65
+ * against the *oldest* snapshot and refused every legitimate restore.
66
+ */
67
+ async list(repoId, limit = 20) {
68
+ const data = await this.client.tryRaw(`query($id: ID!, $first: Int!) {
69
+ pipe(id: $id) {
70
+ snapshots(first: $first) {
71
+ totalCount
72
+ nodes { versionId status sequenceIndex label createdAt }
73
+ }
74
+ }
75
+ }`, { id: String(repoId), first: limit }, 'list snapshots');
76
+ const rows = data?.pipe?.snapshots?.nodes ?? [];
77
+ return [...rows].sort((a, b) => (b.sequenceIndex ?? 0) - (a.sequenceIndex ?? 0));
78
+ }
79
+ /** `snapshot(versionId:)` takes an `ID`; declaring `String!` fails the query. */
80
+ async detail(repoId, versionId) {
81
+ const data = await this.client.tryRaw(`query($id: ID!, $v: ID!) {
82
+ pipe(id: $id) {
83
+ snapshot(versionId: $v) { versionId status signedUrl metrics uploadedAt error }
84
+ }
85
+ }`, { id: String(repoId), v: versionId }, 'snapshot detail');
86
+ return data?.pipe?.snapshot ?? null;
87
+ }
88
+ async create(repoId) {
89
+ const data = await this.client.raw(`mutation($input: CreateRepoSnapshotInput!) {
90
+ createRepoSnapshot(input: $input) { repoSnapshot { versionId status } }
91
+ }`, { input: { repoId: Number(repoId) } }, 'createRepoSnapshot');
92
+ return data.createRepoSnapshot.repoSnapshot.versionId;
93
+ }
94
+ async rename(repoId, versionId, label) {
95
+ const res = await this.client.tryRaw(`mutation($input: RenameRepoSnapshotInput!) {
96
+ renameRepoSnapshot(input: $input) { repoSnapshot { versionId label } }
97
+ }`, { input: { repoId: Number(repoId), versionId, label } }, 'renameRepoSnapshot');
98
+ return res !== null;
99
+ }
100
+ /** Poll to a terminal state. ~63s is normal, so the wait is visible and bounded. */
101
+ async waitFor(repoId, versionId) {
102
+ const started = Date.now();
103
+ let unreadable = 0;
104
+ for (;;) {
105
+ const d = await this.detail(repoId, versionId);
106
+ const elapsed = Math.round((Date.now() - started) / 1000);
107
+ if (d && isDone(d.status)) {
108
+ if (!this.onProgress)
109
+ progressDone();
110
+ return d;
111
+ }
112
+ if (d && isFailed(d.status)) {
113
+ if (!this.onProgress)
114
+ progressDone();
115
+ throw new Error(`snapshot ${versionId} ${d.status}${d.error ? `: ${d.error}` : ''}`);
116
+ }
117
+ /**
118
+ * A null detail means the *query* failed, not that the snapshot is
119
+ * pending. Polling a broken query for five minutes and then reporting
120
+ * "still unknown" hides the real cause — which is how a schema drift in
121
+ * this query cost a wasted snapshot and a 303s wait. Fail fast and say so.
122
+ */
123
+ if (!d) {
124
+ unreadable++;
125
+ if (unreadable >= 3) {
126
+ if (!this.onProgress)
127
+ progressDone();
128
+ throw new Error(`snapshot ${versionId} status is unreadable — pipe.snapshot(versionId:) did not answer 3 times. ` +
129
+ `Run with --verbose to see the GraphQL error; the snapshot itself may well have been created.`);
130
+ }
131
+ }
132
+ else {
133
+ unreadable = 0;
134
+ }
135
+ if (Date.now() - started > LIMITS.snapshotTimeoutMs) {
136
+ if (!this.onProgress)
137
+ progressDone();
138
+ throw new Error(`snapshot ${versionId} still ${d?.status ?? 'unreadable'} after ${elapsed}s`);
139
+ }
140
+ this.report(`${String(d?.status ?? 'unreadable').toLowerCase()} ${elapsed}s (~63s typical)`);
141
+ await sleep(LIMITS.snapshotPollMs);
142
+ }
143
+ }
144
+ /**
145
+ * Download the payload. The signed URL is re-queried immediately before the
146
+ * fetch and never stored, and the raw gzip bytes are returned alongside the
147
+ * parsed payload so the caller can cache the compressed form.
148
+ */
149
+ async download(repoId, versionId) {
150
+ const d = await this.detail(repoId, versionId);
151
+ if (!d?.signedUrl)
152
+ throw new Error(`snapshot ${versionId} has no signedUrl (status ${d?.status ?? 'unknown'})`);
153
+ const bytes = await retry(async () => {
154
+ const res = await fetch(d.signedUrl);
155
+ if (!res.ok) {
156
+ const err = new Error(`snapshot download HTTP ${res.status}`);
157
+ err.retryable = res.status >= 500 || res.status === 429;
158
+ throw err;
159
+ }
160
+ return Buffer.from(await res.arrayBuffer());
161
+ }, { label: 'snapshot download' });
162
+ const gzipped = bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
163
+ const raw = gzipped ? gunzipSync(bytes) : bytes;
164
+ return { payload: JSON.parse(raw.toString('utf8')), gzip: gzipped ? bytes : null, raw };
165
+ }
166
+ /** Create, wait, download — the whole read in one call. */
167
+ async pull(repoId, label) {
168
+ if (this.onProgress)
169
+ this.report('requesting a snapshot');
170
+ else
171
+ step(`creating snapshot of repo ${repoId}`);
172
+ const versionId = await this.create(repoId);
173
+ await this.waitFor(repoId, versionId);
174
+ if (label)
175
+ await this.rename(repoId, versionId, label);
176
+ this.report('downloading');
177
+ const dl = await this.download(repoId, versionId);
178
+ return { ...dl, versionId };
179
+ }
180
+ /** Most recent already-complete snapshot, so a pull can cost zero snapshots. */
181
+ async latestComplete(repoId) {
182
+ const rows = await this.list(repoId);
183
+ return rows.find((r) => isDone(r.status)) ?? null;
184
+ }
185
+ }
@@ -0,0 +1,174 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { promisify } from 'node:util';
6
+ import { debug } from "../util/log.js";
7
+ /**
8
+ * Bridge to the official Pipefy AI toolkit (github.com/pipefy/ai-toolkit).
9
+ *
10
+ * `pipe` does not implement Pipefy login. When the toolkit is installed, its
11
+ * session *is* the credential: `pipefy auth login` runs the browser OAuth flow
12
+ * and stores a session in the OS keychain, and everything here does is ask the
13
+ * toolkit for the current bearer.
14
+ *
15
+ * That means one login for the MCP server, the `pipefy` CLI and this tool, and
16
+ * it means the keychain, the refresh grant and the credential precedence stay in
17
+ * one place — the toolkit's — rather than being reimplemented against a private
18
+ * store shape that can change under us.
19
+ *
20
+ * The bearer is short-lived and never persisted here. It is fetched per process
21
+ * and re-fetched when the API rejects it, which is why callers take a provider
22
+ * rather than a string.
23
+ */
24
+ const exec = promisify(execFile);
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const HELPER = join(here, 'toolkit_bearer.py');
27
+ const WINDOWS = process.platform === 'win32';
28
+ /** `pipefy` / `pipefy-mcp-server`, wherever uv put them. */
29
+ const toolBinDir = () => {
30
+ const home = process.env.USERPROFILE ?? process.env.HOME;
31
+ if (!home)
32
+ return null;
33
+ const dir = join(home, '.local', 'bin');
34
+ return existsSync(dir) ? dir : null;
35
+ };
36
+ export const toolkitBinary = (name) => {
37
+ const dir = toolBinDir();
38
+ if (!dir)
39
+ return null;
40
+ const path = join(dir, WINDOWS ? `${name}.exe` : name);
41
+ return existsSync(path) ? path : null;
42
+ };
43
+ /**
44
+ * The interpreter that can import `pipefy_auth`.
45
+ *
46
+ * `uv tool install` gives each tool its own environment, and `uv tool dir` is
47
+ * the documented way to find them — so the layout is asked for rather than
48
+ * assumed. `uv run --with` is the fallback for a machine where the CLI is not
49
+ * installed as a tool but uv is available.
50
+ */
51
+ const findInterpreter = async () => {
52
+ const roots = [];
53
+ try {
54
+ const { stdout } = await exec('uv', ['tool', 'dir'], { timeout: 15_000 });
55
+ const dir = stdout.trim();
56
+ if (dir)
57
+ roots.push(dir);
58
+ }
59
+ catch {
60
+ debug('uv tool dir failed — uv may not be installed');
61
+ }
62
+ const appdata = process.env.APPDATA;
63
+ if (appdata)
64
+ roots.push(join(appdata, 'uv', 'tools'));
65
+ const home = process.env.USERPROFILE ?? process.env.HOME;
66
+ if (home) {
67
+ roots.push(join(home, '.local', 'share', 'uv', 'tools'));
68
+ roots.push(join(home, 'Library', 'Application Support', 'uv', 'tools'));
69
+ }
70
+ for (const root of roots) {
71
+ for (const tool of ['pipefy-cli', 'pipefy-mcp-server']) {
72
+ const python = WINDOWS
73
+ ? join(root, tool, 'Scripts', 'python.exe')
74
+ : join(root, tool, 'bin', 'python');
75
+ if (existsSync(python)) {
76
+ debug(`toolkit interpreter: ${python}`);
77
+ return { command: python, args: [] };
78
+ }
79
+ }
80
+ }
81
+ // No installed tool env: let uv build an ephemeral one. Slower, and it needs
82
+ // the network the first time, so it is deliberately the last resort.
83
+ try {
84
+ await exec('uv', ['--version'], { timeout: 15_000 });
85
+ debug('falling back to `uv run --with pipefy-auth`');
86
+ return { command: 'uv', args: ['run', '--quiet', '--with', 'pipefy-auth', 'python'] };
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ };
92
+ let cachedInterpreter;
93
+ /** Is the toolkit usable on this machine, and does it currently hold a credential? */
94
+ export const inspectToolkit = async () => {
95
+ cachedInterpreter ??= await findInterpreter();
96
+ if (!cachedInterpreter)
97
+ return { installed: false };
98
+ const run = await runHelper();
99
+ if (!run)
100
+ return { installed: false };
101
+ if ('error' in run) {
102
+ return { installed: true, usable: false, cliPath: toolkitBinary('pipefy'), interpreter: cachedInterpreter.command, error: run.error };
103
+ }
104
+ return {
105
+ installed: true,
106
+ usable: true,
107
+ cliPath: toolkitBinary('pipefy'),
108
+ mcpServerPath: toolkitBinary('pipefy-mcp-server'),
109
+ interpreter: cachedInterpreter.command,
110
+ credential: run.result,
111
+ };
112
+ };
113
+ /**
114
+ * Run the helper. `null` means there is nothing to run it with; `{ error }`
115
+ * means it ran and could not use the toolkit, carrying its own explanation
116
+ * (the helper prints which `pipefy_auth` version it found and what it lacks).
117
+ */
118
+ const runHelper = async () => {
119
+ cachedInterpreter ??= await findInterpreter();
120
+ if (!cachedInterpreter)
121
+ return null;
122
+ if (!existsSync(HELPER)) {
123
+ debug(`toolkit helper missing at ${HELPER}`);
124
+ return { error: `this install of pipe is missing its toolkit helper (${HELPER})` };
125
+ }
126
+ try {
127
+ const { stdout } = await exec(cachedInterpreter.command, [...cachedInterpreter.args, HELPER], { timeout: 60_000, maxBuffer: 1024 * 1024 });
128
+ const parsed = JSON.parse(stdout.trim());
129
+ debug(`toolkit credential: ${parsed.ok ? parsed.source : parsed.reason}`);
130
+ return { result: parsed };
131
+ }
132
+ catch (e) {
133
+ const err = e;
134
+ debug(`toolkit bearer helper failed: ${err.message}`);
135
+ const said = err.stderr?.trim().split(/\r?\n/).filter(Boolean).pop();
136
+ return { error: said || err.message.split(/\r?\n/)[0] || 'the toolkit helper failed' };
137
+ }
138
+ };
139
+ /**
140
+ * Ask the toolkit for a bearer. `null` means the toolkit could not be used at
141
+ * all — distinct from a `{ ok: false }` result, which is the toolkit answering
142
+ * that it holds no credential.
143
+ */
144
+ export const readBearer = async () => {
145
+ const run = await runHelper();
146
+ return run && 'result' in run ? run.result : null;
147
+ };
148
+ /** Run `pipefy auth login`, inheriting the terminal so the browser flow works. */
149
+ export const runToolkitLogin = async (extraArgs = []) => {
150
+ const cli = toolkitBinary('pipefy');
151
+ const { spawn } = await import('node:child_process');
152
+ const command = cli ?? 'pipefy';
153
+ return new Promise((resolve) => {
154
+ const child = spawn(command, ['auth', 'login', ...extraArgs], { stdio: 'inherit' });
155
+ child.on('error', () => resolve(127));
156
+ child.on('exit', (code) => resolve(code ?? 1));
157
+ });
158
+ };
159
+ /** `pipefy auth status --json`, for `pipe doctor`. Null when unavailable. */
160
+ export const toolkitAuthStatus = async () => {
161
+ const cli = toolkitBinary('pipefy');
162
+ if (!cli)
163
+ return null;
164
+ try {
165
+ const { stdout } = await exec(cli, ['auth', 'status', '--json'], { timeout: 60_000 });
166
+ return JSON.parse(stdout.trim());
167
+ }
168
+ catch (e) {
169
+ debug(`pipefy auth status failed: ${e.message}`);
170
+ return null;
171
+ }
172
+ };
173
+ export const UPGRADE_HINT = 'reinstall the toolkit: `uv tool install --force --reinstall pipefy-cli` (any pipefy-auth from 0.3.0a1 to 0.5.4b1 on PyPI works)';
174
+ export const INSTALL_HINT = 'install the Pipefy AI toolkit (github.com/pipefy/ai-toolkit): `uv tool install pipefy-cli`, then `pipefy auth login`';
@@ -31,22 +31,63 @@ def _fail(reason: str, message: str) -> int:
31
31
 
32
32
 
33
33
  def main() -> int:
34
+ # Import the module, then look the names up one by one, so an incompatible
35
+ # install says which names it lacks and which version it is — rather than
36
+ # one ImportError that left `pipe doctor` reporting the toolkit as "not
37
+ # installed" on a machine where it plainly was. Every pipefy-auth release on
38
+ # PyPI (0.3.0a1 through 0.5.4b1) exports all of these; a field report of
39
+ # one that lacked ServiceAccountAuth came from an install none of them
40
+ # explain, so this names what it found instead of guessing why.
34
41
  try:
35
- from pipefy_auth import ( # noqa: PLC0415
36
- AuthSettings,
37
- OidcClient,
38
- ServiceAccountAuth,
39
- StaticTokenAuth,
40
- StoredSessionAuth,
41
- configure_keychain_backend,
42
- ensure_fresh_session,
43
- resolve_pipefy_auth,
44
- )
45
- from pipefy_auth.resolver import ServiceAccount # noqa: PLC0415
42
+ import pipefy_auth # noqa: PLC0415
46
43
  except Exception as exc: # pragma: no cover - environment problem
47
44
  print(f"pipefy_auth is not importable: {exc}", file=sys.stderr)
48
45
  return 3
49
46
 
47
+ version = getattr(pipefy_auth, "__version__", None)
48
+ if version is None:
49
+ try:
50
+ from importlib.metadata import version as dist_version # noqa: PLC0415
51
+
52
+ version = dist_version("pipefy-auth")
53
+ except Exception:
54
+ version = "unknown"
55
+ where = getattr(pipefy_auth, "__file__", "?")
56
+
57
+ # Required for every credential this helper can return.
58
+ required = (
59
+ "AuthSettings",
60
+ "OidcClient",
61
+ "StaticTokenAuth",
62
+ "StoredSessionAuth",
63
+ "ensure_fresh_session",
64
+ "resolve_pipefy_auth",
65
+ )
66
+ missing = [n for n in required if not hasattr(pipefy_auth, n)]
67
+ if missing:
68
+ print(
69
+ f"pipefy_auth {version} at {where} is incompatible: it lacks {', '.join(missing)}",
70
+ file=sys.stderr,
71
+ )
72
+ return 3
73
+
74
+ AuthSettings = pipefy_auth.AuthSettings
75
+ OidcClient = pipefy_auth.OidcClient
76
+ StaticTokenAuth = pipefy_auth.StaticTokenAuth
77
+ StoredSessionAuth = pipefy_auth.StoredSessionAuth
78
+ ensure_fresh_session = pipefy_auth.ensure_fresh_session
79
+ resolve_pipefy_auth = pipefy_auth.resolve_pipefy_auth
80
+ # Only a service-account credential needs these, so their absence costs
81
+ # that one method, not the static token or the browser session.
82
+ ServiceAccountAuth = getattr(pipefy_auth, "ServiceAccountAuth", None)
83
+ ServiceAccount = getattr(pipefy_auth, "ServiceAccount", None)
84
+ if ServiceAccount is None:
85
+ try:
86
+ from pipefy_auth.resolver import ServiceAccount # noqa: PLC0415
87
+ except Exception:
88
+ ServiceAccount = None
89
+ configure_keychain_backend = getattr(pipefy_auth, "configure_keychain_backend", None)
90
+
50
91
  settings = AuthSettings()
51
92
 
52
93
  # Select the keyring backend before any session read. On Windows the default
@@ -54,10 +95,15 @@ def main() -> int:
54
95
  # ~2.5 KB), so machines set `keychain_backend = "file"` — and a reader that
55
96
  # skips this looks in the empty store and reports "not signed in" while
56
97
  # `pipefy auth status` says otherwise.
57
- configure_keychain_backend(settings.keychain_backend)
98
+ if configure_keychain_backend is not None:
99
+ configure_keychain_backend(settings.keychain_backend)
58
100
 
59
101
  service_account = None
60
- if settings.service_account_client_id and settings.service_account_client_secret:
102
+ if (
103
+ ServiceAccount is not None
104
+ and settings.service_account_client_id
105
+ and settings.service_account_client_secret
106
+ ):
61
107
  service_account = ServiceAccount(
62
108
  token_url=settings.service_account_url,
63
109
  client_id=settings.service_account_client_id,
@@ -107,7 +153,7 @@ def main() -> int:
107
153
  )
108
154
  return 0
109
155
 
110
- if isinstance(resolved, ServiceAccountAuth):
156
+ if ServiceAccountAuth is not None and isinstance(resolved, ServiceAccountAuth):
111
157
  # The client-credentials exchange happens here so the secret never
112
158
  # crosses the process boundary — only the short-lived bearer does.
113
159
  import httpx # noqa: PLC0415