@pipefy/pipefy-process-coder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. package/src/workspace/write.ts +225 -0
@@ -0,0 +1,430 @@
1
+ import type { EntityName } from '../model/payload.ts';
2
+
3
+ /**
4
+ * The capability registry — PLAN.md §2.3.
5
+ *
6
+ * Hand-maintained deliberately: the names are not derivable by convention
7
+ * (`createFieldConditionInput` is lowercase-c, `UpdateFieldConditionInput`
8
+ * isn't; `phaseSettings` takes `SettingsInput`; `updatePhaseField.label` is
9
+ * NON_NULL). It is also the evidence-backed API ask list — every wall an FDE
10
+ * hits is a logged data point, and the `ask` field points at the entry in
11
+ * ROUTE-B-V2.md §8 that would remove it.
12
+ *
13
+ * The worst failure mode this exists to prevent: Claude edits something with no
14
+ * write path and the apply reports success having ignored it.
15
+ */
16
+
17
+ export type Op = 'create' | 'update' | 'delete' | 'reorder' | 'archive' | 'change_type' | 'move_phase';
18
+
19
+ export type Status = 'SUPPORTED' | 'PARTIAL' | 'UNSUPPORTED';
20
+
21
+ export type Support = {
22
+ status: Status;
23
+ /** Mutation name, or the REST route for internal calls. */
24
+ via?: string;
25
+ transport?: 'graphql' | 'internal';
26
+ /** Why it is PARTIAL or UNSUPPORTED, in words an FDE can relay to a client. */
27
+ reason?: string;
28
+ /** Which platform ask would fix it (ROUTE-B-V2.md §8 / PLAN.md §8). */
29
+ ask?: number;
30
+ /** Fields that cannot be changed by this path even when the entity is writable. */
31
+ immutableFields?: string[];
32
+ /**
33
+ * When set, the *only* columns this path can write. A change touching anything
34
+ * else is refused by name.
35
+ *
36
+ * Entity-level PARTIAL is not enough on its own: `repo_preferences.update`
37
+ * exists, so a change to `start_form_title` compiled into a mutation that
38
+ * succeeded and changed nothing, leaving the workspace permanently dirty and
39
+ * the author with no way to tell. Which is the failure mode the registry is
40
+ * here to prevent.
41
+ */
42
+ settableColumns?: string[];
43
+ };
44
+
45
+ type Key = `${EntityName | 'repo'}.${Op}`;
46
+
47
+ export const REGISTRY: Partial<Record<Key, Support>> = {
48
+ 'repo.update': {
49
+ status: 'PARTIAL',
50
+ via: 'updatePipe',
51
+ transport: 'graphql',
52
+ reason: 'covers name, noun, icon, color, title_field_id, expiration_*, public and preferences; other repo columns have no input',
53
+ /**
54
+ * `UpdatePipeInput` has fifteen fields and the repos row has thirty-six, so
55
+ * most of what the payload shows about a pipe cannot be written back.
56
+ *
57
+ * `description` is the one that matters in practice: there is no input for
58
+ * it, so a workspace that sets it compiled a mutation that succeeded, changed
59
+ * nothing, and left the file permanently disagreeing with the pipe. Exactly
60
+ * the `start_form_title` failure, one entity up. Introspection filtering
61
+ * dropped the argument silently; naming the settable columns refuses it out
62
+ * loud instead.
63
+ */
64
+ settableColumns: [
65
+ 'name',
66
+ 'noun',
67
+ 'icon',
68
+ 'color',
69
+ 'public',
70
+ 'title_field_id',
71
+ 'expiration_time',
72
+ 'only_admin_can_remove_cards',
73
+ 'only_assignees_can_edit_cards',
74
+ ],
75
+ },
76
+
77
+ 'phases.create': { status: 'SUPPORTED', via: 'createPhase', transport: 'graphql' },
78
+ 'phases.update': { status: 'SUPPORTED', via: 'updatePhase + phaseSettings', transport: 'graphql' },
79
+ 'phases.delete': {
80
+ status: 'PARTIAL',
81
+ via: 'deletePhase',
82
+ transport: 'graphql',
83
+ reason:
84
+ 'cards in the phase must be moved first. The Change Migrator recipes create an "Archive(Migration)" phase and move cards into it; pipe refuses unless --allow-card-moves is given',
85
+ },
86
+ 'phases.reorder': {
87
+ status: 'UNSUPPORTED',
88
+ reason: 'createPhase takes index: Float, updatePhase does not — there is no way to change an existing phase index',
89
+ ask: 4,
90
+ },
91
+
92
+ 'fields.create': { status: 'SUPPORTED', via: 'createPhaseField', transport: 'graphql' },
93
+ 'fields.update': {
94
+ status: 'SUPPORTED',
95
+ via: 'updatePhaseField',
96
+ transport: 'graphql',
97
+ immutableFields: ['type_id', 'phase_id', 'slug'],
98
+ reason: 'label is NON_NULL, so every update resends the full object rather than a sparse patch',
99
+ },
100
+ 'fields.delete': {
101
+ status: 'SUPPORTED',
102
+ via: 'deletePhaseField',
103
+ transport: 'graphql',
104
+ reason: 'requires pipeUuid alongside the field id, and destroys every existing value',
105
+ },
106
+ 'fields.archive': {
107
+ status: 'SUPPORTED',
108
+ via: 'archiveField / unarchiveField',
109
+ transport: 'graphql',
110
+ reason: "keyed on the field's uuid, not its id",
111
+ },
112
+ 'fields.reorder': { status: 'SUPPORTED', via: 'updatePhaseField (index: Float)', transport: 'graphql' },
113
+ 'fields.change_type': {
114
+ status: 'UNSUPPORTED',
115
+ reason:
116
+ 'updatePhaseField has no type_id input. Changing a field type requires delete + create, which destroys every existing value — so pipe refuses instead of doing it silently',
117
+ ask: 2,
118
+ },
119
+ 'fields.move_phase': {
120
+ status: 'UNSUPPORTED',
121
+ reason: 'updatePhaseField has no phase_id input; moving a field between phases would destroy its data',
122
+ ask: 3,
123
+ },
124
+
125
+ 'labels.create': { status: 'SUPPORTED', via: 'createLabel', transport: 'graphql' },
126
+ 'labels.update': { status: 'SUPPORTED', via: 'updateLabel', transport: 'graphql' },
127
+ 'labels.delete': { status: 'SUPPORTED', via: 'deleteLabel', transport: 'graphql' },
128
+
129
+ /**
130
+ * All three are on the PUBLIC endpoint, and were being sent to the internal
131
+ * one — inherited from the Change Migrator recipes and never re-checked.
132
+ * `createAutomation`, `updateAutomation` and `deleteAutomation` are documented
133
+ * public mutations with introspectable inputs; the internal endpoint has no
134
+ * compatibility contract and answered a bad parameter with the single word
135
+ * "is invalid".
136
+ */
137
+ 'automations.create': {
138
+ status: 'SUPPORTED',
139
+ via: 'createAutomation',
140
+ transport: 'graphql',
141
+ reason:
142
+ 'conditions, field_maps, distribute strategy and responseSchema all go in nested. Event and action parameters are checked against automationEvents/automationActions before sending, because both endpoints answer a wrong one with only "is invalid"',
143
+ },
144
+ 'automations.update': {
145
+ status: 'SUPPORTED',
146
+ via: 'updateAutomation',
147
+ transport: 'graphql',
148
+ reason:
149
+ 'updateAutomation in place, keeping the id, uuid and run history. The recipes delete and recreate instead, on the grounds that action_params does not round-trip; measured against the live API and it does, across every field map dimension, action_id and event_id. UpdateAutomationInput accepts every field CreateAutomationInput does. --automation-recreate restores delete+create for the case where an update is refused',
150
+ },
151
+ 'automations.delete': { status: 'SUPPORTED', via: 'deleteAutomation', transport: 'graphql' },
152
+
153
+ /**
154
+ * Start form only. The mutation authorizes `phaseId` and then ignores it,
155
+ * placing every condition on the start form; the UI can target any phase, so
156
+ * there is no equivalent public write path for the rest.
157
+ */
158
+ 'field_conditions.create': {
159
+ status: 'PARTIAL',
160
+ via: 'createFieldCondition',
161
+ transport: 'graphql',
162
+ reason:
163
+ 'input takes phaseId (camel) while update takes phase_id (snake) — recipe steps 85/109. ' +
164
+ 'And phaseId appears to be ignored: a create with phaseId set to a regular phase put the ' +
165
+ 'condition on the start form instead. Verification catches it; there is no known way to ' +
166
+ 'target a non-start-form phase on create',
167
+ },
168
+ 'field_conditions.update': { status: 'SUPPORTED', via: 'updateFieldCondition', transport: 'graphql' },
169
+ 'field_conditions.delete': { status: 'SUPPORTED', via: 'deleteFieldCondition', transport: 'graphql' },
170
+ 'field_conditions.reorder': { status: 'SUPPORTED', via: 'setFieldConditionOrder', transport: 'graphql' },
171
+
172
+ 'webhooks.create': { status: 'SUPPORTED', via: 'createWebhook', transport: 'graphql' },
173
+ 'webhooks.update': { status: 'SUPPORTED', via: 'updateWebhook', transport: 'graphql' },
174
+ 'webhooks.delete': { status: 'SUPPORTED', via: 'deleteWebhook', transport: 'graphql' },
175
+
176
+ 'pipe_relations.create': { status: 'SUPPORTED', via: 'createPipeRelation', transport: 'graphql' },
177
+ 'pipe_relations.update': { status: 'SUPPORTED', via: 'updatePipeRelation', transport: 'graphql' },
178
+ 'pipe_relations.delete': { status: 'SUPPORTED', via: 'deletePipeRelation', transport: 'graphql' },
179
+
180
+ 'phase_jumps.update': {
181
+ status: 'PARTIAL',
182
+ via: 'PUT /internal_api/settings/phases/:id',
183
+ transport: 'internal',
184
+ reason:
185
+ 'no public mutation exists. Unversioned internal endpoint with no compatibility contract — isolated in one adapter, asserted on the echoed next_phase_ids, degrades to UNSUPPORTED rather than throwing',
186
+ ask: 1,
187
+ },
188
+
189
+ 'phase_team_memberships.update': { status: 'SUPPORTED', via: 'phaseSettings (teamMemberIds)', transport: 'graphql' },
190
+
191
+ 'repo_preferences.update': {
192
+ status: 'PARTIAL',
193
+ via: 'updateRepoPreferences (repoUuid) + updatePipe.preferences',
194
+ transport: 'graphql',
195
+ settableColumns: [
196
+ 'hidden_start_form_attributes',
197
+ 'hidden_top_buttons',
198
+ 'findable',
199
+ 'inbox_email_enabled',
200
+ 'main_tab_views',
201
+ ],
202
+ reason:
203
+ 'the surface is small and split. updateRepoPreferences accepts only hiddenStartFormAttributes ' +
204
+ 'and hiddenTopButtons; updatePipe.preferences accepts findable, inboxEmailEnabled and ' +
205
+ 'mainTabViews. Every other column in the payload row — start_form_title, custom_sorting_*, ' +
206
+ 'the AI toggles, guests_can_follow_tickets, default_view — has no input on this endpoint',
207
+ },
208
+ 'public_forms.update': {
209
+ status: 'PARTIAL',
210
+ via: 'updatePipe.publicFormSettings',
211
+ transport: 'graphql',
212
+ settableColumns: [
213
+ 'title',
214
+ 'description',
215
+ 'submit_button_text',
216
+ 'after_submit_message',
217
+ 'brand_color',
218
+ 'background_color',
219
+ 'background_image',
220
+ 'logo',
221
+ 'display_pipefy_logo',
222
+ 'show_submit_another_response_button',
223
+ 'submitter_email_collection_enabled',
224
+ 'submitter_email_required',
225
+ 'submitter_email_field_id',
226
+ 'reuse_last_submission_response',
227
+ 'active',
228
+ ],
229
+ reason:
230
+ 'PublicFormSettingsInput covers title, description, submitButtonText, afterSubmitMessage, ' +
231
+ 'brandColor, backgroundColor/Image, logo, displayPipefyLogo, showSubmitAnotherResponseButton, ' +
232
+ 'submitterEmail* and reuseLastSubmissionResponse. The html and css columns have no input',
233
+ },
234
+
235
+ 'visibilities.update': {
236
+ status: 'UNSUPPORTED',
237
+ reason: 'only reachable through updatePipe.public_form, and slug is not settable',
238
+ },
239
+ 'email_templates.create': { status: 'UNSUPPORTED', reason: 'EmailTemplate is read-only; no Input types exist', ask: 5 },
240
+ 'email_templates.update': { status: 'UNSUPPORTED', reason: 'EmailTemplate is read-only; no Input types exist', ask: 5 },
241
+ 'email_templates.delete': { status: 'UNSUPPORTED', reason: 'EmailTemplate is read-only; no Input types exist', ask: 5 },
242
+ 'email_inboxes.create': { status: 'UNSUPPORTED', reason: 'no mutation found' },
243
+ 'email_inboxes.update': { status: 'UNSUPPORTED', reason: 'no mutation found' },
244
+ 'email_inboxes.delete': { status: 'UNSUPPORTED', reason: 'no mutation found' },
245
+ };
246
+
247
+ /**
248
+ * What can be written on a **database**, which is a different question from the
249
+ * same entity on a pipe.
250
+ *
251
+ * A database's payload is a pipe's — its columns are `fields`, its record
252
+ * statuses are `phases` — so the diff produces the same entity names for both
253
+ * kinds. The *mutations* are not the same: `createTableField` rather than
254
+ * `createPhaseField`, `updateTable` rather than `updatePipe`, and a whole class
255
+ * of pipe entities that a database simply does not have.
256
+ *
257
+ * So this table is authoritative for a database, and anything absent from it is
258
+ * refused. Falling through to the pipe registry would be worse than useless: it
259
+ * would report `automations.create` as SUPPORTED for a repo with no workflow, and
260
+ * then send `createAutomation` at it.
261
+ *
262
+ * Identifier facts behind these rows are in VALIDATION.md §3g, all measured.
263
+ */
264
+ const TABLE_REGISTRY: Partial<Record<Key, Support>> = {
265
+ 'repo.update': {
266
+ status: 'PARTIAL',
267
+ via: 'updateTable',
268
+ transport: 'graphql',
269
+ settableColumns: [
270
+ 'name',
271
+ 'noun',
272
+ 'icon',
273
+ 'color',
274
+ 'public',
275
+ 'description',
276
+ 'authorization',
277
+ 'create_card_label',
278
+ 'title_field_id',
279
+ ],
280
+ reason:
281
+ 'updateTable covers those nine. labels and members can only be set when the database is created — UpdateTableInput has no input for either — and statuses, uuid, url and the record count have none at all. title_field_id takes the field slug or uuid, never the numeric id',
282
+ },
283
+
284
+ 'fields.create': {
285
+ status: 'SUPPORTED',
286
+ via: 'createTableField',
287
+ transport: 'graphql',
288
+ reason:
289
+ 'CreateTableFieldInput has no index, so a new column lands at the end and its position is a separate setTableFieldOrder call',
290
+ },
291
+ 'fields.update': {
292
+ status: 'SUPPORTED',
293
+ via: 'updateTableField',
294
+ transport: 'graphql',
295
+ immutableFields: ['type_id', 'index', 'slug', 'uuid', 'phase_id', 'is_multiple'],
296
+ settableColumns: [
297
+ 'label',
298
+ 'options',
299
+ 'description',
300
+ 'help',
301
+ 'required',
302
+ 'unique',
303
+ 'minimal_view',
304
+ 'custom_validation',
305
+ 'can_search_connected_cards',
306
+ 'can_connect_multiple_cards',
307
+ 'can_create_connected_cards',
308
+ 'child_must_exist_to_finish_parent',
309
+ 'all_children_must_be_done_to_finish_parent',
310
+ ],
311
+ reason:
312
+ 'addressed by the field slug, with table_id alongside it. type and index have no input on UpdateTableFieldInput',
313
+ },
314
+ 'fields.delete': {
315
+ status: 'SUPPORTED',
316
+ via: 'deleteTableField',
317
+ transport: 'graphql',
318
+ reason: 'addressed by the field slug. Deleting a column destroys its value in every record of the database',
319
+ },
320
+ 'fields.reorder': {
321
+ status: 'SUPPORTED',
322
+ via: 'setTableFieldOrder',
323
+ transport: 'graphql',
324
+ reason:
325
+ 'set-replacing per database: the whole ordered list of field slugs is sent. It returns the applied order, so the step asserts its own effect rather than trusting a 200',
326
+ },
327
+ 'fields.change_type': {
328
+ status: 'UNSUPPORTED',
329
+ ask: 2,
330
+ reason:
331
+ 'UpdateTableFieldInput has no type. Changing it would mean delete + create, which destroys that column in every record — the same wall as a pipe field, one repo kind over',
332
+ },
333
+ 'fields.archive': {
334
+ status: 'UNSUPPORTED',
335
+ reason:
336
+ 'there is no archiveTableField. A database column can only be deleted, and that destroys data — so there is no non-destructive way to retire one',
337
+ },
338
+ 'fields.move_phase': {
339
+ status: 'UNSUPPORTED',
340
+ reason: 'a database has one form. There is nowhere to move a column to',
341
+ },
342
+
343
+ /**
344
+ * Labels work on a database exactly as they do on a pipe — measured as full
345
+ * CRUD. The catch is only in the create: `CreateLabelInput` has both `pipe_id`
346
+ * and `table_id`, and a database id sent as `pipe_id` is refused with `Pipe not
347
+ * found with id: <the database id>`, which reads like a missing capability and
348
+ * is not one.
349
+ */
350
+ 'labels.create': {
351
+ status: 'SUPPORTED',
352
+ via: 'createLabel',
353
+ transport: 'graphql',
354
+ reason: 'CreateLabelInput.table_id, not pipe_id — the latter is refused with "Pipe not found"',
355
+ },
356
+ 'labels.update': { status: 'SUPPORTED', via: 'updateLabel', transport: 'graphql' },
357
+ 'labels.delete': { status: 'SUPPORTED', via: 'deleteLabel', transport: 'graphql' },
358
+
359
+ /**
360
+ * Webhooks are refused on a database, and this one is a judgement rather than a
361
+ * missing path — so the measurement is recorded to stop someone "fixing" it.
362
+ *
363
+ * `createWebhook` with a database id **is accepted**, and the webhook really
364
+ * attaches: it reads back under `Table.webhooks`. But Pipefy does not offer
365
+ * webhooks on a database, no database in this org has one, and the actions on
366
+ * offer are card-shaped (`card.create` and friends) while a database holds
367
+ * records. A write path that is accepted and inert is worse than one that is
368
+ * refused, so it is refused until somebody needs it and can say what it should
369
+ * fire on.
370
+ */
371
+ 'webhooks.create': {
372
+ status: 'UNSUPPORTED',
373
+ reason:
374
+ 'createWebhook accepts a database id and the webhook does attach — measured — but Pipefy does not offer webhooks on a database and the available actions are card-shaped, so one would sit there doing nothing. Refused rather than left as a trap',
375
+ },
376
+ 'webhooks.update': { status: 'UNSUPPORTED', reason: 'see webhooks.create — not offered on a database' },
377
+ 'webhooks.delete': { status: 'UNSUPPORTED', reason: 'see webhooks.create — not offered on a database' },
378
+ };
379
+
380
+ /** Entities a database does not have, refused with the reason rather than a shrug. */
381
+ const NO_WORKFLOW = new Set<EntityName>([
382
+ 'phases',
383
+ 'phase_jumps',
384
+ 'phase_team_memberships',
385
+ 'automations',
386
+ 'conditions',
387
+ 'condition_expressions',
388
+ 'field_maps',
389
+ 'distribute_assignments',
390
+ 'response_schemas',
391
+ 'field_conditions',
392
+ 'field_condition_actions',
393
+ ]);
394
+
395
+ export const support = (entity: EntityName | 'repo', op: Op, kind: 'pipe' | 'table' = 'pipe'): Support => {
396
+ if (kind === 'table') {
397
+ const hit = TABLE_REGISTRY[`${entity}.${op}` as Key];
398
+ if (hit) return hit;
399
+ if (entity !== 'repo' && NO_WORKFLOW.has(entity)) {
400
+ return {
401
+ status: 'UNSUPPORTED',
402
+ reason:
403
+ `a database has no workflow, so ${entity} cannot be changed on one. Its phases are record statuses ` +
404
+ 'and its only form holds the columns. If this change is meant to automate something, it belongs on a pipe',
405
+ };
406
+ }
407
+ return {
408
+ status: 'UNSUPPORTED',
409
+ reason:
410
+ `${entity}.${op} is not proven on a database. The mutation may exist and accept a table id, but it has ` +
411
+ 'not been measured, and this tool refuses rather than finding out on your data',
412
+ };
413
+ }
414
+ const hit = REGISTRY[`${entity}.${op}` as Key];
415
+ if (hit) return hit;
416
+ return {
417
+ status: 'UNSUPPORTED',
418
+ reason: `no write path is registered for ${entity}.${op} — if one exists, add it to src/apply/registry.ts`,
419
+ };
420
+ };
421
+
422
+ export const registryRows = (): Array<{ key: string; entity: string; op: string } & Support> =>
423
+ Object.entries(REGISTRY).map(([key, v]) => {
424
+ const [entity, op] = key.split('.') as [string, string];
425
+ return { key, entity, op, ...(v as Support) };
426
+ });
427
+
428
+ /** Distinct asks referenced by the registry — the evidence-backed ask list. */
429
+ export const registryAsks = (): number[] =>
430
+ [...new Set(registryRows().map((r) => r.ask).filter((a): a is number => typeof a === 'number'))].sort((a, b) => a - b);
@@ -0,0 +1,134 @@
1
+ import type { EntityName } from '../model/payload.ts';
2
+ import type { Change, ChangeSet } from '../diff/diff.ts';
3
+ import type { Op, Status, Support } from './registry.ts';
4
+
5
+ export type StepKind =
6
+ /** Public GraphQL mutation. */
7
+ | 'graphql'
8
+ /** GraphQL over the internal endpoint (automations). */
9
+ | 'internal-graphql'
10
+ /** REST over the internal endpoint (phase jumps). */
11
+ | 'internal-rest'
12
+ /** Recorded for the log but sends nothing. */
13
+ | 'noop';
14
+
15
+ export type StepRequest = {
16
+ /** graphql / internal-graphql */
17
+ query?: string;
18
+ variables?: Record<string, unknown>;
19
+ mutation?: string;
20
+ /** internal-rest */
21
+ method?: string;
22
+ path?: string;
23
+ form?: Record<string, string[]>;
24
+ /**
25
+ * Required input fields this request has no value for. Such a request is
26
+ * certain to be rejected, so the compiler refuses to emit the step and names
27
+ * the missing field instead of discovering it mid-apply.
28
+ */
29
+ missingRequired?: string[];
30
+ };
31
+
32
+ /**
33
+ * A placeholder for an id that does not exist yet. New entities have no id
34
+ * until applied, so any step that references one carries the placeholder and
35
+ * the executor substitutes the real id after the creating step succeeds.
36
+ * PLAN.md §9 — this fails only on the combination "create field + reference it
37
+ * in the same plan", which is exactly the common case.
38
+ */
39
+ export type Placeholder = string;
40
+
41
+ export type StepStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
42
+
43
+ export type Step = {
44
+ id: string;
45
+ index: number;
46
+ /** The change this step serves, so the viewer can line them up. */
47
+ changeKey: string;
48
+ entity: EntityName | 'repo';
49
+ op: Op;
50
+ kind: StepKind;
51
+ description: string;
52
+ request: StepRequest;
53
+ /**
54
+ * Records the created id under this placeholder, plus any aliases an author
55
+ * might have written by hand (`%{_new:<uuid>}`, per the generated AGENTS.md).
56
+ */
57
+ produces?: { placeholder: Placeholder; path: string[]; aliases?: Placeholder[] };
58
+ /** Placeholders this step's request depends on. */
59
+ needs: Placeholder[];
60
+ /** True when the shape of this call could not be verified against the schema. */
61
+ unverified?: boolean;
62
+ /** Destructive steps require confirmation before a non-dry run. */
63
+ destructive?: boolean;
64
+ status: StepStatus;
65
+ result?: unknown;
66
+ error?: string;
67
+ startedAt?: string;
68
+ finishedAt?: string;
69
+ };
70
+
71
+ export type Classification = {
72
+ changeKey: string;
73
+ entity: EntityName | 'repo';
74
+ op: Op;
75
+ status: Status;
76
+ via?: string;
77
+ reason?: string;
78
+ ask?: number;
79
+ /** The change, kept so a plan file is self-contained for the viewer. */
80
+ label: string;
81
+ };
82
+
83
+ export type PlanOptions = {
84
+ /** Recreate automations instead of calling updateAutomation (the recipes' choice). */
85
+ automationStrategy: 'recreate' | 'update';
86
+ /** Permit steps that move cards (phase deletes). */
87
+ allowCardMoves: boolean;
88
+ /** Apply even though some changes are unsupported. Never the default. */
89
+ force: boolean;
90
+ };
91
+
92
+ export type Plan = {
93
+ id: string;
94
+ createdAt: string;
95
+ workspace: string;
96
+ repoId: number;
97
+ repoUuid: string | null;
98
+ mode: ChangeSet['mode'];
99
+ options: PlanOptions;
100
+ steps: Step[];
101
+ classifications: Classification[];
102
+ /** UNSUPPORTED classifications — the plan cannot execute while any remain. */
103
+ blocked: Classification[];
104
+ warnings: string[];
105
+ /** Pre-seeded id map: cross-repo migration mappings from the diff. */
106
+ idMap: Record<string, number>;
107
+ /** Snapshot taken before applying, for rollback. */
108
+ preApplyVersionId?: string | null;
109
+ status: 'planned' | 'running' | 'applied' | 'failed' | 'partially-applied';
110
+ };
111
+
112
+ export const stepSummary = (plan: Plan) => {
113
+ const byStatus = plan.steps.reduce<Record<StepStatus, number>>(
114
+ (acc, s) => {
115
+ acc[s.status]++;
116
+ return acc;
117
+ },
118
+ { pending: 0, running: 0, done: 0, failed: 0, skipped: 0 },
119
+ );
120
+ return byStatus;
121
+ };
122
+
123
+ export const changeByKey = (cs: ChangeSet, key: string): Change | undefined => cs.changes.find((c) => c.key === key);
124
+
125
+ export const supportToClassification = (c: Change, s: Support, op: Op): Classification => ({
126
+ changeKey: c.key,
127
+ entity: c.entity,
128
+ op,
129
+ status: s.status,
130
+ via: s.via,
131
+ reason: s.reason,
132
+ ask: s.ask,
133
+ label: c.label,
134
+ });
@@ -0,0 +1,88 @@
1
+ import { UserError } from '../util/log.ts';
2
+
3
+ /**
4
+ * Argument parsing. Hand-rolled because the whole CLI has zero runtime
5
+ * dependencies — `node src/cli.ts` works on a clean checkout with no install,
6
+ * which matters a lot for a tool an FDE will run on a client's laptop.
7
+ */
8
+
9
+ export type Parsed = {
10
+ command: string;
11
+ positionals: string[];
12
+ flags: Record<string, string | boolean | string[]>;
13
+ };
14
+
15
+ export const parse = (argv: string[]): Parsed => {
16
+ const [command = 'help', ...rest] = argv;
17
+ const positionals: string[] = [];
18
+ const flags: Record<string, string | boolean | string[]> = {};
19
+
20
+ for (let i = 0; i < rest.length; i++) {
21
+ const arg = rest[i] as string;
22
+ if (arg === '--') {
23
+ positionals.push(...rest.slice(i + 1));
24
+ break;
25
+ }
26
+ if (arg.startsWith('--')) {
27
+ const [rawName, inlineValue] = arg.slice(2).split('=', 2);
28
+ const name = rawName as string;
29
+ if (inlineValue !== undefined) {
30
+ addFlag(flags, name, inlineValue);
31
+ continue;
32
+ }
33
+ const next = rest[i + 1];
34
+ if (next !== undefined && !next.startsWith('-')) {
35
+ addFlag(flags, name, next);
36
+ i++;
37
+ } else {
38
+ flags[name] = true;
39
+ }
40
+ continue;
41
+ }
42
+ if (arg.startsWith('-') && arg.length > 1) {
43
+ for (const ch of arg.slice(1)) flags[ch] = true;
44
+ continue;
45
+ }
46
+ positionals.push(arg);
47
+ }
48
+
49
+ return { command, positionals, flags };
50
+ };
51
+
52
+ const addFlag = (flags: Parsed['flags'], name: string, value: string) => {
53
+ const existing = flags[name];
54
+ if (existing === undefined) {
55
+ flags[name] = value;
56
+ return;
57
+ }
58
+ if (Array.isArray(existing)) existing.push(value);
59
+ else flags[name] = [String(existing), value];
60
+ };
61
+
62
+ export const str = (p: Parsed, name: string, fallback?: string): string | undefined => {
63
+ const v = p.flags[name];
64
+ if (v === undefined || v === true) return fallback;
65
+ if (Array.isArray(v)) return v[v.length - 1];
66
+ return String(v);
67
+ };
68
+
69
+ export const bool = (p: Parsed, name: string, fallback = false): boolean => {
70
+ const v = p.flags[name];
71
+ if (v === undefined) return fallback;
72
+ if (v === 'false' || v === '0' || v === 'no') return false;
73
+ return Boolean(v);
74
+ };
75
+
76
+ export const int = (p: Parsed, name: string, fallback: number): number => {
77
+ const v = str(p, name);
78
+ if (v === undefined) return fallback;
79
+ const n = Number(v);
80
+ if (!Number.isInteger(n)) throw new UserError(`--${name} must be an integer, got "${v}"`);
81
+ return n;
82
+ };
83
+
84
+ export const requirePositional = (p: Parsed, index: number, what: string): string => {
85
+ const v = p.positionals[index];
86
+ if (!v) throw new UserError(`missing ${what}`, `see \`pipe help ${p.command}\``);
87
+ return v;
88
+ };