@waron97/prbot 3.1.4 → 3.2.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 (39) hide show
  1. package/README.md +50 -11
  2. package/agrippa-pb.md +350 -0
  3. package/package.json +7 -3
  4. package/src/agrippa/commands/clone.js +29 -13
  5. package/src/agrippa/commands/clonePb.js +107 -0
  6. package/src/agrippa/commands/diff.js +94 -18
  7. package/src/agrippa/commands/init.js +66 -12
  8. package/src/agrippa/commands/initPhase.js +16 -17
  9. package/src/agrippa/commands/pb.js +279 -0
  10. package/src/agrippa/commands/pull.js +119 -13
  11. package/src/agrippa/commands/pullPb.js +54 -0
  12. package/src/agrippa/commands/push.js +112 -47
  13. package/src/agrippa/commands/pushPb.js +87 -0
  14. package/src/agrippa/commands/repair.js +3 -1
  15. package/src/agrippa/index.js +138 -14
  16. package/src/agrippa/lib/api.js +17 -3
  17. package/src/agrippa/lib/checksum.js +3 -1
  18. package/src/agrippa/lib/config.js +2 -2
  19. package/src/agrippa/lib/pbApi.js +71 -0
  20. package/src/agrippa/lib/pbEdit.js +467 -0
  21. package/src/agrippa/lib/pbLayout.js +283 -0
  22. package/src/agrippa/lib/pbModel.js +698 -0
  23. package/src/agrippa/lib/pbPreview.js +151 -0
  24. package/src/agrippa/lib/pbProject.js +390 -0
  25. package/src/agrippa/lib/pbWorkspace.js +30 -0
  26. package/src/agrippa/lib/workspace.js +23 -3
  27. package/src/commands/autopr.js +5 -2
  28. package/src/commands/changelog.js +4 -1
  29. package/src/commands/export.js +3 -3
  30. package/src/commands/exportEmailTemplates.js +25 -15
  31. package/src/commands/exportImperex.js +4 -4
  32. package/src/commands/exportLrp.js +10 -7
  33. package/src/commands/exportPb.js +4 -5
  34. package/src/commands/exportWorkflow.js +27 -14
  35. package/src/commands/init.js +7 -0
  36. package/src/commands/routine.js +7 -5
  37. package/src/index.js +24 -7
  38. package/src/lib/premigrate.js +3 -3
  39. package/src/lib/updateCheck.js +5 -2
@@ -0,0 +1,279 @@
1
+ // `agrippa pb` — local editing helpers for a cloned process-builder wizard.
2
+ //
3
+ // All of these operate purely on the local decomposed project (no network):
4
+ // format auto-lay-out the diagram (elkjs) → rewrite layout/waypoints
5
+ // add add a node (+ scaffold script/page/manifest), stub geometry
6
+ // rm remove a node, its edges, and its script/page files
7
+ // connect add a sequenceFlow between two nodes
8
+ // disconnect remove a sequenceFlow
9
+ // set-default mark an existing flow as the source gateway's default
10
+ // ls list nodes/edges (so an agent can discover ids without the YAML)
11
+ //
12
+ // Mutations stub geometry; run `pb format` afterwards to finalize layout. The
13
+ // project is resolved from the workspace by document_id (--pb), single-entry
14
+ // auto-select, or a fuzzy prompt.
15
+
16
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
17
+ import { dirname, join } from 'path';
18
+ import search from '@inquirer/search';
19
+ import { parse as yamlParse } from 'yaml';
20
+ import { fuzzyMatch } from '../../lib/fuzzy.js';
21
+ import { readConfig } from '../lib/config.js';
22
+ import {
23
+ addNode,
24
+ addNodeBetween,
25
+ connect,
26
+ disconnect,
27
+ eachNode,
28
+ lintGateways,
29
+ listGraph,
30
+ removeNode,
31
+ setDefault,
32
+ } from '../lib/pbEdit.js';
33
+ import { autoLayout } from '../lib/pbLayout.js';
34
+ import { toSvg } from '../lib/pbPreview.js';
35
+ import { MANIFEST_FILE, recompose, stringifyStructure, STRUCTURE_FILE } from '../lib/pbProject.js';
36
+ import { projectReader } from '../lib/pbWorkspace.js';
37
+
38
+ // ---------- project resolution ----------
39
+
40
+ async function resolveProjectPath(opts) {
41
+ const config = readConfig();
42
+ const entries = (config.workspace || []).filter((e) => e.object_type === 'process_builder');
43
+ if (!entries.length) {
44
+ throw new Error(
45
+ 'No process-builder wizards in this workspace. Clone one with `agrippa clone --pb`.'
46
+ );
47
+ }
48
+ const sel = opts.pb || opts.name;
49
+ 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;
53
+ }
54
+ if (entries.length === 1) return entries[0].path;
55
+ const entry = await search({
56
+ message: 'Select a cloned wizard:',
57
+ source: (input) => {
58
+ const list = input
59
+ ? entries.filter(
60
+ (e) => fuzzyMatch(e.name, input) || fuzzyMatch(e.document_id, input)
61
+ )
62
+ : entries;
63
+ return list.map((e) => ({ name: `${e.name} (${e.document_id})`, value: e }));
64
+ },
65
+ });
66
+ return entry.path;
67
+ }
68
+
69
+ // ---------- disk helpers ----------
70
+
71
+ function loadProject(dir) {
72
+ const read = projectReader(dir);
73
+ const structure = yamlParse(read(STRUCTURE_FILE));
74
+ const manifest = JSON.parse(read(MANIFEST_FILE));
75
+ return { structure, manifest };
76
+ }
77
+ function saveStructure(dir, structure) {
78
+ writeFileSync(join(dir, STRUCTURE_FILE), stringifyStructure(structure), 'utf-8');
79
+ }
80
+ function saveManifest(dir, manifest) {
81
+ writeFileSync(join(dir, MANIFEST_FILE), JSON.stringify(manifest, null, 2), 'utf-8');
82
+ }
83
+ function applyEffects(dir, { writes, deletes }) {
84
+ for (const [rel, content] of Object.entries(writes || {})) {
85
+ const full = join(dir, rel);
86
+ mkdirSync(dirname(full), { recursive: true });
87
+ writeFileSync(full, content, 'utf-8');
88
+ }
89
+ for (const rel of deletes || []) {
90
+ const full = join(dir, rel);
91
+ if (existsSync(full)) unlinkSync(full);
92
+ }
93
+ }
94
+ function listScriptFiles(dir) {
95
+ const sdir = join(dir, 'scripts');
96
+ if (!existsSync(sdir)) return [];
97
+ return readdirSync(sdir)
98
+ .filter((f) => f.endsWith('.js'))
99
+ .map((f) => `scripts/${f}`);
100
+ }
101
+ // Confirm the project still recomposes (graph valid, diagram builds).
102
+ function validate(dir) {
103
+ try {
104
+ recompose(projectReader(dir));
105
+ } catch (e) {
106
+ console.warn(`WARNING: project no longer recomposes cleanly: ${e.message}`);
107
+ }
108
+ }
109
+
110
+ // ---------- commands ----------
111
+
112
+ async function pbFormat(opts) {
113
+ const dir = await resolveProjectPath(opts);
114
+ const { structure } = loadProject(dir);
115
+ await autoLayout(structure);
116
+ saveStructure(dir, structure);
117
+
118
+ let nodes = 0;
119
+ let missing = 0;
120
+ eachNode(structure.nodes, null, (n) => {
121
+ nodes++;
122
+ if (!n.layout) missing++;
123
+ });
124
+ validate(dir);
125
+ console.log(
126
+ `Formatted ${dir} (${nodes} node(s) laid out${missing ? `, ${missing} without layout` : ''}).`
127
+ );
128
+ const issues = lintGateways(structure);
129
+ if (issues.length) {
130
+ console.warn('Gateway issues (exclusiveGateway default/condition rule):');
131
+ for (const w of issues) console.warn(` ! ${w}`);
132
+ }
133
+ }
134
+
135
+ async function pbAdd(opts) {
136
+ if (!opts.type)
137
+ throw new Error(
138
+ '--type is required (e.g. scriptTask, serviceTask, userTask, exclusiveGateway, subProcess, endEvent...)'
139
+ );
140
+ if ((opts.from || opts.to) && !(opts.from && opts.to))
141
+ throw new Error('--from and --to must be used together');
142
+ if (opts.from && opts.parent)
143
+ throw new Error('--parent is implied by --from/--to; pass only one');
144
+
145
+ const dir = await resolveProjectPath(opts);
146
+ const { structure, manifest } = loadProject(dir);
147
+ const ctx = { existingScripts: listScriptFiles(dir), documentId: manifest.document_id };
148
+
149
+ if (opts.from) {
150
+ const { writes, result } = addNodeBetween(
151
+ structure,
152
+ manifest,
153
+ { from: opts.from, to: opts.to, type: opts.type, name: opts.name },
154
+ ctx
155
+ );
156
+ applyEffects(dir, { writes, deletes: [] });
157
+ saveStructure(dir, structure);
158
+ saveManifest(dir, manifest);
159
+ validate(dir);
160
+ console.log(
161
+ `Added ${result.type} ${result.id}${result.file ? ` (${result.file})` : ''} between ${opts.from} → ${opts.to}.`
162
+ );
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.');
167
+ return;
168
+ }
169
+
170
+ const { writes, result } = addNode(
171
+ structure,
172
+ manifest,
173
+ { type: opts.type, name: opts.name, parentId: opts.parent },
174
+ ctx
175
+ );
176
+ applyEffects(dir, { writes, deletes: [] });
177
+ saveStructure(dir, structure);
178
+ saveManifest(dir, manifest);
179
+ 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
+ );
184
+ }
185
+
186
+ async function pbRemove(opts) {
187
+ if (!opts.id) throw new Error('--id is required');
188
+ const dir = await resolveProjectPath(opts);
189
+ const { structure, manifest } = loadProject(dir);
190
+ const { deletes, result } = removeNode(structure, manifest, { id: opts.id });
191
+ applyEffects(dir, { writes: {}, deletes });
192
+ saveStructure(dir, structure);
193
+ saveManifest(dir, manifest);
194
+ validate(dir);
195
+ console.log(
196
+ `Removed ${result.removed.length} node(s) [${result.removed.join(', ')}], ` +
197
+ `${result.removedEdges} dangling edge(s), ${deletes.length} file(s).`
198
+ );
199
+ }
200
+
201
+ async function pbConnect(opts) {
202
+ if (!opts.from || !opts.to) throw new Error('--from and --to are required');
203
+ const dir = await resolveProjectPath(opts);
204
+ const { structure } = loadProject(dir);
205
+ const { result } = connect(structure, {
206
+ from: opts.from,
207
+ to: opts.to,
208
+ name: opts.name,
209
+ condition: opts.condition,
210
+ conditionType: opts.conditionType,
211
+ makeDefault: opts.default,
212
+ });
213
+ saveStructure(dir, structure);
214
+ validate(dir);
215
+ console.log(
216
+ `Connected ${result.from} → ${result.to} (${result.id})${opts.default ? ' [default]' : ''}.`
217
+ );
218
+ for (const w of result.warnings || []) console.warn(` ! ${w}`);
219
+ console.log('Run `agrippa pb format` to route it.');
220
+ }
221
+
222
+ async function pbDisconnect(opts) {
223
+ if (!opts.id && !(opts.from && opts.to))
224
+ throw new Error('provide --id, or both --from and --to');
225
+ const dir = await resolveProjectPath(opts);
226
+ const { structure } = loadProject(dir);
227
+ const { result } = disconnect(structure, { id: opts.id, from: opts.from, to: opts.to });
228
+ saveStructure(dir, structure);
229
+ validate(dir);
230
+ console.log(`Removed ${result.removed} edge(s)${result.id ? ` (${result.id})` : ''}.`);
231
+ }
232
+
233
+ async function pbSetDefault(opts) {
234
+ if (!opts.id && !(opts.from && opts.to))
235
+ throw new Error('provide --id, or both --from and --to');
236
+ const dir = await resolveProjectPath(opts);
237
+ const { structure } = loadProject(dir);
238
+ const { result } = setDefault(structure, { id: opts.id, from: opts.from, to: opts.to });
239
+ saveStructure(dir, structure);
240
+ validate(dir);
241
+ console.log(
242
+ `Default flow on ${result.from} is now ${result.id} (→ ${result.to})` +
243
+ `${result.prev && result.prev !== result.id ? `, was ${result.prev}` : ''}.`
244
+ );
245
+ for (const w of result.warnings || []) console.warn(` ! ${w}`);
246
+ }
247
+
248
+ async function pbList(opts) {
249
+ const dir = await resolveProjectPath(opts);
250
+ const { structure } = loadProject(dir);
251
+ const rows = listGraph(structure);
252
+ for (const r of rows) {
253
+ const where = r.parent ? ` [in ${r.parent}]` : '';
254
+ const label = r.name ? ` "${r.name}"` : '';
255
+ console.log(`${r.id} (${r.type})${label}${where}`);
256
+ for (const e of r.edges) {
257
+ const tag = [
258
+ e.isDefault && '[default]',
259
+ e.name && `"${e.name}"`,
260
+ e.condition && `if ${e.condition}`,
261
+ ]
262
+ .filter(Boolean)
263
+ .join(' ');
264
+ console.log(` → ${e.target} (${e.id})${tag ? ` ${tag}` : ''}`);
265
+ }
266
+ }
267
+ console.log(`\n${rows.length} node(s).`);
268
+ }
269
+
270
+ async function pbPreview(opts) {
271
+ const dir = await resolveProjectPath(opts);
272
+ const { structure } = loadProject(dir);
273
+ const svg = toSvg(structure);
274
+ const out = opts.out || join(dir, 'preview.svg');
275
+ writeFileSync(out, svg, 'utf-8');
276
+ console.log(`Wrote ${out} (${svg.length} bytes).`);
277
+ }
278
+
279
+ export { pbFormat, pbAdd, pbRemove, pbConnect, pbDisconnect, pbSetDefault, pbList, pbPreview };
@@ -1,17 +1,28 @@
1
- import inquirer from 'inquirer';
2
1
  import { dirname } from 'path';
3
- import { readConfig, writeConfig, loadEffectiveEnv } from '../lib/config.js';
4
- import { getPhasesByIds, getPhasesByWorkflow, listMfas } from '../lib/api.js';
2
+ import inquirer from 'inquirer';
5
3
  import { getToken } from '../../lib/auth.js';
4
+ import { describeWorkflow, getPhasesByIds, getPhasesByWorkflow, listMfas } from '../lib/api.js';
6
5
  import { computeChecksum } from '../lib/checksum.js';
7
- import { readCodeFile, writeCodeFile, fileExists, toSlug } from '../lib/workspace.js';
6
+ import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
7
+ import { getProcess } from '../lib/pbApi.js';
8
+ import { localChecksum } from '../lib/pbProject.js';
9
+ import { projectReader } from '../lib/pbWorkspace.js';
10
+ import {
11
+ fileExists,
12
+ readCodeFile,
13
+ toSlug,
14
+ writeCodeFile,
15
+ writeWorkflowDoc,
16
+ } from '../lib/workspace.js';
17
+ import { pullPbEntry } from './pullPb.js';
8
18
 
9
19
  async function pull() {
10
20
  const config = readConfig();
11
21
  loadEffectiveEnv(config);
12
22
 
13
23
  const ripUrl = process.env.RIP_URL;
14
- if (!ripUrl) throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
24
+ if (!ripUrl)
25
+ throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
15
26
 
16
27
  // Stale-file check
17
28
  const stale = config.workspace.filter((e) => !fileExists(e.path));
@@ -34,12 +45,19 @@ async function pull() {
34
45
 
35
46
  const token = await getToken();
36
47
 
48
+ // workflow_ids whose phase code changed this run -> their workflow.yml is
49
+ // refreshed (the graph fetch is skipped for untouched workflows).
50
+ const changedWorkflowIds = new Set();
51
+
37
52
  // ── pull existing tracked entries ─────────────────────────────────────────
38
- if (config.workspace.length) {
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');
56
+ if (pullable.length) {
39
57
  console.log('Fetching remote code...');
40
- const remoteCodeMap = await fetchRemoteCode(token, ripUrl, config.workspace);
58
+ const remoteCodeMap = await fetchRemoteCode(token, ripUrl, pullable);
41
59
 
42
- const classified = config.workspace.map((entry) => {
60
+ const classified = pullable.map((entry) => {
43
61
  const key = `${entry.object_type}:${entry.id}`;
44
62
  const remoteCode = remoteCodeMap.get(key) ?? null;
45
63
  const localCode = readCodeFile(entry.path);
@@ -72,8 +90,11 @@ async function pull() {
72
90
  } else {
73
91
  for (const entry of selected) {
74
92
  writeCodeFile(entry.path, entry.remoteCode);
93
+ if (entry.object_type === 'phase' && entry.workflow_id) {
94
+ changedWorkflowIds.add(entry.workflow_id);
95
+ }
75
96
  const idx = config.workspace.findIndex(
76
- (e) => e.id === entry.id && e.object_type === entry.object_type,
97
+ (e) => e.id === entry.id && e.object_type === entry.object_type
77
98
  );
78
99
  if (idx !== -1) {
79
100
  config.workspace[idx].checksum_at_pull = computeChecksum(entry.remoteCode);
@@ -87,15 +108,86 @@ async function pull() {
87
108
  console.log('No tracked resources. Run `agrippa clone` first.');
88
109
  }
89
110
 
111
+ // ── refresh tracked process-builder wizards ───────────────────────────────
112
+ await pullPbEntries(token, config);
113
+
90
114
  // ── discover new phases on tracked workflows ──────────────────────────────
91
- await discoverNewPhases(token, ripUrl, config);
115
+ await discoverNewPhases(token, ripUrl, config, changedWorkflowIds);
92
116
  }
93
117
 
94
- async function discoverNewPhases(token, ripUrl, config) {
118
+ // 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
124
+ async function pullPbEntries(token, config) {
125
+ const entries = config.workspace.filter((e) => e.object_type === 'process_builder');
126
+ if (!entries.length) return;
127
+ if (!process.env.PB_URL)
128
+ throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
129
+
130
+ console.log('Checking process-builder wizards...');
131
+ const classified = [];
132
+ for (const entry of entries) {
133
+ let upstream = null;
134
+ try {
135
+ upstream = await getProcess(token, entry.guid);
136
+ } catch {
137
+ upstream = null;
138
+ }
139
+ if (!upstream) {
140
+ console.warn(` ${entry.name}: could not fetch upstream, skipping`);
141
+ continue;
142
+ }
143
+ const local = localChecksum(projectReader(entry.path));
144
+ const localChanged = entry.checksum_at_pull !== local;
145
+ const remoteChanged = upstream.updated_date !== entry.updated_date;
146
+ let status;
147
+ if (!remoteChanged) status = 'unchanged';
148
+ else status = localChanged ? 'conflict' : 'fast-forward';
149
+ classified.push({ ...entry, upstream, status });
150
+ }
151
+
152
+ const changed = classified.filter((e) => e.status !== 'unchanged');
153
+ if (!changed.length) {
154
+ console.log('Wizards are up to date.');
155
+ return;
156
+ }
157
+
158
+ const selected = await selectEntries(changed, 'pull (overwrites local wizard files)');
159
+ if (!selected.length) {
160
+ console.log('No wizards selected.');
161
+ return;
162
+ }
163
+
164
+ const backupTs = new Date().toISOString().replace(/:/g, '-').replace('T', '_').slice(0, 19);
165
+ for (const entry of selected) {
166
+ const res = await pullPbEntry(token, entry, '.backup', backupTs);
167
+ const idx = config.workspace.findIndex(
168
+ (e) => e.object_type === 'process_builder' && e.guid === entry.guid
169
+ );
170
+ if (idx !== -1) {
171
+ config.workspace[idx].checksum_at_pull = res.newChecksum;
172
+ if (res.newUpdatedDate) config.workspace[idx].updated_date = res.newUpdatedDate;
173
+ if (res.newStatus) config.workspace[idx].status = res.newStatus;
174
+ }
175
+ const note = res.diffs.length ? ` (WARNING: ${res.diffs.length} round-trip diff(s))` : '';
176
+ console.log(` ${entry.name} → refreshed${note}`);
177
+ }
178
+ writeConfig(config);
179
+ console.log(`\nPulled ${selected.length} wizard(s). Local backups in .backup/${backupTs}/`);
180
+ }
181
+
182
+ async function discoverNewPhases(token, ripUrl, config, changedWorkflowIds = new Set()) {
95
183
  // Build map of workflow_id → { workflow_name, basePath } from existing phase entries
96
184
  const workflows = new Map();
97
185
  for (const entry of config.workspace) {
98
- if (entry.object_type === 'phase' && entry.workflow_id && !workflows.has(entry.workflow_id)) {
186
+ if (
187
+ entry.object_type === 'phase' &&
188
+ entry.workflow_id &&
189
+ !workflows.has(entry.workflow_id)
190
+ ) {
99
191
  workflows.set(entry.workflow_id, {
100
192
  name: entry.workflow_name,
101
193
  basePath: dirname(entry.path),
@@ -106,12 +198,13 @@ async function discoverNewPhases(token, ripUrl, config) {
106
198
  if (!workflows.size) return;
107
199
 
108
200
  const trackedIds = new Set(
109
- config.workspace.filter((e) => e.object_type === 'phase').map((e) => e.id),
201
+ config.workspace.filter((e) => e.object_type === 'phase').map((e) => e.id)
110
202
  );
111
203
 
112
204
  let newCount = 0;
113
205
  for (const [wfId, { name: wfName, basePath }] of workflows) {
114
206
  const phases = await getPhasesByWorkflow(token, ripUrl, wfId, { fromCode: true });
207
+ let wfChanged = changedWorkflowIds.has(wfId);
115
208
  for (const phase of phases) {
116
209
  if (trackedIds.has(phase.id)) continue;
117
210
  const filePath = `${basePath}/${toSlug(phase.name)}.py`;
@@ -127,6 +220,19 @@ async function discoverNewPhases(token, ripUrl, config) {
127
220
  });
128
221
  console.log(` new phase: ${filePath}`);
129
222
  newCount++;
223
+ wfChanged = true;
224
+ }
225
+
226
+ // Refresh the read-only workflow graph only when this workflow's code
227
+ // changed this run, or when its doc is missing (one-time backfill).
228
+ // Skips the per-workflow graph fetch for untouched workflows.
229
+ if (wfChanged || !fileExists(`${basePath}/workflow.yml`)) {
230
+ try {
231
+ const structure = await describeWorkflow(token, ripUrl, wfId);
232
+ writeWorkflowDoc(basePath, structure);
233
+ } catch (err) {
234
+ console.warn(` could not refresh workflow.yml for ${wfName}: ${err.message}`);
235
+ }
130
236
  }
131
237
  }
132
238
 
@@ -0,0 +1,54 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { computeChecksum } from '../lib/checksum.js';
4
+ import { getProcess } from '../lib/pbApi.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>/<sub> that are not in the fresh decompose map, so
9
+ // a refresh that renames/removes scripts or pages doesn't leave orphans behind.
10
+ function pruneOrphans(baseDir, sub, files) {
11
+ const dir = join(baseDir, sub);
12
+ if (!existsSync(dir)) return;
13
+ for (const f of readdirSync(dir)) {
14
+ if (!(`${sub}/${f}` in files)) rmSync(join(dir, f));
15
+ }
16
+ }
17
+
18
+ // Refresh one cloned wizard from upstream (the pull counterpart of pushPbEntry):
19
+ // back up the current local state, re-decompose the upstream payload, overwrite
20
+ // local files (pruning orphans), and verify the round-trip. `entry.upstream` is
21
+ // the payload fetched during classification; refetched if absent.
22
+ async function pullPbEntry(token, entry, backupDir, backupTs) {
23
+ const upstream = entry.upstream ?? (await getProcess(token, entry.guid));
24
+ if (!upstream) throw new Error(`could not fetch upstream wizard ${entry.guid}`);
25
+
26
+ // 1. Backup the current local state (recomposed payload) before overwriting.
27
+ try {
28
+ const localPayload = recompose(projectReader(entry.path));
29
+ const backupPath = join(backupDir, backupTs, entry.path, 'local.json');
30
+ mkdirSync(dirname(backupPath), { recursive: true });
31
+ writeFileSync(backupPath, JSON.stringify(localPayload, null, 2), 'utf-8');
32
+ } catch {
33
+ // local files unreadable (e.g. mid-edit) — nothing safe to back up, proceed.
34
+ }
35
+
36
+ // 2. Decompose upstream → fresh file map; prune orphans; write.
37
+ const { files } = decompose(upstream);
38
+ pruneOrphans(entry.path, 'scripts', files);
39
+ pruneOrphans(entry.path, 'pages', files);
40
+ writeProject(entry.path, files);
41
+
42
+ // 3. Verify the freshly-written project reconstructs the upstream payload.
43
+ const rebuilt = recompose(projectReader(entry.path));
44
+ const diffs = comparePayload(upstream, rebuilt);
45
+
46
+ return {
47
+ newChecksum: computeChecksum(stableStringify(rebuilt)),
48
+ newUpdatedDate: upstream.updated_date,
49
+ newStatus: upstream.status,
50
+ diffs,
51
+ };
52
+ }
53
+
54
+ export { pullPbEntry };