@waron97/prbot 2.8.0 → 3.0.1

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.
@@ -0,0 +1,221 @@
1
+ import inquirer from 'inquirer';
2
+ import { dirname } from 'path';
3
+ import { readConfig, writeConfig, loadEffectiveEnv } from '../lib/config.js';
4
+ import { getPhasesByIds, getPhasesByWorkflow, listMfas } from '../lib/api.js';
5
+ import { getToken } from '../../lib/auth.js';
6
+ import { computeChecksum } from '../lib/checksum.js';
7
+ import { readCodeFile, writeCodeFile, fileExists, toSlug } from '../lib/workspace.js';
8
+
9
+ async function pull() {
10
+ const config = readConfig();
11
+ loadEffectiveEnv(config);
12
+
13
+ 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.');
15
+
16
+ // Stale-file check
17
+ const stale = config.workspace.filter((e) => !fileExists(e.path));
18
+ if (stale.length) {
19
+ console.log('\nThe following tracked files no longer exist on disk:');
20
+ stale.forEach((e) => console.log(` - ${e.path} (${e.name})`));
21
+ const { cleanup } = await inquirer.prompt([
22
+ {
23
+ type: 'confirm',
24
+ name: 'cleanup',
25
+ message: 'Remove these entries from the workspace config?',
26
+ default: true,
27
+ },
28
+ ]);
29
+ if (cleanup) {
30
+ config.workspace = config.workspace.filter((e) => fileExists(e.path));
31
+ writeConfig(config);
32
+ }
33
+ }
34
+
35
+ const token = await getToken();
36
+
37
+ // ── pull existing tracked entries ─────────────────────────────────────────
38
+ if (config.workspace.length) {
39
+ console.log('Fetching remote code...');
40
+ const remoteCodeMap = await fetchRemoteCode(token, ripUrl, config.workspace);
41
+
42
+ const classified = config.workspace.map((entry) => {
43
+ const key = `${entry.object_type}:${entry.id}`;
44
+ const remoteCode = remoteCodeMap.get(key) ?? null;
45
+ const localCode = readCodeFile(entry.path);
46
+
47
+ const remoteChecksum = computeChecksum(remoteCode ?? '');
48
+ const localChecksum = computeChecksum(localCode ?? '');
49
+ const pullChecksum = entry.checksum_at_pull;
50
+
51
+ let status;
52
+ if (pullChecksum === localChecksum && pullChecksum === remoteChecksum) {
53
+ status = 'unchanged';
54
+ } else if (pullChecksum === localChecksum) {
55
+ status = 'fast-forward';
56
+ } else {
57
+ status = 'conflict';
58
+ }
59
+
60
+ return { ...entry, remoteCode, status };
61
+ });
62
+
63
+ const changed = classified.filter((e) => e.status !== 'unchanged');
64
+
65
+ if (!changed.length) {
66
+ console.log('Everything is up to date.');
67
+ } else {
68
+ const selected = await selectEntries(changed, 'pull (overwrites local files)');
69
+
70
+ if (!selected.length) {
71
+ console.log('Nothing selected. No changes made.');
72
+ } else {
73
+ for (const entry of selected) {
74
+ writeCodeFile(entry.path, entry.remoteCode);
75
+ const idx = config.workspace.findIndex(
76
+ (e) => e.id === entry.id && e.object_type === entry.object_type,
77
+ );
78
+ if (idx !== -1) {
79
+ config.workspace[idx].checksum_at_pull = computeChecksum(entry.remoteCode);
80
+ }
81
+ }
82
+ writeConfig(config);
83
+ console.log(`\nPulled ${selected.length} record(s).`);
84
+ }
85
+ }
86
+ } else {
87
+ console.log('No tracked resources. Run `agrippa clone` first.');
88
+ }
89
+
90
+ // ── discover new phases on tracked workflows ──────────────────────────────
91
+ await discoverNewPhases(token, ripUrl, config);
92
+ }
93
+
94
+ async function discoverNewPhases(token, ripUrl, config) {
95
+ // Build map of workflow_id → { workflow_name, basePath } from existing phase entries
96
+ const workflows = new Map();
97
+ for (const entry of config.workspace) {
98
+ if (entry.object_type === 'phase' && entry.workflow_id && !workflows.has(entry.workflow_id)) {
99
+ workflows.set(entry.workflow_id, {
100
+ name: entry.workflow_name,
101
+ basePath: dirname(entry.path),
102
+ });
103
+ }
104
+ }
105
+
106
+ if (!workflows.size) return;
107
+
108
+ const trackedIds = new Set(
109
+ config.workspace.filter((e) => e.object_type === 'phase').map((e) => e.id),
110
+ );
111
+
112
+ let newCount = 0;
113
+ for (const [wfId, { name: wfName, basePath }] of workflows) {
114
+ const phases = await getPhasesByWorkflow(token, ripUrl, wfId, { fromCode: true });
115
+ for (const phase of phases) {
116
+ if (trackedIds.has(phase.id)) continue;
117
+ const filePath = `${basePath}/${toSlug(phase.name)}.py`;
118
+ writeCodeFile(filePath, phase.code);
119
+ config.workspace.push({
120
+ path: filePath,
121
+ id: phase.id,
122
+ object_type: 'phase',
123
+ workflow_id: wfId,
124
+ workflow_name: wfName,
125
+ checksum_at_pull: computeChecksum(phase.code),
126
+ name: `${wfName} / ${phase.name}`,
127
+ });
128
+ console.log(` new phase: ${filePath}`);
129
+ newCount++;
130
+ }
131
+ }
132
+
133
+ if (newCount) {
134
+ writeConfig(config);
135
+ console.log(`Added ${newCount} new phase(s) from tracked workflows.`);
136
+ }
137
+ }
138
+
139
+ // ── shared helpers ────────────────────────────────────────────────────────────
140
+
141
+ async function fetchRemoteCode(token, ripUrl, workspace) {
142
+ const map = new Map();
143
+
144
+ const phaseEntries = workspace.filter((e) => e.object_type === 'phase');
145
+ const mfaEntries = workspace.filter((e) => e.object_type === 'mfa');
146
+
147
+ if (phaseEntries.length) {
148
+ const ids = phaseEntries.map((e) => e.id);
149
+ const phases = await getPhasesByIds(token, ripUrl, ids);
150
+ phases.forEach((p) => map.set(`phase:${p.id}`, p.code));
151
+ }
152
+
153
+ if (mfaEntries.length) {
154
+ const allMfas = await listMfas(token, ripUrl);
155
+ mfaEntries.forEach((e) => {
156
+ const remote = allMfas.find((m) => m.id === e.id);
157
+ if (remote) map.set(`mfa:${e.id}`, remote.code);
158
+ });
159
+ }
160
+
161
+ return map;
162
+ }
163
+
164
+ async function selectEntries(changed, verb, mode = 'pull') {
165
+ // Group by parent folder for folder-level pre-selection
166
+ const folderMap = new Map();
167
+ for (const entry of changed) {
168
+ const folder = dirname(entry.path) || '.';
169
+ if (!folderMap.has(folder)) folderMap.set(folder, []);
170
+ folderMap.get(folder).push(entry);
171
+ }
172
+
173
+ const folders = [...folderMap.keys()];
174
+
175
+ // Folder-level pre-selection (only shown when there are multiple folders)
176
+ let includedFolders = new Set(folders);
177
+ if (folders.length > 1) {
178
+ const { selectedFolders } = await inquirer.prompt([
179
+ {
180
+ type: 'checkbox',
181
+ name: 'selectedFolders',
182
+ message: `Select workflows/folders to ${verb}:`,
183
+ choices: folders.map((f) => ({
184
+ name: `${f}/ (${folderMap.get(f).length} changed)`,
185
+ value: f,
186
+ checked: true,
187
+ })),
188
+ },
189
+ ]);
190
+ includedFolders = new Set(selectedFolders);
191
+ }
192
+
193
+ const candidates = changed.filter((e) => includedFolders.has(dirname(e.path) || '.'));
194
+
195
+ if (!candidates.length) return [];
196
+
197
+ const badgeFor = (status) => {
198
+ if (mode === 'push') {
199
+ return status === 'fast-forward' ? '↑ local updated' : '⚠ remote changes will be overwritten';
200
+ }
201
+ return status === 'fast-forward' ? '↑ remote updated' : '⚠ local changes will be lost';
202
+ };
203
+
204
+ const { selected } = await inquirer.prompt([
205
+ {
206
+ type: 'checkbox',
207
+ name: 'selected',
208
+ message: `Fine-tune records to ${verb}: (space to toggle, a to toggle all)`,
209
+ choices: candidates.map((e) => ({
210
+ name: `[${dirname(e.path) || '.'}] ${e.name} [${badgeFor(e.status)}]`,
211
+ value: e,
212
+ checked: true,
213
+ })),
214
+ pageSize: 20,
215
+ },
216
+ ]);
217
+
218
+ return selected;
219
+ }
220
+
221
+ export { pull, fetchRemoteCode, selectEntries };
@@ -0,0 +1,126 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import inquirer from 'inquirer';
4
+ import { readConfig, writeConfig, loadEffectiveEnv } from '../lib/config.js';
5
+ import { updatePhase, updateMfa } from '../lib/api.js';
6
+ import { getToken } from '../../lib/auth.js';
7
+ import { computeChecksum } from '../lib/checksum.js';
8
+ import { readCodeFile, fileExists } from '../lib/workspace.js';
9
+ import { fetchRemoteCode, selectEntries } from './pull.js';
10
+
11
+ const BACKUP_DIR = '.backup';
12
+
13
+ async function push() {
14
+ const config = readConfig();
15
+ loadEffectiveEnv(config);
16
+
17
+ const ripUrl = process.env.RIP_URL;
18
+ if (!ripUrl) throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
19
+
20
+ // Stale-file check
21
+ const stale = config.workspace.filter((e) => !fileExists(e.path));
22
+ if (stale.length) {
23
+ console.log('\nThe following tracked files no longer exist on disk:');
24
+ stale.forEach((e) => console.log(` - ${e.path} (${e.name})`));
25
+ const { cleanup } = await inquirer.prompt([
26
+ {
27
+ type: 'confirm',
28
+ name: 'cleanup',
29
+ message: 'Remove these entries from the workspace config?',
30
+ default: true,
31
+ },
32
+ ]);
33
+ if (cleanup) {
34
+ config.workspace = config.workspace.filter((e) => fileExists(e.path));
35
+ writeConfig(config);
36
+ }
37
+ }
38
+
39
+ if (!config.workspace.length) {
40
+ console.log('No tracked resources. Run `agrippa clone` first.');
41
+ return;
42
+ }
43
+
44
+ console.log('Fetching remote code...');
45
+ const token = await getToken();
46
+
47
+ const remoteCodeMap = await fetchRemoteCode(token, ripUrl, config.workspace);
48
+
49
+ // Classify — concern here is remote work being overwritten
50
+ const classified = config.workspace.map((entry) => {
51
+ const key = `${entry.object_type}:${entry.id}`;
52
+ const remoteCode = remoteCodeMap.get(key) ?? null;
53
+ const localCode = readCodeFile(entry.path);
54
+
55
+ const remoteChecksum = computeChecksum(remoteCode ?? '');
56
+ const localChecksum = computeChecksum(localCode ?? '');
57
+ const pullChecksum = entry.checksum_at_pull;
58
+
59
+ let status;
60
+ if (pullChecksum === localChecksum && pullChecksum === remoteChecksum) {
61
+ status = 'unchanged';
62
+ } else if (pullChecksum === remoteChecksum) {
63
+ // remote untouched since last pull, local has new changes
64
+ status = 'fast-forward';
65
+ } else {
66
+ // remote changed since last pull — pushing will overwrite remote work
67
+ status = 'conflict';
68
+ }
69
+
70
+ return { ...entry, remoteCode, localCode, status };
71
+ });
72
+
73
+ const changed = classified.filter((e) => e.status !== 'unchanged');
74
+
75
+ if (!changed.length) {
76
+ console.log('Nothing to push — local files match the last-pulled state.');
77
+ return;
78
+ }
79
+
80
+ const selected = await selectEntries(changed, 'push (overwrites remote code)', 'push');
81
+
82
+ if (!selected.length) {
83
+ console.log('Nothing selected. No changes made.');
84
+ return;
85
+ }
86
+
87
+ // Write backups of remote code before overwriting
88
+ const backupTs = new Date()
89
+ .toISOString()
90
+ .replace(/:/g, '-')
91
+ .replace('T', '_')
92
+ .slice(0, 19);
93
+
94
+ for (const entry of selected) {
95
+ if (entry.remoteCode !== null) {
96
+ const backupPath = join(BACKUP_DIR, backupTs, entry.path);
97
+ mkdirSync(dirname(backupPath), { recursive: true });
98
+ writeFileSync(backupPath, (entry.remoteCode ?? '').trim() + '\n', 'utf-8');
99
+ }
100
+ }
101
+ console.log(`Remote backups written to ${BACKUP_DIR}/${backupTs}/`);
102
+
103
+ // Push local code to API
104
+ let pushed = 0;
105
+ for (const entry of selected) {
106
+ const code = entry.localCode ?? '';
107
+ if (entry.object_type === 'phase') {
108
+ await updatePhase(token, ripUrl, entry.id, code);
109
+ } else {
110
+ await updateMfa(token, ripUrl, entry.id, code);
111
+ }
112
+
113
+ const idx = config.workspace.findIndex(
114
+ (e) => e.id === entry.id && e.object_type === entry.object_type,
115
+ );
116
+ if (idx !== -1) {
117
+ config.workspace[idx].checksum_at_pull = computeChecksum(code);
118
+ }
119
+ pushed++;
120
+ }
121
+
122
+ writeConfig(config);
123
+ console.log(`\nPushed ${pushed} record(s).`);
124
+ }
125
+
126
+ export { push };
@@ -0,0 +1,24 @@
1
+ import { readConfig, writeConfig } from '../lib/config.js';
2
+ import { fileExists } from '../lib/workspace.js';
3
+
4
+ async function repair() {
5
+ const config = readConfig();
6
+ const before = config.workspace.length;
7
+
8
+ const stale = config.workspace.filter((entry) => !fileExists(entry.path));
9
+ config.workspace = config.workspace.filter((entry) => fileExists(entry.path));
10
+
11
+ if (!stale.length) {
12
+ console.log('No stale entries found.');
13
+ return;
14
+ }
15
+
16
+ for (const entry of stale) {
17
+ console.log(` removed: ${entry.path} (${entry.name})`);
18
+ }
19
+
20
+ writeConfig(config);
21
+ console.log(`Removed ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'} (${before} → ${config.workspace.length}).`);
22
+ }
23
+
24
+ export { repair };
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'module';
3
+ import { program } from 'commander';
4
+ import { init } from './commands/init.js';
5
+ import { clone } from './commands/clone.js';
6
+ import { pull } from './commands/pull.js';
7
+ import { push } from './commands/push.js';
8
+ import { diff } from './commands/diff.js';
9
+ import { initPhase } from './commands/initPhase.js';
10
+ import { repair } from './commands/repair.js';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { version } = require('../../package.json');
14
+
15
+ process.on('unhandledRejection', (err) => {
16
+ console.error(`Error: ${err.message}`);
17
+ process.exit(1);
18
+ });
19
+
20
+ program.name('agrippa').version(version);
21
+
22
+ program
23
+ .command('init')
24
+ .description('Create agrippa.yaml workspace config in the current directory')
25
+ .action(() => init().catch((err) => { console.error(`Error: ${err.message}`); process.exit(1); }));
26
+
27
+ program
28
+ .command('clone')
29
+ .description('Clone a phase or MFA from RIP into this workspace')
30
+ .option('--phase', 'Clone a phase (select a workflow)')
31
+ .option('--mfa', 'Clone a Model Function Access record')
32
+ .option('--id <id>', 'Record ID to clone')
33
+ .option('--path <path>', 'Destination path (file for MFA, base dir for workflow)')
34
+ .action((opts) => clone(opts).catch((err) => { console.error(`Error: ${err.message}`); process.exit(1); }));
35
+
36
+ program
37
+ .command('pull')
38
+ .description('Pull remote changes into local files')
39
+ .action(() => pull().catch((err) => { console.error(`Error: ${err.message}`); process.exit(1); }));
40
+
41
+ program
42
+ .command('push')
43
+ .description('Push local changes to RIP (backs up remote code first)')
44
+ .action(() => push().catch((err) => { console.error(`Error: ${err.message}`); process.exit(1); }));
45
+
46
+ program
47
+ .command('diff [path]')
48
+ .description('Show differences between local files and remote code')
49
+ .action((path) => diff(path).catch((err) => { console.error(`Error: ${err.message}`); process.exit(1); }));
50
+
51
+ program
52
+ .command('init-phase')
53
+ .description('Initialize a phase with default code template and result vars')
54
+ .action(() => initPhase().catch((err) => { console.error(`Error: ${err.message}`); process.exit(1); }));
55
+
56
+ program
57
+ .command('repair')
58
+ .description('Remove stale workspace entries where local file no longer exists')
59
+ .action(() => repair().catch((err) => { console.error(`Error: ${err.message}`); process.exit(1); }));
60
+
61
+ program.parse();
@@ -0,0 +1,106 @@
1
+ import fetch from 'node-fetch';
2
+
3
+ async function makeRequest(method, path, token, ripUrl, body) {
4
+ const headers = {
5
+ Authorization: `Bearer ${token}`,
6
+ ...(method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
7
+ };
8
+ const res = await fetch(`${ripUrl}${path}`, {
9
+ method,
10
+ headers,
11
+ body: body !== undefined ? JSON.stringify(body) : undefined,
12
+ });
13
+ if (!res.ok) {
14
+ throw new Error(`API ${method} ${path} failed with ${res.status}: ${await res.text()}`);
15
+ }
16
+ return res.json();
17
+ }
18
+
19
+ function listWorkflows(token, ripUrl) {
20
+ return makeRequest('GET', '/symple.workflow/*', token, ripUrl);
21
+ }
22
+
23
+ function getPhasesByWorkflow(token, ripUrl, workflowId, { fromCode = false } = {}) {
24
+ const filter = fromCode
25
+ ? `[('workflow_id', '=', ${workflowId}), ('set_result_automatically', '=', 'from_code')]`
26
+ : `[('workflow_id', '=', ${workflowId})]`;
27
+ return makeRequest('GET', `/symple.triplet.phase/*?_filter_=${filter}`, token, ripUrl);
28
+ }
29
+
30
+ function getPhasesByIds(token, ripUrl, ids) {
31
+ return makeRequest(
32
+ 'GET',
33
+ `/symple.triplet.phase/*?_filter_=[('id', 'in', [${ids.join(',')}])]`,
34
+ token,
35
+ ripUrl,
36
+ );
37
+ }
38
+
39
+ function updatePhase(token, ripUrl, phaseId, code) {
40
+ return makeRequest('PUT', `/symple.triplet.phase/${phaseId}`, token, ripUrl, { code });
41
+ }
42
+
43
+ function listMfas(token, ripUrl) {
44
+ return makeRequest('GET', '/symple.workflow/get_mfas', token, ripUrl);
45
+ }
46
+
47
+ function updateMfa(token, ripUrl, mfaId, code) {
48
+ return makeRequest('POST', '/symple.workflow/update_mfa', token, ripUrl, { id: mfaId, code });
49
+ }
50
+
51
+ async function getPhaseResults(token, ripUrl, phaseId) {
52
+ try {
53
+ return await makeRequest(
54
+ 'GET',
55
+ `/symple.triplet.phase.result/*?_filter_=[('code_phase_id', '=', ${phaseId})]`,
56
+ token,
57
+ ripUrl,
58
+ );
59
+ } catch (err) {
60
+ if (err.message.includes('404')) return [];
61
+ throw err;
62
+ }
63
+ }
64
+
65
+ function initPhaseRemote(token, ripUrl, phaseId, code) {
66
+ return makeRequest('PUT', `/symple.triplet.phase/${phaseId}`, token, ripUrl, {
67
+ set_result_automatically: 'from_code',
68
+ code,
69
+ });
70
+ }
71
+
72
+ async function getPhaseConfigurators(token, ripUrl, phaseId) {
73
+ try {
74
+ return await makeRequest(
75
+ 'GET',
76
+ `/result.code.configurator/*?_filter_=[('code_phase_id', '=', ${phaseId})]`,
77
+ token,
78
+ ripUrl,
79
+ );
80
+ } catch (err) {
81
+ if (err.message.includes('404')) return [];
82
+ throw err;
83
+ }
84
+ }
85
+
86
+ function deleteConfigurator(token, ripUrl, id) {
87
+ return makeRequest('DELETE', `/result.code.configurator/${id}`, token, ripUrl);
88
+ }
89
+
90
+ function createConfigurator(token, ripUrl, data) {
91
+ return makeRequest('POST', `/result.code.configurator`, token, ripUrl, data);
92
+ }
93
+
94
+ export {
95
+ listWorkflows,
96
+ getPhasesByWorkflow,
97
+ getPhasesByIds,
98
+ updatePhase,
99
+ listMfas,
100
+ updateMfa,
101
+ getPhaseResults,
102
+ initPhaseRemote,
103
+ getPhaseConfigurators,
104
+ deleteConfigurator,
105
+ createConfigurator,
106
+ };
@@ -0,0 +1,7 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ function computeChecksum(code) {
4
+ return createHash('md5').update((code ?? '').trim()).digest('hex');
5
+ }
6
+
7
+ export { computeChecksum };
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { parse, stringify } from 'yaml';
3
+ import { configDotenv } from 'dotenv';
4
+ import { CONFIG_FILE } from '../../config.js';
5
+
6
+ const WORKSPACE_FILE = 'agrippa.yaml';
7
+
8
+ function readConfig() {
9
+ if (!existsSync(WORKSPACE_FILE)) {
10
+ throw new Error('agrippa.yaml not found in current directory. Run `agrippa init` first.');
11
+ }
12
+ return parse(readFileSync(WORKSPACE_FILE, 'utf-8'));
13
+ }
14
+
15
+ function writeConfig(config) {
16
+ writeFileSync(WORKSPACE_FILE, stringify(config, { lineWidth: 0 }), 'utf-8');
17
+ }
18
+
19
+ function loadEffectiveEnv(localConfig) {
20
+ // Load global prbot config as the base (KC_URL, KC_USER, RIP_URL, etc. live here)
21
+ if (existsSync(CONFIG_FILE)) {
22
+ configDotenv({ path: CONFIG_FILE });
23
+ }
24
+
25
+ // Overlay workspace-level overrides from agrippa.yaml's agrippa: section
26
+ const overrides = localConfig?.agrippa ?? {};
27
+ const keys = ['KC_URL', 'KC_USER', 'KC_PASSWORD', 'KC_ID', 'KC_SECRET', 'RIP_URL'];
28
+ for (const key of keys) {
29
+ if (overrides[key]) {
30
+ process.env[key] = overrides[key];
31
+ }
32
+ }
33
+ }
34
+
35
+ export { readConfig, writeConfig, loadEffectiveEnv, WORKSPACE_FILE };
@@ -0,0 +1,42 @@
1
+ import slugify from 'slugify';
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
3
+ import { dirname } from 'path';
4
+
5
+ function toSlug(name) {
6
+ return slugify(name, { lower: true, strict: true });
7
+ }
8
+
9
+ function defaultPhasePath(workflowName, phaseName) {
10
+ return `${toSlug(workflowName)}/${toSlug(phaseName)}.py`;
11
+ }
12
+
13
+ function toMfaSlug(name) {
14
+ return slugify(name, { lower: true, replacement: '_' });
15
+ }
16
+
17
+ function defaultMfaPath(modelName, mfaName) {
18
+ return `${modelName}/${toMfaSlug(mfaName)}.py`;
19
+ }
20
+
21
+ function ensureDir(filePath) {
22
+ const dir = dirname(filePath);
23
+ if (dir && dir !== '.') {
24
+ mkdirSync(dir, { recursive: true });
25
+ }
26
+ }
27
+
28
+ function writeCodeFile(filePath, content) {
29
+ ensureDir(filePath);
30
+ writeFileSync(filePath, (content ?? '').trim() + '\n', 'utf-8');
31
+ }
32
+
33
+ function readCodeFile(filePath) {
34
+ if (!existsSync(filePath)) return null;
35
+ return readFileSync(filePath, 'utf-8');
36
+ }
37
+
38
+ function fileExists(filePath) {
39
+ return existsSync(filePath);
40
+ }
41
+
42
+ export { toSlug, defaultPhasePath, defaultMfaPath, ensureDir, writeCodeFile, readCodeFile, fileExists };
@@ -125,7 +125,7 @@ async function exportEmailTemplates(opts) {
125
125
  const dataDir = path.join(ADDONS_PATH, 'config', module, 'data');
126
126
  await fs.mkdir(dataDir, { recursive: true });
127
127
 
128
- const outPath = path.join(dataDir, 'mail_templates.xml');
128
+ const outPath = path.join(dataDir, 'mail_template.xml');
129
129
  await fs.writeFile(outPath, generateXml(templates), 'utf-8');
130
130
  console.log(`Written: ${outPath}`);
131
131
 
package/.prettierrc.mjs DELETED
@@ -1,11 +0,0 @@
1
- /** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
2
- const config = {
3
- tabWidth: 4,
4
- printWidth: 100,
5
- singleQuote: true,
6
- trailingComma: 'es5',
7
- plugins: ['@ianvs/prettier-plugin-sort-imports'],
8
- importOrder: ['<BUILTIN_MODULES>', '<THIRD_PARTY_MODULES>', '^../.*$', '^./.*$'],
9
- };
10
-
11
- export default config;
package/CLAUDE.md DELETED
File without changes
package/eslint.config.mjs DELETED
@@ -1,16 +0,0 @@
1
- import js from '@eslint/js';
2
- import { defineConfig } from 'eslint/config';
3
- import globals from 'globals';
4
-
5
- export default defineConfig(
6
- js.configs.recommended,
7
- { ignores: ['node_modules'] },
8
- {
9
- languageOptions: {
10
- globals: globals.node,
11
- },
12
- rules: {
13
- 'no-console': 'warn',
14
- },
15
- }
16
- );