@mailmodo/cli 0.0.56 → 0.0.57-beta.pr59.109
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.
- package/dist/commands/deploy/index.js +8 -3
- package/dist/commands/edit/index.js +4 -1
- package/dist/commands/init/index.js +12 -3
- package/dist/commands/login/index.js +2 -5
- package/dist/commands/preview/index.js +2 -0
- package/dist/commands/report/index.d.ts +22 -0
- package/dist/commands/report/index.js +123 -0
- package/dist/lib/api-client.d.ts +2 -0
- package/dist/lib/api-client.js +2 -2
- package/dist/lib/base-command.d.ts +38 -10
- package/dist/lib/base-command.js +174 -18
- package/dist/lib/commands/edit/diff.js +2 -2
- package/dist/lib/commands/edit/persist.js +6 -4
- package/dist/lib/commands/edit/types.d.ts +1 -0
- package/dist/lib/commands/emails/editor.js +1 -1
- package/dist/lib/commands/init/analysis.js +5 -1
- package/dist/lib/commands/login/output.d.ts +2 -2
- package/dist/lib/commands/login/output.js +5 -18
- package/dist/lib/commands/preview/types.d.ts +3 -0
- package/dist/lib/commands/report/output-entries.d.ts +2 -0
- package/dist/lib/commands/report/output-entries.js +59 -0
- package/dist/lib/commands/report/output-timeseries.d.ts +2 -0
- package/dist/lib/commands/report/output-timeseries.js +28 -0
- package/dist/lib/commands/report/output.d.ts +3 -0
- package/dist/lib/commands/report/output.js +56 -0
- package/dist/lib/commands/report/payload.d.ts +2 -0
- package/dist/lib/commands/report/payload.js +49 -0
- package/dist/lib/commands/report/prompt.d.ts +2 -0
- package/dist/lib/commands/report/prompt.js +82 -0
- package/dist/lib/commands/report/types.d.ts +97 -0
- package/dist/lib/commands/report/types.js +1 -0
- package/dist/lib/config.d.ts +2 -0
- package/dist/lib/config.js +19 -10
- package/dist/lib/constants.d.ts +4 -2
- package/dist/lib/constants.js +5 -5
- package/dist/lib/messages.d.ts +18 -0
- package/dist/lib/messages.js +38 -0
- package/dist/lib/templates/missing-templates.d.ts +15 -1
- package/dist/lib/templates/missing-templates.js +34 -22
- package/dist/lib/templates/regenerate.d.ts +10 -0
- package/dist/lib/templates/regenerate.js +29 -0
- package/dist/lib/templates/sync.d.ts +33 -0
- package/dist/lib/templates/sync.js +106 -0
- package/dist/lib/templates/types.d.ts +3 -0
- package/dist/lib/yaml-config.d.ts +1 -0
- package/dist/lib/yaml-config.js +8 -0
- package/oclif.manifest.json +168 -1
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import { confirm } from '@inquirer/prompts';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { BaseCommand } from '../../lib/base-command.js';
|
|
5
5
|
import { API_ENDPOINTS } from '../../lib/constants.js';
|
|
6
|
-
import { MISSING_TEMPLATES } from '../../lib/messages.js';
|
|
6
|
+
import { MISSING_TEMPLATES, restoredFromServerHint, } from '../../lib/messages.js';
|
|
7
7
|
import { buildDeployPayload } from '../../lib/commands/deploy/payload.js';
|
|
8
8
|
import { logDeploySuccessInstructions, logPreDeploySummary, } from '../../lib/commands/deploy/output.js';
|
|
9
9
|
import { pauseSequence, resumeSequence, } from '../../lib/commands/deploy/sequence-status.js';
|
|
@@ -44,8 +44,10 @@ export default class Deploy extends BaseCommand {
|
|
|
44
44
|
const yamlConfig = await this.ensureYaml();
|
|
45
45
|
const missingIds = getMissingTemplateIds(yamlConfig);
|
|
46
46
|
if (missingIds.length > 0) {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
47
|
+
const result = await handleMissingTemplates(ctx, yamlConfig, missingIds, baseFlags);
|
|
48
|
+
if (result === 'restored')
|
|
49
|
+
ctx.log(`\n ${chalk.green('✓')} ${restoredFromServerHint(missingIds)}\n`);
|
|
50
|
+
else if (result === 'regenerated')
|
|
49
51
|
ctx.log(`\n ${chalk.green('✓')} ${MISSING_TEMPLATES.REVIEW_HINT}\n`);
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
@@ -69,6 +71,7 @@ export default class Deploy extends BaseCommand {
|
|
|
69
71
|
if (!response.ok)
|
|
70
72
|
ctx.onApiError(response);
|
|
71
73
|
await ctx.syncYaml();
|
|
74
|
+
await ctx.syncTemplates(yamlConfig);
|
|
72
75
|
if (flags.json) {
|
|
73
76
|
this.log(JSON.stringify({
|
|
74
77
|
deployed: response.data.deployed,
|
|
@@ -86,6 +89,7 @@ export default class Deploy extends BaseCommand {
|
|
|
86
89
|
collectDomainInputs: (yaml, skip) => this.collectDomainSetupInputs(yaml, skip),
|
|
87
90
|
error: (msg) => this.error(msg),
|
|
88
91
|
exit: (code) => this.exit(code),
|
|
92
|
+
fetchTemplate: (emailId) => this.getTemplateFromServer(emailId),
|
|
89
93
|
get: (path, params) => this.apiClient.get(path, params),
|
|
90
94
|
getBillingCap: async () => {
|
|
91
95
|
const s = await this.fetchBillingStatus();
|
|
@@ -97,6 +101,7 @@ export default class Deploy extends BaseCommand {
|
|
|
97
101
|
registerDomainAndSave: (yaml, inputs, json) => this.registerDomain(yaml, inputs, json),
|
|
98
102
|
showDnsRecords: (records, url, json) => this.logDnsRecords(records, url, json),
|
|
99
103
|
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
104
|
+
syncTemplates: (yaml) => this.syncTemplatesToServer(yaml),
|
|
100
105
|
syncYaml: () => this.syncYamlToServer(),
|
|
101
106
|
};
|
|
102
107
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../../lib/base-command.js';
|
|
3
|
-
import { getTemplateFilename, loadTemplate } from '../../lib/yaml-config.js';
|
|
3
|
+
import { getTemplateFilename, loadTemplate, } from '../../lib/yaml-config.js';
|
|
4
4
|
import { handleMissingTemplates } from '../../lib/templates/missing-templates.js';
|
|
5
5
|
import { askChangeDescription, runEditStep, } from '../../lib/commands/edit/flow.js';
|
|
6
6
|
export default class Edit extends BaseCommand {
|
|
@@ -63,6 +63,7 @@ export default class Edit extends BaseCommand {
|
|
|
63
63
|
post: (path, body) => this.apiClient.post(path, body),
|
|
64
64
|
runCommand: (id, argv) => this.config.runCommand(id, argv),
|
|
65
65
|
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
66
|
+
syncTemplate: (emailId) => this.syncTemplateToServer(emailId),
|
|
66
67
|
syncYaml: () => this.syncYamlToServer(),
|
|
67
68
|
};
|
|
68
69
|
}
|
|
@@ -70,10 +71,12 @@ export default class Edit extends BaseCommand {
|
|
|
70
71
|
return {
|
|
71
72
|
error: (msg) => this.error(msg),
|
|
72
73
|
exit: (code) => this.exit(code),
|
|
74
|
+
fetchTemplate: (emailId) => this.getTemplateFromServer(emailId),
|
|
73
75
|
log: (msg) => this.log(msg),
|
|
74
76
|
onApiError: (r) => this.handleApiError(r),
|
|
75
77
|
post: (path, body) => this.apiClient.post(path, body),
|
|
76
78
|
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
79
|
+
syncTemplates: (yaml) => this.syncTemplatesToServer(yaml),
|
|
77
80
|
syncYaml: () => this.syncYamlToServer(),
|
|
78
81
|
};
|
|
79
82
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../../lib/base-command.js';
|
|
3
3
|
import { API_ENDPOINTS } from '../../lib/constants.js';
|
|
4
|
-
import { loadYaml, saveYaml } from '../../lib/yaml-config.js';
|
|
4
|
+
import { loadYaml, saveYaml, } from '../../lib/yaml-config.js';
|
|
5
5
|
import { analyzeProduct, promptProductUrl, } from '../../lib/commands/init/analysis.js';
|
|
6
6
|
import { confirmOverwrite, logInitSuccess, } from '../../lib/commands/init/output.js';
|
|
7
7
|
import { applyMonthlyCap, buildEmailConfigs, buildYamlConfig, preserveUserFields, saveAllTemplates, } from '../../lib/commands/init/payload.js';
|
|
@@ -20,6 +20,13 @@ export default class Init extends BaseCommand {
|
|
|
20
20
|
await this.ensureAuth();
|
|
21
21
|
const ctx = this.makeCtx();
|
|
22
22
|
const baseFlags = { json: flags.json, yes: flags.yes };
|
|
23
|
+
let serverYaml = null;
|
|
24
|
+
if (this.isBlankDirectory()) {
|
|
25
|
+
const result = await this.promptInitServerRestore(baseFlags);
|
|
26
|
+
if (result.restored)
|
|
27
|
+
return;
|
|
28
|
+
serverYaml = result.serverYaml;
|
|
29
|
+
}
|
|
23
30
|
const existing = await loadYaml();
|
|
24
31
|
if (!(await confirmOverwrite(ctx, baseFlags, existing)))
|
|
25
32
|
return;
|
|
@@ -33,12 +40,14 @@ export default class Init extends BaseCommand {
|
|
|
33
40
|
const generatedEmails = generateResponse.data?.emails || [];
|
|
34
41
|
const emailConfigs = buildEmailConfigs(analysisPayload, generatedEmails);
|
|
35
42
|
const yamlConfig = buildYamlConfig(analysisPayload, emailConfigs, productUrl);
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const fieldSource = existing ?? serverYaml;
|
|
44
|
+
if (fieldSource)
|
|
45
|
+
preserveUserFields(yamlConfig, fieldSource);
|
|
38
46
|
await saveYaml(yamlConfig);
|
|
39
47
|
await applyMonthlyCap(ctx, yamlConfig);
|
|
40
48
|
await saveAllTemplates(analysisPayload.recommendedEmails, generatedEmails);
|
|
41
49
|
await ctx.syncYaml();
|
|
50
|
+
await this.syncTemplatesToServer(yamlConfig);
|
|
42
51
|
logInitSuccess(ctx, {
|
|
43
52
|
brand: analysisPayload.brand,
|
|
44
53
|
emailConfigs,
|
|
@@ -27,9 +27,7 @@ export default class Login extends BaseCommand {
|
|
|
27
27
|
if (!envKey) {
|
|
28
28
|
const existing = await loadConfig();
|
|
29
29
|
if (existing?.apiKey) {
|
|
30
|
-
|
|
31
|
-
const yamlRestored = await this.recoverYamlAfterLogin(existingClient);
|
|
32
|
-
logAlreadyLoggedIn(ctx, existing, yamlRestored, { json: flags.json });
|
|
30
|
+
logAlreadyLoggedIn(ctx, existing, { json: flags.json });
|
|
33
31
|
return;
|
|
34
32
|
}
|
|
35
33
|
}
|
|
@@ -42,8 +40,7 @@ export default class Login extends BaseCommand {
|
|
|
42
40
|
}
|
|
43
41
|
const { email, totalFreeRemaining, paidEmailsRemaining, plan } = response.data;
|
|
44
42
|
await saveConfig({ apiKey: trimmedKey, email, totalFreeRemaining });
|
|
45
|
-
|
|
46
|
-
logLoginSuccess(ctx, { email, paidEmailsRemaining, plan, totalFreeRemaining }, yamlRestored, { json: flags.json });
|
|
43
|
+
logLoginSuccess(ctx, { email, paidEmailsRemaining, plan, totalFreeRemaining }, { json: flags.json });
|
|
47
44
|
}
|
|
48
45
|
makeCtx() {
|
|
49
46
|
return {
|
|
@@ -82,10 +82,12 @@ export default class Preview extends BaseCommand {
|
|
|
82
82
|
return {
|
|
83
83
|
error: (msg) => this.error(msg),
|
|
84
84
|
exit: (code) => this.exit(code),
|
|
85
|
+
fetchTemplate: (emailId) => this.getTemplateFromServer(emailId),
|
|
85
86
|
log: (msg) => this.log(msg),
|
|
86
87
|
onApiError: (r) => this.handleApiError(r),
|
|
87
88
|
post: (path, body) => this.apiClient.post(path, body),
|
|
88
89
|
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
90
|
+
syncTemplates: (yaml) => this.syncTemplatesToServer(yaml),
|
|
89
91
|
syncYaml: () => this.syncYamlToServer(),
|
|
90
92
|
};
|
|
91
93
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
2
|
+
export default class Report extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
contact: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'email-id': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
event: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
from: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
'group-by': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
preset: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
sequence: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
to: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
20
|
+
run(): Promise<void>;
|
|
21
|
+
private makeCtx;
|
|
22
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
3
|
+
import { API_ENDPOINTS } from '../../lib/constants.js';
|
|
4
|
+
import { REPORTS } from '../../lib/messages.js';
|
|
5
|
+
import { buildReportPayload } from '../../lib/commands/report/payload.js';
|
|
6
|
+
import { renderReport } from '../../lib/commands/report/output.js';
|
|
7
|
+
import { promptReportFlags } from '../../lib/commands/report/prompt.js';
|
|
8
|
+
export default class Report extends BaseCommand {
|
|
9
|
+
static description = 'Fetch an email analytics report';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> report --preset last7d',
|
|
12
|
+
'<%= config.bin %> report --preset last30d --group-by emailId',
|
|
13
|
+
'<%= config.bin %> report --from YYYY-MM-DD --to YYYY-MM-DD --output timeseries',
|
|
14
|
+
'<%= config.bin %> report --preset last7d --output entries --page 1',
|
|
15
|
+
'<%= config.bin %> report --preset last30d --sequence welcome-flow --event opened',
|
|
16
|
+
'<%= config.bin %> report --preset last7d --json',
|
|
17
|
+
];
|
|
18
|
+
static flags = {
|
|
19
|
+
...BaseCommand.baseFlags,
|
|
20
|
+
contact: Flags.string({
|
|
21
|
+
description: 'Filter by contact email (repeatable)',
|
|
22
|
+
multiple: true,
|
|
23
|
+
}),
|
|
24
|
+
'email-id': Flags.string({
|
|
25
|
+
description: 'Filter by email template ID (repeatable)',
|
|
26
|
+
multiple: true,
|
|
27
|
+
}),
|
|
28
|
+
event: Flags.string({
|
|
29
|
+
description: 'Filter by event type (repeatable)',
|
|
30
|
+
multiple: true,
|
|
31
|
+
options: [
|
|
32
|
+
'bounced',
|
|
33
|
+
'clicked',
|
|
34
|
+
'complained',
|
|
35
|
+
'delivered',
|
|
36
|
+
'opened',
|
|
37
|
+
'sent',
|
|
38
|
+
'skipped',
|
|
39
|
+
'unsubscribed',
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
from: Flags.string({
|
|
43
|
+
description: 'Start of time range, inclusive (YYYY-MM-DD)',
|
|
44
|
+
exclusive: ['preset'],
|
|
45
|
+
}),
|
|
46
|
+
'group-by': Flags.string({
|
|
47
|
+
default: 'none',
|
|
48
|
+
description: 'Group results by dimension',
|
|
49
|
+
options: [
|
|
50
|
+
'contact',
|
|
51
|
+
'day',
|
|
52
|
+
'emailId',
|
|
53
|
+
'hour',
|
|
54
|
+
'none',
|
|
55
|
+
'sequenceId',
|
|
56
|
+
'status',
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
limit: Flags.integer({
|
|
60
|
+
default: 50,
|
|
61
|
+
description: 'Entries per page, max 200 (entries output only)',
|
|
62
|
+
max: 200,
|
|
63
|
+
}),
|
|
64
|
+
output: Flags.string({
|
|
65
|
+
default: 'summary',
|
|
66
|
+
description: 'Output shape: summary | entries | timeseries',
|
|
67
|
+
options: ['entries', 'summary', 'timeseries'],
|
|
68
|
+
}),
|
|
69
|
+
page: Flags.integer({
|
|
70
|
+
default: 1,
|
|
71
|
+
description: 'Page number (entries output only)',
|
|
72
|
+
}),
|
|
73
|
+
preset: Flags.string({
|
|
74
|
+
description: 'Relative time range preset',
|
|
75
|
+
exclusive: ['from', 'to'],
|
|
76
|
+
options: [
|
|
77
|
+
'last30d',
|
|
78
|
+
'last7d',
|
|
79
|
+
'last90d',
|
|
80
|
+
'lastMonth',
|
|
81
|
+
'thisMonth',
|
|
82
|
+
'today',
|
|
83
|
+
'yesterday',
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
sequence: Flags.string({
|
|
87
|
+
description: 'Filter by sequence ID (repeatable)',
|
|
88
|
+
multiple: true,
|
|
89
|
+
}),
|
|
90
|
+
to: Flags.string({
|
|
91
|
+
description: 'End of time range, exclusive (YYYY-MM-DD)',
|
|
92
|
+
exclusive: ['preset'],
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
async run() {
|
|
96
|
+
const { flags } = await this.parse(Report);
|
|
97
|
+
await this.ensureAuth();
|
|
98
|
+
if ((flags.from && !flags.to) || (!flags.from && flags.to)) {
|
|
99
|
+
this.error(REPORTS.FROM_TO_REQUIRED);
|
|
100
|
+
}
|
|
101
|
+
const resolved = flags.json
|
|
102
|
+
? flags
|
|
103
|
+
: await promptReportFlags(flags);
|
|
104
|
+
const ctx = this.makeCtx();
|
|
105
|
+
const payload = buildReportPayload(resolved, (m) => this.error(m));
|
|
106
|
+
const response = await ctx.spinner(REPORTS.FETCH_SPINNER, flags.json, () => ctx.post(API_ENDPOINTS.REPORTS, payload));
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
ctx.onApiError(response);
|
|
109
|
+
if (flags.json) {
|
|
110
|
+
this.log(JSON.stringify(response.data, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
renderReport(ctx, response.data, resolved.output);
|
|
114
|
+
}
|
|
115
|
+
makeCtx() {
|
|
116
|
+
return {
|
|
117
|
+
log: (msg) => this.log(msg),
|
|
118
|
+
onApiError: (r) => this.handleApiError(r),
|
|
119
|
+
post: (path, body) => this.apiClient.post(path, body),
|
|
120
|
+
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface ApiRequestDebugInfo {
|
|
|
8
8
|
causeCode?: string;
|
|
9
9
|
/** Fully resolved request URL (origin, path, and query string). */
|
|
10
10
|
fullUrl: string;
|
|
11
|
+
/** Truncated JSON summary of the request body sent, when present. */
|
|
12
|
+
requestBody?: string;
|
|
11
13
|
/** Truncated JSON summary of a non-empty error response body, when available. */
|
|
12
14
|
responseSummary?: string;
|
|
13
15
|
}
|
package/dist/lib/api-client.js
CHANGED
|
@@ -82,12 +82,12 @@ export class ApiClient {
|
|
|
82
82
|
const data = await response.json().catch(() => ({}));
|
|
83
83
|
if (!response.ok) {
|
|
84
84
|
const errorData = data;
|
|
85
|
-
const summary = summarizeResponseBody(data);
|
|
86
85
|
return {
|
|
87
86
|
data: data,
|
|
88
87
|
debug: {
|
|
89
88
|
...debug,
|
|
90
|
-
|
|
89
|
+
requestBody: summarizeResponseBody(body),
|
|
90
|
+
responseSummary: summarizeResponseBody(data),
|
|
91
91
|
},
|
|
92
92
|
error: errorData?.message ||
|
|
93
93
|
errorData?.error ||
|
|
@@ -68,28 +68,56 @@ export declare abstract class BaseCommand extends Command {
|
|
|
68
68
|
* settings and all email sequence definitions.
|
|
69
69
|
*/
|
|
70
70
|
protected ensureYaml(): Promise<MailmodoYaml>;
|
|
71
|
+
protected isBlankDirectory(): boolean;
|
|
72
|
+
protected fetchYamlText(): Promise<null | string>;
|
|
73
|
+
private promptBlankDirRestore;
|
|
74
|
+
protected promptInitServerRestore(flags: {
|
|
75
|
+
json?: boolean;
|
|
76
|
+
yes?: boolean;
|
|
77
|
+
}): Promise<{
|
|
78
|
+
restored: boolean;
|
|
79
|
+
serverYaml: MailmodoYaml | null;
|
|
80
|
+
}>;
|
|
71
81
|
private fetchAndWriteYaml;
|
|
72
82
|
/**
|
|
73
83
|
* Attempts to fetch mailmodo.yaml from the server and save it locally.
|
|
74
84
|
* Returns null silently on any failure so callers can fall through to an error.
|
|
75
85
|
*/
|
|
76
86
|
private restoreYamlFromServer;
|
|
77
|
-
/**
|
|
78
|
-
* If `mailmodo.yaml` is absent from the current directory, attempts to restore
|
|
79
|
-
* it from the server using the given client. Returns `true` if the file was
|
|
80
|
-
* successfully written, `false` otherwise (file already present, server 404,
|
|
81
|
-
* or any network error). Silent — never throws.
|
|
82
|
-
*
|
|
83
|
-
* Used by `mailmodo login` right after the API key is validated so a returning
|
|
84
|
-
* user automatically gets their config back without having to run `init` again.
|
|
85
|
-
*/
|
|
86
|
-
protected recoverYamlAfterLogin(client: ApiClient): Promise<boolean>;
|
|
87
87
|
/**
|
|
88
88
|
* Uploads the current local mailmodo.yaml to the server as a backup.
|
|
89
89
|
* Best-effort: silently ignores all errors so the originating command
|
|
90
90
|
* always succeeds regardless of sync failures.
|
|
91
91
|
*/
|
|
92
92
|
protected syncYamlToServer(): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* Bulk-uploads all template HTML files referenced in the YAML to the server
|
|
95
|
+
* as a backup. Best-effort: silently ignores all errors so the originating
|
|
96
|
+
* command always succeeds regardless of sync failures.
|
|
97
|
+
* Called after init, deploy, and AI regeneration.
|
|
98
|
+
*/
|
|
99
|
+
protected syncTemplatesToServer(yaml: MailmodoYaml): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Uploads a single template's HTML files to the server for incremental sync.
|
|
102
|
+
* Best-effort: silently ignores all errors. Called after the edit command
|
|
103
|
+
* applies changes to a specific template.
|
|
104
|
+
*/
|
|
105
|
+
protected syncTemplateToServer(emailId: string): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Fetches a single backed-up template from the server and writes it to the
|
|
108
|
+
* local mailmodo/ folder. Returns true if the template was successfully
|
|
109
|
+
* restored, false if it is not backed up or on any error.
|
|
110
|
+
* Used as the `ctx.fetchTemplate` bridge passed to handleMissingTemplates.
|
|
111
|
+
*/
|
|
112
|
+
protected getTemplateFromServer(emailId: string): Promise<boolean>;
|
|
113
|
+
/**
|
|
114
|
+
* Silently restores any template files missing from disk by fetching them
|
|
115
|
+
* from the server. Uses the bulk endpoint when all templates are missing
|
|
116
|
+
* (single round-trip), or individual per-ID requests when only a subset is
|
|
117
|
+
* missing to avoid overwriting files that are already present. Best-effort —
|
|
118
|
+
* never throws.
|
|
119
|
+
*/
|
|
120
|
+
private fetchMissingTemplates;
|
|
93
121
|
/**
|
|
94
122
|
* Handles a failed API response by mapping HTTP status codes to
|
|
95
123
|
* user-friendly error messages and exiting the process.
|
package/dist/lib/base-command.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { input } from '@inquirer/prompts';
|
|
4
|
+
import { input, select } from '@inquirer/prompts';
|
|
5
5
|
import { Command, Flags } from '@oclif/core';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import ora from 'ora';
|
|
8
8
|
import { ApiClient } from './api-client.js';
|
|
9
9
|
import { loadConfig } from './config.js';
|
|
10
|
-
import { API_ENDPOINTS, IS_DEV_MODE, YAML_FILE } from './constants.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { API_ENDPOINTS, IS_DEV_MODE, TEMPLATES_DIR, YAML_FILE, } from './constants.js';
|
|
11
|
+
import { syncTemplatesToServer, syncTemplateToServer, fetchTemplatesFromServer, fetchTemplateFromServer, } from './templates/sync.js';
|
|
12
|
+
import { getMissingTemplateIds } from './templates/missing-templates.js';
|
|
13
|
+
import { BLANK_DIR, ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
|
|
14
|
+
import { loadYaml, parseYamlText, saveYaml, } from './yaml-config.js';
|
|
13
15
|
export const FREE_TIER = 'free';
|
|
14
16
|
/**
|
|
15
17
|
* Abstract base command providing shared functionality for all Mailmodo CLI commands.
|
|
@@ -90,11 +92,90 @@ export class BaseCommand extends Command {
|
|
|
90
92
|
const config = await loadYaml();
|
|
91
93
|
if (config)
|
|
92
94
|
return config;
|
|
95
|
+
if (this.isBlankDirectory())
|
|
96
|
+
return this.promptBlankDirRestore();
|
|
93
97
|
const restored = await this.restoreYamlFromServer();
|
|
94
98
|
if (restored)
|
|
95
99
|
return restored;
|
|
96
100
|
this.error(ERRORS.NO_YAML);
|
|
97
101
|
}
|
|
102
|
+
isBlankDirectory() {
|
|
103
|
+
return (!existsSync(join(process.cwd(), YAML_FILE)) &&
|
|
104
|
+
!existsSync(join(process.cwd(), TEMPLATES_DIR)));
|
|
105
|
+
}
|
|
106
|
+
async fetchYamlText() {
|
|
107
|
+
try {
|
|
108
|
+
let client = this.apiClient;
|
|
109
|
+
if (!client) {
|
|
110
|
+
const apiKey = process.env.MAILMODO_API_KEY ?? (await loadConfig())?.apiKey;
|
|
111
|
+
if (!apiKey)
|
|
112
|
+
return null;
|
|
113
|
+
client = new ApiClient(apiKey);
|
|
114
|
+
}
|
|
115
|
+
const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
|
|
116
|
+
return response.ok && response.data ? response.data : null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async promptBlankDirRestore() {
|
|
123
|
+
const yamlText = await this.fetchYamlText();
|
|
124
|
+
if (!yamlText)
|
|
125
|
+
this.error(ERRORS.NO_YAML);
|
|
126
|
+
const autoRestore = this.argv.includes('--yes') || this.argv.includes('--json');
|
|
127
|
+
if (!autoRestore) {
|
|
128
|
+
this.log(`\n ${BLANK_DIR.PROMPT}\n`);
|
|
129
|
+
const choice = await select({
|
|
130
|
+
choices: [
|
|
131
|
+
{ name: BLANK_DIR.CHOICE_RESTORE, value: 'restore' },
|
|
132
|
+
{ name: BLANK_DIR.CHOICE_SKIP, value: 'skip' },
|
|
133
|
+
],
|
|
134
|
+
message: 'How would you like to proceed?',
|
|
135
|
+
});
|
|
136
|
+
if (choice === 'skip') {
|
|
137
|
+
this.log(`\n ${BLANK_DIR.SKIP_HINT}\n`);
|
|
138
|
+
this.exit(0);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await writeFile(join(process.cwd(), YAML_FILE), yamlText);
|
|
142
|
+
this.logToStderr(INFO.YAML_RESTORED_FROM_SERVER);
|
|
143
|
+
const yaml = await loadYaml();
|
|
144
|
+
if (!yaml)
|
|
145
|
+
this.error(ERRORS.NO_YAML);
|
|
146
|
+
const client = this.apiClient;
|
|
147
|
+
if (client)
|
|
148
|
+
await this.fetchMissingTemplates(client, yaml);
|
|
149
|
+
return yaml;
|
|
150
|
+
}
|
|
151
|
+
async promptInitServerRestore(flags) {
|
|
152
|
+
const yamlText = await this.fetchYamlText();
|
|
153
|
+
if (!yamlText)
|
|
154
|
+
return { restored: false, serverYaml: null };
|
|
155
|
+
let shouldRestore = Boolean(flags.yes || flags.json);
|
|
156
|
+
if (!shouldRestore) {
|
|
157
|
+
const choice = await select({
|
|
158
|
+
choices: [
|
|
159
|
+
{ name: BLANK_DIR.CHOICE_RESTORE_INIT, value: 'restore' },
|
|
160
|
+
{ name: BLANK_DIR.CHOICE_FRESH, value: 'fresh' },
|
|
161
|
+
],
|
|
162
|
+
message: BLANK_DIR.PROMPT_INIT,
|
|
163
|
+
});
|
|
164
|
+
shouldRestore = choice === 'restore';
|
|
165
|
+
}
|
|
166
|
+
if (!shouldRestore) {
|
|
167
|
+
return { restored: false, serverYaml: parseYamlText(yamlText) };
|
|
168
|
+
}
|
|
169
|
+
await writeFile(join(process.cwd(), YAML_FILE), yamlText);
|
|
170
|
+
const yaml = await loadYaml();
|
|
171
|
+
if (!yaml)
|
|
172
|
+
return { restored: false, serverYaml: null };
|
|
173
|
+
const client = this.apiClient;
|
|
174
|
+
if (client)
|
|
175
|
+
await this.fetchMissingTemplates(client, yaml);
|
|
176
|
+
this.log(`\n ${BLANK_DIR.RESTORED_INIT}\n`);
|
|
177
|
+
return { restored: true, serverYaml: yaml };
|
|
178
|
+
}
|
|
98
179
|
async fetchAndWriteYaml(client) {
|
|
99
180
|
try {
|
|
100
181
|
const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
|
|
@@ -131,20 +212,6 @@ export class BaseCommand extends Command {
|
|
|
131
212
|
return null;
|
|
132
213
|
}
|
|
133
214
|
}
|
|
134
|
-
/**
|
|
135
|
-
* If `mailmodo.yaml` is absent from the current directory, attempts to restore
|
|
136
|
-
* it from the server using the given client. Returns `true` if the file was
|
|
137
|
-
* successfully written, `false` otherwise (file already present, server 404,
|
|
138
|
-
* or any network error). Silent — never throws.
|
|
139
|
-
*
|
|
140
|
-
* Used by `mailmodo login` right after the API key is validated so a returning
|
|
141
|
-
* user automatically gets their config back without having to run `init` again.
|
|
142
|
-
*/
|
|
143
|
-
async recoverYamlAfterLogin(client) {
|
|
144
|
-
if (existsSync(join(process.cwd(), YAML_FILE)))
|
|
145
|
-
return false;
|
|
146
|
-
return this.fetchAndWriteYaml(client);
|
|
147
|
-
}
|
|
148
215
|
/**
|
|
149
216
|
* Uploads the current local mailmodo.yaml to the server as a backup.
|
|
150
217
|
* Best-effort: silently ignores all errors so the originating command
|
|
@@ -173,6 +240,92 @@ export class BaseCommand extends Command {
|
|
|
173
240
|
// Silently ignore — local file remains authoritative
|
|
174
241
|
}
|
|
175
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Bulk-uploads all template HTML files referenced in the YAML to the server
|
|
245
|
+
* as a backup. Best-effort: silently ignores all errors so the originating
|
|
246
|
+
* command always succeeds regardless of sync failures.
|
|
247
|
+
* Called after init, deploy, and AI regeneration.
|
|
248
|
+
*/
|
|
249
|
+
async syncTemplatesToServer(yaml) {
|
|
250
|
+
try {
|
|
251
|
+
let client = this.apiClient;
|
|
252
|
+
if (!client) {
|
|
253
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
254
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
255
|
+
if (!apiKey)
|
|
256
|
+
return;
|
|
257
|
+
client = new ApiClient(apiKey);
|
|
258
|
+
}
|
|
259
|
+
await syncTemplatesToServer(client, yaml);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Silently ignore — local files remain authoritative
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Uploads a single template's HTML files to the server for incremental sync.
|
|
267
|
+
* Best-effort: silently ignores all errors. Called after the edit command
|
|
268
|
+
* applies changes to a specific template.
|
|
269
|
+
*/
|
|
270
|
+
async syncTemplateToServer(emailId) {
|
|
271
|
+
try {
|
|
272
|
+
let client = this.apiClient;
|
|
273
|
+
if (!client) {
|
|
274
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
275
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
276
|
+
if (!apiKey)
|
|
277
|
+
return;
|
|
278
|
+
client = new ApiClient(apiKey);
|
|
279
|
+
}
|
|
280
|
+
await syncTemplateToServer(client, emailId);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// Silently ignore — local files remain authoritative
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Fetches a single backed-up template from the server and writes it to the
|
|
288
|
+
* local mailmodo/ folder. Returns true if the template was successfully
|
|
289
|
+
* restored, false if it is not backed up or on any error.
|
|
290
|
+
* Used as the `ctx.fetchTemplate` bridge passed to handleMissingTemplates.
|
|
291
|
+
*/
|
|
292
|
+
async getTemplateFromServer(emailId) {
|
|
293
|
+
try {
|
|
294
|
+
let client = this.apiClient;
|
|
295
|
+
if (!client) {
|
|
296
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
297
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
298
|
+
if (!apiKey)
|
|
299
|
+
return false;
|
|
300
|
+
client = new ApiClient(apiKey);
|
|
301
|
+
}
|
|
302
|
+
return fetchTemplateFromServer(client, emailId);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Silently restores any template files missing from disk by fetching them
|
|
310
|
+
* from the server. Uses the bulk endpoint when all templates are missing
|
|
311
|
+
* (single round-trip), or individual per-ID requests when only a subset is
|
|
312
|
+
* missing to avoid overwriting files that are already present. Best-effort —
|
|
313
|
+
* never throws.
|
|
314
|
+
*/
|
|
315
|
+
async fetchMissingTemplates(client, yaml) {
|
|
316
|
+
try {
|
|
317
|
+
const missingIds = getMissingTemplateIds(yaml);
|
|
318
|
+
if (missingIds.length === 0)
|
|
319
|
+
return;
|
|
320
|
+
// Use bulk endpoint when every template is missing; per-ID otherwise
|
|
321
|
+
await (missingIds.length === yaml.emails.length
|
|
322
|
+
? fetchTemplatesFromServer(client, yaml)
|
|
323
|
+
: Promise.all(missingIds.map((id) => fetchTemplateFromServer(client, id))));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// best-effort, silently ignore
|
|
327
|
+
}
|
|
328
|
+
}
|
|
176
329
|
/**
|
|
177
330
|
* Handles a failed API response by mapping HTTP status codes to
|
|
178
331
|
* user-friendly error messages and exiting the process.
|
|
@@ -405,6 +558,9 @@ export class BaseCommand extends Command {
|
|
|
405
558
|
status > 0
|
|
406
559
|
? chalk.dim(` Status: ${String(status)}`)
|
|
407
560
|
: chalk.dim(' Status: (No HTTP response — network or client error)'),
|
|
561
|
+
...(debug.requestBody
|
|
562
|
+
? [chalk.dim(` Payload: ${debug.requestBody}`)]
|
|
563
|
+
: []),
|
|
408
564
|
...(debug.responseSummary
|
|
409
565
|
? [chalk.dim(` Response: ${debug.responseSummary}`)]
|
|
410
566
|
: []),
|
|
@@ -42,12 +42,12 @@ export function buildDiffPreview(email, updated, templateHtml) {
|
|
|
42
42
|
diff.subject = subjectChanged
|
|
43
43
|
? { new: updated.subject, old: email.subject }
|
|
44
44
|
: { unchanged: true, value: email.subject };
|
|
45
|
-
if (email.previewText
|
|
45
|
+
if (email.previewText || updated.previewText) {
|
|
46
46
|
diff.previewText = previewChanged
|
|
47
47
|
? { new: updated.previewText, old: email.previewText }
|
|
48
48
|
: { unchanged: true, value: email.previewText };
|
|
49
49
|
}
|
|
50
|
-
if (templateHtml
|
|
50
|
+
if (templateHtml || updated.html) {
|
|
51
51
|
const oldText = templateHtml
|
|
52
52
|
? truncate(stripHtml(templateHtml), 500)
|
|
53
53
|
: null;
|