@mailmodo/cli 0.0.55 → 0.0.56-beta.pr58.100
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/billing/index.d.ts +1 -11
- package/dist/commands/billing/index.js +28 -184
- package/dist/commands/contacts/index.d.ts +1 -19
- package/dist/commands/contacts/index.js +21 -114
- package/dist/commands/deploy/index.js +12 -7
- package/dist/commands/deployments/index.d.ts +1 -4
- package/dist/commands/deployments/index.js +11 -52
- package/dist/commands/domain/index.d.ts +1 -14
- package/dist/commands/domain/index.js +19 -100
- package/dist/commands/edit/index.d.ts +2 -20
- package/dist/commands/edit/index.js +33 -258
- package/dist/commands/emails/index.d.ts +1 -2
- package/dist/commands/emails/index.js +26 -91
- package/dist/commands/init/index.d.ts +1 -3
- package/dist/commands/init/index.js +47 -199
- package/dist/commands/login/index.d.ts +2 -0
- package/dist/commands/login/index.js +32 -79
- package/dist/commands/logs/index.d.ts +1 -8
- package/dist/commands/logs/index.js +12 -55
- package/dist/commands/preview/index.d.ts +1 -19
- package/dist/commands/preview/index.js +32 -212
- package/dist/commands/sdk/index.d.ts +1 -3
- package/dist/commands/sdk/index.js +14 -46
- package/dist/commands/settings/index.d.ts +1 -22
- package/dist/commands/settings/index.js +34 -246
- package/dist/commands/status/index.d.ts +1 -0
- package/dist/commands/status/index.js +13 -39
- package/dist/lib/base-command.d.ts +35 -10
- package/dist/lib/base-command.js +169 -17
- package/dist/lib/commands/billing/checkout-status.d.ts +3 -0
- package/dist/lib/commands/billing/checkout-status.js +63 -0
- package/dist/lib/commands/billing/format.d.ts +7 -0
- package/dist/lib/commands/billing/format.js +63 -0
- package/dist/lib/commands/billing/purchase-cap.d.ts +7 -0
- package/dist/lib/commands/billing/purchase-cap.js +57 -0
- package/dist/lib/commands/billing/types.d.ts +72 -0
- package/dist/lib/commands/contacts/actions.d.ts +3 -0
- package/dist/lib/commands/contacts/actions.js +49 -0
- package/dist/lib/commands/contacts/export-delete.d.ts +9 -0
- package/dist/lib/commands/contacts/export-delete.js +51 -0
- package/dist/lib/commands/contacts/types.d.ts +35 -0
- package/dist/lib/commands/contacts/types.js +1 -0
- package/dist/lib/{deploy → commands/deploy}/domain-setup.d.ts +1 -1
- package/dist/lib/{deploy → commands/deploy}/domain-setup.js +2 -2
- package/dist/lib/{deploy → commands/deploy}/output.d.ts +1 -1
- package/dist/lib/{deploy → commands/deploy}/output.js +2 -2
- package/dist/lib/{deploy → commands/deploy}/payload.d.ts +1 -1
- package/dist/lib/{deploy → commands/deploy}/payload.js +2 -2
- package/dist/lib/{deploy → commands/deploy}/sequence-status.js +2 -2
- package/dist/lib/{deploy → commands/deploy}/types.d.ts +4 -4
- package/dist/lib/commands/deploy/types.js +1 -0
- package/dist/lib/commands/deployments/output.d.ts +2 -0
- package/dist/lib/commands/deployments/output.js +68 -0
- package/dist/lib/commands/deployments/types.d.ts +24 -0
- package/dist/lib/commands/deployments/types.js +1 -0
- package/dist/lib/commands/domain/setup.d.ts +8 -0
- package/dist/lib/commands/domain/setup.js +53 -0
- package/dist/lib/commands/domain/types.d.ts +56 -0
- package/dist/lib/commands/domain/types.js +1 -0
- package/dist/lib/commands/domain/verify.d.ts +5 -0
- package/dist/lib/commands/domain/verify.js +50 -0
- package/dist/lib/commands/edit/diff.d.ts +7 -0
- package/dist/lib/commands/edit/diff.js +65 -0
- package/dist/lib/commands/edit/display.d.ts +5 -0
- package/dist/lib/commands/edit/display.js +53 -0
- package/dist/lib/commands/edit/flow.d.ts +8 -0
- package/dist/lib/commands/edit/flow.js +70 -0
- package/dist/lib/commands/edit/persist.d.ts +5 -0
- package/dist/lib/commands/edit/persist.js +67 -0
- package/dist/lib/commands/edit/types.d.ts +38 -0
- package/dist/lib/commands/edit/types.js +1 -0
- package/dist/lib/commands/emails/editor.d.ts +2 -0
- package/dist/lib/commands/emails/editor.js +43 -0
- package/dist/lib/commands/emails/output.d.ts +4 -0
- package/dist/lib/commands/emails/output.js +36 -0
- package/dist/lib/commands/emails/types.d.ts +3 -0
- package/dist/lib/commands/emails/types.js +1 -0
- package/dist/lib/commands/init/analysis.d.ts +3 -0
- package/dist/lib/commands/init/analysis.js +73 -0
- package/dist/lib/commands/init/output.d.ts +12 -0
- package/dist/lib/commands/init/output.js +39 -0
- package/dist/lib/commands/init/payload.d.ts +8 -0
- package/dist/lib/commands/init/payload.js +78 -0
- package/dist/lib/commands/init/types.d.ts +57 -0
- package/dist/lib/commands/init/types.js +1 -0
- package/dist/lib/commands/login/output.d.ts +8 -0
- package/dist/lib/commands/login/output.js +40 -0
- package/dist/lib/commands/login/types.d.ts +19 -0
- package/dist/lib/commands/login/types.js +1 -0
- package/dist/lib/commands/logs/output.d.ts +2 -0
- package/dist/lib/commands/logs/output.js +52 -0
- package/dist/lib/commands/logs/types.d.ts +23 -0
- package/dist/lib/commands/logs/types.js +1 -0
- package/dist/lib/commands/preview/actions.d.ts +11 -0
- package/dist/lib/commands/preview/actions.js +43 -0
- package/dist/lib/commands/preview/render.d.ts +3 -0
- package/dist/lib/commands/preview/render.js +30 -0
- package/dist/lib/commands/preview/server.d.ts +8 -0
- package/dist/lib/commands/preview/server.js +63 -0
- package/dist/lib/commands/preview/types.d.ts +22 -0
- package/dist/lib/commands/preview/types.js +1 -0
- package/dist/lib/commands/preview/wrapper-html.d.ts +2 -0
- package/dist/lib/commands/preview/wrapper-html.js +35 -0
- package/dist/lib/commands/sdk/output.d.ts +2 -0
- package/dist/lib/commands/sdk/output.js +42 -0
- package/dist/lib/commands/sdk/types.d.ts +21 -0
- package/dist/lib/commands/sdk/types.js +1 -0
- package/dist/lib/commands/settings/actions.d.ts +10 -0
- package/dist/lib/commands/settings/actions.js +56 -0
- package/dist/lib/commands/settings/display.d.ts +15 -0
- package/dist/lib/commands/settings/display.js +69 -0
- package/dist/lib/commands/settings/logo-domain.d.ts +3 -0
- package/dist/lib/commands/settings/logo-domain.js +47 -0
- package/dist/lib/commands/settings/prompt.d.ts +2 -0
- package/dist/lib/commands/settings/prompt.js +82 -0
- package/dist/lib/commands/settings/types.d.ts +65 -0
- package/dist/lib/commands/settings/types.js +1 -0
- package/dist/lib/commands/status/output.d.ts +2 -0
- package/dist/lib/commands/status/output.js +49 -0
- package/dist/lib/commands/status/types.d.ts +28 -0
- package/dist/lib/commands/status/types.js +1 -0
- package/dist/lib/constants.d.ts +3 -2
- package/dist/lib/constants.js +4 -5
- package/dist/lib/messages.d.ts +11 -0
- package/dist/lib/messages.js +31 -0
- package/dist/lib/templates/missing-templates.d.ts +16 -2
- 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/oclif.manifest.json +54 -54
- package/package.json +1 -1
- /package/dist/lib/{deploy → commands/billing}/types.js +0 -0
- /package/dist/lib/{deploy → commands/deploy}/sequence-status.d.ts +0 -0
|
@@ -1,26 +1,10 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
|
-
import { input, select } from '@inquirer/prompts';
|
|
3
2
|
import chalk from 'chalk';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
5
|
-
import { readFile } from 'node:fs/promises';
|
|
6
|
-
import { resolve } from 'node:path';
|
|
7
3
|
import { BaseCommand, FREE_TIER } from '../../lib/base-command.js';
|
|
8
|
-
import { API_ENDPOINTS } from '../../lib/constants.js';
|
|
9
|
-
import { INFO } from '../../lib/messages.js';
|
|
10
|
-
import { settingKeyToProp } from '../../lib/utils.js';
|
|
11
4
|
import { saveYaml } from '../../lib/yaml-config.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
domain: ['domain', 'address'],
|
|
16
|
-
identity: ['from_name', 'from_email', 'reply_to'],
|
|
17
|
-
integrations: ['webhook_url'],
|
|
18
|
-
});
|
|
19
|
-
const SETUP_HINTS = {
|
|
20
|
-
address: "'mailmodo domain'",
|
|
21
|
-
domain: "'mailmodo domain'",
|
|
22
|
-
monthlyCap: "'mailmodo billing --cap <n>'",
|
|
23
|
-
};
|
|
5
|
+
import { applySetFlag } from '../../lib/commands/settings/actions.js';
|
|
6
|
+
import { displaySettingsGroup, fetchDomainVerified, SETTINGS_GROUPS, } from '../../lib/commands/settings/display.js';
|
|
7
|
+
import { promptEditSetting } from '../../lib/commands/settings/prompt.js';
|
|
24
8
|
export default class Settings extends BaseCommand {
|
|
25
9
|
static description = 'View and update project settings';
|
|
26
10
|
static examples = [
|
|
@@ -35,14 +19,18 @@ export default class Settings extends BaseCommand {
|
|
|
35
19
|
async run() {
|
|
36
20
|
const { flags } = await this.parse(Settings);
|
|
37
21
|
const yamlConfig = await this.ensureYaml();
|
|
22
|
+
const ctx = this.makeCtx();
|
|
38
23
|
if (flags.set) {
|
|
39
|
-
await
|
|
24
|
+
await applySetFlag(ctx, yamlConfig, {
|
|
25
|
+
isJson: flags.json ?? false,
|
|
26
|
+
setFlag: flags.set,
|
|
27
|
+
});
|
|
40
28
|
return;
|
|
41
29
|
}
|
|
42
30
|
const [domainVerified, billingStatus] = await Promise.all([
|
|
43
31
|
flags.json
|
|
44
32
|
? Promise.resolve(null)
|
|
45
|
-
:
|
|
33
|
+
: fetchDomainVerified(ctx, yamlConfig.project.domain),
|
|
46
34
|
this.fetchBillingStatus(),
|
|
47
35
|
]);
|
|
48
36
|
const tier = billingStatus?.tier ?? null;
|
|
@@ -60,235 +48,35 @@ export default class Settings extends BaseCommand {
|
|
|
60
48
|
for (const [group, keys] of Object.entries(SETTINGS_GROUPS)) {
|
|
61
49
|
if (group === 'billing' && tier === FREE_TIER)
|
|
62
50
|
continue;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
async applySetFlag(setFlag, yamlConfig, isJson) {
|
|
70
|
-
const { project } = yamlConfig;
|
|
71
|
-
const eqIndex = setFlag.indexOf('=');
|
|
72
|
-
if (eqIndex === -1) {
|
|
73
|
-
this.error('Invalid format. Use --set key=value (e.g., --set brand_color=#0F3460)');
|
|
74
|
-
}
|
|
75
|
-
const key = setFlag.slice(0, eqIndex).trim();
|
|
76
|
-
const propKey = settingKeyToProp(key);
|
|
77
|
-
const value = setFlag.slice(eqIndex + 1).trim();
|
|
78
|
-
if (!(propKey in project) && key !== 'logo_file') {
|
|
79
|
-
this.error(`Unknown setting: ${key}`);
|
|
80
|
-
}
|
|
81
|
-
if (propKey === 'monthlyCap') {
|
|
82
|
-
await this.applyMonthlyCapChange(yamlConfig, value, isJson, null);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
project[propKey] = value;
|
|
86
|
-
await saveYaml(yamlConfig);
|
|
87
|
-
await this.syncYamlToServer();
|
|
88
|
-
if (isJson) {
|
|
89
|
-
this.log(JSON.stringify({ [propKey]: value, status: 'updated' }, null, 2));
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
this.log(`\n ${chalk.green('✓')} ${key} updated to ${chalk.cyan(value)}`);
|
|
93
|
-
this.log(` ${INFO.DEPLOY_TO_APPLY}\n`);
|
|
94
|
-
}
|
|
95
|
-
async applyMonthlyCapChange(yamlConfig, rawValue, isJson, knownTier) {
|
|
96
|
-
const parsed = Number(rawValue);
|
|
97
|
-
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
98
|
-
this.error('monthly_cap must be a positive integer (blocks).');
|
|
99
|
-
}
|
|
100
|
-
await this.ensureAuth();
|
|
101
|
-
const tier = knownTier ?? (await this.fetchBillingTier());
|
|
102
|
-
if (tier === FREE_TIER) {
|
|
103
|
-
this.warnFreeTierCapBlocked(isJson);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const data = await this.applyBillingCap({ cap: parsed, json: isJson });
|
|
107
|
-
yamlConfig.project.monthlyCap = data.capBlocks;
|
|
108
|
-
await saveYaml(yamlConfig);
|
|
109
|
-
await this.syncYamlToServer();
|
|
110
|
-
if (isJson) {
|
|
111
|
-
this.log(JSON.stringify({ monthlyCap: data.capBlocks, status: 'updated' }, null, 2));
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
this.log(`\n ${chalk.green('✓')} monthly_cap updated to ${chalk.cyan(String(data.capBlocks))} (${data.capEmails.toLocaleString()} emails)\n`);
|
|
115
|
-
}
|
|
116
|
-
displaySettingsGroup(group, keys, project, domainVerified) {
|
|
117
|
-
const availableKeys = keys.filter((key) => {
|
|
118
|
-
if (group === 'brand' && key === 'logo_file')
|
|
119
|
-
return true;
|
|
120
|
-
return settingKeyToProp(key) in project;
|
|
121
|
-
});
|
|
122
|
-
const groupTitle = ` ${chalk.bold(group.charAt(0).toUpperCase() + group.slice(1))}`;
|
|
123
|
-
if (availableKeys.length === 0) {
|
|
124
|
-
const hint = SETUP_HINTS[settingKeyToProp(keys[0])];
|
|
125
|
-
if (hint) {
|
|
126
|
-
this.log(groupTitle);
|
|
127
|
-
this.log(` ${'─'.repeat(49)}`);
|
|
128
|
-
this.log(` ${chalk.dim(`Run ${hint} to configure.`)}`);
|
|
129
|
-
this.log('');
|
|
130
|
-
}
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
this.log(groupTitle);
|
|
134
|
-
this.log(` ${'─'.repeat(49)}`);
|
|
135
|
-
for (const key of availableKeys) {
|
|
136
|
-
const propKey = settingKeyToProp(key);
|
|
137
|
-
const value = project[propKey];
|
|
138
|
-
let displayValue = value ? String(value) : chalk.dim('(not set)');
|
|
139
|
-
if (key === 'domain' && value && domainVerified === true) {
|
|
140
|
-
displayValue += ` ${chalk.green('✓ verified')}`;
|
|
141
|
-
}
|
|
142
|
-
else if (key === 'domain' && value && domainVerified === false) {
|
|
143
|
-
displayValue += ` ${chalk.red('✗ not verified')}`;
|
|
144
|
-
}
|
|
145
|
-
this.log(` ${key.padEnd(16)} ${displayValue}`);
|
|
146
|
-
}
|
|
147
|
-
const missingKeys = keys.filter((key) => !(settingKeyToProp(key) in project) &&
|
|
148
|
-
!(group === 'brand' && key === 'logo_file'));
|
|
149
|
-
for (const key of missingKeys) {
|
|
150
|
-
const hint = SETUP_HINTS[settingKeyToProp(key)];
|
|
151
|
-
if (hint) {
|
|
152
|
-
this.log(` ${key.padEnd(16)} ${chalk.dim(`(run ${hint} to set up)`)}`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
this.log('');
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Prompts the user to pick a setting key to edit and dispatches
|
|
159
|
-
* to the appropriate handler for that key.
|
|
160
|
-
*/
|
|
161
|
-
async promptEditSetting(yamlConfig, tier) {
|
|
162
|
-
const { project } = yamlConfig;
|
|
163
|
-
const editKey = await input({
|
|
164
|
-
default: 'n',
|
|
165
|
-
message: "Edit a setting? (key or 'n'):",
|
|
166
|
-
});
|
|
167
|
-
if (editKey === 'n')
|
|
168
|
-
return;
|
|
169
|
-
if (editKey === 'monthly_cap' && tier === FREE_TIER) {
|
|
170
|
-
this.warnFreeTierCapBlocked(false);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
const editPropKey = settingKeyToProp(editKey);
|
|
174
|
-
if (!(editPropKey in project)) {
|
|
175
|
-
if (editKey === 'logo_file') {
|
|
176
|
-
await this.handleLogoUpload(yamlConfig);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
const hint = SETUP_HINTS[editPropKey];
|
|
180
|
-
if (hint) {
|
|
181
|
-
this.log(`\n ${editKey} is not configured yet. Run ${chalk.cyan(hint)} to set it up.\n`);
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
this.log(`\n Unknown setting: ${editKey}\n`);
|
|
185
|
-
}
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
if (editKey === 'logo_file') {
|
|
189
|
-
await this.handleLogoUpload(yamlConfig);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (editKey === 'domain') {
|
|
193
|
-
await this.handleDomainChange(yamlConfig);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
if (editKey === 'monthly_cap') {
|
|
197
|
-
const newValue = await input({
|
|
198
|
-
message: 'New monthly cap (blocks):',
|
|
199
|
-
});
|
|
200
|
-
await this.applyMonthlyCapChange(yamlConfig, newValue, false, tier);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (editKey === 'email_style') {
|
|
204
|
-
const style = await select({
|
|
205
|
-
choices: [
|
|
206
|
-
{ name: 'plain', value: 'plain' },
|
|
207
|
-
{ name: 'branded', value: 'branded' },
|
|
208
|
-
],
|
|
209
|
-
message: 'Email style:',
|
|
51
|
+
displaySettingsGroup(ctx, group, {
|
|
52
|
+
domainVerified,
|
|
53
|
+
keys,
|
|
54
|
+
project: yamlConfig.project,
|
|
210
55
|
});
|
|
211
|
-
project.emailStyle = style;
|
|
212
|
-
await saveYaml(yamlConfig);
|
|
213
|
-
await this.syncYamlToServer();
|
|
214
|
-
this.log(`\n ${chalk.green('✓')} email_style updated to ${chalk.cyan(style)}`);
|
|
215
|
-
this.log(` ${INFO.DEPLOY_TO_APPLY}\n`);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const newValue = await input({
|
|
219
|
-
message: `New value for ${editKey}:`,
|
|
220
|
-
});
|
|
221
|
-
project[editPropKey] = newValue;
|
|
222
|
-
await saveYaml(yamlConfig);
|
|
223
|
-
await this.syncYamlToServer();
|
|
224
|
-
this.log(`\n ${chalk.green('✓')} Updated. ${INFO.DEPLOY_TO_APPLY}\n`);
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Fetches the domain verification status from the API.
|
|
228
|
-
* Returns true/false for verified/unverified, or null if unavailable.
|
|
229
|
-
*/
|
|
230
|
-
async fetchDomainVerified(domain) {
|
|
231
|
-
if (!domain)
|
|
232
|
-
return null;
|
|
233
|
-
try {
|
|
234
|
-
await this.ensureAuth();
|
|
235
|
-
const response = await this.apiClient.get(API_ENDPOINTS.DOMAIN_STATUS, { domain });
|
|
236
|
-
if (!response.ok) {
|
|
237
|
-
this.log(` ${chalk.dim('Could not fetch domain status. Run')} ${chalk.cyan("'mailmodo domain --status'")} ${chalk.dim('to check manually.')}`);
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
return response.data?.verified === true;
|
|
241
56
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return null;
|
|
57
|
+
if (!flags.yes) {
|
|
58
|
+
await promptEditSetting(ctx, yamlConfig, tier);
|
|
245
59
|
}
|
|
246
60
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (!existsSync(resolvedPath)) {
|
|
267
|
-
this.log(`\n File not found: ${resolvedPath}\n`);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
await this.ensureAuth();
|
|
271
|
-
const fileBuffer = await readFile(resolvedPath);
|
|
272
|
-
const ext = resolvedPath.split('.').pop()?.toLowerCase();
|
|
273
|
-
const mimeTypes = {
|
|
274
|
-
png: 'image/png',
|
|
275
|
-
jpg: 'image/jpeg',
|
|
276
|
-
jpeg: 'image/jpeg',
|
|
277
|
-
svg: 'image/svg+xml',
|
|
61
|
+
makeCtx() {
|
|
62
|
+
return {
|
|
63
|
+
applyBillingCap: (opts) => this.applyBillingCap(opts),
|
|
64
|
+
collectDomainInputs: (yaml, skip) => this.collectDomainSetupInputs(yaml, skip),
|
|
65
|
+
ensureAuth: async () => {
|
|
66
|
+
await this.ensureAuth();
|
|
67
|
+
},
|
|
68
|
+
error: (msg) => this.error(msg),
|
|
69
|
+
fetchBillingStatus: () => this.fetchBillingStatus(),
|
|
70
|
+
fetchBillingTier: () => this.fetchBillingTier(),
|
|
71
|
+
get: (path, params) => this.apiClient.get(path, params),
|
|
72
|
+
log: (msg) => this.log(msg),
|
|
73
|
+
onApiError: (r) => this.handleApiError(r),
|
|
74
|
+
postFormData: (path, formData) => this.apiClient.postFormData(path, formData),
|
|
75
|
+
registerDomainAndSave: (yaml, inputs, json) => this.registerDomain(yaml, inputs, json),
|
|
76
|
+
showDnsRecords: (records, url, json) => this.logDnsRecords(records, url, json),
|
|
77
|
+
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
78
|
+
syncYaml: () => this.syncYamlToServer(),
|
|
79
|
+
warnFreeTierCapBlocked: (json) => this.warnFreeTierCapBlocked(json),
|
|
278
80
|
};
|
|
279
|
-
const mimeType = mimeTypes[ext ?? ''] ?? 'application/octet-stream';
|
|
280
|
-
const formData = new FormData();
|
|
281
|
-
formData.append('logo', new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), logoPath.split(/[/\\]/).pop() || 'logo.png');
|
|
282
|
-
const response = await this.withApiSpinner({ json: false, text: ' Uploading logo file...' }, () => this.apiClient.postFormData(API_ENDPOINTS.ASSETS_LOGO, formData));
|
|
283
|
-
if (!response.ok) {
|
|
284
|
-
this.handleApiError(response);
|
|
285
|
-
}
|
|
286
|
-
yamlConfig.project.logoUrl = response.data?.url || '';
|
|
287
|
-
yamlConfig.project.logoFile = logoPath;
|
|
288
|
-
await saveYaml(yamlConfig);
|
|
289
|
-
await this.syncYamlToServer();
|
|
290
|
-
this.log(`\n Logo uploaded and hosted at:`);
|
|
291
|
-
this.log(` ${chalk.cyan(String(response.data?.url))}`);
|
|
292
|
-
this.log(` Run ${chalk.cyan("'mailmodo deploy'")} to apply to all branded emails.\n`);
|
|
293
81
|
}
|
|
294
82
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
1
|
import { BaseCommand } from '../../lib/base-command.js';
|
|
3
2
|
import { API_ENDPOINTS } from '../../lib/constants.js';
|
|
3
|
+
import { logStatusOutput } from '../../lib/commands/status/output.js';
|
|
4
4
|
export default class Status extends BaseCommand {
|
|
5
5
|
static description = 'View email performance metrics and quota usage';
|
|
6
6
|
static examples = [
|
|
@@ -13,49 +13,23 @@ export default class Status extends BaseCommand {
|
|
|
13
13
|
async run() {
|
|
14
14
|
const { flags } = await this.parse(Status);
|
|
15
15
|
await this.ensureAuth();
|
|
16
|
-
const
|
|
16
|
+
const ctx = this.makeCtx();
|
|
17
|
+
const response = await ctx.spinner(' Loading analytics...', flags.json, () => ctx.get(API_ENDPOINTS.ANALYTICS));
|
|
17
18
|
if (!response.ok) {
|
|
18
|
-
|
|
19
|
+
ctx.onApiError(response);
|
|
19
20
|
}
|
|
20
|
-
const { emails, monthlySent, quota } = response.data;
|
|
21
21
|
if (flags.json) {
|
|
22
22
|
this.log(JSON.stringify(response.data, null, 2));
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.log(` ${id}${sent}${openRate}${clickRate}${convRate}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
this.log(` ${chalk.dim('No data yet. Deploy emails first.')}`);
|
|
39
|
-
}
|
|
40
|
-
this.log('');
|
|
41
|
-
if (monthlySent !== null && monthlySent !== undefined) {
|
|
42
|
-
this.log(` Emails sent this month: ${chalk.bold(String(monthlySent))}`);
|
|
43
|
-
}
|
|
44
|
-
if (quota) {
|
|
45
|
-
if (quota.plan === 'free') {
|
|
46
|
-
this.log(` Free tier remaining: ${chalk.cyan(String(quota.freeRemaining))} emails`);
|
|
47
|
-
}
|
|
48
|
-
else if (quota.plan === 'paid') {
|
|
49
|
-
if (quota.currentBlockEmailsRemaining !== null &&
|
|
50
|
-
quota.currentBlockEmailsRemaining !== undefined) {
|
|
51
|
-
const { blockSize } = quota;
|
|
52
|
-
const sent = blockSize - quota.currentBlockEmailsRemaining;
|
|
53
|
-
const remaining = quota.currentBlockEmailsRemaining;
|
|
54
|
-
this.log(` Current paid block (${blockSize} emails) : ${chalk.cyan(`${sent} emails sent, ${remaining} emails remaining`)}`);
|
|
55
|
-
}
|
|
56
|
-
this.log(` Blocks used: ${quota.blocksUsed}`);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
this.log('');
|
|
25
|
+
logStatusOutput(ctx, response.data);
|
|
26
|
+
}
|
|
27
|
+
makeCtx() {
|
|
28
|
+
return {
|
|
29
|
+
get: (path, params) => this.apiClient.get(path, params),
|
|
30
|
+
log: (msg) => this.log(msg),
|
|
31
|
+
onApiError: (r) => this.handleApiError(r),
|
|
32
|
+
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
33
|
+
};
|
|
60
34
|
}
|
|
61
35
|
}
|
|
@@ -68,28 +68,53 @@ 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<boolean>;
|
|
71
78
|
private fetchAndWriteYaml;
|
|
72
79
|
/**
|
|
73
80
|
* Attempts to fetch mailmodo.yaml from the server and save it locally.
|
|
74
81
|
* Returns null silently on any failure so callers can fall through to an error.
|
|
75
82
|
*/
|
|
76
83
|
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
84
|
/**
|
|
88
85
|
* Uploads the current local mailmodo.yaml to the server as a backup.
|
|
89
86
|
* Best-effort: silently ignores all errors so the originating command
|
|
90
87
|
* always succeeds regardless of sync failures.
|
|
91
88
|
*/
|
|
92
89
|
protected syncYamlToServer(): Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Bulk-uploads all template HTML files referenced in the YAML to the server
|
|
92
|
+
* as a backup. Best-effort: silently ignores all errors so the originating
|
|
93
|
+
* command always succeeds regardless of sync failures.
|
|
94
|
+
* Called after init, deploy, and AI regeneration.
|
|
95
|
+
*/
|
|
96
|
+
protected syncTemplatesToServer(yaml: MailmodoYaml): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Uploads a single template's HTML files to the server for incremental sync.
|
|
99
|
+
* Best-effort: silently ignores all errors. Called after the edit command
|
|
100
|
+
* applies changes to a specific template.
|
|
101
|
+
*/
|
|
102
|
+
protected syncTemplateToServer(emailId: string): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Fetches a single backed-up template from the server and writes it to the
|
|
105
|
+
* local mailmodo/ folder. Returns true if the template was successfully
|
|
106
|
+
* restored, false if it is not backed up or on any error.
|
|
107
|
+
* Used as the `ctx.fetchTemplate` bridge passed to handleMissingTemplates.
|
|
108
|
+
*/
|
|
109
|
+
protected getTemplateFromServer(emailId: string): Promise<boolean>;
|
|
110
|
+
/**
|
|
111
|
+
* Silently restores any template files missing from disk by fetching them
|
|
112
|
+
* from the server. Uses the bulk endpoint when all templates are missing
|
|
113
|
+
* (single round-trip), or individual per-ID requests when only a subset is
|
|
114
|
+
* missing to avoid overwriting files that are already present. Best-effort —
|
|
115
|
+
* never throws.
|
|
116
|
+
*/
|
|
117
|
+
private fetchMissingTemplates;
|
|
93
118
|
/**
|
|
94
119
|
* Handles a failed API response by mapping HTTP status codes to
|
|
95
120
|
* user-friendly error messages and exiting the process.
|
package/dist/lib/base-command.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
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 {
|
|
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';
|
|
12
14
|
import { loadYaml, saveYaml } from './yaml-config.js';
|
|
13
15
|
export const FREE_TIER = 'free';
|
|
14
16
|
/**
|
|
@@ -90,11 +92,89 @@ 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 false;
|
|
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 false;
|
|
168
|
+
await writeFile(join(process.cwd(), YAML_FILE), yamlText);
|
|
169
|
+
const yaml = await loadYaml();
|
|
170
|
+
if (!yaml)
|
|
171
|
+
return false;
|
|
172
|
+
const client = this.apiClient;
|
|
173
|
+
if (client)
|
|
174
|
+
await this.fetchMissingTemplates(client, yaml);
|
|
175
|
+
this.log(`\n ${BLANK_DIR.RESTORED_INIT}\n`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
98
178
|
async fetchAndWriteYaml(client) {
|
|
99
179
|
try {
|
|
100
180
|
const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
|
|
@@ -131,20 +211,6 @@ export class BaseCommand extends Command {
|
|
|
131
211
|
return null;
|
|
132
212
|
}
|
|
133
213
|
}
|
|
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
214
|
/**
|
|
149
215
|
* Uploads the current local mailmodo.yaml to the server as a backup.
|
|
150
216
|
* Best-effort: silently ignores all errors so the originating command
|
|
@@ -173,6 +239,92 @@ export class BaseCommand extends Command {
|
|
|
173
239
|
// Silently ignore — local file remains authoritative
|
|
174
240
|
}
|
|
175
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Bulk-uploads all template HTML files referenced in the YAML to the server
|
|
244
|
+
* as a backup. Best-effort: silently ignores all errors so the originating
|
|
245
|
+
* command always succeeds regardless of sync failures.
|
|
246
|
+
* Called after init, deploy, and AI regeneration.
|
|
247
|
+
*/
|
|
248
|
+
async syncTemplatesToServer(yaml) {
|
|
249
|
+
try {
|
|
250
|
+
let client = this.apiClient;
|
|
251
|
+
if (!client) {
|
|
252
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
253
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
254
|
+
if (!apiKey)
|
|
255
|
+
return;
|
|
256
|
+
client = new ApiClient(apiKey);
|
|
257
|
+
}
|
|
258
|
+
await syncTemplatesToServer(client, yaml);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Silently ignore — local files remain authoritative
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Uploads a single template's HTML files to the server for incremental sync.
|
|
266
|
+
* Best-effort: silently ignores all errors. Called after the edit command
|
|
267
|
+
* applies changes to a specific template.
|
|
268
|
+
*/
|
|
269
|
+
async syncTemplateToServer(emailId) {
|
|
270
|
+
try {
|
|
271
|
+
let client = this.apiClient;
|
|
272
|
+
if (!client) {
|
|
273
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
274
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
275
|
+
if (!apiKey)
|
|
276
|
+
return;
|
|
277
|
+
client = new ApiClient(apiKey);
|
|
278
|
+
}
|
|
279
|
+
await syncTemplateToServer(client, emailId);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Silently ignore — local files remain authoritative
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Fetches a single backed-up template from the server and writes it to the
|
|
287
|
+
* local mailmodo/ folder. Returns true if the template was successfully
|
|
288
|
+
* restored, false if it is not backed up or on any error.
|
|
289
|
+
* Used as the `ctx.fetchTemplate` bridge passed to handleMissingTemplates.
|
|
290
|
+
*/
|
|
291
|
+
async getTemplateFromServer(emailId) {
|
|
292
|
+
try {
|
|
293
|
+
let client = this.apiClient;
|
|
294
|
+
if (!client) {
|
|
295
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
296
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
297
|
+
if (!apiKey)
|
|
298
|
+
return false;
|
|
299
|
+
client = new ApiClient(apiKey);
|
|
300
|
+
}
|
|
301
|
+
return fetchTemplateFromServer(client, emailId);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Silently restores any template files missing from disk by fetching them
|
|
309
|
+
* from the server. Uses the bulk endpoint when all templates are missing
|
|
310
|
+
* (single round-trip), or individual per-ID requests when only a subset is
|
|
311
|
+
* missing to avoid overwriting files that are already present. Best-effort —
|
|
312
|
+
* never throws.
|
|
313
|
+
*/
|
|
314
|
+
async fetchMissingTemplates(client, yaml) {
|
|
315
|
+
try {
|
|
316
|
+
const missingIds = getMissingTemplateIds(yaml);
|
|
317
|
+
if (missingIds.length === 0)
|
|
318
|
+
return;
|
|
319
|
+
// Use bulk endpoint when every template is missing; per-ID otherwise
|
|
320
|
+
await (missingIds.length === yaml.emails.length
|
|
321
|
+
? fetchTemplatesFromServer(client, yaml)
|
|
322
|
+
: Promise.all(missingIds.map((id) => fetchTemplateFromServer(client, id))));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// best-effort, silently ignore
|
|
326
|
+
}
|
|
327
|
+
}
|
|
176
328
|
/**
|
|
177
329
|
* Handles a failed API response by mapping HTTP status codes to
|
|
178
330
|
* user-friendly error messages and exiting the process.
|