@waron97/prbot 3.2.0 → 3.3.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.
@@ -47,19 +47,30 @@ async function diff(targetArg) {
47
47
  const token = await getToken();
48
48
 
49
49
  let diffCount = 0;
50
+ const chunks = [];
50
51
 
51
52
  if (codeEntries.length) {
52
53
  const remoteCodeMap = await fetchRemoteCode(token, process.env.RIP_URL, codeEntries);
53
- diffCount += diffCodeEntries(codeEntries, remoteCodeMap);
54
+ const { diffCount: c, chunks: cs } = diffCodeEntries(codeEntries, remoteCodeMap);
55
+ diffCount += c;
56
+ chunks.push(...cs);
54
57
  }
55
58
 
56
59
  for (const entry of pbEntries) {
57
- if (await diffPbEntry(token, entry)) diffCount++;
60
+ const { hasDiff, chunk } = await diffPbEntry(token, entry);
61
+ if (hasDiff) {
62
+ diffCount++;
63
+ chunks.push(chunk);
64
+ }
58
65
  }
59
66
 
60
67
  if (diffCount === 0) {
61
68
  console.log('No differences found — all tracked files match the remote.');
62
69
  } else {
70
+ const combined = Buffer.concat(chunks);
71
+ const pager = process.env.PAGER || 'less';
72
+ const pagerArgs = pager === 'less' ? ['-R', '-F'] : [];
73
+ spawnSync(pager, pagerArgs, { input: combined, stdio: ['pipe', 'inherit', 'inherit'] });
63
74
  console.log(`\n${diffCount} file(s) differ from remote.`);
64
75
  }
65
76
  }
@@ -68,6 +79,7 @@ async function diff(targetArg) {
68
79
  // hand both sides to `git diff --no-index`.
69
80
  function diffCodeEntries(entries, remoteCodeMap) {
70
81
  let diffCount = 0;
82
+ const chunks = [];
71
83
  const tmpFiles = [];
72
84
  try {
73
85
  for (const entry of entries) {
@@ -86,17 +98,17 @@ function diffCodeEntries(entries, remoteCodeMap) {
86
98
  writeFileSync(tmpPath, (remoteCode ?? '').trim() + '\n', 'utf-8');
87
99
  tmpFiles.push(tmpPath);
88
100
 
89
- console.log(`\n=== ${entry.path} [${entry.name}] ===`);
90
-
91
101
  const result = spawnSync(
92
102
  'git',
93
103
  ['diff', '--no-index', '--color=always', tmpPath, entry.path],
94
- { stdio: ['ignore', 'inherit', 'inherit'] }
104
+ { stdio: ['ignore', 'pipe', 'pipe'] }
95
105
  );
96
106
  // exit code 1 means differences found (normal), 0 means identical, >1 means error
97
107
  if (result.status !== null && result.status > 1) {
98
108
  console.error(`git diff failed for ${entry.path}`);
99
109
  }
110
+ const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] ===\n`);
111
+ chunks.push(Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]));
100
112
  diffCount++;
101
113
  }
102
114
  } finally {
@@ -108,7 +120,7 @@ function diffCodeEntries(entries, remoteCodeMap) {
108
120
  }
109
121
  }
110
122
  }
111
- return diffCount;
123
+ return { diffCount, chunks };
112
124
  }
113
125
 
114
126
  // Process-builder wizard: decompose the upstream payload into a throwaway
@@ -136,18 +148,19 @@ async function diffPbEntry(token, entry) {
136
148
  // and never matches the raw upstream xml byte-for-byte even when nothing
137
149
  // structural changed, so it'd otherwise look like a permanent false diff.
138
150
  if (localChecksum(projectReader(upstreamDir)) === localChecksum(projectReader(localDir)))
139
- return false;
151
+ return { hasDiff: false };
140
152
 
141
- console.log(`\n=== ${entry.path} [${entry.name}] (process-builder) ===`);
142
153
  const result = spawnSync(
143
154
  'git',
144
155
  ['diff', '--no-index', '--color=always', 'upstream', 'local'],
145
- { cwd: tmpRoot, stdio: ['ignore', 'inherit', 'inherit'] }
156
+ { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] }
146
157
  );
147
158
  if (result.status !== null && result.status > 1) {
148
159
  console.error(`git diff failed for ${entry.path}`);
149
160
  }
150
- return true;
161
+ const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (process-builder) ===\n`);
162
+ const chunk = Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]);
163
+ return { hasDiff: true, chunk };
151
164
  } finally {
152
165
  rmSync(tmpRoot, { recursive: true, force: true });
153
166
  }
@@ -100,27 +100,26 @@ async function initPhase() {
100
100
  return;
101
101
  }
102
102
 
103
- const phase = await search({
104
- message: 'Select a phase:',
105
- source: (input) => {
106
- const filtered = input ? phases.filter((p) => fuzzyMatch(p.name, input)) : phases;
107
- return filtered.map((p) => ({ name: p.name, value: p }));
103
+ const { selectedPhases } = await inquirer.prompt([
104
+ {
105
+ type: 'checkbox',
106
+ name: 'selectedPhases',
107
+ message: 'Select phases to initialize:',
108
+ choices: phases.map((p) => ({ name: p.name, value: p })),
109
+ loop: false,
108
110
  },
109
- });
111
+ ]);
110
112
 
111
- // Resolve result objects — API may return IDs or full objects
112
- let results = phase.allowed_phase_result_ids || [];
113
- if (results.length > 0 && typeof results[0] === 'number') {
114
- results = await getPhaseResults(token, ripUrl, results);
113
+ if (!selectedPhases.length) {
114
+ console.log('No phases selected. Aborted.');
115
+ return;
115
116
  }
116
117
 
117
- const code = generateCode(results);
118
-
119
118
  const { confirm } = await inquirer.prompt([
120
119
  {
121
120
  type: 'confirm',
122
121
  name: 'confirm',
123
- message: `Initialize phase "${phase.name}"? This will overwrite existing code.`,
122
+ message: `Initialize ${selectedPhases.length} phase(s)? This will overwrite existing code.`,
124
123
  default: true,
125
124
  },
126
125
  ]);
@@ -130,29 +129,40 @@ async function initPhase() {
130
129
  return;
131
130
  }
132
131
 
133
- // Delete existing configurator records for this phase
134
- const existing = await getPhaseConfigurators(token, ripUrl, phase.id);
135
- for (const cfg of existing) {
136
- await deleteConfigurator(token, ripUrl, cfg.id);
132
+ for (const phase of selectedPhases) {
133
+ // Resolve result objects API may return IDs or full objects
134
+ let results = phase.allowed_phase_result_ids || [];
135
+ if (results.length > 0 && typeof results[0] === 'number') {
136
+ results = await getPhaseResults(token, ripUrl, results);
137
+ }
138
+
139
+ const code = generateCode(results);
140
+
141
+ // Delete existing configurator records for this phase
142
+ const existing = await getPhaseConfigurators(token, ripUrl, phase.id);
143
+ for (const cfg of existing) {
144
+ await deleteConfigurator(token, ripUrl, cfg.id);
145
+ }
146
+
147
+ // Update phase code + set_result_automatically
148
+ await initPhaseRemote(token, ripUrl, phase.id, code);
149
+
150
+ // Create new configurator records for each result
151
+ const vars = results.map((_, i) => `RES${i + 1}`);
152
+ for (let i = 0; i < results.length; i++) {
153
+ await createConfigurator(token, ripUrl, {
154
+ result_value: vars[i],
155
+ code_phase_id: phase.id,
156
+ triplet_phase_result_id: results[i].id,
157
+ });
158
+ }
159
+
160
+ console.log(`Initialized phase "${phase.name}".`);
161
+ if (results.length > 0) {
162
+ console.log(` ${results.length} configurator(s) created: ${vars.join(', ')}`);
163
+ }
137
164
  }
138
165
 
139
- // Update phase code + set_result_automatically
140
- await initPhaseRemote(token, ripUrl, phase.id, code);
141
-
142
- // Create new configurator records for each result
143
- const vars = results.map((_, i) => `RES${i + 1}`);
144
- for (let i = 0; i < results.length; i++) {
145
- await createConfigurator(token, ripUrl, {
146
- result_value: vars[i],
147
- code_phase_id: phase.id,
148
- triplet_phase_result_id: results[i].id,
149
- });
150
- }
151
-
152
- console.log(`Initialized phase "${phase.name}".`);
153
- if (results.length > 0) {
154
- console.log(` ${results.length} configurator(s) created: ${vars.join(', ')}`);
155
- }
156
166
  console.log(
157
167
  `Run 'agrippa pull' in workspaces that track this workflow to fetch the updated code.`
158
168
  );
@@ -25,7 +25,7 @@ import {
25
25
  connect,
26
26
  disconnect,
27
27
  eachNode,
28
- lintGateways,
28
+ lintAll,
29
29
  listGraph,
30
30
  removeNode,
31
31
  setDefault,
@@ -37,32 +37,49 @@ import { projectReader } from '../lib/pbWorkspace.js';
37
37
 
38
38
  // ---------- project resolution ----------
39
39
 
40
- async function resolveProjectPath(opts) {
40
+ // Resolves against both process-builder wizards and long-running processes —
41
+ // `structure.yaml`-level editing is identical for both (see pbEdit.js). PBs
42
+ // select by --pb/--name matched against document_id; LRPs have no document_id
43
+ // so the same flag matches against name instead.
44
+ async function resolveProjectEntry(opts) {
41
45
  const config = readConfig();
42
- const entries = (config.workspace || []).filter((e) => e.object_type === 'process_builder');
46
+ const entries = (config.workspace || []).filter(
47
+ (e) => e.object_type === 'process_builder' || e.object_type === 'long_running_process'
48
+ );
43
49
  if (!entries.length) {
44
50
  throw new Error(
45
- 'No process-builder wizards in this workspace. Clone one with `agrippa clone --pb`.'
51
+ 'No process-builder wizards or long-running processes in this workspace. ' +
52
+ 'Clone one with `agrippa clone --pb` or `agrippa clone --lrp`.'
46
53
  );
47
54
  }
48
55
  const sel = opts.pb || opts.name;
49
56
  if (sel) {
50
- const entry = entries.find((e) => e.document_id === sel);
51
- if (!entry) throw new Error(`No cloned wizard with document_id "${sel}"`);
52
- return entry.path;
57
+ const entry = entries.find((e) => e.document_id === sel || e.name === sel);
58
+ if (!entry) throw new Error(`No cloned project with document_id/name "${sel}"`);
59
+ return entry;
53
60
  }
54
- if (entries.length === 1) return entries[0].path;
61
+ if (entries.length === 1) return entries[0];
55
62
  const entry = await search({
56
- message: 'Select a cloned wizard:',
63
+ message: 'Select a cloned project:',
57
64
  source: (input) => {
58
65
  const list = input
59
66
  ? entries.filter(
60
- (e) => fuzzyMatch(e.name, input) || fuzzyMatch(e.document_id, input)
67
+ (e) =>
68
+ fuzzyMatch(e.name, input) ||
69
+ (e.document_id && fuzzyMatch(e.document_id, input))
61
70
  )
62
71
  : entries;
63
- return list.map((e) => ({ name: `${e.name} (${e.document_id})`, value: e }));
72
+ return list.map((e) => ({
73
+ name: e.document_id ? `${e.name} (${e.document_id})` : e.name,
74
+ value: e,
75
+ }));
64
76
  },
65
77
  });
78
+ return entry;
79
+ }
80
+
81
+ async function resolveProjectPath(opts) {
82
+ const entry = await resolveProjectEntry(opts);
66
83
  return entry.path;
67
84
  }
68
85
 
@@ -125,9 +142,9 @@ async function pbFormat(opts) {
125
142
  console.log(
126
143
  `Formatted ${dir} (${nodes} node(s) laid out${missing ? `, ${missing} without layout` : ''}).`
127
144
  );
128
- const issues = lintGateways(structure);
145
+ const issues = lintAll(structure);
129
146
  if (issues.length) {
130
- console.warn('Gateway issues (exclusiveGateway default/condition rule):');
147
+ console.warn('Diagram issues:');
131
148
  for (const w of issues) console.warn(` ! ${w}`);
132
149
  }
133
150
  }
@@ -135,14 +152,20 @@ async function pbFormat(opts) {
135
152
  async function pbAdd(opts) {
136
153
  if (!opts.type)
137
154
  throw new Error(
138
- '--type is required (e.g. scriptTask, serviceTask, userTask, exclusiveGateway, subProcess, endEvent...)'
155
+ '--type is required (e.g. scriptTask, serviceTask, userTask, exclusiveGateway, subProcess, endEvent, callActivity...)'
139
156
  );
140
157
  if ((opts.from || opts.to) && !(opts.from && opts.to))
141
158
  throw new Error('--from and --to must be used together');
142
159
  if (opts.from && opts.parent)
143
160
  throw new Error('--parent is implied by --from/--to; pass only one');
144
161
 
145
- const dir = await resolveProjectPath(opts);
162
+ const projectEntry = await resolveProjectEntry(opts);
163
+ if (opts.type === 'userTask' && projectEntry.object_type === 'long_running_process') {
164
+ throw new Error(
165
+ 'userTask is not valid on a long-running process — LRPs have no pages (user tasks are a process-builder-only concept).'
166
+ );
167
+ }
168
+ const dir = projectEntry.path;
146
169
  const { structure, manifest } = loadProject(dir);
147
170
  const ctx = { existingScripts: listScriptFiles(dir), documentId: manifest.document_id };
148
171
 
@@ -276,4 +299,26 @@ async function pbPreview(opts) {
276
299
  console.log(`Wrote ${out} (${svg.length} bytes).`);
277
300
  }
278
301
 
279
- export { pbFormat, pbAdd, pbRemove, pbConnect, pbDisconnect, pbSetDefault, pbList, pbPreview };
302
+ async function pbLint(opts) {
303
+ const dir = await resolveProjectPath(opts);
304
+ const { structure } = loadProject(dir);
305
+ const issues = lintAll(structure);
306
+ if (!issues.length) {
307
+ console.log('No issues.');
308
+ } else {
309
+ for (const w of issues) console.warn(` ! ${w}`);
310
+ process.exitCode = 1;
311
+ }
312
+ }
313
+
314
+ export {
315
+ pbFormat,
316
+ pbAdd,
317
+ pbRemove,
318
+ pbConnect,
319
+ pbDisconnect,
320
+ pbSetDefault,
321
+ pbList,
322
+ pbPreview,
323
+ pbLint,
324
+ };
@@ -4,8 +4,9 @@ import { getToken } from '../../lib/auth.js';
4
4
  import { describeWorkflow, getPhasesByIds, getPhasesByWorkflow, listMfas } from '../lib/api.js';
5
5
  import { computeChecksum } from '../lib/checksum.js';
6
6
  import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
7
+ import { fetchUpstream } from '../lib/lrpApi.js';
7
8
  import { getProcess } from '../lib/pbApi.js';
8
- import { localChecksum } from '../lib/pbProject.js';
9
+ import { localChecksum, remoteChecksumPb } from '../lib/pbProject.js';
9
10
  import { projectReader } from '../lib/pbWorkspace.js';
10
11
  import {
11
12
  fileExists,
@@ -14,6 +15,7 @@ import {
14
15
  writeCodeFile,
15
16
  writeWorkflowDoc,
16
17
  } from '../lib/workspace.js';
18
+ import { pullLrpEntry } from './pullLrp.js';
17
19
  import { pullPbEntry } from './pullPb.js';
18
20
 
19
21
  async function pull() {
@@ -50,9 +52,11 @@ async function pull() {
50
52
  const changedWorkflowIds = new Set();
51
53
 
52
54
  // ── pull existing tracked entries ─────────────────────────────────────────
53
- // process_builder wizards are refreshed separately (different checksum model);
54
- // see pullPbEntries below.
55
- const pullable = config.workspace.filter((e) => e.object_type !== 'process_builder');
55
+ // process_builder wizards and LRPs are refreshed separately (different
56
+ // checksum/identity model); see pullPbEntries/pullLrpEntries below.
57
+ const pullable = config.workspace.filter(
58
+ (e) => e.object_type !== 'process_builder' && e.object_type !== 'long_running_process'
59
+ );
56
60
  if (pullable.length) {
57
61
  console.log('Fetching remote code...');
58
62
  const remoteCodeMap = await fetchRemoteCode(token, ripUrl, pullable);
@@ -63,13 +67,13 @@ async function pull() {
63
67
  const localCode = readCodeFile(entry.path);
64
68
 
65
69
  const remoteChecksum = computeChecksum(remoteCode ?? '');
66
- const localChecksum = computeChecksum(localCode ?? '');
70
+ const localChecksumVal = computeChecksum(localCode ?? '');
67
71
  const pullChecksum = entry.checksum_at_pull;
68
72
 
69
73
  let status;
70
- if (pullChecksum === localChecksum && pullChecksum === remoteChecksum) {
74
+ if (localChecksumVal === remoteChecksum) {
71
75
  status = 'unchanged';
72
- } else if (pullChecksum === localChecksum) {
76
+ } else if (pullChecksum === localChecksumVal) {
73
77
  status = 'fast-forward';
74
78
  } else {
75
79
  status = 'conflict';
@@ -111,16 +115,18 @@ async function pull() {
111
115
  // ── refresh tracked process-builder wizards ───────────────────────────────
112
116
  await pullPbEntries(token, config);
113
117
 
118
+ // ── refresh tracked long-running processes ────────────────────────────────
119
+ await pullLrpEntries(token, config);
120
+
114
121
  // ── discover new phases on tracked workflows ──────────────────────────────
115
122
  await discoverNewPhases(token, ripUrl, config, changedWorkflowIds);
116
123
  }
117
124
 
118
125
  // Refresh tracked wizards from upstream. Pull concern (inverted from push):
119
- // overwriting *local* edits. Status per entry from three states —
120
- // checksum_at_pull, current local (recomposed), upstream updated_date:
121
- // unchanged remote not advanced nothing to bring down
122
- // fast-forward remote advanced, local untouched since pull safe overwrite
123
- // conflict remote advanced AND local diverged → overwrite loses local work
126
+ // overwriting *local* edits. Status per entry:
127
+ // unchanged local === remote semantically nothing to do
128
+ // fast-forward remote changed, local untouched since pull safe overwrite
129
+ // conflict local diverged from checksum_at_pull overwrite loses local work
124
130
  async function pullPbEntries(token, config) {
125
131
  const entries = config.workspace.filter((e) => e.object_type === 'process_builder');
126
132
  if (!entries.length) return;
@@ -140,12 +146,13 @@ async function pullPbEntries(token, config) {
140
146
  console.warn(` ${entry.name}: could not fetch upstream, skipping`);
141
147
  continue;
142
148
  }
143
- const local = localChecksum(projectReader(entry.path));
144
- const localChanged = entry.checksum_at_pull !== local;
145
- const remoteChanged = upstream.updated_date !== entry.updated_date;
149
+ const localSemantic = localChecksum(projectReader(entry.path));
150
+ const remoteSemantic = remoteChecksumPb(upstream);
151
+ const pullChecksum = entry.checksum_at_pull;
146
152
  let status;
147
- if (!remoteChanged) status = 'unchanged';
148
- else status = localChanged ? 'conflict' : 'fast-forward';
153
+ if (localSemantic === remoteSemantic) status = 'unchanged';
154
+ else if (pullChecksum === localSemantic) status = 'fast-forward';
155
+ else status = 'conflict';
149
156
  classified.push({ ...entry, upstream, status });
150
157
  }
151
158
 
@@ -179,6 +186,75 @@ async function pullPbEntries(token, config) {
179
186
  console.log(`\nPulled ${selected.length} wizard(s). Local backups in .backup/${backupTs}/`);
180
187
  }
181
188
 
189
+ // Refresh tracked long-running processes from upstream. Same classification
190
+ // concern as pullPbEntries, but entries are located by NAME (the tabulator id
191
+ // changes on every save/version — never a stable key).
192
+ async function pullLrpEntries(token, config) {
193
+ const entries = config.workspace.filter((e) => e.object_type === 'long_running_process');
194
+ if (!entries.length) return;
195
+ if (!process.env.IMPORTEXPORT_URL)
196
+ throw new Error(
197
+ 'IMPORTEXPORT_URL is not configured. Run `prbot init` or set it in agrippa.yaml.'
198
+ );
199
+
200
+ console.log('Checking long-running processes...');
201
+ const classified = [];
202
+ for (const entry of entries) {
203
+ let upstream = null;
204
+ try {
205
+ upstream = await fetchUpstream(token, entry.name);
206
+ } catch {
207
+ upstream = null;
208
+ }
209
+ if (!upstream) {
210
+ console.warn(` ${entry.name}: could not fetch upstream, skipping`);
211
+ continue;
212
+ }
213
+ const localSemantic = localChecksum(projectReader(entry.path));
214
+ const remoteSemantic = remoteChecksumPb(upstream.payload);
215
+ const pullChecksum = entry.checksum_at_pull;
216
+ let status;
217
+ if (localSemantic === remoteSemantic) status = 'unchanged';
218
+ else if (pullChecksum === localSemantic) status = 'fast-forward';
219
+ else status = 'conflict';
220
+ classified.push({ ...entry, upstream, status });
221
+ }
222
+
223
+ const changed = classified.filter((e) => e.status !== 'unchanged');
224
+ if (!changed.length) {
225
+ console.log('Long-running processes are up to date.');
226
+ return;
227
+ }
228
+
229
+ const selected = await selectEntries(changed, 'pull (overwrites local LRP files)');
230
+ if (!selected.length) {
231
+ console.log('No long-running processes selected.');
232
+ return;
233
+ }
234
+
235
+ const backupTs = new Date().toISOString().replace(/:/g, '-').replace('T', '_').slice(0, 19);
236
+ for (const entry of selected) {
237
+ const res = await pullLrpEntry(token, entry, '.backup', backupTs);
238
+ const idx = config.workspace.findIndex(
239
+ (e) => e.object_type === 'long_running_process' && e.name === entry.name
240
+ );
241
+ if (idx !== -1) {
242
+ config.workspace[idx].checksum_at_pull = res.newChecksum;
243
+ config.workspace[idx].tenant_id = res.newRow.tenantId;
244
+ config.workspace[idx].svg = res.newRow.bpmnFileSvg;
245
+ config.workspace[idx].description = res.newRow.description;
246
+ config.workspace[idx].version = res.newRow.version;
247
+ config.workspace[idx].status = res.newRow.status;
248
+ }
249
+ const note = res.diffs.length ? ` (WARNING: ${res.diffs.length} round-trip diff(s))` : '';
250
+ console.log(` ${entry.name} → refreshed${note}`);
251
+ }
252
+ writeConfig(config);
253
+ console.log(
254
+ `\nPulled ${selected.length} long-running process(es). Local backups in .backup/${backupTs}/`
255
+ );
256
+ }
257
+
182
258
  async function discoverNewPhases(token, ripUrl, config, changedWorkflowIds = new Set()) {
183
259
  // Build map of workflow_id → { workflow_name, basePath } from existing phase entries
184
260
  const workflows = new Map();
@@ -0,0 +1,55 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { computeChecksum } from '../lib/checksum.js';
4
+ import { fetchUpstream } from '../lib/lrpApi.js';
5
+ import { comparePayload, decompose, recompose, stableStringify } from '../lib/pbProject.js';
6
+ import { projectReader, writeProject } from '../lib/pbWorkspace.js';
7
+
8
+ // Delete files under <baseDir>/scripts that are not in the fresh decompose
9
+ // map, so a refresh that renames/removes scripts doesn't leave orphans behind
10
+ // (LRPs have no pages/ dir — no user tasks).
11
+ function pruneOrphans(baseDir, files) {
12
+ const dir = join(baseDir, 'scripts');
13
+ if (!existsSync(dir)) return;
14
+ for (const f of readdirSync(dir)) {
15
+ if (!(`scripts/${f}` in files)) rmSync(join(dir, f));
16
+ }
17
+ }
18
+
19
+ // Refresh one cloned LRP from upstream (the pull counterpart of
20
+ // pushLrpEntry): back up the current local state, re-decompose the upstream
21
+ // XML, overwrite local files (pruning orphans), and verify the round-trip.
22
+ // `entry.upstream` is the {row, payload} fetched during classification;
23
+ // re-resolved by name if absent (id may have changed since the last pull).
24
+ async function pullLrpEntry(token, entry, backupDir, backupTs) {
25
+ const upstream = entry.upstream ?? (await fetchUpstream(token, entry.name));
26
+ if (!upstream) throw new Error(`could not fetch upstream LRP "${entry.name}"`);
27
+
28
+ // 1. Backup the current local state (recomposed payload) before overwriting.
29
+ try {
30
+ const localPayload = recompose(projectReader(entry.path));
31
+ const backupPath = join(backupDir, backupTs, entry.path, 'local.json');
32
+ mkdirSync(dirname(backupPath), { recursive: true });
33
+ writeFileSync(backupPath, JSON.stringify(localPayload, null, 2), 'utf-8');
34
+ } catch {
35
+ // local files unreadable (e.g. mid-edit) — nothing safe to back up, proceed.
36
+ }
37
+
38
+ // 2. Decompose upstream → fresh file map; prune orphans; write.
39
+ const { files } = decompose(upstream.payload);
40
+ pruneOrphans(entry.path, files);
41
+ writeProject(entry.path, files);
42
+
43
+ // 3. Verify the freshly-written project reconstructs the upstream XML.
44
+ // (pages diff is expected noise for LRPs — see cloneLrp.js.)
45
+ const rebuilt = recompose(projectReader(entry.path));
46
+ const diffs = comparePayload(upstream.payload, rebuilt).filter((d) => !d.startsWith('pages:'));
47
+
48
+ return {
49
+ newChecksum: computeChecksum(stableStringify(rebuilt)),
50
+ newRow: upstream.row,
51
+ diffs,
52
+ };
53
+ }
54
+
55
+ export { pullLrpEntry };