@pipefy/pipefy-process-coder 0.1.1 → 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.
package/AGENTS.md CHANGED
@@ -250,6 +250,8 @@ live running. Do not re-derive them. The shapes that bite most often:
250
250
  | a field condition testing the field it hides | stored happily, and the **phase form stops opening**. The API validates none of this — not the self-reference, not the operation, not even `not_a_real_operation` |
251
251
  | `automationVariables.active` | omitted for the life of the tool, so disabling an automation was a no-op that reported success |
252
252
  | `updateAutomation` | works, across every field map dimension. The recipes' delete-and-recreate was inherited, not measured |
253
+ | internal `AutomationActionParams.aiBehaviorParams` | an **object** (`AiBehaviorParams`), the same type the public `aiAgents` read selects. Selected bare, the whole `loadAutomation` document fails validation — so *every* automation in the pipe loads without `action_params`, not only the AI ones. One selection, `AI_BEHAVIOR_PARAMS_SELECTION`, serves both reads |
254
+ | a snapshot's `signedUrl` | `app.pipefy.com/storage/v1/signed/…`, and it can answer **HTTP 500** with Pipefy's own error page while `createRepoSnapshot` and the `UPLOADED` status both succeed — measured on two unrelated pipes, old snapshots and new. A pull falls back to reconstruction rather than write a workspace with no pipe in it; `--no-fallback` refuses instead |
253
255
  | iPaaS `IMPORT_FLOW` | migrates the tree it is given; never use it |
254
256
  | iPaaS connections | the host marks any credential `ACTIVE` **without validating it** |
255
257
  | `POST /app-connections/{id}` | accepts `scope` and `projectIds` and returns 200 ignoring both |
@@ -308,11 +310,20 @@ way `pipe pull` would notice.
308
310
 
309
311
  All three automation mutations are **public** and were being sent to the internal
310
312
  endpoint — inherited from the Change Migrator recipes and never re-checked, which
311
- made automations the one entity written through an unversioned endpoint with no
312
- introspection, for no reason. Before adding anything to `registry.ts` with
313
+ made automations the one entity written through an unversioned endpoint, for
314
+ no reason. Before adding anything to `registry.ts` with
313
315
  `transport: 'internal'`, introspect the public schema for it. If it genuinely is
314
316
  not there, say which public mutation is missing and how that was verified.
315
317
 
318
+ The internal endpoint is unversioned, but it **does** answer introspection
319
+ (`__type(name:)` worked on it live) — this file used to say it did not. So a
320
+ query sent there can be checked against its schema like any other. Every read
321
+ query the internal adapter sends lives in `READ_QUERIES` (internal.ts), and
322
+ `pipe doctor` validates each one with ids that match nothing: a document the
323
+ schema refuses comes back with `errors` and no `data`, a valid one with `data`
324
+ and a not-found. That is how the bare-`aiBehaviorParams` drift would have shown
325
+ up before a user's pull did. Add a new internal read there, never a write.
326
+
316
327
  ### Probing is not free on this platform
317
328
 
318
329
  Two separate incidents from the same wrong assumption — that schema validation
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,11 @@
1
+ Pipefy Process Coder
2
+ Copyright 2026 Pipefy, Inc.
3
+
4
+ This product includes software developed at Pipefy (https://www.pipefy.com).
5
+
6
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not
7
+ use the files in this repository except in compliance with the License.
8
+
9
+ The "Pipefy" name, logo, and brand assets are trademarks of Pipefy, Inc.
10
+ and are NOT licensed under the Apache License 2.0. Use of the Pipefy
11
+ trademarks is subject to Pipefy's brand guidelines and prior authorization.
package/README.md CHANGED
@@ -697,7 +697,7 @@ research/snap.json the pipe <pipeId> fixture every test runs against
697
697
  | `pipe doctor` | capabilities, missing mutations, internal-API status |
698
698
  | `pipe skills` | the agent skills this install ships with; `install` hands off to `npx skills add` |
699
699
  | `pipe create <name>` | create an empty pipe and pull it, ready to be built from files |
700
- | `pipe pull <id>` | pull into a workspace; `--depth`, `--reuse-snapshot`, `--from-file` |
700
+ | `pipe pull <id>` | pull into a workspace; `--depth`, `--reuse-snapshot`, `--no-fallback`, `--from-file` |
701
701
  | `pipe export <id>` | one repo as a single payload, no workspace, no token needed to consume |
702
702
  | `pipe validate` | offline shape + integrity |
703
703
  | `pipe diff` | baseline, live, or cross-repo; `--html`, `--open`, `--json` |
@@ -823,4 +823,8 @@ A successful pipe apply rewrites your files. It adopts each created entity's rea
823
823
  [] - Check the baseline is not stale before planning: compare the pulled version id against the live one, and refuse to propose deletions when the pipe has moved since the pull. A stale baseline turns someone else's changes into a diff — seen live, where a verification diff listed five changes made elsewhere as deletions the workspace wanted
824
824
  [] - pipe pull inside a workspace pulls the pipes automatically
825
825
  [] - One design point worth stating
826
- Each repo still applies independently — own plan file, own pre-apply snapshot, own verify. That's deliberate: they're separate repos with separate baselines, and a failure in one must not roll back or abandon another that already succeeded. So it's a loop over the real thing, not a merged mega-plan. Exit code is the worst of them. --plan and --resume stay single-repo, since a saved plan belongs to exactly one.
826
+ Each repo still applies independently — own plan file, own pre-apply snapshot, own verify. That's deliberate: they're separate repos with separate baselines, and a failure in one must not roll back or abandon another that already succeeded. So it's a loop over the real thing, not a merged mega-plan. Exit code is the worst of them. --plan and --resume stay single-repo, since a saved plan belongs to exactly one.
827
+
828
+ ## License
829
+
830
+ Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE). Copyright 2026 Pipefy, Inc. The Pipefy name, logo and brand assets are trademarks of Pipefy, Inc. and are not covered by the license.
package/dist/cli.js CHANGED
@@ -129,12 +129,9 @@ const main = async () => {
129
129
  * question behind the version check.
130
130
  */
131
131
  if (parsed.command === '--version' || parsed.command === '-V' || parsed.command === 'version') {
132
- const { readFile } = await import('node:fs/promises');
133
132
  const { fileURLToPath } = await import('node:url');
134
- const { dirname, join } = await import('node:path');
135
- const here = dirname(fileURLToPath(import.meta.url));
136
- const pkg = JSON.parse(await readFile(join(here, '..', 'package.json'), 'utf8'));
137
- out(`${pkg.name} ${pkg.version}`);
133
+ const { PACKAGE_NAME, TOOL_VERSION } = await import("./version.js");
134
+ out(`${PACKAGE_NAME} ${TOOL_VERSION}`);
138
135
  out(dim(` node ${process.version} (needs >=22.18 for native type stripping)`));
139
136
  out(dim(` running ${fileURLToPath(import.meta.url)}`));
140
137
  return 0;
@@ -164,6 +161,18 @@ const main = async () => {
164
161
  const code = await cmd.run(parsed);
165
162
  return typeof code === 'number' ? code : 0;
166
163
  };
164
+ /**
165
+ * A reader that stops early — `pipe doctor | head`, a pager quit — closes the
166
+ * pipe, and the next write raised an unhandled EPIPE with a Node stack trace.
167
+ * The reader has what it wanted; there is no one left to tell.
168
+ */
169
+ for (const stream of [process.stdout, process.stderr]) {
170
+ stream.on('error', (e) => {
171
+ if (e.code === 'EPIPE')
172
+ process.exit(process.exitCode ?? 0);
173
+ throw e;
174
+ });
175
+ }
167
176
  main()
168
177
  .then((code) => {
169
178
  process.exitCode = code;
@@ -36,11 +36,14 @@ export const login = async (p) => {
36
36
  const token = str(p, 'token');
37
37
  // ── the intended path: delegate to the toolkit's OAuth flow ───────────────
38
38
  if (!token) {
39
- const { inspectToolkit, runToolkitLogin, INSTALL_HINT } = await import("../pipefy/toolkit.js");
39
+ const { inspectToolkit, runToolkitLogin, INSTALL_HINT, UPGRADE_HINT } = await import("../pipefy/toolkit.js");
40
40
  const toolkit = await inspectToolkit();
41
41
  if (!toolkit.installed) {
42
42
  throw new UserError('the Pipefy AI toolkit is not installed, so there is no browser login to run', `${INSTALL_HINT} — or use \`pipe login --token <token>\` for a personal API token`);
43
43
  }
44
+ if (!toolkit.usable) {
45
+ throw new UserError(`the Pipefy AI toolkit is installed but this tool cannot use it: ${toolkit.error}`, `${UPGRADE_HINT} — or use \`pipe login --token <token>\` for a personal API token`);
46
+ }
44
47
  step('running `pipefy auth login` (browser sign-in)');
45
48
  const code = await runToolkitLogin(bool(p, 'device') ? ['--device'] : []);
46
49
  if (code !== 0) {
@@ -91,7 +94,7 @@ export const doctorHelp = `pipe doctor [--repo <pipeId>]
91
94
  With --repo it also probes that pipe's read paths.`;
92
95
  export const doctor = async (p) => {
93
96
  const { resolveCredential } = await import("../config.js");
94
- const { inspectToolkit, toolkitAuthStatus, INSTALL_HINT } = await import("../pipefy/toolkit.js");
97
+ const { inspectToolkit, toolkitAuthStatus, INSTALL_HINT, UPGRADE_HINT } = await import("../pipefy/toolkit.js");
95
98
  out(bold('endpoints'));
96
99
  out(` graphql ${ENDPOINTS.graphql}`);
97
100
  out(` internal ${ENDPOINTS.internal}`);
@@ -99,9 +102,18 @@ export const doctor = async (p) => {
99
102
  out(bold('credential'));
100
103
  const toolkit = await inspectToolkit();
101
104
  const credential = await resolveCredential();
102
- out(` toolkit ${toolkit.installed ? green('installed') : red('not installed')}` +
103
- (toolkit.installed ? dim(` ${toolkit.cliPath ?? toolkit.interpreter}`) : dim(` ${INSTALL_HINT}`)));
104
- if (toolkit.installed) {
105
+ if (!toolkit.installed) {
106
+ out(` toolkit ${red('not installed')}${dim(` ${INSTALL_HINT}`)}`);
107
+ }
108
+ else if (!toolkit.usable) {
109
+ out(` toolkit ${yellow('installed, unusable')}${dim(` ${toolkit.cliPath ?? toolkit.interpreter}`)}`);
110
+ out(` ${dim(toolkit.error)}`);
111
+ out(` ${dim(UPGRADE_HINT)}`);
112
+ }
113
+ else {
114
+ out(` toolkit ${green('installed')}${dim(` ${toolkit.cliPath ?? toolkit.interpreter}`)}`);
115
+ }
116
+ if (toolkit.installed && toolkit.usable) {
105
117
  const status = await toolkitAuthStatus();
106
118
  if (status) {
107
119
  const identity = status['identity'];
@@ -118,7 +130,11 @@ export const doctor = async (p) => {
118
130
  dim(credential?.detail ? ` ${credential.detail}` : ''));
119
131
  if (!credential) {
120
132
  info('');
121
- throw new UserError('no credential', toolkit.installed ? 'run `pipe login` for the browser flow' : `${INSTALL_HINT}, or \`pipe login --token <token>\``);
133
+ throw new UserError('no credential', !toolkit.installed
134
+ ? `${INSTALL_HINT}, or \`pipe login --token <token>\``
135
+ : !toolkit.usable
136
+ ? `${UPGRADE_HINT}, or \`pipe login --token <token>\``
137
+ : 'run `pipe login` for the browser flow');
122
138
  }
123
139
  const net = await connect(p);
124
140
  const caps = await net.capabilities();
@@ -158,7 +174,25 @@ export const doctor = async (p) => {
158
174
  }
159
175
  out('');
160
176
  out(bold('internal API'));
161
- out(` status ${net.internal.status === 'ok' ? green('ok') : net.internal.status === 'unavailable' ? red('unavailable') : dim('unknown')}`);
177
+ // Validates the query documents themselves, with ids that match nothing —
178
+ // "status unknown" used to be all this said, while every automation read in
179
+ // a pull was failing on a query the schema had stopped accepting.
180
+ const checks = await net.internal.checkQueries();
181
+ if (!checks)
182
+ out(` queries ${red('no answer')}${dim(' the endpoint could not be reached')}`);
183
+ else {
184
+ for (const c of checks) {
185
+ out(` ${c.ok ? green('ok ') : red('FAIL')} ${c.label.padEnd(18)}${c.ok ? '' : ` ${dim(c.message ?? '')}`}`);
186
+ }
187
+ }
188
+ const status = net.internal.status;
189
+ out(` status ${status === 'ok'
190
+ ? green('ok')
191
+ : status === 'incompatible'
192
+ ? red('incompatible') + dim(' a query this version of pipe sends is refused — a bug in pipe, worth reporting')
193
+ : status === 'unavailable'
194
+ ? red('unavailable')
195
+ : dim('unknown')}`);
162
196
  info(dim(' Unversioned, no compatibility contract (PLAN.md §2.1). When it breaks, automation\n' +
163
197
  ' detail and phase-jump editing degrade to unknown rather than failing an apply.'));
164
198
  if (!caps.snapshots) {
@@ -19,6 +19,7 @@ import { baselineFile, paths } from "../workspace/layout.js";
19
19
  import { connect } from "./context.js";
20
20
  import { RunRecorder, invocation } from "../report/run.js";
21
21
  import { pullAndWriteFlows } from "./flows.js";
22
+ import { TOOL } from "../version.js";
22
23
  /**
23
24
  * `pipe pull <pipeId>` — the read path.
24
25
  *
@@ -39,6 +40,7 @@ export const help = `pipe pull <pipeId> [options]
39
40
  --org <id> organization id, for the internal automations query
40
41
  --reuse-snapshot use the newest complete snapshot instead of creating one
41
42
  --no-snapshot force GraphQL reconstruction even if snapshots exist
43
+ --no-fallback if a snapshot read fails, fail rather than reconstruct over GraphQL
42
44
  --no-internal skip the internal API (automations and jumps become unknown)
43
45
  --label <text> label the snapshot this pull creates
44
46
  --skip-roundtrip do not run the codec self-check (not recommended)
@@ -57,6 +59,7 @@ export const run = async (p) => {
57
59
  const noInternal = bool(p, 'no-internal');
58
60
  const reuseSnapshot = bool(p, 'reuse-snapshot');
59
61
  const skipRoundTrip = bool(p, 'skip-roundtrip');
62
+ const noFallback = bool(p, 'no-fallback');
60
63
  const label = str(p, 'label');
61
64
  const startedAt = new Date();
62
65
  const net = await connect(p);
@@ -114,7 +117,28 @@ export const run = async (p) => {
114
117
  * relation arrays a pipe's does (VALIDATION.md §3g).
115
118
  */
116
119
  if (useSnapshots) {
117
- env = await readViaSnapshot(net, ref, { reuseSnapshot, label });
120
+ try {
121
+ env = await readViaSnapshot(net, ref, { reuseSnapshot, label });
122
+ }
123
+ catch (e) {
124
+ /**
125
+ * A failed snapshot read falls back to reconstruction rather than
126
+ * leaving the repo out. Found live: Pipefy's storage answered every
127
+ * signed snapshot URL with HTTP 500 — the creation succeeded, the
128
+ * download did not, on every pipe tried — and the pull wrote a
129
+ * workspace with no pipe in it. Reconstruction is less complete, and
130
+ * says so per group: what it cannot see is `unknown`, never empty, so
131
+ * the fallback cannot license a delete.
132
+ */
133
+ if (noFallback)
134
+ throw e;
135
+ warn(`snapshot read failed for ${ref.kind} ${ref.id} (${ref.name ?? '?'}): ${e.message} — ` +
136
+ 'reading it over GraphQL instead (--no-fallback to stop here)');
137
+ env = await net.reconstructor.reconstruct(ref.id, {
138
+ organizationId: net.organizationId,
139
+ noInternal,
140
+ });
141
+ }
118
142
  }
119
143
  else {
120
144
  env = await net.reconstructor.reconstruct(ref.id, {
@@ -164,6 +188,20 @@ export const run = async (p) => {
164
188
  });
165
189
  const rootRef = discovery.repos.find((r) => r.id === rootRepoId);
166
190
  const outDir = resolve(str(p, 'out') ?? `${slugName(rootRef.name ?? String(rootRepoId))}-workspace`);
191
+ /**
192
+ * The root repo is the workspace. Without it there is nothing to validate,
193
+ * diff or plan against — and writing lock.json and workspace.json anyway
194
+ * did worse than produce an empty directory: re-pulling into an existing
195
+ * workspace replaced its lock with one that no longer listed the pipe, so
196
+ * the next diff had no baseline. This used to finish with exit code 0 and a
197
+ * "next: pipe validate", which scripts and agents believed.
198
+ */
199
+ if (!(await readRepo(rootRef))) {
200
+ ui.stop();
201
+ throw new UserError(`could not read the root repo ${rootRepoId}${rootRef.name ? ` (${rootRef.name})` : ''} — nothing was written to ${outDir}`, useSnapshots && noFallback
202
+ ? 'drop --no-fallback to read it over GraphQL, or run with --verbose for the cause'
203
+ : 'run with --verbose for the cause');
204
+ }
167
205
  await mkdirp(outDir);
168
206
  const lock = emptyLock(rootRepoId, net.organizationId, depth);
169
207
  lock.edges = discovery.edges;
@@ -284,7 +322,7 @@ export const run = async (p) => {
284
322
  organizationId: net.organizationId,
285
323
  depth,
286
324
  lastPullSource: sources.size > 1 ? 'mixed' : (sources.values().next().value ?? 'reconstructed'),
287
- tool: { name: 'pipefy-process-coder', version: '0.1.0' },
325
+ tool: { ...TOOL },
288
326
  });
289
327
  /**
290
328
  * Read the closed sets while the client is open. Two queries, and it is what
@@ -492,7 +530,7 @@ const pullFromFile = async (p, fromFile) => {
492
530
  organizationId: str(p, 'org') ?? null,
493
531
  depth: 0,
494
532
  lastPullSource: env.source,
495
- tool: { name: 'pipefy-process-coder', version: '0.1.0' },
533
+ tool: { ...TOOL },
496
534
  });
497
535
  await writeDocs(outDir, lock, [tree], null, await loadReference(outDir));
498
536
  ok(`${rootRef.kind} ${repoIdNum} ${green(rootRef.name ?? '')} — ${tree.phases.length} phases, ` +
@@ -1,4 +1,39 @@
1
1
  import { debug } from "../util/log.js";
2
+ /**
3
+ * The selection under `aiBehaviorParams`, aliased to the snake_case the files
4
+ * use. Shared with `internal.ts`'s `loadAutomation`, because `AiBehaviorParams`
5
+ * is the same object type on both endpoints — and the internal query once
6
+ * selected it bare, as though it were a scalar. GraphQL rejects that before
7
+ * resolving anything, so every automation in the pipe failed to load, not only
8
+ * the AI ones. One selection, two call sites, so the two cannot drift apart.
9
+ */
10
+ export const AI_BEHAVIOR_PARAMS_SELECTION = `{
11
+ data_source_ids: dataSourceIds
12
+ instruction
13
+ referenced_field_ids: referencedFieldIds
14
+ provider_id: providerId
15
+ system_provider_id: systemProviderId
16
+ actions_attributes: actionsAttributes {
17
+ id
18
+ name
19
+ action_type: actionType
20
+ reference_id: referenceId
21
+ metadata {
22
+ destination_phase_id: destinationPhaseId
23
+ pipe_id: pipeId
24
+ table_id: tableId
25
+ email_template_id: emailTemplateId
26
+ allow_template_modifications: allowTemplateModifications
27
+ mcp_server_id: mcpServerId
28
+ tool_name: toolName
29
+ title
30
+ emails
31
+ tool_inputs: toolInputs { name source value field_id: fieldId }
32
+ fields_attributes: fieldsAttributes { field_id: fieldId input_mode: inputMode value }
33
+ }
34
+ }
35
+ capabilities_attributes: capabilitiesAttributes { capability_type: capabilityType enabled }
36
+ }`;
2
37
  const QUERY = `
3
38
  query($repoUuid: ID!, $after: String) {
4
39
  aiAgents(repoUuid: $repoUuid, first: 50, after: $after) {
@@ -34,33 +69,7 @@ const QUERY = `
34
69
  expressions { id field_address operation value structure_id }
35
70
  }
36
71
  action_params {
37
- ai_behavior_params: aiBehaviorParams {
38
- data_source_ids: dataSourceIds
39
- instruction
40
- referenced_field_ids: referencedFieldIds
41
- provider_id: providerId
42
- system_provider_id: systemProviderId
43
- actions_attributes: actionsAttributes {
44
- id
45
- name
46
- action_type: actionType
47
- reference_id: referenceId
48
- metadata {
49
- destination_phase_id: destinationPhaseId
50
- pipe_id: pipeId
51
- table_id: tableId
52
- email_template_id: emailTemplateId
53
- allow_template_modifications: allowTemplateModifications
54
- mcp_server_id: mcpServerId
55
- tool_name: toolName
56
- title
57
- emails
58
- tool_inputs: toolInputs { name source value field_id: fieldId }
59
- fields_attributes: fieldsAttributes { field_id: fieldId input_mode: inputMode value }
60
- }
61
- }
62
- capabilities_attributes: capabilitiesAttributes { capability_type: capabilityType enabled }
63
- }
72
+ ai_behavior_params: aiBehaviorParams ${AI_BEHAVIOR_PARAMS_SELECTION}
64
73
  }
65
74
  }
66
75
  }
@@ -1,6 +1,120 @@
1
1
  import { ENDPOINTS } from "../config.js";
2
2
  import { debug, warn } from "../util/log.js";
3
3
  import { retry } from "../util/pool.js";
4
+ import { AI_BEHAVIOR_PARAMS_SELECTION } from "./agents.js";
5
+ /**
6
+ * The internal-API adapter. PLAN.md §2.1 flags this as an unversioned endpoint
7
+ * with no compatibility contract, so it is isolated here, behind one module,
8
+ * and every entry point degrades to `null`/`unsupported` rather than throwing.
9
+ *
10
+ * Two things live only here — both taken from the Change Migrator recipes,
11
+ * which have been running them in production:
12
+ *
13
+ * 1. Automations. Public `getAutomations` returns a summary; the recipes use
14
+ * `loadAutomationToEdit` to get `action_params.field_map`, `searchFor`,
15
+ * `responseSchema`, `schedulerCron` and `aiParams` — everything a
16
+ * round trip needs and the summary omits.
17
+ * 2. Phase jumps. `GET/PUT /internal_api/settings/phases/:id`, set-replacing.
18
+ *
19
+ * Both accept the same personal API token as the public endpoint, so there is
20
+ * no session-cookie or CSRF subsystem here.
21
+ */
22
+ /** Sent by `listAutomations`; module-level so `checkQueries` validates the same text. */
23
+ const LIST_AUTOMATIONS_QUERY = `
24
+ query getAutomations($orgId: ID!, $repoId: ID) {
25
+ automations(organizationId: $orgId, repoId: $repoId) {
26
+ id
27
+ name
28
+ action_id
29
+ event_id
30
+ created_at
31
+ active
32
+ action_repo: action_repo_v2 {
33
+ __typename
34
+ ... on Pipe { id }
35
+ ... on Table { id }
36
+ }
37
+ event_repo { __typename id }
38
+ }
39
+ }`;
40
+ /** Sent by `loadAutomation`; module-level so `checkQueries` validates the same text. */
41
+ const LOAD_AUTOMATION_QUERY = `
42
+ query loadAutomationToEdit($id: ID!) {
43
+ automation(id: $id) {
44
+ name
45
+ event_id
46
+ active
47
+ event_repo { id }
48
+ event_params {
49
+ toPhaseId: to_phase_id
50
+ fromPhaseId
51
+ inPhaseId
52
+ triggerFieldIds
53
+ kindOfSla
54
+ triggerAutomationId
55
+ }
56
+ action_id
57
+ action_repo: action_repo_v2 {
58
+ ... on Pipe { id uuid }
59
+ ... on Table { id uuid }
60
+ }
61
+ scheduler_frequency
62
+ schedulerCron { minute hour dayOfWeek dayOfMonth month }
63
+ searchFor { id field operation value }
64
+ responseSchema
65
+ action_params {
66
+ toPhaseId: to_phase_id
67
+ fieldsMapOrder: fields_map_order
68
+ email_template_id
69
+ fieldMap: field_map { fieldId value inputMode }
70
+ cardId: card_id
71
+ headers
72
+ body
73
+ url
74
+ httpMethod
75
+ authenticationKey
76
+ hasAuthenticationValue
77
+ authenticationAddTo
78
+ authenticationType
79
+ strategy
80
+ # taskParams is not in the recipe's query, and the omission is not
81
+ # cosmetic: every send_a_task automation came back with an empty
82
+ # action_params, so a reconstructed read lost its recipients and
83
+ # title, and a recreate would have rebuilt it blank. Introspected
84
+ # from AutomationActionParams on the internal endpoint.
85
+ taskParams { recipients title }
86
+ aiParams { fieldIds value skillsIds }
87
+ aiBehaviorParams ${AI_BEHAVIOR_PARAMS_SELECTION}
88
+ slaParams {
89
+ monday { startHour endHour enabled }
90
+ tuesday { startHour endHour enabled }
91
+ wednesday { startHour endHour enabled }
92
+ thursday { startHour endHour enabled }
93
+ friday { startHour endHour enabled }
94
+ saturday { startHour endHour enabled }
95
+ sunday { startHour endHour enabled }
96
+ timezone
97
+ holidays { date recurrence description }
98
+ }
99
+ }
100
+ condition {
101
+ expressions_structure
102
+ expressions { id field_address operation value structure_id }
103
+ }
104
+ }
105
+ }`;
106
+ /**
107
+ * Every read query document this adapter sends over GraphQL, with variables
108
+ * that match nothing. GraphQL validates the document before resolving any of
109
+ * it, so a query that has drifted from the schema is refused the same way
110
+ * whichever id it names — and a valid one answers `data` with a not-found
111
+ * error. No write is in this list, and none may be added: probing is not free
112
+ * on this platform (AGENTS.md §4).
113
+ */
114
+ const READ_QUERIES = [
115
+ { label: 'listAutomations', query: LIST_AUTOMATIONS_QUERY, variables: { orgId: '0', repoId: '0' } },
116
+ { label: 'loadAutomation', query: LOAD_AUTOMATION_QUERY, variables: { id: '0' } },
117
+ ];
4
118
  export class InternalApi {
5
119
  tokenProvider;
6
120
  status = 'unknown';
@@ -14,13 +128,66 @@ export class InternalApi {
14
128
  return this.tokenProvider();
15
129
  }
16
130
  markUnavailable(reason) {
17
- this.status = 'unavailable';
131
+ if (this.status !== 'incompatible')
132
+ this.status = 'unavailable';
18
133
  if (!this.warned) {
19
134
  this.warned = true;
20
135
  warn(`internal API unavailable (${reason}). Automation details and phase jumps ` +
21
136
  `will be reported as unknown rather than empty — see PLAN.md §2.1.`);
22
137
  }
23
138
  }
139
+ /** One answered call does not clear a refused query: that stays visible. */
140
+ markOk() {
141
+ if (this.status !== 'incompatible')
142
+ this.status = 'ok';
143
+ }
144
+ markIncompatible(label, reason) {
145
+ this.status = 'incompatible';
146
+ if (!this.warned) {
147
+ this.warned = true;
148
+ warn(`internal API rejected this tool's ${label} query as invalid (${reason}). ` +
149
+ `That is a bug in pipe, not an outage — the endpoint changed under it. Automation ` +
150
+ `details will be reported as unknown rather than empty; \`pipe doctor\` checks every internal query.`);
151
+ }
152
+ }
153
+ /**
154
+ * Validate every read query against the live schema without reading anything
155
+ * real. Null when the endpoint itself could not be reached, which says
156
+ * nothing about the queries.
157
+ */
158
+ async checkQueries() {
159
+ const results = [];
160
+ for (const { label, query, variables } of READ_QUERIES) {
161
+ let body;
162
+ try {
163
+ this.requestCount++;
164
+ const res = await fetch(ENDPOINTS.internal, {
165
+ method: 'POST',
166
+ headers: {
167
+ authorization: `Bearer ${await this.bearer()}`,
168
+ 'content-type': 'application/json',
169
+ accept: 'application/json',
170
+ },
171
+ body: JSON.stringify({ query, variables }),
172
+ });
173
+ if (!res.ok) {
174
+ debug(`internal query check ${label}: HTTP ${res.status}`);
175
+ return null;
176
+ }
177
+ body = JSON.parse(await res.text());
178
+ }
179
+ catch (e) {
180
+ debug(`internal query check ${label}: ${e.message}`);
181
+ return null;
182
+ }
183
+ if ('data' in body)
184
+ results.push({ label, ok: true });
185
+ else
186
+ results.push({ label, ok: false, message: (body.errors ?? []).map((e) => e.message).join('; ') || 'no data' });
187
+ }
188
+ this.status = results.every((r) => r.ok) ? 'ok' : 'incompatible';
189
+ return results;
190
+ }
24
191
  /** GraphQL over the internal endpoint. Returns null when the endpoint fails. */
25
192
  async gql(query, variables, label) {
26
193
  try {
@@ -45,41 +212,30 @@ export class InternalApi {
45
212
  if (body.errors?.length) {
46
213
  const err = new Error(body.errors.map((e) => e.message).join('; '));
47
214
  err.retryable = false;
215
+ // No `data` key at all is a document the schema refused, not a
216
+ // resolver failing on this one id — see InternalStatus.
217
+ err.invalidQuery = !('data' in body);
48
218
  throw err;
49
219
  }
50
220
  return body.data ?? null;
51
221
  }, { label });
52
222
  if (data)
53
- this.status = 'ok';
223
+ this.markOk();
54
224
  return data;
55
225
  }
56
226
  catch (e) {
57
227
  debug(`internal gql ${label} failed: ${e.message}`);
58
- this.markUnavailable(`${label}: ${e.message}`);
228
+ if (e.invalidQuery)
229
+ this.markIncompatible(label, e.message);
230
+ else
231
+ this.markUnavailable(`${label}: ${e.message}`);
59
232
  return null;
60
233
  }
61
234
  }
62
235
  // ── Automations ────────────────────────────────────────────────────────────
63
236
  /** Recipe `Pipe Data Treatment` step 156. */
64
237
  async listAutomations(orgId, repoId) {
65
- const q = `
66
- query getAutomations($orgId: ID!, $repoId: ID) {
67
- automations(organizationId: $orgId, repoId: $repoId) {
68
- id
69
- name
70
- action_id
71
- event_id
72
- created_at
73
- active
74
- action_repo: action_repo_v2 {
75
- __typename
76
- ... on Pipe { id }
77
- ... on Table { id }
78
- }
79
- event_repo { __typename id }
80
- }
81
- }`;
82
- const data = await this.gql(q, { orgId: String(orgId), repoId: String(repoId) }, 'listAutomations');
238
+ const data = await this.gql(LIST_AUTOMATIONS_QUERY, { orgId: String(orgId), repoId: String(repoId) }, 'listAutomations');
83
239
  return data?.automations ?? null;
84
240
  }
85
241
  /**
@@ -89,72 +245,7 @@ export class InternalApi {
89
245
  * cannot avoid the internal API for automations.
90
246
  */
91
247
  async loadAutomation(id) {
92
- const q = `
93
- query loadAutomationToEdit($id: ID!) {
94
- automation(id: $id) {
95
- name
96
- event_id
97
- active
98
- event_repo { id }
99
- event_params {
100
- toPhaseId: to_phase_id
101
- fromPhaseId
102
- inPhaseId
103
- triggerFieldIds
104
- kindOfSla
105
- triggerAutomationId
106
- }
107
- action_id
108
- action_repo: action_repo_v2 {
109
- ... on Pipe { id uuid }
110
- ... on Table { id uuid }
111
- }
112
- scheduler_frequency
113
- schedulerCron { minute hour dayOfWeek dayOfMonth month }
114
- searchFor { id field operation value }
115
- responseSchema
116
- action_params {
117
- toPhaseId: to_phase_id
118
- fieldsMapOrder: fields_map_order
119
- email_template_id
120
- fieldMap: field_map { fieldId value inputMode }
121
- cardId: card_id
122
- headers
123
- body
124
- url
125
- httpMethod
126
- authenticationKey
127
- hasAuthenticationValue
128
- authenticationAddTo
129
- authenticationType
130
- strategy
131
- # taskParams is not in the recipe's query, and the omission is not
132
- # cosmetic: every send_a_task automation came back with an empty
133
- # action_params, so a reconstructed read lost its recipients and
134
- # title, and a recreate would have rebuilt it blank. Introspected
135
- # from AutomationActionParams on the internal endpoint.
136
- taskParams { recipients title }
137
- aiParams { fieldIds value skillsIds }
138
- aiBehaviorParams
139
- slaParams {
140
- monday { startHour endHour enabled }
141
- tuesday { startHour endHour enabled }
142
- wednesday { startHour endHour enabled }
143
- thursday { startHour endHour enabled }
144
- friday { startHour endHour enabled }
145
- saturday { startHour endHour enabled }
146
- sunday { startHour endHour enabled }
147
- timezone
148
- holidays { date recurrence description }
149
- }
150
- }
151
- condition {
152
- expressions_structure
153
- expressions { id field_address operation value structure_id }
154
- }
155
- }
156
- }`;
157
- const data = await this.gql(q, { id: String(id) }, `loadAutomation ${id}`);
248
+ const data = await this.gql(LOAD_AUTOMATION_QUERY, { id: String(id) }, `loadAutomation ${id}`);
158
249
  return data?.automation ?? null;
159
250
  }
160
251
  /** Recipe `Apply Changes` step 121. `input` is the AutomationInput variable set. */
@@ -236,7 +327,7 @@ export class InternalApi {
236
327
  const d = res.data ?? {};
237
328
  const attrs = d.attributes ?? {};
238
329
  const targets = attrs.jump_targets ?? d.jump_targets ?? [...(d.next_phase_ids ?? []), ...(d.back_phase_ids ?? [])];
239
- this.status = 'ok';
330
+ this.markOk();
240
331
  return {
241
332
  jump_targets: [...new Set(targets.map(Number).filter((n) => Number.isFinite(n)))],
242
333
  phases: (attrs.phases ?? []).map(([id, name, done]) => ({ id: Number(id), name: String(name), done: Boolean(done) })),
@@ -302,7 +393,7 @@ export class InternalApi {
302
393
  const want = [...new Set(targetPhaseIds.map(Number))].sort((a, b) => a - b);
303
394
  const got = [...new Set(echoed)].sort((a, b) => a - b);
304
395
  const ok = want.length === got.length && want.every((v, i) => v === got[i]);
305
- this.status = 'ok';
396
+ this.markOk();
306
397
  return ok
307
398
  ? { ok, echoed }
308
399
  : { ok, echoed, error: `echo mismatch: wanted [${want.join(',')}], endpoint reports [${got.join(',')}]` };
@@ -626,6 +626,8 @@ const CAMEL_TO_SNAKE = {
626
626
  hasAuthenticationValue: 'has_authentication_value',
627
627
  authenticationAddTo: 'authentication_add_to',
628
628
  aiParams: 'ai_params',
629
+ // Its own subfields arrive already aliased to snake_case (AI_BEHAVIOR_PARAMS_SELECTION).
630
+ aiBehaviorParams: 'ai_behavior_params',
629
631
  slaParams: 'sla_params',
630
632
  taskParams: 'task_params',
631
633
  fieldIds: 'field_ids',
@@ -95,41 +95,56 @@ export const inspectToolkit = async () => {
95
95
  cachedInterpreter ??= await findInterpreter();
96
96
  if (!cachedInterpreter)
97
97
  return { installed: false };
98
- const credential = await readBearer();
99
- if (!credential)
98
+ const run = await runHelper();
99
+ if (!run)
100
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
+ }
101
104
  return {
102
105
  installed: true,
106
+ usable: true,
103
107
  cliPath: toolkitBinary('pipefy'),
104
108
  mcpServerPath: toolkitBinary('pipefy-mcp-server'),
105
109
  interpreter: cachedInterpreter.command,
106
- credential,
110
+ credential: run.result,
107
111
  };
108
112
  };
109
113
  /**
110
- * Ask the toolkit for a bearer. `null` means the toolkit could not be used at
111
- * all — distinct from a `{ ok: false }` result, which is the toolkit answering
112
- * that it holds no credential.
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).
113
117
  */
114
- export const readBearer = async () => {
118
+ const runHelper = async () => {
115
119
  cachedInterpreter ??= await findInterpreter();
116
120
  if (!cachedInterpreter)
117
121
  return null;
118
122
  if (!existsSync(HELPER)) {
119
123
  debug(`toolkit helper missing at ${HELPER}`);
120
- return null;
124
+ return { error: `this install of pipe is missing its toolkit helper (${HELPER})` };
121
125
  }
122
126
  try {
123
127
  const { stdout } = await exec(cachedInterpreter.command, [...cachedInterpreter.args, HELPER], { timeout: 60_000, maxBuffer: 1024 * 1024 });
124
128
  const parsed = JSON.parse(stdout.trim());
125
129
  debug(`toolkit credential: ${parsed.ok ? parsed.source : parsed.reason}`);
126
- return parsed;
130
+ return { result: parsed };
127
131
  }
128
132
  catch (e) {
129
- debug(`toolkit bearer helper failed: ${e.message}`);
130
- return null;
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' };
131
137
  }
132
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
+ };
133
148
  /** Run `pipefy auth login`, inheriting the terminal so the browser flow works. */
134
149
  export const runToolkitLogin = async (extraArgs = []) => {
135
150
  const cli = toolkitBinary('pipefy');
@@ -155,4 +170,5 @@ export const toolkitAuthStatus = async () => {
155
170
  return null;
156
171
  }
157
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)';
158
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
@@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs';
3
3
  import { mkdirp, writeFile, writeJson, exists } from "../util/fsx.js";
4
4
  import { paths } from "../workspace/layout.js";
5
5
  import { integrationsFrom, renderIntegrations } from "./integrations.js";
6
+ import { TOOL } from "../version.js";
6
7
  const stamp = (d = new Date()) => d.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
7
8
  /**
8
9
  * The command as typed, for reproduction — with any token elided. `--token` is
@@ -105,7 +106,7 @@ export class RunRecorder {
105
106
  id: this.id,
106
107
  kind: this.kind,
107
108
  command: this.ctx.command,
108
- tool: { name: 'pipefy-process-coder', version: '0.1.0' },
109
+ tool: { ...TOOL },
109
110
  startedAt: this.startedAt.toISOString(),
110
111
  finishedAt: finished.toISOString(),
111
112
  durationMs: finished.getTime() - this.startedAt.getTime(),
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * The tool's identity as recorded in workspace.json and every run.json.
6
+ *
7
+ * The version is read from package.json, the same file `pipe --version` reads,
8
+ * because it used to be a literal `'0.1.0'` in three places that a release did
9
+ * not touch — so 0.1.1 stamped 0.1.0 on everything it wrote, and the one field
10
+ * meant to say which build produced a workspace said the wrong one.
11
+ *
12
+ * `dist/` mirrors `src/` one-for-one, so `../package.json` from here is the
13
+ * package root from either tree.
14
+ */
15
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
16
+ export const PACKAGE_NAME = pkg.name;
17
+ export const TOOL_VERSION = pkg.version;
18
+ export const TOOL = { name: 'pipefy-process-coder', version: TOOL_VERSION };
@@ -280,7 +280,7 @@ const CAPABILITIES_MD = (caps) => {
280
280
  | \`importRepoSnapshot\` | ${caps.importRepoSnapshot ? 'yes' : 'no'} | Route B is unavailable; writes go through the mutation compiler |
281
281
  | internal API | ${caps.internalApi} | automation detail and phase-jump editing depend on it |
282
282
 
283
- ${caps.internalApi === 'unavailable' ? '> The internal endpoint did not answer. Automations and phase jumps are reported as **unknown** rather than empty, and any change to them is refused rather than silently skipped.\n' : ''}`
283
+ ${caps.internalApi === 'unavailable' ? '> The internal endpoint did not answer. Automations and phase jumps are reported as **unknown** rather than empty, and any change to them is refused rather than silently skipped.\n' : ''}${caps.internalApi === 'incompatible' ? '> The internal endpoint refused one of this tool\'s own queries as invalid — a bug in `pipe`, not an outage. Automations are reported as **unknown** or **partial** rather than empty, and any change to them is refused. `pipe doctor` names the query.\n' : ''}`
284
284
  : '';
285
285
  return `# Capabilities
286
286
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@pipefy/pipefy-process-coder",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Pull a Pipefy pipe to disk as readable JSON, edit it with an agent, diff it, push it back.",
5
5
  "type": "module",
6
- "license": "UNLICENSED",
6
+ "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/Guilherme-CA-Dias/pipefy-process-coder.git"
@@ -15,7 +15,8 @@
15
15
  "bin",
16
16
  "dist",
17
17
  ".agents/skills",
18
- "AGENTS.md"
18
+ "AGENTS.md",
19
+ "NOTICE"
19
20
  ],
20
21
  "engines": {
21
22
  "node": ">=22.18"