@waron97/prbot 3.2.1 → 3.4.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.
@@ -18,6 +18,7 @@ import { dirname, join } from 'path';
18
18
  import search from '@inquirer/search';
19
19
  import { parse as yamlParse } from 'yaml';
20
20
  import { fuzzyMatch } from '../../lib/fuzzy.js';
21
+ import { log, warn } from '../../lib/logger.js';
21
22
  import { readConfig } from '../lib/config.js';
22
23
  import {
23
24
  addNode,
@@ -37,32 +38,49 @@ import { projectReader } from '../lib/pbWorkspace.js';
37
38
 
38
39
  // ---------- project resolution ----------
39
40
 
40
- async function resolveProjectPath(opts) {
41
+ // Resolves against both process-builder wizards and long-running processes —
42
+ // `structure.yaml`-level editing is identical for both (see pbEdit.js). PBs
43
+ // select by --pb/--name matched against document_id; LRPs have no document_id
44
+ // so the same flag matches against name instead.
45
+ async function resolveProjectEntry(opts) {
41
46
  const config = readConfig();
42
- const entries = (config.workspace || []).filter((e) => e.object_type === 'process_builder');
47
+ const entries = (config.workspace || []).filter(
48
+ (e) => e.object_type === 'process_builder' || e.object_type === 'long_running_process'
49
+ );
43
50
  if (!entries.length) {
44
51
  throw new Error(
45
- 'No process-builder wizards in this workspace. Clone one with `agrippa clone --pb`.'
52
+ 'No process-builder wizards or long-running processes in this workspace. ' +
53
+ 'Clone one with `agrippa clone --pb` or `agrippa clone --lrp`.'
46
54
  );
47
55
  }
48
56
  const sel = opts.pb || opts.name;
49
57
  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;
58
+ const entry = entries.find((e) => e.document_id === sel || e.name === sel);
59
+ if (!entry) throw new Error(`No cloned project with document_id/name "${sel}"`);
60
+ return entry;
53
61
  }
54
- if (entries.length === 1) return entries[0].path;
62
+ if (entries.length === 1) return entries[0];
55
63
  const entry = await search({
56
- message: 'Select a cloned wizard:',
64
+ message: 'Select a cloned project:',
57
65
  source: (input) => {
58
66
  const list = input
59
67
  ? entries.filter(
60
- (e) => fuzzyMatch(e.name, input) || fuzzyMatch(e.document_id, input)
68
+ (e) =>
69
+ fuzzyMatch(e.name, input) ||
70
+ (e.document_id && fuzzyMatch(e.document_id, input))
61
71
  )
62
72
  : entries;
63
- return list.map((e) => ({ name: `${e.name} (${e.document_id})`, value: e }));
73
+ return list.map((e) => ({
74
+ name: e.document_id ? `${e.name} (${e.document_id})` : e.name,
75
+ value: e,
76
+ }));
64
77
  },
65
78
  });
79
+ return entry;
80
+ }
81
+
82
+ async function resolveProjectPath(opts) {
83
+ const entry = await resolveProjectEntry(opts);
66
84
  return entry.path;
67
85
  }
68
86
 
@@ -103,7 +121,7 @@ function validate(dir) {
103
121
  try {
104
122
  recompose(projectReader(dir));
105
123
  } catch (e) {
106
- console.warn(`WARNING: project no longer recomposes cleanly: ${e.message}`);
124
+ warn(`WARNING: project no longer recomposes cleanly: ${e.message}`);
107
125
  }
108
126
  }
109
127
 
@@ -122,27 +140,33 @@ async function pbFormat(opts) {
122
140
  if (!n.layout) missing++;
123
141
  });
124
142
  validate(dir);
125
- console.log(
143
+ log(
126
144
  `Formatted ${dir} (${nodes} node(s) laid out${missing ? `, ${missing} without layout` : ''}).`
127
145
  );
128
146
  const issues = lintAll(structure);
129
147
  if (issues.length) {
130
- console.warn('Diagram issues:');
131
- for (const w of issues) console.warn(` ! ${w}`);
148
+ warn('Diagram issues:');
149
+ for (const w of issues) warn(` ! ${w}`);
132
150
  }
133
151
  }
134
152
 
135
153
  async function pbAdd(opts) {
136
154
  if (!opts.type)
137
155
  throw new Error(
138
- '--type is required (e.g. scriptTask, serviceTask, userTask, exclusiveGateway, subProcess, endEvent...)'
156
+ '--type is required (e.g. scriptTask, serviceTask, userTask, exclusiveGateway, subProcess, endEvent, callActivity...)'
139
157
  );
140
158
  if ((opts.from || opts.to) && !(opts.from && opts.to))
141
159
  throw new Error('--from and --to must be used together');
142
160
  if (opts.from && opts.parent)
143
161
  throw new Error('--parent is implied by --from/--to; pass only one');
144
162
 
145
- const dir = await resolveProjectPath(opts);
163
+ const projectEntry = await resolveProjectEntry(opts);
164
+ if (opts.type === 'userTask' && projectEntry.object_type === 'long_running_process') {
165
+ throw new Error(
166
+ 'userTask is not valid on a long-running process — LRPs have no pages (user tasks are a process-builder-only concept).'
167
+ );
168
+ }
169
+ const dir = projectEntry.path;
146
170
  const { structure, manifest } = loadProject(dir);
147
171
  const ctx = { existingScripts: listScriptFiles(dir), documentId: manifest.document_id };
148
172
 
@@ -157,13 +181,13 @@ async function pbAdd(opts) {
157
181
  saveStructure(dir, structure);
158
182
  saveManifest(dir, manifest);
159
183
  validate(dir);
160
- console.log(
184
+ log(
161
185
  `Added ${result.type} ${result.id}${result.file ? ` (${result.file})` : ''} between ${opts.from} → ${opts.to}.`
162
186
  );
163
- console.log(` ${opts.from} → ${result.id} (${result.edgeId}, retargeted)`);
164
- console.log(` ${result.id} → ${opts.to} (${result.newEdgeId})`);
165
- for (const w of result.warnings || []) console.warn(` ! ${w}`);
166
- console.log('Run `agrippa pb format` to lay it out.');
187
+ log(` ${opts.from} → ${result.id} (${result.edgeId}, retargeted)`);
188
+ log(` ${result.id} → ${opts.to} (${result.newEdgeId})`);
189
+ for (const w of result.warnings || []) warn(` ! ${w}`);
190
+ log('Run `agrippa pb format` to lay it out.');
167
191
  return;
168
192
  }
169
193
 
@@ -177,10 +201,8 @@ async function pbAdd(opts) {
177
201
  saveStructure(dir, structure);
178
202
  saveManifest(dir, manifest);
179
203
  validate(dir);
180
- console.log(`Added ${result.type} ${result.id}${result.file ? ` (${result.file})` : ''}.`);
181
- console.log(
182
- 'Connect it with `agrippa pb connect`, then run `agrippa pb format` to lay it out.'
183
- );
204
+ log(`Added ${result.type} ${result.id}${result.file ? ` (${result.file})` : ''}.`);
205
+ log('Connect it with `agrippa pb connect`, then run `agrippa pb format` to lay it out.');
184
206
  }
185
207
 
186
208
  async function pbRemove(opts) {
@@ -192,7 +214,7 @@ async function pbRemove(opts) {
192
214
  saveStructure(dir, structure);
193
215
  saveManifest(dir, manifest);
194
216
  validate(dir);
195
- console.log(
217
+ log(
196
218
  `Removed ${result.removed.length} node(s) [${result.removed.join(', ')}], ` +
197
219
  `${result.removedEdges} dangling edge(s), ${deletes.length} file(s).`
198
220
  );
@@ -212,11 +234,11 @@ async function pbConnect(opts) {
212
234
  });
213
235
  saveStructure(dir, structure);
214
236
  validate(dir);
215
- console.log(
237
+ log(
216
238
  `Connected ${result.from} → ${result.to} (${result.id})${opts.default ? ' [default]' : ''}.`
217
239
  );
218
- for (const w of result.warnings || []) console.warn(` ! ${w}`);
219
- console.log('Run `agrippa pb format` to route it.');
240
+ for (const w of result.warnings || []) warn(` ! ${w}`);
241
+ log('Run `agrippa pb format` to route it.');
220
242
  }
221
243
 
222
244
  async function pbDisconnect(opts) {
@@ -227,7 +249,7 @@ async function pbDisconnect(opts) {
227
249
  const { result } = disconnect(structure, { id: opts.id, from: opts.from, to: opts.to });
228
250
  saveStructure(dir, structure);
229
251
  validate(dir);
230
- console.log(`Removed ${result.removed} edge(s)${result.id ? ` (${result.id})` : ''}.`);
252
+ log(`Removed ${result.removed} edge(s)${result.id ? ` (${result.id})` : ''}.`);
231
253
  }
232
254
 
233
255
  async function pbSetDefault(opts) {
@@ -238,11 +260,11 @@ async function pbSetDefault(opts) {
238
260
  const { result } = setDefault(structure, { id: opts.id, from: opts.from, to: opts.to });
239
261
  saveStructure(dir, structure);
240
262
  validate(dir);
241
- console.log(
263
+ log(
242
264
  `Default flow on ${result.from} is now ${result.id} (→ ${result.to})` +
243
265
  `${result.prev && result.prev !== result.id ? `, was ${result.prev}` : ''}.`
244
266
  );
245
- for (const w of result.warnings || []) console.warn(` ! ${w}`);
267
+ for (const w of result.warnings || []) warn(` ! ${w}`);
246
268
  }
247
269
 
248
270
  async function pbList(opts) {
@@ -252,7 +274,7 @@ async function pbList(opts) {
252
274
  for (const r of rows) {
253
275
  const where = r.parent ? ` [in ${r.parent}]` : '';
254
276
  const label = r.name ? ` "${r.name}"` : '';
255
- console.log(`${r.id} (${r.type})${label}${where}`);
277
+ log(`${r.id} (${r.type})${label}${where}`);
256
278
  for (const e of r.edges) {
257
279
  const tag = [
258
280
  e.isDefault && '[default]',
@@ -261,10 +283,10 @@ async function pbList(opts) {
261
283
  ]
262
284
  .filter(Boolean)
263
285
  .join(' ');
264
- console.log(` → ${e.target} (${e.id})${tag ? ` ${tag}` : ''}`);
286
+ log(` → ${e.target} (${e.id})${tag ? ` ${tag}` : ''}`);
265
287
  }
266
288
  }
267
- console.log(`\n${rows.length} node(s).`);
289
+ log(`\n${rows.length} node(s).`);
268
290
  }
269
291
 
270
292
  async function pbPreview(opts) {
@@ -273,7 +295,7 @@ async function pbPreview(opts) {
273
295
  const svg = toSvg(structure);
274
296
  const out = opts.out || join(dir, 'preview.svg');
275
297
  writeFileSync(out, svg, 'utf-8');
276
- console.log(`Wrote ${out} (${svg.length} bytes).`);
298
+ log(`Wrote ${out} (${svg.length} bytes).`);
277
299
  }
278
300
 
279
301
  async function pbLint(opts) {
@@ -281,11 +303,21 @@ async function pbLint(opts) {
281
303
  const { structure } = loadProject(dir);
282
304
  const issues = lintAll(structure);
283
305
  if (!issues.length) {
284
- console.log('No issues.');
306
+ log('No issues.');
285
307
  } else {
286
- for (const w of issues) console.warn(` ! ${w}`);
308
+ for (const w of issues) warn(` ! ${w}`);
287
309
  process.exitCode = 1;
288
310
  }
289
311
  }
290
312
 
291
- export { pbFormat, pbAdd, pbRemove, pbConnect, pbDisconnect, pbSetDefault, pbList, pbPreview, pbLint };
313
+ export {
314
+ pbFormat,
315
+ pbAdd,
316
+ pbRemove,
317
+ pbConnect,
318
+ pbDisconnect,
319
+ pbSetDefault,
320
+ pbList,
321
+ pbPreview,
322
+ pbLint,
323
+ };
@@ -1,9 +1,11 @@
1
1
  import { dirname } from 'path';
2
2
  import inquirer from 'inquirer';
3
3
  import { getToken } from '../../lib/auth.js';
4
+ import { log, warn } from '../../lib/logger.js';
4
5
  import { describeWorkflow, getPhasesByIds, getPhasesByWorkflow, listMfas } from '../lib/api.js';
5
6
  import { computeChecksum } from '../lib/checksum.js';
6
7
  import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
8
+ import { fetchUpstream } from '../lib/lrpApi.js';
7
9
  import { getProcess } from '../lib/pbApi.js';
8
10
  import { localChecksum, remoteChecksumPb } from '../lib/pbProject.js';
9
11
  import { projectReader } from '../lib/pbWorkspace.js';
@@ -14,9 +16,10 @@ import {
14
16
  writeCodeFile,
15
17
  writeWorkflowDoc,
16
18
  } from '../lib/workspace.js';
19
+ import { pullLrpEntry } from './pullLrp.js';
17
20
  import { pullPbEntry } from './pullPb.js';
18
21
 
19
- async function pull() {
22
+ async function pull(opts = {}) {
20
23
  const config = readConfig();
21
24
  loadEffectiveEnv(config);
22
25
 
@@ -27,19 +30,23 @@ async function pull() {
27
30
  // Stale-file check
28
31
  const stale = config.workspace.filter((e) => !fileExists(e.path));
29
32
  if (stale.length) {
30
- console.log('\nThe following tracked files no longer exist on disk:');
31
- stale.forEach((e) => console.log(` - ${e.path} (${e.name})`));
32
- const { cleanup } = await inquirer.prompt([
33
- {
34
- type: 'confirm',
35
- name: 'cleanup',
36
- message: 'Remove these entries from the workspace config?',
37
- default: true,
38
- },
39
- ]);
40
- if (cleanup) {
41
- config.workspace = config.workspace.filter((e) => fileExists(e.path));
42
- writeConfig(config);
33
+ log('\nThe following tracked files no longer exist on disk:');
34
+ stale.forEach((e) => log(` - ${e.path} (${e.name})`));
35
+ if (opts.nonInteractive) {
36
+ log(' (non-interactive: leaving them tracked; run interactively to clean up)');
37
+ } else {
38
+ const { cleanup } = await inquirer.prompt([
39
+ {
40
+ type: 'confirm',
41
+ name: 'cleanup',
42
+ message: 'Remove these entries from the workspace config?',
43
+ default: true,
44
+ },
45
+ ]);
46
+ if (cleanup) {
47
+ config.workspace = config.workspace.filter((e) => fileExists(e.path));
48
+ writeConfig(config);
49
+ }
43
50
  }
44
51
  }
45
52
 
@@ -50,11 +57,13 @@ async function pull() {
50
57
  const changedWorkflowIds = new Set();
51
58
 
52
59
  // ── 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');
60
+ // process_builder wizards and LRPs are refreshed separately (different
61
+ // checksum/identity model); see pullPbEntries/pullLrpEntries below.
62
+ const pullable = config.workspace.filter(
63
+ (e) => e.object_type !== 'process_builder' && e.object_type !== 'long_running_process'
64
+ );
56
65
  if (pullable.length) {
57
- console.log('Fetching remote code...');
66
+ log('Fetching remote code...');
58
67
  const remoteCodeMap = await fetchRemoteCode(token, ripUrl, pullable);
59
68
 
60
69
  const classified = pullable.map((entry) => {
@@ -81,12 +90,12 @@ async function pull() {
81
90
  const changed = classified.filter((e) => e.status !== 'unchanged');
82
91
 
83
92
  if (!changed.length) {
84
- console.log('Everything is up to date.');
93
+ log('Everything is up to date.');
85
94
  } else {
86
- const selected = await selectEntries(changed, 'pull (overwrites local files)');
95
+ const selected = await selectEntries(changed, 'pull (overwrites local files)', opts);
87
96
 
88
97
  if (!selected.length) {
89
- console.log('Nothing selected. No changes made.');
98
+ log('Nothing selected. No changes made.');
90
99
  } else {
91
100
  for (const entry of selected) {
92
101
  writeCodeFile(entry.path, entry.remoteCode);
@@ -101,15 +110,18 @@ async function pull() {
101
110
  }
102
111
  }
103
112
  writeConfig(config);
104
- console.log(`\nPulled ${selected.length} record(s).`);
113
+ log(`\nPulled ${selected.length} record(s).`);
105
114
  }
106
115
  }
107
116
  } else {
108
- console.log('No tracked resources. Run `agrippa clone` first.');
117
+ log('No tracked resources. Run `agrippa clone` first.');
109
118
  }
110
119
 
111
120
  // ── refresh tracked process-builder wizards ───────────────────────────────
112
- await pullPbEntries(token, config);
121
+ await pullPbEntries(token, config, opts);
122
+
123
+ // ── refresh tracked long-running processes ────────────────────────────────
124
+ await pullLrpEntries(token, config, opts);
113
125
 
114
126
  // ── discover new phases on tracked workflows ──────────────────────────────
115
127
  await discoverNewPhases(token, ripUrl, config, changedWorkflowIds);
@@ -120,13 +132,13 @@ async function pull() {
120
132
  // unchanged local === remote semantically → nothing to do
121
133
  // fast-forward remote changed, local untouched since pull → safe overwrite
122
134
  // conflict local diverged from checksum_at_pull → overwrite loses local work
123
- async function pullPbEntries(token, config) {
135
+ async function pullPbEntries(token, config, opts = {}) {
124
136
  const entries = config.workspace.filter((e) => e.object_type === 'process_builder');
125
137
  if (!entries.length) return;
126
138
  if (!process.env.PB_URL)
127
139
  throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
128
140
 
129
- console.log('Checking process-builder wizards...');
141
+ log('Checking process-builder wizards...');
130
142
  const classified = [];
131
143
  for (const entry of entries) {
132
144
  let upstream = null;
@@ -136,7 +148,7 @@ async function pullPbEntries(token, config) {
136
148
  upstream = null;
137
149
  }
138
150
  if (!upstream) {
139
- console.warn(` ${entry.name}: could not fetch upstream, skipping`);
151
+ warn(` ${entry.name}: could not fetch upstream, skipping`);
140
152
  continue;
141
153
  }
142
154
  const localSemantic = localChecksum(projectReader(entry.path));
@@ -151,13 +163,13 @@ async function pullPbEntries(token, config) {
151
163
 
152
164
  const changed = classified.filter((e) => e.status !== 'unchanged');
153
165
  if (!changed.length) {
154
- console.log('Wizards are up to date.');
166
+ log('Wizards are up to date.');
155
167
  return;
156
168
  }
157
169
 
158
- const selected = await selectEntries(changed, 'pull (overwrites local wizard files)');
170
+ const selected = await selectEntries(changed, 'pull (overwrites local wizard files)', opts);
159
171
  if (!selected.length) {
160
- console.log('No wizards selected.');
172
+ log('No wizards selected.');
161
173
  return;
162
174
  }
163
175
 
@@ -173,10 +185,79 @@ async function pullPbEntries(token, config) {
173
185
  if (res.newStatus) config.workspace[idx].status = res.newStatus;
174
186
  }
175
187
  const note = res.diffs.length ? ` (WARNING: ${res.diffs.length} round-trip diff(s))` : '';
176
- console.log(` ${entry.name} → refreshed${note}`);
188
+ log(` ${entry.name} → refreshed${note}`);
177
189
  }
178
190
  writeConfig(config);
179
- console.log(`\nPulled ${selected.length} wizard(s). Local backups in .backup/${backupTs}/`);
191
+ log(`\nPulled ${selected.length} wizard(s). Local backups in .backup/${backupTs}/`);
192
+ }
193
+
194
+ // Refresh tracked long-running processes from upstream. Same classification
195
+ // concern as pullPbEntries, but entries are located by NAME (the tabulator id
196
+ // changes on every save/version — never a stable key).
197
+ async function pullLrpEntries(token, config, opts = {}) {
198
+ const entries = config.workspace.filter((e) => e.object_type === 'long_running_process');
199
+ if (!entries.length) return;
200
+ if (!process.env.IMPORTEXPORT_URL)
201
+ throw new Error(
202
+ 'IMPORTEXPORT_URL is not configured. Run `prbot init` or set it in agrippa.yaml.'
203
+ );
204
+
205
+ log('Checking long-running processes...');
206
+ const classified = [];
207
+ for (const entry of entries) {
208
+ let upstream = null;
209
+ try {
210
+ upstream = await fetchUpstream(token, entry.name);
211
+ } catch {
212
+ upstream = null;
213
+ }
214
+ if (!upstream) {
215
+ warn(` ${entry.name}: could not fetch upstream, skipping`);
216
+ continue;
217
+ }
218
+ const localSemantic = localChecksum(projectReader(entry.path));
219
+ const remoteSemantic = remoteChecksumPb(upstream.payload);
220
+ const pullChecksum = entry.checksum_at_pull;
221
+ let status;
222
+ if (localSemantic === remoteSemantic) status = 'unchanged';
223
+ else if (pullChecksum === localSemantic) status = 'fast-forward';
224
+ else status = 'conflict';
225
+ classified.push({ ...entry, upstream, status });
226
+ }
227
+
228
+ const changed = classified.filter((e) => e.status !== 'unchanged');
229
+ if (!changed.length) {
230
+ log('Long-running processes are up to date.');
231
+ return;
232
+ }
233
+
234
+ const selected = await selectEntries(changed, 'pull (overwrites local LRP files)', opts);
235
+ if (!selected.length) {
236
+ log('No long-running processes selected.');
237
+ return;
238
+ }
239
+
240
+ const backupTs = new Date().toISOString().replace(/:/g, '-').replace('T', '_').slice(0, 19);
241
+ for (const entry of selected) {
242
+ const res = await pullLrpEntry(token, entry, '.backup', backupTs);
243
+ const idx = config.workspace.findIndex(
244
+ (e) => e.object_type === 'long_running_process' && e.name === entry.name
245
+ );
246
+ if (idx !== -1) {
247
+ config.workspace[idx].checksum_at_pull = res.newChecksum;
248
+ config.workspace[idx].tenant_id = res.newRow.tenantId;
249
+ config.workspace[idx].svg = res.newRow.bpmnFileSvg;
250
+ config.workspace[idx].description = res.newRow.description;
251
+ config.workspace[idx].version = res.newRow.version;
252
+ config.workspace[idx].status = res.newRow.status;
253
+ }
254
+ const note = res.diffs.length ? ` (WARNING: ${res.diffs.length} round-trip diff(s))` : '';
255
+ log(` ${entry.name} → refreshed${note}`);
256
+ }
257
+ writeConfig(config);
258
+ log(
259
+ `\nPulled ${selected.length} long-running process(es). Local backups in .backup/${backupTs}/`
260
+ );
180
261
  }
181
262
 
182
263
  async function discoverNewPhases(token, ripUrl, config, changedWorkflowIds = new Set()) {
@@ -218,7 +299,7 @@ async function discoverNewPhases(token, ripUrl, config, changedWorkflowIds = new
218
299
  checksum_at_pull: computeChecksum(phase.code),
219
300
  name: `${wfName} / ${phase.name}`,
220
301
  });
221
- console.log(` new phase: ${filePath}`);
302
+ log(` new phase: ${filePath}`);
222
303
  newCount++;
223
304
  wfChanged = true;
224
305
  }
@@ -231,14 +312,14 @@ async function discoverNewPhases(token, ripUrl, config, changedWorkflowIds = new
231
312
  const structure = await describeWorkflow(token, ripUrl, wfId);
232
313
  writeWorkflowDoc(basePath, structure);
233
314
  } catch (err) {
234
- console.warn(` could not refresh workflow.yml for ${wfName}: ${err.message}`);
315
+ warn(` could not refresh workflow.yml for ${wfName}: ${err.message}`);
235
316
  }
236
317
  }
237
318
  }
238
319
 
239
320
  if (newCount) {
240
321
  writeConfig(config);
241
- console.log(`Added ${newCount} new phase(s) from tracked workflows.`);
322
+ log(`Added ${newCount} new phase(s) from tracked workflows.`);
242
323
  }
243
324
  }
244
325
 
@@ -267,18 +348,32 @@ async function fetchRemoteCode(token, ripUrl, workspace) {
267
348
  return map;
268
349
  }
269
350
 
270
- async function selectEntries(changed, verb, mode = 'pull') {
271
- const badgeFor = (status) => {
272
- if (mode === 'push') {
273
- return status === 'fast-forward' ? ' safe' : '⚠ conflict';
351
+ // `changed` only ever contains `fast-forward` and `conflict` entries (the
352
+ // caller already filtered out `unchanged`). `conflict` means local and remote
353
+ // have both diverged from the last-known-good baseline — applying it blindly
354
+ // can silently lose either side's work, so it is never preselected and is
355
+ // refused outright in `--non-interactive` mode instead of being silently
356
+ // skipped or applied.
357
+ async function selectEntries(changed, verb, opts = {}) {
358
+ const conflicts = changed.filter((e) => e.status === 'conflict');
359
+
360
+ if (opts.nonInteractive) {
361
+ if (conflicts.length) {
362
+ throw new Error(
363
+ `CONFLICT: refusing to ${verb} non-interactively — ${conflicts.length} resource(s) ` +
364
+ `in conflict: ${conflicts.map((e) => e.name).join(', ')}. Resolve interactively ` +
365
+ `or re-run once the conflicting side is reconciled.`
366
+ );
274
367
  }
275
- return status === 'fast-forward' ? '↑ safe' : '⚠ conflict';
276
- };
368
+ return changed;
369
+ }
370
+
371
+ const badgeFor = (status) => (status === 'fast-forward' ? '↑ safe' : '⚠ conflict');
277
372
 
278
373
  const choices = changed.map((e) => ({
279
374
  name: `${e.name} [${badgeFor(e.status)}]`,
280
375
  value: e,
281
- checked: true,
376
+ checked: e.status !== 'conflict',
282
377
  }));
283
378
 
284
379
  const { selected } = await inquirer.prompt([
@@ -0,0 +1,54 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fetchUpstream } from '../lib/lrpApi.js';
4
+ import { checksumOfPayload, comparePayload, decompose, recompose } from '../lib/pbProject.js';
5
+ import { projectReader, writeProject } from '../lib/pbWorkspace.js';
6
+
7
+ // Delete files under <baseDir>/scripts that are not in the fresh decompose
8
+ // map, so a refresh that renames/removes scripts doesn't leave orphans behind
9
+ // (LRPs have no pages/ dir — no user tasks).
10
+ function pruneOrphans(baseDir, files) {
11
+ const dir = join(baseDir, 'scripts');
12
+ if (!existsSync(dir)) return;
13
+ for (const f of readdirSync(dir)) {
14
+ if (!(`scripts/${f}` in files)) rmSync(join(dir, f));
15
+ }
16
+ }
17
+
18
+ // Refresh one cloned LRP from upstream (the pull counterpart of
19
+ // pushLrpEntry): back up the current local state, re-decompose the upstream
20
+ // XML, overwrite local files (pruning orphans), and verify the round-trip.
21
+ // `entry.upstream` is the {row, payload} fetched during classification;
22
+ // re-resolved by name if absent (id may have changed since the last pull).
23
+ async function pullLrpEntry(token, entry, backupDir, backupTs) {
24
+ const upstream = entry.upstream ?? (await fetchUpstream(token, entry.name));
25
+ if (!upstream) throw new Error(`could not fetch upstream LRP "${entry.name}"`);
26
+
27
+ // 1. Backup the current local state (recomposed payload) before overwriting.
28
+ try {
29
+ const localPayload = recompose(projectReader(entry.path));
30
+ const backupPath = join(backupDir, backupTs, entry.path, 'local.json');
31
+ mkdirSync(dirname(backupPath), { recursive: true });
32
+ writeFileSync(backupPath, JSON.stringify(localPayload, null, 2), 'utf-8');
33
+ } catch {
34
+ // local files unreadable (e.g. mid-edit) — nothing safe to back up, proceed.
35
+ }
36
+
37
+ // 2. Decompose upstream → fresh file map; prune orphans; write.
38
+ const { files } = decompose(upstream.payload);
39
+ pruneOrphans(entry.path, files);
40
+ writeProject(entry.path, files);
41
+
42
+ // 3. Verify the freshly-written project reconstructs the upstream XML.
43
+ // (pages diff is expected noise for LRPs — see cloneLrp.js.)
44
+ const rebuilt = recompose(projectReader(entry.path));
45
+ const diffs = comparePayload(upstream.payload, rebuilt).filter((d) => !d.startsWith('pages:'));
46
+
47
+ return {
48
+ newChecksum: checksumOfPayload(rebuilt),
49
+ newRow: upstream.row,
50
+ diffs,
51
+ };
52
+ }
53
+
54
+ export { pullLrpEntry };
@@ -1,8 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
- import { computeChecksum } from '../lib/checksum.js';
4
3
  import { getProcess } from '../lib/pbApi.js';
5
- import { comparePayload, decompose, recompose, stableStringify } from '../lib/pbProject.js';
4
+ import { checksumOfPayload, comparePayload, decompose, recompose } from '../lib/pbProject.js';
6
5
  import { projectReader, writeProject } from '../lib/pbWorkspace.js';
7
6
 
8
7
  // Delete files under <baseDir>/<sub> that are not in the fresh decompose map, so
@@ -44,7 +43,7 @@ async function pullPbEntry(token, entry, backupDir, backupTs) {
44
43
  const diffs = comparePayload(upstream, rebuilt);
45
44
 
46
45
  return {
47
- newChecksum: computeChecksum(stableStringify(rebuilt)),
46
+ newChecksum: checksumOfPayload(rebuilt),
48
47
  newUpdatedDate: upstream.updated_date,
49
48
  newStatus: upstream.status,
50
49
  diffs,