@waron97/prbot 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@ CLI tool for managing PRs, changelogs, and Odoo workflow XML files in the addons
4
4
 
5
5
  ## Install
6
6
 
7
+ Requires Node >= 20.
8
+
7
9
  ```bash
8
10
  npm install -g @waron97/prbot
9
11
  ```
@@ -129,9 +131,11 @@ prbot export workflow --no-commit
129
131
 
130
132
  Options:
131
133
 
132
- | Flag | Description |
133
- | ------------- | ------------------------ |
134
- | `--no-commit` | Skip the git commit step |
134
+ | Flag | Description |
135
+ | -------------- | ------------------------------------------------------------- |
136
+ | `--no-commit` | Skip the git commit step |
137
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
138
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
135
139
 
136
140
  ### `prbot export pb`
137
141
 
@@ -144,9 +148,11 @@ prbot export pb --no-commit
144
148
 
145
149
  Options:
146
150
 
147
- | Flag | Description |
148
- | ------------- | ------------------------ |
149
- | `--no-commit` | Skip the git commit step |
151
+ | Flag | Description |
152
+ | -------------- | ------------------------------------------------------------- |
153
+ | `--no-commit` | Skip the git commit step |
154
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
155
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
150
156
 
151
157
  ### `prbot export imperex`
152
158
 
@@ -159,9 +165,11 @@ prbot export imperex --no-commit
159
165
 
160
166
  Options:
161
167
 
162
- | Flag | Description |
163
- | ------------- | ------------------------ |
164
- | `--no-commit` | Skip the git commit step |
168
+ | Flag | Description |
169
+ | -------------- | ------------------------------------------------------------- |
170
+ | `--no-commit` | Skip the git commit step |
171
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
172
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
165
173
 
166
174
  ### `prbot export email-templates`
167
175
 
@@ -174,9 +182,11 @@ prbot export email-templates --no-commit
174
182
 
175
183
  Options:
176
184
 
177
- | Flag | Description |
178
- | ------------- | ------------------------ |
179
- | `--no-commit` | Skip the git commit step |
185
+ | Flag | Description |
186
+ | -------------- | ------------------------------------------------------------- |
187
+ | `--no-commit` | Skip the git commit step |
188
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
189
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
180
190
 
181
191
  ### `prbot init`
182
192
 
@@ -184,10 +194,11 @@ Interactive setup: writes `~/.config/prbot/config`.
184
194
 
185
195
  ### `prbot update`
186
196
 
187
- Reinstalls the latest published version from npm.
197
+ Reinstalls prbot from npm — latest by default, or a pinned version.
188
198
 
189
199
  ```bash
190
- prbot update
200
+ prbot update # latest
201
+ prbot update 3.4.0 # pin an exact version
191
202
  ```
192
203
 
193
204
  ---
@@ -225,26 +236,27 @@ agrippa clone --lrp --name B2WA_ml_IFS_passive_trigger --path my-lrp/
225
236
 
226
237
  Options:
227
238
 
228
- | Flag | Description |
229
- | ----------------- | --------------------------------------------------------------------------- |
230
- | `--phase` | Clone a phase (select a workflow) |
231
- | `--mfa` | Clone an MFA record |
232
- | `--pb` | Clone a process-builder wizard |
233
- | `--lrp` | Clone a long-running process |
234
- | `--id <id>` | Skip selection, clone by ID (phase/mfa) |
235
- | `--name <name>` | Skip selection: `document_id` (with `--pb`) or process name (with `--lrp`) |
236
- | `--path <path>` | Destination path (base dir for phases/wizard/lrp, file for MFA) |
239
+ | Flag | Description |
240
+ | --------------- | -------------------------------------------------------------------------- |
241
+ | `--phase` | Clone a phase (select a workflow) |
242
+ | `--mfa` | Clone an MFA record |
243
+ | `--pb` | Clone a process-builder wizard |
244
+ | `--lrp` | Clone a long-running process |
245
+ | `--id <id>` | Skip selection, clone by ID (phase/mfa) |
246
+ | `--name <name>` | Skip selection: `document_id` (with `--pb`) or process name (with `--lrp`) |
247
+ | `--path <path>` | Destination path (base dir for phases/wizard/lrp, file for MFA) |
237
248
 
238
249
  ### `agrippa pull`
239
250
 
240
- Fetches remote code for all tracked entries and shows what changed. Classifies each as `fast-forward` (safe overwrite) or `conflict` (local edits would be lost). Lets you select which to pull.
251
+ Fetches remote code for all tracked entries and shows what changed. Classifies each as `fast-forward` (safe overwrite) or `conflict` (local edits would be lost). `conflict` entries are shown but **not** preselected — you opt in explicitly. Lets you select which to pull.
241
252
 
242
253
  After pulling, also checks tracked workflows for newly added `from_code` phases and auto-clones any not yet present locally.
243
254
 
244
- Tracked **process-builder wizards** and **long-running processes** are also refreshed from upstream: the local project is re-decomposed from the latest payload (orphan script files pruned; PB wizards also prune orphan pages), with the same `fast-forward`/`conflict` classification (PB: upstream `updated_date`; LRP: re-resolved-by-name payload checksum vs. the last pulled state LRPs have no stable id, so every pull re-resolves the current id/tenantId by name first). The current local state is backed up to `.backup/<timestamp>/<path>/local.json` first.
255
+ Tracked **process-builder wizards** and **long-running processes** are also refreshed from upstream: the local project is re-decomposed from the latest payload (orphan script files pruned; PB wizards also prune orphan pages), with the same `fast-forward`/`conflict` classification — a semantic checksum of the recomposed payload (not raw `updated_date`), canonicalized so cosmetic noise never triggers a false conflict: whitespace-only BPMN text nodes, `format`-only `labelPos`, page ordering, and the `updated_date`/`modified_by` audit fields Odoo/Symple bump on any server touch are all excluded from the comparison. The current local state is backed up to `.backup/<timestamp>/<path>/local.json` first.
245
256
 
246
257
  ```bash
247
258
  agrippa pull
259
+ agrippa pull --non-interactive # no prompts: auto-select fast-forward, fail if any conflict
248
260
  ```
249
261
 
250
262
  ### `agrippa push`
@@ -257,18 +269,21 @@ Pushing a wizard saves it as a **draft**; publish it so live consumers see the c
257
269
  agrippa push # prompts whether to publish/deploy each pushed wizard/LRP
258
270
  agrippa push --publish # auto-publish wizards, auto-deploy LRPs
259
271
  agrippa push --skip-publish # never publish/deploy (no prompt)
272
+ agrippa push --non-interactive # no prompts: auto-select fast-forward, fail if any conflict;
273
+ # publish/deploy defaults to skip unless --publish is also passed
260
274
  ```
261
275
 
262
- ### `agrippa diff [path]`
276
+ ### `agrippa diff [target]`
263
277
 
264
- Shows a diff between local files and remote code. Optionally filter to a specific file path.
278
+ Shows a diff between local files and remote code. `[target]` optionally narrows it to a single file, a workflow folder, a cloned project directory, or a `document_id`/name; omit it for the whole workspace.
265
279
 
266
- For process-builder wizards, diffs the whole project tree against what the workspace would look like if the wizard were decomposed fresh from upstream right now (`.backup/` and `preview.svg` are excluded as local-only artifacts).
280
+ For process-builder wizards and long-running processes, diffs the whole project tree against what the workspace would look like if the project were decomposed fresh from upstream right now (`.backup/` and `preview.svg` are excluded as local-only artifacts).
267
281
 
268
282
  ```bash
269
283
  agrippa diff
270
284
  agrippa diff my-workflow/some-phase.py
271
285
  agrippa diff ml_review_billing # a cloned wizard's directory
286
+ agrippa diff B2WA_M2C_passthrough # a cloned LRP, by directory or name
272
287
  ```
273
288
 
274
289
  ### `agrippa init-phase`
@@ -295,15 +310,16 @@ These commands exist mainly so an **AI agent** can add/remove/connect blocks wit
295
310
 
296
311
  Newly added blocks get placeholder geometry. `pb format` re-lays-out the **whole** diagram (discarding any hand-tuned layout), so running it is a deliberate **human decision** — the alternative is positioning the new blocks by hand in the UI. Agents are expected to make structural edits but **not** to run `format` or to `pull`/`push`; a human reviews and syncs.
297
312
 
298
- | Command | Purpose |
299
- | --------------- | ------------------------------------------------------------------------------------ |
300
- | `pb format` | Re-lay-out the **entire** diagram (elkjs, left→right); overwrites existing layout |
301
- | `pb add` | Add a node (`--type`, `--name`, `--parent`); scaffolds script/page files |
302
- | `pb rm` | Remove a node (`--id`), its edges, and its script/page files |
303
- | `pb connect` | Add a flow (`--from`, `--to`, `--condition`, `--default`); enforces the gateway rule |
304
- | `pb disconnect` | Remove a flow (`--id`, or `--from`/`--to`) |
305
- | `pb ls` | List nodes and edges with their ids (discover targets without reading the YAML) |
306
- | `pb preview` | Render the diagram to an SVG (`--out`) for a quick visual check |
313
+ | Command | Purpose |
314
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
315
+ | `pb format` | Re-lay-out the **entire** diagram (elkjs, left→right); overwrites existing layout |
316
+ | `pb add` | Add a node (`--type`, `--name`, `--parent`); scaffolds script/page files |
317
+ | `pb rm` | Remove a node (`--id`), its edges, and its script/page files |
318
+ | `pb connect` | Add a flow (`--from`, `--to`, `--condition`, `--default`); enforces the gateway rule |
319
+ | `pb disconnect` | Remove a flow (`--id`, or `--from`/`--to`); clears the source gateway's `default` if it pointed at that flow |
320
+ | `pb lint` | Check the structural rules: gateway defaults/conditions, branch names, incoming-flow limits, and `default`s pointing at flows that no longer exist |
321
+ | `pb ls` | List nodes and edges with their ids (discover targets without reading the YAML) |
322
+ | `pb preview` | Render the diagram to an SVG (`--out`) for a quick visual check |
307
323
 
308
324
  ```bash
309
325
  agrippa pb ls --pb ml_review_billing
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@waron97/prbot",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
+ "engines": {
7
+ "node": ">=20.0.0"
8
+ },
9
+ "packageManager": "npm@10.9.8",
6
10
  "scripts": {
7
11
  "test": "echo \"Error: no test specified\" && exit 1"
8
12
  },
@@ -3,6 +3,7 @@ import select from '@inquirer/select';
3
3
  import inquirer from 'inquirer';
4
4
  import { getToken } from '../../lib/auth.js';
5
5
  import { fuzzyMatch } from '../../lib/fuzzy.js';
6
+ import { log, warn } from '../../lib/logger.js';
6
7
  import { describeWorkflow, getPhasesByWorkflow, listMfas, listWorkflows } from '../lib/api.js';
7
8
  import { computeChecksum } from '../lib/checksum.js';
8
9
  import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
@@ -42,7 +43,7 @@ async function clone(opts) {
42
43
  if (!ripUrl)
43
44
  throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
44
45
 
45
- console.log('Fetching records...');
46
+ log('Fetching records...');
46
47
  const token = await getToken();
47
48
 
48
49
  let records;
@@ -53,7 +54,7 @@ async function clone(opts) {
53
54
  }
54
55
 
55
56
  if (!records.length) {
56
- console.log(`No ${objectType} records found.`);
57
+ log(`No ${objectType} records found.`);
57
58
  return;
58
59
  }
59
60
 
@@ -92,11 +93,11 @@ async function clone(opts) {
92
93
  basePath = inputPath;
93
94
  }
94
95
 
95
- console.log(`Fetching phases for "${record.name}"...`);
96
+ log(`Fetching phases for "${record.name}"...`);
96
97
  const phases = await getPhasesByWorkflow(token, ripUrl, record.id, { fromCode: true });
97
98
 
98
99
  if (!phases.length) {
99
- console.log('No phases found.');
100
+ log('No phases found.');
100
101
  return;
101
102
  }
102
103
 
@@ -112,19 +113,19 @@ async function clone(opts) {
112
113
  checksum_at_pull: computeChecksum(phase.code),
113
114
  name: `${record.name} / ${phase.name}`,
114
115
  });
115
- console.log(` wrote ${filePath}`);
116
+ log(` wrote ${filePath}`);
116
117
  }
117
118
 
118
119
  // Drop the workflow graph alongside the phase files as read-only context.
119
120
  try {
120
121
  const structure = await describeWorkflow(token, ripUrl, record.id);
121
122
  const docPath = writeWorkflowDoc(basePath, structure);
122
- console.log(` wrote ${docPath}`);
123
+ log(` wrote ${docPath}`);
123
124
  } catch (err) {
124
- console.warn(` could not fetch workflow structure: ${err.message}`);
125
+ warn(` could not fetch workflow structure: ${err.message}`);
125
126
  }
126
127
 
127
- console.log(`Cloned ${phases.length} phase(s) to ${basePath}/`);
128
+ log(`Cloned ${phases.length} phase(s) to ${basePath}/`);
128
129
  } else {
129
130
  const defaultPath = defaultMfaPath(record.model_name, record.name);
130
131
  if (!basePath) {
@@ -147,7 +148,7 @@ async function clone(opts) {
147
148
  checksum_at_pull: computeChecksum(record.code),
148
149
  name: `${record.model_name} / ${record.name}`,
149
150
  });
150
- console.log(`Cloned MFA to ${basePath}`);
151
+ log(`Cloned MFA to ${basePath}`);
151
152
  }
152
153
 
153
154
  writeConfig(config);
@@ -1,10 +1,10 @@
1
1
  import search from '@inquirer/search';
2
2
  import inquirer from 'inquirer';
3
3
  import { getToken } from '../../lib/auth.js';
4
- import { computeChecksum } from '../lib/checksum.js';
4
+ import { log, warn } from '../../lib/logger.js';
5
5
  import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
6
6
  import { getLrpXml, listLrps, resolveLrpByName } from '../lib/lrpApi.js';
7
- import { comparePayload, decompose, recompose, stableStringify } from '../lib/pbProject.js';
7
+ import { checksumOfPayload, comparePayload, decompose, recompose } from '../lib/pbProject.js';
8
8
  import { projectReader, writeProject } from '../lib/pbWorkspace.js';
9
9
 
10
10
  // Clone a long-running process (LRP). Structurally identical to a PB clone
@@ -32,7 +32,7 @@ async function cloneLrp(opts) {
32
32
  if (opts.name) {
33
33
  chosen = await resolveLrpByName(token, opts.name);
34
34
  } else {
35
- console.log('Fetching long-running process list...');
35
+ log('Fetching long-running process list...');
36
36
  const initial = await listLrps(token, null);
37
37
  let controller = null;
38
38
  chosen = await search({
@@ -65,7 +65,7 @@ async function cloneLrp(opts) {
65
65
  dest = inputPath;
66
66
  }
67
67
 
68
- console.log(`Fetching "${chosen.name}"...`);
68
+ log(`Fetching "${chosen.name}"...`);
69
69
  const { xml, description } = await getLrpXml(token, chosen.id);
70
70
  const payload = { built_page: xml };
71
71
 
@@ -73,7 +73,7 @@ async function cloneLrp(opts) {
73
73
  writeProject(dest, files);
74
74
 
75
75
  const scriptCount = Object.keys(files).filter((p) => p.startsWith('scripts/')).length;
76
- console.log(`Cloned to ${dest}/ (${scriptCount} script(s)).`);
76
+ log(`Cloned to ${dest}/ (${scriptCount} script(s)).`);
77
77
 
78
78
  // Prove the clone reconstructs the original XML (0-loss bar A). LRPs have
79
79
  // no `pages` at all — recompose always synthesizes `pages: []`, which
@@ -82,10 +82,10 @@ async function cloneLrp(opts) {
82
82
  const rebuilt = recompose(projectReader(dest));
83
83
  const diffs = comparePayload(payload, rebuilt).filter((d) => !d.startsWith('pages:'));
84
84
  if (diffs.length) {
85
- console.warn('WARNING: round-trip verification found differences:');
86
- diffs.forEach((d) => console.warn(' - ' + d));
85
+ warn('WARNING: round-trip verification found differences:');
86
+ diffs.forEach((d) => warn(' - ' + d));
87
87
  } else {
88
- console.log('Round-trip verified: recomposed payload is identical (0 information loss).');
88
+ log('Round-trip verified: recomposed payload is identical (0 information loss).');
89
89
  }
90
90
 
91
91
  // Register in the workspace for later pull/push — keyed by NAME, not id.
@@ -102,7 +102,7 @@ async function cloneLrp(opts) {
102
102
  description,
103
103
  // Baseline for push classification (see pull.js/push.js): checksum of
104
104
  // the *recomposed* payload, changes only when local files change.
105
- checksum_at_pull: computeChecksum(stableStringify(rebuilt)),
105
+ checksum_at_pull: checksumOfPayload(rebuilt),
106
106
  version: chosen.version,
107
107
  status: chosen.status,
108
108
  };
@@ -2,10 +2,10 @@ import search from '@inquirer/search';
2
2
  import inquirer from 'inquirer';
3
3
  import { getToken } from '../../lib/auth.js';
4
4
  import { fuzzyMatch } from '../../lib/fuzzy.js';
5
- import { computeChecksum } from '../lib/checksum.js';
5
+ import { log, warn } from '../../lib/logger.js';
6
6
  import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
7
7
  import { getProcess, listProcesses } from '../lib/pbApi.js';
8
- import { comparePayload, decompose, recompose, stableStringify } from '../lib/pbProject.js';
8
+ import { checksumOfPayload, comparePayload, decompose, recompose } from '../lib/pbProject.js';
9
9
  import { projectReader, writeProject } from '../lib/pbWorkspace.js';
10
10
 
11
11
  async function clonePb(opts) {
@@ -16,11 +16,11 @@ async function clonePb(opts) {
16
16
  throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
17
17
  }
18
18
 
19
- console.log('Fetching process list...');
19
+ log('Fetching process list...');
20
20
  const token = await getToken();
21
21
  const processes = await listProcesses(token);
22
22
  if (!processes.length) {
23
- console.log('No process-builder wizards found.');
23
+ log('No process-builder wizards found.');
24
24
  return;
25
25
  }
26
26
 
@@ -61,7 +61,7 @@ async function clonePb(opts) {
61
61
  dest = inputPath;
62
62
  }
63
63
 
64
- console.log(`Fetching "${chosen.process_name}"...`);
64
+ log(`Fetching "${chosen.process_name}"...`);
65
65
  const payload = await getProcess(token, chosen.guid);
66
66
 
67
67
  const { files } = decompose(payload);
@@ -69,17 +69,17 @@ async function clonePb(opts) {
69
69
 
70
70
  const scriptCount = Object.keys(files).filter((p) => p.startsWith('scripts/')).length;
71
71
  const pageCount = Object.keys(files).filter((p) => p.startsWith('pages/')).length;
72
- console.log(`Cloned to ${dest}/ (${scriptCount} script(s), ${pageCount} page(s))`);
72
+ log(`Cloned to ${dest}/ (${scriptCount} script(s), ${pageCount} page(s))`);
73
73
 
74
74
  // Prove the clone reconstructs the original payload (0-loss bar A) by
75
75
  // reading the files back from disk and recomposing.
76
76
  const rebuilt = recompose(projectReader(dest));
77
77
  const diffs = comparePayload(payload, rebuilt);
78
78
  if (diffs.length) {
79
- console.warn('WARNING: round-trip verification found differences:');
80
- diffs.forEach((d) => console.warn(' - ' + d));
79
+ warn('WARNING: round-trip verification found differences:');
80
+ diffs.forEach((d) => warn(' - ' + d));
81
81
  } else {
82
- console.log('Round-trip verified: recomposed payload is identical (0 information loss).');
82
+ log('Round-trip verified: recomposed payload is identical (0 information loss).');
83
83
  }
84
84
 
85
85
  // Register in the workspace for later pull/push.
@@ -95,7 +95,7 @@ async function clonePb(opts) {
95
95
  name: chosen.process_name,
96
96
  // Baselines for `push` classification: checksum of the *recomposed* payload
97
97
  // (changes only when local files change) + upstream updated_date/status.
98
- checksum_at_pull: computeChecksum(stableStringify(rebuilt)),
98
+ checksum_at_pull: checksumOfPayload(rebuilt),
99
99
  updated_date: payload.updated_date,
100
100
  status: payload.status,
101
101
  };
@@ -12,8 +12,10 @@ import {
12
12
  import { tmpdir } from 'os';
13
13
  import { join } from 'path';
14
14
  import { getToken } from '../../lib/auth.js';
15
+ import { error, log } from '../../lib/logger.js';
15
16
  import { computeChecksum } from '../lib/checksum.js';
16
17
  import { loadEffectiveEnv, readConfig } from '../lib/config.js';
18
+ import { fetchUpstream } from '../lib/lrpApi.js';
17
19
  import { getProcess } from '../lib/pbApi.js';
18
20
  import { decompose, localChecksum } from '../lib/pbProject.js';
19
21
  import { projectReader, writeProject } from '../lib/pbWorkspace.js';
@@ -25,25 +27,36 @@ async function diff(targetArg) {
25
27
  loadEffectiveEnv(config);
26
28
 
27
29
  if (!config.workspace.length) {
28
- console.log('No tracked resources. Run `agrippa clone` first.');
30
+ log('No tracked resources. Run `agrippa clone` first.');
29
31
  return;
30
32
  }
31
33
 
32
34
  const entries = filterEntries(config.workspace, targetArg);
33
35
  if (!entries.length) {
34
- console.log('No tracked files match the given path.');
36
+ log('No tracked files match the given path.');
35
37
  return;
36
38
  }
37
39
 
38
- const pbEntries = entries.filter((e) => e.object_type === 'process_builder');
39
- const codeEntries = entries.filter((e) => e.object_type !== 'process_builder');
40
+ // Decomposed projects (pb + lrp) diff as whole directory trees; phases and
41
+ // MFAs are single code files. An LRP left in the code bucket used to reach
42
+ // readCodeFile() with a directory path and blow up with EISDIR, so the
43
+ // split is by "is this a project" rather than "is this a wizard".
44
+ const projectEntries = entries.filter((e) => PROJECT_TYPES.has(e.object_type));
45
+ const codeEntries = entries.filter((e) => !PROJECT_TYPES.has(e.object_type));
40
46
 
41
47
  if (codeEntries.length && !process.env.RIP_URL)
42
48
  throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
43
- if (pbEntries.length && !process.env.PB_URL)
49
+ if (projectEntries.some((e) => e.object_type === 'process_builder') && !process.env.PB_URL)
44
50
  throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
51
+ if (
52
+ projectEntries.some((e) => e.object_type === 'long_running_process') &&
53
+ !process.env.IMPORTEXPORT_URL
54
+ )
55
+ throw new Error(
56
+ 'IMPORTEXPORT_URL is not configured. Run `prbot init` or set it in agrippa.yaml.'
57
+ );
45
58
 
46
- console.log('Fetching remote code...');
59
+ log('Fetching remote code...');
47
60
  const token = await getToken();
48
61
 
49
62
  let diffCount = 0;
@@ -56,8 +69,8 @@ async function diff(targetArg) {
56
69
  chunks.push(...cs);
57
70
  }
58
71
 
59
- for (const entry of pbEntries) {
60
- const { hasDiff, chunk } = await diffPbEntry(token, entry);
72
+ for (const entry of projectEntries) {
73
+ const { hasDiff, chunk } = await diffProjectEntry(token, entry);
61
74
  if (hasDiff) {
62
75
  diffCount++;
63
76
  chunks.push(chunk);
@@ -65,13 +78,13 @@ async function diff(targetArg) {
65
78
  }
66
79
 
67
80
  if (diffCount === 0) {
68
- console.log('No differences found — all tracked files match the remote.');
81
+ log('No differences found — all tracked files match the remote.');
69
82
  } else {
70
83
  const combined = Buffer.concat(chunks);
71
84
  const pager = process.env.PAGER || 'less';
72
85
  const pagerArgs = pager === 'less' ? ['-R', '-F'] : [];
73
86
  spawnSync(pager, pagerArgs, { input: combined, stdio: ['pipe', 'inherit', 'inherit'] });
74
- console.log(`\n${diffCount} file(s) differ from remote.`);
87
+ log(`\n${diffCount} file(s) differ from remote.`);
75
88
  }
76
89
  }
77
90
 
@@ -90,7 +103,7 @@ function diffCodeEntries(entries, remoteCodeMap) {
90
103
  if (computeChecksum(localCode) === computeChecksum(remoteCode)) continue;
91
104
 
92
105
  if (!fileExists(entry.path)) {
93
- console.log(`\n--- ${entry.path} (local file missing)`);
106
+ log(`\n--- ${entry.path} (local file missing)`);
94
107
  continue;
95
108
  }
96
109
 
@@ -105,7 +118,7 @@ function diffCodeEntries(entries, remoteCodeMap) {
105
118
  );
106
119
  // exit code 1 means differences found (normal), 0 means identical, >1 means error
107
120
  if (result.status !== null && result.status > 1) {
108
- console.error(`git diff failed for ${entry.path}`);
121
+ error(`git diff failed for ${entry.path}`);
109
122
  }
110
123
  const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] ===\n`);
111
124
  chunks.push(Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]));
@@ -123,14 +136,33 @@ function diffCodeEntries(entries, remoteCodeMap) {
123
136
  return { diffCount, chunks };
124
137
  }
125
138
 
126
- // Process-builder wizard: decompose the upstream payload into a throwaway
127
- // project tree (exactly what re-cloning now would produce) and diff it against
128
- // a copy of the local project, recursively. `.backup/` and `preview.svg` are
129
- // local-only artifacts (push backups, dev preview render) with no upstream
130
- // counterpart, so they're stripped from the local copy before diffing.
131
- async function diffPbEntry(token, entry) {
139
+ // Decomposed project (process-builder wizard or long-running process): fetch
140
+ // the upstream payload, decompose it into a throwaway project tree (exactly
141
+ // what re-cloning now would produce) and diff it against a copy of the local
142
+ // project, recursively. `.backup/` and `preview.svg` are local-only artifacts
143
+ // (push backups, dev preview render) with no upstream counterpart, so they're
144
+ // stripped from the local copy before diffing.
145
+ //
146
+ // The two types differ only in how upstream is reached: a wizard by guid, an
147
+ // LRP by name (its tabulator id changes on every save — never a stable key,
148
+ // see pullLrpEntry). Everything downstream is the shared decompose pipeline.
149
+ const PROJECT_TYPES = new Set(['process_builder', 'long_running_process']);
150
+
151
+ async function fetchProjectUpstream(token, entry) {
152
+ if (entry.object_type === 'long_running_process') {
153
+ const res = await fetchUpstream(token, entry.name);
154
+ if (!res?.payload) throw new Error(`could not fetch upstream LRP "${entry.name}"`);
155
+ return res.payload;
156
+ }
132
157
  const upstream = await getProcess(token, entry.guid);
133
158
  if (!upstream) throw new Error(`could not fetch upstream wizard ${entry.guid}`);
159
+ return upstream;
160
+ }
161
+
162
+ async function diffProjectEntry(token, entry) {
163
+ const upstream = await fetchProjectUpstream(token, entry);
164
+ const label =
165
+ entry.object_type === 'long_running_process' ? 'long-running process' : 'process-builder';
134
166
 
135
167
  const tmpRoot = mkdtempSync(join(tmpdir(), 'agrippa-pb-diff-'));
136
168
  try {
@@ -156,9 +188,9 @@ async function diffPbEntry(token, entry) {
156
188
  { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] }
157
189
  );
158
190
  if (result.status !== null && result.status > 1) {
159
- console.error(`git diff failed for ${entry.path}`);
191
+ error(`git diff failed for ${entry.path}`);
160
192
  }
161
- const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (process-builder) ===\n`);
193
+ const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (${label}) ===\n`);
162
194
  const chunk = Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]);
163
195
  return { hasDiff: true, chunk };
164
196
  } finally {
@@ -172,14 +204,21 @@ function filterEntries(workspace, targetArg) {
172
204
  // Normalise: strip trailing slash
173
205
  const target = targetArg.replace(/\/$/, '');
174
206
 
175
- // If it exists on disk and is a directory, filter by prefix
207
+ // If it exists on disk and is a directory, take both the entries *under* it
208
+ // (a workflow folder holding one file per phase) and any entry that *is* it
209
+ // — a pb/lrp project is tracked as the directory itself, so prefix matching
210
+ // alone silently returned nothing for the most common target.
176
211
  if (existsSync(target) && statSync(target).isDirectory()) {
177
- const prefix = target.endsWith('/') ? target : target + '/';
178
- return workspace.filter((e) => e.path.startsWith(prefix));
212
+ const prefix = target + '/';
213
+ const matches = workspace.filter((e) => e.path === target || e.path.startsWith(prefix));
214
+ if (matches.length) return matches;
179
215
  }
180
216
 
181
- // Otherwise treat as exact file path (whether it exists yet or not)
182
- return workspace.filter((e) => e.path === target);
217
+ // Exact file path (whether it exists yet or not), else fall back to the
218
+ // identifiers the other commands accept — document_id (`--pb`) and name.
219
+ const byPath = workspace.filter((e) => e.path === target);
220
+ if (byPath.length) return byPath;
221
+ return workspace.filter((e) => e.document_id === target || e.name === target);
183
222
  }
184
223
 
185
224
  export { diff };
@@ -9,6 +9,7 @@ import {
9
9
  import { join } from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
  import inquirer from 'inquirer';
12
+ import { log } from '../../lib/logger.js';
12
13
  import { WORKSPACE_FILE } from '../lib/config.js';
13
14
 
14
15
  const TEMPLATE = `# agrippa workspace configuration
@@ -93,23 +94,23 @@ async function init() {
93
94
  },
94
95
  ]);
95
96
  if (!overwrite) {
96
- console.log('Skipped workspace file.');
97
+ log('Skipped workspace file.');
97
98
  } else {
98
99
  writeFileSync(WORKSPACE_FILE, TEMPLATE, 'utf-8');
99
- console.log(`Created ${WORKSPACE_FILE}`);
100
- console.log(`Run 'agrippa clone' to add resources to this workspace.`);
101
- console.log(`Add ${WORKSPACE_FILE} to .gitignore if it contains credentials.`);
100
+ log(`Created ${WORKSPACE_FILE}`);
101
+ log(`Run 'agrippa clone' to add resources to this workspace.`);
102
+ log(`Add ${WORKSPACE_FILE} to .gitignore if it contains credentials.`);
102
103
  }
103
104
  } else {
104
105
  writeFileSync(WORKSPACE_FILE, TEMPLATE, 'utf-8');
105
- console.log(`Created ${WORKSPACE_FILE}`);
106
- console.log(`Run 'agrippa clone' to add resources to this workspace.`);
107
- console.log(`Add ${WORKSPACE_FILE} to .gitignore if it contains credentials.`);
106
+ log(`Created ${WORKSPACE_FILE}`);
107
+ log(`Run 'agrippa clone' to add resources to this workspace.`);
108
+ log(`Add ${WORKSPACE_FILE} to .gitignore if it contains credentials.`);
108
109
  }
109
110
 
110
111
  if (!existsSync('pyproject.toml')) {
111
112
  writeFileSync('pyproject.toml', PYPROJECT_TOML, 'utf-8');
112
- console.log('Created pyproject.toml (ruff builtins)');
113
+ log('Created pyproject.toml (ruff builtins)');
113
114
  }
114
115
 
115
116
  const { importTypings } = await inquirer.prompt([
@@ -124,7 +125,7 @@ async function init() {
124
125
  if (importTypings) {
125
126
  if (!existsSync('pyrightconfig.json')) {
126
127
  writeFileSync('pyrightconfig.json', PYRIGHTCONFIG, 'utf-8');
127
- console.log('Created pyrightconfig.json');
128
+ log('Created pyrightconfig.json');
128
129
  }
129
130
 
130
131
  const typingsDir = fileURLToPath(new URL('../../../agrippa_typings', import.meta.url));
@@ -132,7 +133,7 @@ async function init() {
132
133
  for (const file of TYPING_FILES) {
133
134
  copyFileSync(join(typingsDir, file), join('typings', file));
134
135
  }
135
- console.log(`Copied ${TYPING_FILES.length} type stubs to typings/`);
136
+ log(`Copied ${TYPING_FILES.length} type stubs to typings/`);
136
137
  }
137
138
 
138
139
  const { importInstructions } = await inquirer.prompt([
@@ -149,13 +150,13 @@ async function init() {
149
150
  const block = pbBlock(readFileSync(guidePath, 'utf-8'));
150
151
  if (!existsSync('CLAUDE.md')) {
151
152
  writeFileSync('CLAUDE.md', block, 'utf-8');
152
- console.log('Created CLAUDE.md with agrippa-pb guidance');
153
+ log('Created CLAUDE.md with agrippa-pb guidance');
153
154
  } else {
154
155
  const current = readFileSync('CLAUDE.md', 'utf-8');
155
156
  const begin = current.indexOf(PB_BEGIN);
156
157
  if (begin === -1) {
157
158
  appendFileSync('CLAUDE.md', `\n${block}`, 'utf-8');
158
- console.log('Appended agrippa-pb guidance to CLAUDE.md');
159
+ log('Appended agrippa-pb guidance to CLAUDE.md');
159
160
  } else {
160
161
  // Replace the existing managed block in place; leave the rest as-is.
161
162
  const endIdx = current.indexOf(PB_END, begin);
@@ -165,7 +166,7 @@ async function init() {
165
166
  );
166
167
  const after = current.slice(endIdx + PB_END.length).replace(/^\n/, '');
167
168
  writeFileSync('CLAUDE.md', current.slice(0, begin) + block + after, 'utf-8');
168
- console.log('Refreshed agrippa-pb guidance in CLAUDE.md');
169
+ log('Refreshed agrippa-pb guidance in CLAUDE.md');
169
170
  }
170
171
  }
171
172
  }