@funnelsgrove/cli 0.1.75 → 0.1.78

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,517 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createValidationDiagnostic, } from './diagnosticOutput.js';
5
+ const EMAIL_EVENTS = new Set([
6
+ 'email_captured',
7
+ 'purchase_completed',
8
+ 'registration_completed',
9
+ ]);
10
+ const VARIABLE_TYPES = new Set(['string', 'number', 'boolean']);
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+ const RESERVED_VARIABLE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
15
+ const STEP_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const TEMPLATE_METADATA_KEYS = new Set([
17
+ 'id',
18
+ 'slug',
19
+ 'name',
20
+ 'subject',
21
+ 'previewText',
22
+ 'variables',
23
+ ]);
24
+ const SEQUENCE_KEYS = new Set([
25
+ 'id',
26
+ 'slug',
27
+ 'name',
28
+ 'triggerEventType',
29
+ 'funnelIds',
30
+ 'steps',
31
+ 'exitEventTypes',
32
+ ]);
33
+ const SEQUENCE_STEP_KEYS = new Set(['key', 'delaySeconds', 'templateVersionId']);
34
+ const isRecord = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value));
35
+ const assertNoSymlinkPath = async (root, relativePath = '') => {
36
+ const parts = relativePath ? relativePath.split('/') : [];
37
+ let current = path.resolve(root);
38
+ for (const part of ['', ...parts]) {
39
+ if (part)
40
+ current = path.join(current, part);
41
+ try {
42
+ const metadata = await lstat(current);
43
+ if (metadata.isSymbolicLink()) {
44
+ throw new Error(`Managed email path cannot be a symlink: ${current}`);
45
+ }
46
+ }
47
+ catch (error) {
48
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
49
+ return;
50
+ throw error;
51
+ }
52
+ }
53
+ };
54
+ const assertWriterSlug = (slug) => {
55
+ if (slug.length > 80 || !SLUG.test(slug)) {
56
+ throw new Error(`Invalid email file slug: ${slug}`);
57
+ }
58
+ };
59
+ const writeAtomic = async (filePath, content) => {
60
+ const temporaryPath = `${filePath}.tmp-${randomUUID()}`;
61
+ try {
62
+ await writeFile(temporaryPath, content, { flag: 'wx' });
63
+ await rename(temporaryPath, filePath);
64
+ }
65
+ finally {
66
+ await rm(temporaryPath, { force: true });
67
+ }
68
+ };
69
+ const diagnostic = (input) => createValidationDiagnostic({
70
+ ...input,
71
+ stepId: null,
72
+ guide: 'Run `fgrove email validate` after repairing the file.',
73
+ });
74
+ const schemaDiagnostic = (file, field, expected, received) => diagnostic({
75
+ code: 'FG-EMAIL-002',
76
+ file,
77
+ reason: `Invalid email field: ${field}`,
78
+ expected,
79
+ received,
80
+ repair: `Set ${field} to the documented email file value.`,
81
+ });
82
+ const missingFileDiagnostic = (file) => diagnostic({
83
+ code: 'FG-EMAIL-001',
84
+ file,
85
+ reason: `Missing required email file: ${file}`,
86
+ expected: 'file',
87
+ received: null,
88
+ repair: `Create ${file}.`,
89
+ });
90
+ const readUtf8 = async (absolutePath, relativePath, diagnostics) => {
91
+ try {
92
+ return await readFile(absolutePath, 'utf8');
93
+ }
94
+ catch (error) {
95
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
96
+ diagnostics.push(missingFileDiagnostic(relativePath));
97
+ return null;
98
+ }
99
+ throw error;
100
+ }
101
+ };
102
+ const readJson = async (absolutePath, relativePath, diagnostics) => {
103
+ const source = await readUtf8(absolutePath, relativePath, diagnostics);
104
+ if (source === null)
105
+ return null;
106
+ try {
107
+ return JSON.parse(source);
108
+ }
109
+ catch {
110
+ diagnostics.push(diagnostic({
111
+ code: 'FG-EMAIL-001',
112
+ file: relativePath,
113
+ reason: 'Invalid JSON in email file',
114
+ expected: 'valid JSON',
115
+ received: source,
116
+ repair: `Repair the JSON syntax in ${relativePath}.`,
117
+ }));
118
+ return null;
119
+ }
120
+ };
121
+ const validateExactKeys = (value, allowed, file, diagnostics) => {
122
+ for (const key of Object.keys(value)) {
123
+ if (!allowed.has(key)) {
124
+ diagnostics.push(schemaDiagnostic(file, key, 'no unknown fields', value[key]));
125
+ }
126
+ }
127
+ };
128
+ const validateIdentity = (value, pathSlug, file, diagnostics) => {
129
+ let valid = true;
130
+ if (value.id !== null && (typeof value.id !== 'string' || !UUID.test(value.id))) {
131
+ diagnostics.push(schemaDiagnostic(file, 'id', 'UUID or null', value.id));
132
+ valid = false;
133
+ }
134
+ if (typeof value.slug !== 'string' || value.slug.length > 80 || !SLUG.test(value.slug)) {
135
+ diagnostics.push(schemaDiagnostic(file, 'slug', 'lowercase kebab-case slug', value.slug));
136
+ valid = false;
137
+ }
138
+ else if (value.slug !== pathSlug) {
139
+ diagnostics.push(diagnostic({
140
+ code: 'FG-EMAIL-003',
141
+ file,
142
+ reason: `Email slug ${value.slug} does not match path slug ${pathSlug}`,
143
+ expected: pathSlug,
144
+ received: value.slug,
145
+ repair: `Restore the path to ${value.slug} or restore slug to ${pathSlug}.`,
146
+ }));
147
+ valid = false;
148
+ }
149
+ if (typeof value.name !== 'string' || !value.name.trim() || value.name.length > 120) {
150
+ diagnostics.push(schemaDiagnostic(file, 'name', 'non-empty string up to 120 characters', value.name));
151
+ valid = false;
152
+ }
153
+ else if (value.name !== value.name.trim()) {
154
+ diagnostics.push(diagnostic({
155
+ code: 'FG-EMAIL-002',
156
+ file,
157
+ reason: 'Email name contains surrounding whitespace',
158
+ expected: value.name.trim(),
159
+ received: value.name,
160
+ repair: 'Remove surrounding whitespace from name.',
161
+ }));
162
+ valid = false;
163
+ }
164
+ return valid;
165
+ };
166
+ const validateTemplateMetadata = (value, pathSlug, file, diagnostics) => {
167
+ if (!isRecord(value)) {
168
+ diagnostics.push(schemaDiagnostic(file, 'root', 'object', value));
169
+ return null;
170
+ }
171
+ const initialCount = diagnostics.length;
172
+ validateExactKeys(value, TEMPLATE_METADATA_KEYS, file, diagnostics);
173
+ validateIdentity(value, pathSlug, file, diagnostics);
174
+ if (typeof value.subject !== 'string' || Buffer.byteLength(value.subject, 'utf8') > 998) {
175
+ diagnostics.push(schemaDiagnostic(file, 'subject', 'string up to 998 UTF-8 bytes', value.subject));
176
+ }
177
+ else if ([...value.subject].some((character) => {
178
+ const code = character.codePointAt(0);
179
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
180
+ })) {
181
+ diagnostics.push(diagnostic({
182
+ code: 'FG-EMAIL-002',
183
+ file,
184
+ reason: 'Email subject contains invalid control characters',
185
+ expected: 'subject without control characters',
186
+ received: value.subject,
187
+ repair: 'Remove control characters from subject.',
188
+ }));
189
+ }
190
+ if (value.previewText !== null
191
+ && (typeof value.previewText !== 'string' || Buffer.byteLength(value.previewText, 'utf8') > 500)) {
192
+ diagnostics.push(schemaDiagnostic(file, 'previewText', 'string up to 500 UTF-8 bytes or null', value.previewText));
193
+ }
194
+ if (!isRecord(value.variables)) {
195
+ diagnostics.push(schemaDiagnostic(file, 'variables', 'object', value.variables));
196
+ }
197
+ else {
198
+ const names = Object.keys(value.variables);
199
+ if (names.length > 50) {
200
+ diagnostics.push(schemaDiagnostic(file, 'variables', 'at most 50 variables', names.length));
201
+ }
202
+ for (const name of names) {
203
+ if (!VARIABLE_NAME.test(name) || Buffer.byteLength(name, 'utf8') > 64) {
204
+ diagnostics.push(schemaDiagnostic(file, `variables.${name}`, 'safe variable name', name));
205
+ }
206
+ if (RESERVED_VARIABLE_NAMES.has(name)) {
207
+ diagnostics.push(schemaDiagnostic(file, `variables.${name}`, 'non-reserved variable name', name));
208
+ }
209
+ if (!VARIABLE_TYPES.has(value.variables[name])) {
210
+ diagnostics.push(schemaDiagnostic(file, `variables.${name}`, 'string, number, or boolean', value.variables[name]));
211
+ }
212
+ }
213
+ }
214
+ if (diagnostics.length !== initialCount)
215
+ return null;
216
+ return value;
217
+ };
218
+ const validateSequence = (value, pathSlug, file, diagnostics) => {
219
+ if (!isRecord(value)) {
220
+ diagnostics.push(schemaDiagnostic(file, 'root', 'object', value));
221
+ return null;
222
+ }
223
+ const initialCount = diagnostics.length;
224
+ validateExactKeys(value, SEQUENCE_KEYS, file, diagnostics);
225
+ validateIdentity(value, pathSlug, file, diagnostics);
226
+ if (!EMAIL_EVENTS.has(value.triggerEventType)) {
227
+ diagnostics.push(schemaDiagnostic(file, 'triggerEventType', [...EMAIL_EVENTS], value.triggerEventType));
228
+ }
229
+ if (!Array.isArray(value.funnelIds) || value.funnelIds.length < 1 || value.funnelIds.length > 50) {
230
+ diagnostics.push(schemaDiagnostic(file, 'funnelIds', '1 to 50 UUIDs', value.funnelIds));
231
+ }
232
+ else {
233
+ value.funnelIds.forEach((id, index) => {
234
+ if (typeof id !== 'string' || !UUID.test(id)) {
235
+ diagnostics.push(schemaDiagnostic(file, `funnelIds[${index}]`, 'UUID', id));
236
+ }
237
+ });
238
+ }
239
+ if (!Array.isArray(value.steps) || value.steps.length < 1 || value.steps.length > 50) {
240
+ diagnostics.push(schemaDiagnostic(file, 'steps', '1 to 50 steps', value.steps));
241
+ }
242
+ else {
243
+ const seenKeys = new Set();
244
+ value.steps.forEach((step, index) => {
245
+ if (!isRecord(step)) {
246
+ diagnostics.push(schemaDiagnostic(file, `steps[${index}]`, 'object', step));
247
+ return;
248
+ }
249
+ validateExactKeys(step, SEQUENCE_STEP_KEYS, file, diagnostics);
250
+ if (typeof step.key !== 'string' || !STEP_KEY.test(step.key) || step.key.length > 64) {
251
+ diagnostics.push(schemaDiagnostic(file, `steps[${index}].key`, 'lowercase kebab-case key', step.key));
252
+ }
253
+ else if (seenKeys.has(step.key)) {
254
+ diagnostics.push(schemaDiagnostic(file, `steps[${index}].key`, 'unique step key', step.key));
255
+ }
256
+ else {
257
+ seenKeys.add(step.key);
258
+ }
259
+ if (!Number.isInteger(step.delaySeconds) || Number(step.delaySeconds) < 0 || Number(step.delaySeconds) > 7_776_000) {
260
+ diagnostics.push(schemaDiagnostic(file, `steps[${index}].delaySeconds`, 'integer from 0 to 7776000', step.delaySeconds));
261
+ }
262
+ if (typeof step.templateVersionId !== 'string' || !UUID.test(step.templateVersionId)) {
263
+ diagnostics.push(schemaDiagnostic(file, `steps[${index}].templateVersionId`, 'UUID', step.templateVersionId));
264
+ }
265
+ });
266
+ }
267
+ if (!Array.isArray(value.exitEventTypes) || value.exitEventTypes.length > EMAIL_EVENTS.size) {
268
+ diagnostics.push(schemaDiagnostic(file, 'exitEventTypes', 'up to 3 event types', value.exitEventTypes));
269
+ }
270
+ else {
271
+ value.exitEventTypes.forEach((event, index) => {
272
+ if (!EMAIL_EVENTS.has(event)) {
273
+ diagnostics.push(schemaDiagnostic(file, `exitEventTypes[${index}]`, [...EMAIL_EVENTS], event));
274
+ }
275
+ });
276
+ if (value.exitEventTypes.includes(value.triggerEventType)) {
277
+ diagnostics.push(schemaDiagnostic(file, 'exitEventTypes', 'events excluding triggerEventType', value.exitEventTypes));
278
+ }
279
+ }
280
+ if (diagnostics.length !== initialCount)
281
+ return null;
282
+ return {
283
+ id: value.id,
284
+ slug: value.slug,
285
+ name: value.name,
286
+ draft: {
287
+ triggerEventType: value.triggerEventType,
288
+ funnelIds: value.funnelIds,
289
+ steps: value.steps,
290
+ exitEventTypes: value.exitEventTypes,
291
+ },
292
+ };
293
+ };
294
+ const listDirectories = async (directory) => {
295
+ try {
296
+ const entries = await readdir(directory, { withFileTypes: true });
297
+ const symlink = entries.find((entry) => entry.isSymbolicLink());
298
+ if (symlink)
299
+ throw new Error(`Managed email path cannot be a symlink: ${path.join(directory, symlink.name)}`);
300
+ return entries
301
+ .filter((entry) => entry.isDirectory())
302
+ .map((entry) => entry.name)
303
+ .sort();
304
+ }
305
+ catch (error) {
306
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
307
+ return [];
308
+ throw error;
309
+ }
310
+ };
311
+ const listJsonFiles = async (directory) => {
312
+ try {
313
+ const entries = await readdir(directory, { withFileTypes: true });
314
+ const symlink = entries.find((entry) => entry.isSymbolicLink());
315
+ if (symlink)
316
+ throw new Error(`Managed email path cannot be a symlink: ${path.join(directory, symlink.name)}`);
317
+ return entries
318
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
319
+ .map((entry) => entry.name)
320
+ .sort();
321
+ }
322
+ catch (error) {
323
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
324
+ return [];
325
+ throw error;
326
+ }
327
+ };
328
+ export async function readEmailFiles(sourceDir) {
329
+ const diagnostics = [];
330
+ const templates = [];
331
+ const sequences = [];
332
+ const templateRoot = path.join(sourceDir, 'emails', 'templates');
333
+ const sequenceRoot = path.join(sourceDir, 'emails', 'sequences');
334
+ await Promise.all([
335
+ assertNoSymlinkPath(sourceDir),
336
+ assertNoSymlinkPath(sourceDir, 'emails'),
337
+ assertNoSymlinkPath(sourceDir, 'emails/templates'),
338
+ assertNoSymlinkPath(sourceDir, 'emails/sequences'),
339
+ ]);
340
+ for (const slug of await listDirectories(templateRoot)) {
341
+ const relativeRoot = path.posix.join('emails', 'templates', slug);
342
+ const metadataFile = path.posix.join(relativeRoot, 'template.json');
343
+ const htmlFile = path.posix.join(relativeRoot, 'body.html');
344
+ const textFile = path.posix.join(relativeRoot, 'body.txt');
345
+ await Promise.all([
346
+ assertNoSymlinkPath(sourceDir, relativeRoot),
347
+ assertNoSymlinkPath(sourceDir, metadataFile),
348
+ assertNoSymlinkPath(sourceDir, htmlFile),
349
+ assertNoSymlinkPath(sourceDir, textFile),
350
+ ]);
351
+ const metadataValue = await readJson(path.join(templateRoot, slug, 'template.json'), metadataFile, diagnostics);
352
+ const html = await readUtf8(path.join(templateRoot, slug, 'body.html'), htmlFile, diagnostics);
353
+ const text = await readUtf8(path.join(templateRoot, slug, 'body.txt'), textFile, diagnostics);
354
+ const metadata = metadataValue === null
355
+ ? null
356
+ : validateTemplateMetadata(metadataValue, slug, metadataFile, diagnostics);
357
+ if (metadata && html !== null && text !== null) {
358
+ if (Buffer.byteLength(html, 'utf8') > 256_000) {
359
+ diagnostics.push(schemaDiagnostic(htmlFile, 'body', 'up to 256000 UTF-8 bytes', html.length));
360
+ }
361
+ else if (Buffer.byteLength(text, 'utf8') > 100_000) {
362
+ diagnostics.push(schemaDiagnostic(textFile, 'body', 'up to 100000 UTF-8 bytes', text.length));
363
+ }
364
+ else {
365
+ templates.push({
366
+ id: metadata.id,
367
+ slug: metadata.slug,
368
+ name: metadata.name,
369
+ draft: {
370
+ subject: metadata.subject,
371
+ previewText: metadata.previewText,
372
+ html,
373
+ text,
374
+ variables: metadata.variables,
375
+ },
376
+ });
377
+ }
378
+ }
379
+ }
380
+ for (const filename of await listJsonFiles(sequenceRoot)) {
381
+ const slug = filename.slice(0, -'.json'.length);
382
+ const relativeFile = path.posix.join('emails', 'sequences', filename);
383
+ const value = await readJson(path.join(sequenceRoot, filename), relativeFile, diagnostics);
384
+ const sequence = value === null ? null : validateSequence(value, slug, relativeFile, diagnostics);
385
+ if (sequence)
386
+ sequences.push(sequence);
387
+ }
388
+ return {
389
+ valid: diagnostics.length === 0,
390
+ diagnostics,
391
+ templates,
392
+ sequences,
393
+ };
394
+ }
395
+ const sortedVariables = (variables) => Object.fromEntries(Object.entries(variables).sort(([left], [right]) => (Buffer.compare(Buffer.from(left), Buffer.from(right)))));
396
+ const compareUtf8 = (left, right) => (Buffer.compare(Buffer.from(left), Buffer.from(right)));
397
+ const sortedUnique = (values) => [...new Set(values)].sort(compareUtf8);
398
+ export async function writeEmailTemplateFile(sourceDir, template) {
399
+ assertWriterSlug(template.slug);
400
+ const relativeDirectory = path.posix.join('emails', 'templates', template.slug);
401
+ const directory = path.join(sourceDir, relativeDirectory);
402
+ await Promise.all([
403
+ assertNoSymlinkPath(sourceDir),
404
+ assertNoSymlinkPath(sourceDir, 'emails'),
405
+ assertNoSymlinkPath(sourceDir, 'emails/templates'),
406
+ assertNoSymlinkPath(sourceDir, relativeDirectory),
407
+ assertNoSymlinkPath(sourceDir, path.posix.join(relativeDirectory, 'template.json')),
408
+ assertNoSymlinkPath(sourceDir, path.posix.join(relativeDirectory, 'body.html')),
409
+ assertNoSymlinkPath(sourceDir, path.posix.join(relativeDirectory, 'body.txt')),
410
+ ]);
411
+ await mkdir(directory, { recursive: true });
412
+ const metadata = {
413
+ id: template.id,
414
+ slug: template.slug,
415
+ name: template.name,
416
+ subject: template.draft.subject,
417
+ previewText: template.draft.previewText,
418
+ variables: sortedVariables(template.draft.variables),
419
+ };
420
+ await writeAtomic(path.join(directory, 'template.json'), `${JSON.stringify(metadata, null, 2)}\n`);
421
+ await Promise.all([
422
+ writeAtomic(path.join(directory, 'body.html'), template.draft.html),
423
+ writeAtomic(path.join(directory, 'body.txt'), template.draft.text),
424
+ ]);
425
+ }
426
+ export async function writeEmailSequenceFile(sourceDir, sequence) {
427
+ assertWriterSlug(sequence.slug);
428
+ const relativeRoot = path.posix.join('emails', 'sequences');
429
+ const relativeFile = path.posix.join(relativeRoot, `${sequence.slug}.json`);
430
+ await Promise.all([
431
+ assertNoSymlinkPath(sourceDir),
432
+ assertNoSymlinkPath(sourceDir, 'emails'),
433
+ assertNoSymlinkPath(sourceDir, relativeRoot),
434
+ assertNoSymlinkPath(sourceDir, relativeFile),
435
+ ]);
436
+ const sequenceRoot = path.join(sourceDir, relativeRoot);
437
+ await mkdir(sequenceRoot, { recursive: true });
438
+ const serialized = {
439
+ id: sequence.id,
440
+ slug: sequence.slug,
441
+ name: sequence.name,
442
+ triggerEventType: sequence.draft.triggerEventType,
443
+ funnelIds: sortedUnique(sequence.draft.funnelIds),
444
+ steps: sequence.draft.steps.map((step) => ({
445
+ key: step.key,
446
+ delaySeconds: step.delaySeconds,
447
+ templateVersionId: step.templateVersionId,
448
+ })),
449
+ exitEventTypes: sortedUnique(sequence.draft.exitEventTypes),
450
+ };
451
+ await writeAtomic(path.join(sequenceRoot, `${sequence.slug}.json`), `${JSON.stringify(serialized, null, 2)}\n`);
452
+ }
453
+ export async function writeEmailFiles(input) {
454
+ await Promise.all([
455
+ assertNoSymlinkPath(input.sourceDir),
456
+ assertNoSymlinkPath(input.sourceDir, 'emails'),
457
+ assertNoSymlinkPath(input.sourceDir, 'emails/templates'),
458
+ assertNoSymlinkPath(input.sourceDir, 'emails/sequences'),
459
+ ]);
460
+ await Promise.all([
461
+ mkdir(path.join(input.sourceDir, 'emails', 'templates'), { recursive: true }),
462
+ mkdir(path.join(input.sourceDir, 'emails', 'sequences'), { recursive: true }),
463
+ ]);
464
+ for (const template of [...input.templates].sort((left, right) => compareUtf8(left.slug, right.slug))) {
465
+ await writeEmailTemplateFile(input.sourceDir, template);
466
+ }
467
+ for (const sequence of [...input.sequences].sort((left, right) => compareUtf8(left.slug, right.slug))) {
468
+ await writeEmailSequenceFile(input.sourceDir, sequence);
469
+ }
470
+ }
471
+ const pathExists = async (value) => {
472
+ try {
473
+ await lstat(value);
474
+ return true;
475
+ }
476
+ catch (error) {
477
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
478
+ return false;
479
+ throw error;
480
+ }
481
+ };
482
+ export async function replaceEmailFiles(input) {
483
+ await mkdir(input.sourceDir, { recursive: true });
484
+ await Promise.all([
485
+ assertNoSymlinkPath(input.sourceDir),
486
+ assertNoSymlinkPath(input.sourceDir, 'emails'),
487
+ ]);
488
+ const stagingRoot = await mkdtemp(path.join(input.sourceDir, '.fgrove-email-pull-'));
489
+ const target = path.join(input.sourceDir, 'emails');
490
+ const staged = path.join(stagingRoot, 'emails');
491
+ const backup = path.join(input.sourceDir, `.fgrove-email-backup-${randomUUID()}`);
492
+ let backedUp = false;
493
+ try {
494
+ await writeEmailFiles({ ...input, sourceDir: stagingRoot });
495
+ const validation = await readEmailFiles(stagingRoot);
496
+ if (!validation.valid)
497
+ throw new Error('Invalid remote email resources');
498
+ if (await pathExists(target)) {
499
+ await rename(target, backup);
500
+ backedUp = true;
501
+ }
502
+ try {
503
+ await rename(staged, target);
504
+ }
505
+ catch (error) {
506
+ if (backedUp)
507
+ await rename(backup, target);
508
+ backedUp = false;
509
+ throw error;
510
+ }
511
+ if (backedUp)
512
+ await rm(backup, { recursive: true, force: true });
513
+ }
514
+ finally {
515
+ await rm(stagingRoot, { recursive: true, force: true });
516
+ }
517
+ }