@mailmodo/cli 0.0.54 → 0.0.55-beta.pr57.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/commands/billing/index.d.ts +1 -11
  2. package/dist/commands/billing/index.js +28 -181
  3. package/dist/commands/contacts/index.d.ts +1 -19
  4. package/dist/commands/contacts/index.js +21 -114
  5. package/dist/commands/deploy/index.d.ts +1 -32
  6. package/dist/commands/deploy/index.js +52 -303
  7. package/dist/commands/deployments/index.d.ts +1 -4
  8. package/dist/commands/deployments/index.js +11 -52
  9. package/dist/commands/domain/index.d.ts +1 -14
  10. package/dist/commands/domain/index.js +19 -100
  11. package/dist/commands/edit/index.d.ts +2 -20
  12. package/dist/commands/edit/index.js +35 -244
  13. package/dist/commands/emails/index.d.ts +1 -2
  14. package/dist/commands/emails/index.js +26 -91
  15. package/dist/commands/init/index.d.ts +1 -2
  16. package/dist/commands/init/index.js +43 -179
  17. package/dist/commands/login/index.d.ts +2 -0
  18. package/dist/commands/login/index.js +35 -64
  19. package/dist/commands/logs/index.d.ts +1 -8
  20. package/dist/commands/logs/index.js +12 -55
  21. package/dist/commands/preview/index.d.ts +1 -19
  22. package/dist/commands/preview/index.js +40 -210
  23. package/dist/commands/sdk/index.d.ts +1 -3
  24. package/dist/commands/sdk/index.js +14 -46
  25. package/dist/commands/settings/index.d.ts +1 -22
  26. package/dist/commands/settings/index.js +35 -241
  27. package/dist/commands/status/index.d.ts +1 -0
  28. package/dist/commands/status/index.js +13 -39
  29. package/dist/lib/api-client.d.ts +5 -0
  30. package/dist/lib/api-client.js +45 -0
  31. package/dist/lib/base-command.d.ts +25 -1
  32. package/dist/lib/base-command.js +91 -5
  33. package/dist/lib/commands/billing/checkout-status.d.ts +3 -0
  34. package/dist/lib/commands/billing/checkout-status.js +63 -0
  35. package/dist/lib/commands/billing/format.d.ts +7 -0
  36. package/dist/lib/commands/billing/format.js +63 -0
  37. package/dist/lib/commands/billing/purchase-cap.d.ts +7 -0
  38. package/dist/lib/commands/billing/purchase-cap.js +57 -0
  39. package/dist/lib/commands/billing/types.d.ts +72 -0
  40. package/dist/lib/commands/billing/types.js +1 -0
  41. package/dist/lib/commands/contacts/actions.d.ts +3 -0
  42. package/dist/lib/commands/contacts/actions.js +49 -0
  43. package/dist/lib/commands/contacts/export-delete.d.ts +9 -0
  44. package/dist/lib/commands/contacts/export-delete.js +51 -0
  45. package/dist/lib/commands/contacts/types.d.ts +35 -0
  46. package/dist/lib/commands/contacts/types.js +1 -0
  47. package/dist/lib/commands/deploy/domain-setup.d.ts +8 -0
  48. package/dist/lib/commands/deploy/domain-setup.js +82 -0
  49. package/dist/lib/commands/deploy/output.d.ts +5 -0
  50. package/dist/lib/commands/deploy/output.js +61 -0
  51. package/dist/lib/commands/deploy/payload.d.ts +41 -0
  52. package/dist/lib/commands/deploy/payload.js +95 -0
  53. package/dist/lib/commands/deploy/sequence-status.d.ts +3 -0
  54. package/dist/lib/commands/deploy/sequence-status.js +56 -0
  55. package/dist/lib/commands/deploy/types.d.ts +88 -0
  56. package/dist/lib/commands/deploy/types.js +1 -0
  57. package/dist/lib/commands/deployments/output.d.ts +2 -0
  58. package/dist/lib/commands/deployments/output.js +68 -0
  59. package/dist/lib/commands/deployments/types.d.ts +24 -0
  60. package/dist/lib/commands/deployments/types.js +1 -0
  61. package/dist/lib/commands/domain/setup.d.ts +8 -0
  62. package/dist/lib/commands/domain/setup.js +53 -0
  63. package/dist/lib/commands/domain/types.d.ts +56 -0
  64. package/dist/lib/commands/domain/types.js +1 -0
  65. package/dist/lib/commands/domain/verify.d.ts +5 -0
  66. package/dist/lib/commands/domain/verify.js +50 -0
  67. package/dist/lib/commands/edit/diff.d.ts +7 -0
  68. package/dist/lib/commands/edit/diff.js +65 -0
  69. package/dist/lib/commands/edit/display.d.ts +5 -0
  70. package/dist/lib/commands/edit/display.js +53 -0
  71. package/dist/lib/commands/edit/flow.d.ts +8 -0
  72. package/dist/lib/commands/edit/flow.js +70 -0
  73. package/dist/lib/commands/edit/persist.d.ts +5 -0
  74. package/dist/lib/commands/edit/persist.js +65 -0
  75. package/dist/lib/commands/edit/types.d.ts +37 -0
  76. package/dist/lib/commands/edit/types.js +1 -0
  77. package/dist/lib/commands/emails/editor.d.ts +2 -0
  78. package/dist/lib/commands/emails/editor.js +43 -0
  79. package/dist/lib/commands/emails/output.d.ts +4 -0
  80. package/dist/lib/commands/emails/output.js +36 -0
  81. package/dist/lib/commands/emails/types.d.ts +3 -0
  82. package/dist/lib/commands/emails/types.js +1 -0
  83. package/dist/lib/commands/init/analysis.d.ts +3 -0
  84. package/dist/lib/commands/init/analysis.js +69 -0
  85. package/dist/lib/commands/init/output.d.ts +12 -0
  86. package/dist/lib/commands/init/output.js +39 -0
  87. package/dist/lib/commands/init/payload.d.ts +8 -0
  88. package/dist/lib/commands/init/payload.js +78 -0
  89. package/dist/lib/commands/init/types.d.ts +57 -0
  90. package/dist/lib/commands/init/types.js +1 -0
  91. package/dist/lib/commands/login/output.d.ts +8 -0
  92. package/dist/lib/commands/login/output.js +53 -0
  93. package/dist/lib/commands/login/types.d.ts +19 -0
  94. package/dist/lib/commands/login/types.js +1 -0
  95. package/dist/lib/commands/logs/output.d.ts +2 -0
  96. package/dist/lib/commands/logs/output.js +52 -0
  97. package/dist/lib/commands/logs/types.d.ts +23 -0
  98. package/dist/lib/commands/logs/types.js +1 -0
  99. package/dist/lib/commands/preview/actions.d.ts +11 -0
  100. package/dist/lib/commands/preview/actions.js +43 -0
  101. package/dist/lib/commands/preview/render.d.ts +3 -0
  102. package/dist/lib/commands/preview/render.js +30 -0
  103. package/dist/lib/commands/preview/server.d.ts +8 -0
  104. package/dist/lib/commands/preview/server.js +63 -0
  105. package/dist/lib/commands/preview/types.d.ts +19 -0
  106. package/dist/lib/commands/preview/types.js +1 -0
  107. package/dist/lib/commands/preview/wrapper-html.d.ts +2 -0
  108. package/dist/lib/commands/preview/wrapper-html.js +35 -0
  109. package/dist/lib/commands/sdk/output.d.ts +2 -0
  110. package/dist/lib/commands/sdk/output.js +42 -0
  111. package/dist/lib/commands/sdk/types.d.ts +21 -0
  112. package/dist/lib/commands/sdk/types.js +1 -0
  113. package/dist/lib/commands/settings/actions.d.ts +10 -0
  114. package/dist/lib/commands/settings/actions.js +56 -0
  115. package/dist/lib/commands/settings/display.d.ts +15 -0
  116. package/dist/lib/commands/settings/display.js +69 -0
  117. package/dist/lib/commands/settings/logo-domain.d.ts +3 -0
  118. package/dist/lib/commands/settings/logo-domain.js +47 -0
  119. package/dist/lib/commands/settings/prompt.d.ts +2 -0
  120. package/dist/lib/commands/settings/prompt.js +82 -0
  121. package/dist/lib/commands/settings/types.d.ts +65 -0
  122. package/dist/lib/commands/settings/types.js +1 -0
  123. package/dist/lib/commands/status/output.d.ts +2 -0
  124. package/dist/lib/commands/status/output.js +49 -0
  125. package/dist/lib/commands/status/types.d.ts +28 -0
  126. package/dist/lib/commands/status/types.js +1 -0
  127. package/dist/lib/constants.d.ts +1 -0
  128. package/dist/lib/constants.js +1 -0
  129. package/dist/lib/messages.d.ts +22 -0
  130. package/dist/lib/messages.js +22 -0
  131. package/dist/lib/templates/missing-templates.d.ts +5 -0
  132. package/dist/lib/templates/missing-templates.js +61 -0
  133. package/dist/lib/templates/types.d.ts +13 -0
  134. package/dist/lib/templates/types.js +1 -0
  135. package/oclif.manifest.json +66 -66
  136. package/package.json +1 -1
@@ -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
- const SETTINGS_GROUPS = Object.freeze({
13
- billing: ['monthly_cap'],
14
- brand: ['email_style', 'brand_color', 'logo_url', 'logo_file'],
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 this.applySetFlag(flags.set, yamlConfig, flags.json ?? false);
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
- : this.fetchDomainVerified(yamlConfig.project.domain),
33
+ : fetchDomainVerified(ctx, yamlConfig.project.domain),
46
34
  this.fetchBillingStatus(),
47
35
  ]);
48
36
  const tier = billingStatus?.tier ?? null;
@@ -50,6 +38,7 @@ export default class Settings extends BaseCommand {
50
38
  if (capFromApi !== null) {
51
39
  yamlConfig.project.monthlyCap = capFromApi;
52
40
  await saveYaml(yamlConfig);
41
+ await this.syncYamlToServer();
53
42
  }
54
43
  if (flags.json) {
55
44
  this.log(JSON.stringify({ settings: yamlConfig.project }, null, 2));
@@ -59,230 +48,35 @@ export default class Settings extends BaseCommand {
59
48
  for (const [group, keys] of Object.entries(SETTINGS_GROUPS)) {
60
49
  if (group === 'billing' && tier === FREE_TIER)
61
50
  continue;
62
- this.displaySettingsGroup(group, keys, yamlConfig.project, domainVerified);
63
- }
64
- if (!flags.yes) {
65
- await this.promptEditSetting(yamlConfig, tier);
66
- }
67
- }
68
- async applySetFlag(setFlag, yamlConfig, isJson) {
69
- const { project } = yamlConfig;
70
- const eqIndex = setFlag.indexOf('=');
71
- if (eqIndex === -1) {
72
- this.error('Invalid format. Use --set key=value (e.g., --set brand_color=#0F3460)');
73
- }
74
- const key = setFlag.slice(0, eqIndex).trim();
75
- const propKey = settingKeyToProp(key);
76
- const value = setFlag.slice(eqIndex + 1).trim();
77
- if (!(propKey in project) && key !== 'logo_file') {
78
- this.error(`Unknown setting: ${key}`);
79
- }
80
- if (propKey === 'monthlyCap') {
81
- await this.applyMonthlyCapChange(yamlConfig, value, isJson, null);
82
- return;
83
- }
84
- project[propKey] = value;
85
- await saveYaml(yamlConfig);
86
- if (isJson) {
87
- this.log(JSON.stringify({ [propKey]: value, status: 'updated' }, null, 2));
88
- return;
89
- }
90
- this.log(`\n ${chalk.green('✓')} ${key} updated to ${chalk.cyan(value)}`);
91
- this.log(` ${INFO.DEPLOY_TO_APPLY}\n`);
92
- }
93
- async applyMonthlyCapChange(yamlConfig, rawValue, isJson, knownTier) {
94
- const parsed = Number(rawValue);
95
- if (!Number.isInteger(parsed) || parsed < 1) {
96
- this.error('monthly_cap must be a positive integer (blocks).');
97
- }
98
- await this.ensureAuth();
99
- const tier = knownTier ?? (await this.fetchBillingTier());
100
- if (tier === FREE_TIER) {
101
- this.warnFreeTierCapBlocked(isJson);
102
- return;
103
- }
104
- const data = await this.applyBillingCap({ cap: parsed, json: isJson });
105
- yamlConfig.project.monthlyCap = data.capBlocks;
106
- await saveYaml(yamlConfig);
107
- if (isJson) {
108
- this.log(JSON.stringify({ monthlyCap: data.capBlocks, status: 'updated' }, null, 2));
109
- return;
110
- }
111
- this.log(`\n ${chalk.green('✓')} monthly_cap updated to ${chalk.cyan(String(data.capBlocks))} (${data.capEmails.toLocaleString()} emails)\n`);
112
- }
113
- displaySettingsGroup(group, keys, project, domainVerified) {
114
- const availableKeys = keys.filter((key) => {
115
- if (group === 'brand' && key === 'logo_file')
116
- return true;
117
- return settingKeyToProp(key) in project;
118
- });
119
- const groupTitle = ` ${chalk.bold(group.charAt(0).toUpperCase() + group.slice(1))}`;
120
- if (availableKeys.length === 0) {
121
- const hint = SETUP_HINTS[settingKeyToProp(keys[0])];
122
- if (hint) {
123
- this.log(groupTitle);
124
- this.log(` ${'─'.repeat(49)}`);
125
- this.log(` ${chalk.dim(`Run ${hint} to configure.`)}`);
126
- this.log('');
127
- }
128
- return;
129
- }
130
- this.log(groupTitle);
131
- this.log(` ${'─'.repeat(49)}`);
132
- for (const key of availableKeys) {
133
- const propKey = settingKeyToProp(key);
134
- const value = project[propKey];
135
- let displayValue = value ? String(value) : chalk.dim('(not set)');
136
- if (key === 'domain' && value && domainVerified === true) {
137
- displayValue += ` ${chalk.green('✓ verified')}`;
138
- }
139
- else if (key === 'domain' && value && domainVerified === false) {
140
- displayValue += ` ${chalk.red('✗ not verified')}`;
141
- }
142
- this.log(` ${key.padEnd(16)} ${displayValue}`);
143
- }
144
- const missingKeys = keys.filter((key) => !(settingKeyToProp(key) in project) &&
145
- !(group === 'brand' && key === 'logo_file'));
146
- for (const key of missingKeys) {
147
- const hint = SETUP_HINTS[settingKeyToProp(key)];
148
- if (hint) {
149
- this.log(` ${key.padEnd(16)} ${chalk.dim(`(run ${hint} to set up)`)}`);
150
- }
151
- }
152
- this.log('');
153
- }
154
- /**
155
- * Prompts the user to pick a setting key to edit and dispatches
156
- * to the appropriate handler for that key.
157
- */
158
- async promptEditSetting(yamlConfig, tier) {
159
- const { project } = yamlConfig;
160
- const editKey = await input({
161
- default: 'n',
162
- message: "Edit a setting? (key or 'n'):",
163
- });
164
- if (editKey === 'n')
165
- return;
166
- if (editKey === 'monthly_cap' && tier === FREE_TIER) {
167
- this.warnFreeTierCapBlocked(false);
168
- return;
169
- }
170
- const editPropKey = settingKeyToProp(editKey);
171
- if (!(editPropKey in project)) {
172
- if (editKey === 'logo_file') {
173
- await this.handleLogoUpload(yamlConfig);
174
- return;
175
- }
176
- const hint = SETUP_HINTS[editPropKey];
177
- if (hint) {
178
- this.log(`\n ${editKey} is not configured yet. Run ${chalk.cyan(hint)} to set it up.\n`);
179
- }
180
- else {
181
- this.log(`\n Unknown setting: ${editKey}\n`);
182
- }
183
- return;
184
- }
185
- if (editKey === 'logo_file') {
186
- await this.handleLogoUpload(yamlConfig);
187
- return;
188
- }
189
- if (editKey === 'domain') {
190
- await this.handleDomainChange(yamlConfig);
191
- return;
192
- }
193
- if (editKey === 'monthly_cap') {
194
- const newValue = await input({
195
- message: 'New monthly cap (blocks):',
196
- });
197
- await this.applyMonthlyCapChange(yamlConfig, newValue, false, tier);
198
- return;
199
- }
200
- if (editKey === 'email_style') {
201
- const style = await select({
202
- choices: [
203
- { name: 'plain', value: 'plain' },
204
- { name: 'branded', value: 'branded' },
205
- ],
206
- message: 'Email style:',
51
+ displaySettingsGroup(ctx, group, {
52
+ domainVerified,
53
+ keys,
54
+ project: yamlConfig.project,
207
55
  });
208
- project.emailStyle = style;
209
- await saveYaml(yamlConfig);
210
- this.log(`\n ${chalk.green('✓')} email_style updated to ${chalk.cyan(style)}`);
211
- this.log(` ${INFO.DEPLOY_TO_APPLY}\n`);
212
- return;
213
- }
214
- const newValue = await input({
215
- message: `New value for ${editKey}:`,
216
- });
217
- project[editPropKey] = newValue;
218
- await saveYaml(yamlConfig);
219
- this.log(`\n ${chalk.green('✓')} Updated. ${INFO.DEPLOY_TO_APPLY}\n`);
220
- }
221
- /**
222
- * Fetches the domain verification status from the API.
223
- * Returns true/false for verified/unverified, or null if unavailable.
224
- */
225
- async fetchDomainVerified(domain) {
226
- if (!domain)
227
- return null;
228
- try {
229
- await this.ensureAuth();
230
- const response = await this.apiClient.get(API_ENDPOINTS.DOMAIN_STATUS, { domain });
231
- if (!response.ok) {
232
- this.log(` ${chalk.dim('Could not fetch domain status. Run')} ${chalk.cyan("'mailmodo domain --status'")} ${chalk.dim('to check manually.')}`);
233
- return null;
234
- }
235
- return response.data?.verified === true;
236
56
  }
237
- catch {
238
- this.log(` ${chalk.dim('Could not reach API for domain status. Skipping verification check.')}`);
239
- return null;
57
+ if (!flags.yes) {
58
+ await promptEditSetting(ctx, yamlConfig, tier);
240
59
  }
241
60
  }
242
- async handleDomainChange(yamlConfig) {
243
- await this.ensureAuth();
244
- const inputs = await this.collectDomainSetupInputs(yamlConfig, false);
245
- const { dnsRecords, dnsGuideUrl } = await this.registerDomain(yamlConfig, inputs, false);
246
- this.log(`\n Domain and sender details updated. You will need to re-verify.`);
247
- this.logDnsRecords(dnsRecords, dnsGuideUrl, false);
248
- this.log(` Run ${chalk.cyan("'mailmodo domain --verify'")} once records are added.`);
249
- this.log(` Emails will not send until the new domain is verified.`);
250
- }
251
- /**
252
- * Handles the logo file upload flow: validates the local file exists,
253
- * reads it, uploads to Mailmodo CDN via API, and updates both logoFile
254
- * and logoUrl in the project config.
255
- *
256
- * @param {import('../../lib/yaml-config.js').MailmodoYaml} yamlConfig - The full YAML config to update and save.
257
- */
258
- async handleLogoUpload(yamlConfig) {
259
- const logoPath = await input({ message: 'Path to logo file:' });
260
- const resolvedPath = resolve(logoPath);
261
- if (!existsSync(resolvedPath)) {
262
- this.log(`\n File not found: ${resolvedPath}\n`);
263
- return;
264
- }
265
- await this.ensureAuth();
266
- const fileBuffer = await readFile(resolvedPath);
267
- const ext = resolvedPath.split('.').pop()?.toLowerCase();
268
- const mimeTypes = {
269
- png: 'image/png',
270
- jpg: 'image/jpeg',
271
- jpeg: 'image/jpeg',
272
- 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),
273
80
  };
274
- const mimeType = mimeTypes[ext ?? ''] ?? 'application/octet-stream';
275
- const formData = new FormData();
276
- formData.append('logo', new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), logoPath.split(/[/\\]/).pop() || 'logo.png');
277
- const response = await this.withApiSpinner({ json: false, text: ' Uploading logo file...' }, () => this.apiClient.postFormData(API_ENDPOINTS.ASSETS_LOGO, formData));
278
- if (!response.ok) {
279
- this.handleApiError(response);
280
- }
281
- yamlConfig.project.logoUrl = response.data?.url || '';
282
- yamlConfig.project.logoFile = logoPath;
283
- await saveYaml(yamlConfig);
284
- this.log(`\n Logo uploaded and hosted at:`);
285
- this.log(` ${chalk.cyan(String(response.data?.url))}`);
286
- this.log(` Run ${chalk.cyan("'mailmodo deploy'")} to apply to all branded emails.\n`);
287
81
  }
288
82
  }
@@ -7,4 +7,5 @@ export default class Status extends BaseCommand {
7
7
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
8
  };
9
9
  run(): Promise<void>;
10
+ private makeCtx;
10
11
  }
@@ -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 response = await this.withApiSpinner({ json: flags.json, text: ' Loading analytics...' }, () => this.apiClient.get(API_ENDPOINTS.ANALYTICS));
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
- this.handleApiError(response);
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
- this.log(`\n ${chalk.bold('Last 7 days')}${''.padEnd(20)}Sent Open Click Conv`);
26
- this.log(` ${'─'.repeat(62)}`);
27
- if (emails?.length) {
28
- for (const metric of emails) {
29
- const id = (metric.emailId || '').padEnd(30);
30
- const sent = String(metric.sent ?? 0).padEnd(7);
31
- const openRate = (metric.open || '0%').padEnd(7);
32
- const clickRate = (metric.click || '0%').padEnd(8);
33
- const convRate = metric.conv || '0%';
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
  }
@@ -46,6 +46,11 @@ export declare class ApiClient {
46
46
  * Bearer auth as other requests; does not parse JSON.
47
47
  */
48
48
  getFile(url: string): Promise<FileFetchResult>;
49
+ /**
50
+ * GET an endpoint and return the raw response body as a string (e.g. YAML files).
51
+ * Uses Bearer auth; does not parse JSON.
52
+ */
53
+ getRawText(path: string): Promise<ApiResponse<string>>;
49
54
  /**
50
55
  * GET an external URL (e.g. blob storage) without auth headers.
51
56
  */
@@ -135,6 +135,51 @@ export class ApiClient {
135
135
  async getFile(url) {
136
136
  return fetchFileWithBearerAuth(url, this.apiKey);
137
137
  }
138
+ /**
139
+ * GET an endpoint and return the raw response body as a string (e.g. YAML files).
140
+ * Uses Bearer auth; does not parse JSON.
141
+ */
142
+ async getRawText(path) {
143
+ const url = this.resolveUrl(path);
144
+ const debug = this.requestDebug(url);
145
+ try {
146
+ const response = await fetch(url.toString(), {
147
+ headers: {
148
+ Authorization: `Bearer ${this.apiKey}`,
149
+ 'User-Agent': '@mailmodo/cli',
150
+ },
151
+ method: 'GET',
152
+ });
153
+ const text = await response.text().catch(() => '');
154
+ if (!response.ok) {
155
+ return {
156
+ data: '',
157
+ debug: {
158
+ ...debug,
159
+ ...(text ? { responseSummary: text.slice(0, 200) } : {}),
160
+ },
161
+ error: text || `Request failed with status ${response.status}`,
162
+ ok: false,
163
+ status: response.status,
164
+ };
165
+ }
166
+ return { data: text, ok: true, status: response.status };
167
+ }
168
+ catch (error) {
169
+ const err = error;
170
+ const isConnectionError = err?.cause?.code === 'ECONNREFUSED' || err?.cause?.code === 'ENOTFOUND';
171
+ const causeCode = err?.cause?.code;
172
+ return {
173
+ data: '',
174
+ debug: { ...debug, ...(causeCode ? { causeCode } : {}) },
175
+ error: isConnectionError
176
+ ? 'Cannot connect to Mailmodo API. The API service may not be available yet.'
177
+ : err?.message || 'An unexpected network error occurred.',
178
+ ok: false,
179
+ status: 0,
180
+ };
181
+ }
182
+ }
138
183
  /**
139
184
  * GET an external URL (e.g. blob storage) without auth headers.
140
185
  */
@@ -60,12 +60,36 @@ export declare abstract class BaseCommand extends Command {
60
60
  }, work: () => Promise<T>): Promise<T>;
61
61
  /**
62
62
  * Loads and returns the mailmodo.yaml configuration from the current directory.
63
- * Exits with an error if the file is not found, directing the user to run init.
63
+ * If the file is not found locally and the API client is available, attempts to
64
+ * restore it from the server. Exits with an error if the file cannot be found
65
+ * or restored, directing the user to run init.
64
66
  *
65
67
  * @returns {Promise<MailmodoYaml>} The parsed mailmodo.yaml containing project
66
68
  * settings and all email sequence definitions.
67
69
  */
68
70
  protected ensureYaml(): Promise<MailmodoYaml>;
71
+ private fetchAndWriteYaml;
72
+ /**
73
+ * Attempts to fetch mailmodo.yaml from the server and save it locally.
74
+ * Returns null silently on any failure so callers can fall through to an error.
75
+ */
76
+ 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
+ /**
88
+ * Uploads the current local mailmodo.yaml to the server as a backup.
89
+ * Best-effort: silently ignores all errors so the originating command
90
+ * always succeeds regardless of sync failures.
91
+ */
92
+ protected syncYamlToServer(): Promise<void>;
69
93
  /**
70
94
  * Handles a failed API response by mapping HTTP status codes to
71
95
  * user-friendly error messages and exiting the process.
@@ -1,10 +1,13 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
1
4
  import { input } from '@inquirer/prompts';
2
5
  import { Command, Flags } from '@oclif/core';
3
6
  import chalk from 'chalk';
4
7
  import ora from 'ora';
5
8
  import { ApiClient } from './api-client.js';
6
9
  import { loadConfig } from './config.js';
7
- import { API_ENDPOINTS, IS_DEV_MODE } from './constants.js';
10
+ import { API_ENDPOINTS, IS_DEV_MODE, YAML_FILE } from './constants.js';
8
11
  import { ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
9
12
  import { loadYaml, saveYaml } from './yaml-config.js';
10
13
  export const FREE_TIER = 'free';
@@ -76,17 +79,99 @@ export class BaseCommand extends Command {
76
79
  }
77
80
  /**
78
81
  * Loads and returns the mailmodo.yaml configuration from the current directory.
79
- * Exits with an error if the file is not found, directing the user to run init.
82
+ * If the file is not found locally and the API client is available, attempts to
83
+ * restore it from the server. Exits with an error if the file cannot be found
84
+ * or restored, directing the user to run init.
80
85
  *
81
86
  * @returns {Promise<MailmodoYaml>} The parsed mailmodo.yaml containing project
82
87
  * settings and all email sequence definitions.
83
88
  */
84
89
  async ensureYaml() {
85
90
  const config = await loadYaml();
86
- if (!config) {
87
- this.error(ERRORS.NO_YAML);
91
+ if (config)
92
+ return config;
93
+ const restored = await this.restoreYamlFromServer();
94
+ if (restored)
95
+ return restored;
96
+ this.error(ERRORS.NO_YAML);
97
+ }
98
+ async fetchAndWriteYaml(client) {
99
+ try {
100
+ const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
101
+ if (!response.ok || !response.data)
102
+ return false;
103
+ await writeFile(join(process.cwd(), YAML_FILE), response.data);
104
+ return true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ /**
111
+ * Attempts to fetch mailmodo.yaml from the server and save it locally.
112
+ * Returns null silently on any failure so callers can fall through to an error.
113
+ */
114
+ async restoreYamlFromServer() {
115
+ try {
116
+ let client = this.apiClient;
117
+ if (!client) {
118
+ const envKey = process.env.MAILMODO_API_KEY;
119
+ const apiKey = envKey ?? (await loadConfig())?.apiKey;
120
+ if (!apiKey)
121
+ return null;
122
+ client = new ApiClient(apiKey);
123
+ }
124
+ const written = await this.fetchAndWriteYaml(client);
125
+ if (!written)
126
+ return null;
127
+ this.logToStderr(INFO.YAML_RESTORED_FROM_SERVER);
128
+ return loadYaml();
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
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
+ /**
149
+ * Uploads the current local mailmodo.yaml to the server as a backup.
150
+ * Best-effort: silently ignores all errors so the originating command
151
+ * always succeeds regardless of sync failures.
152
+ */
153
+ async syncYamlToServer() {
154
+ try {
155
+ let client = this.apiClient;
156
+ if (!client) {
157
+ const envKey = process.env.MAILMODO_API_KEY;
158
+ const apiKey = envKey ?? (await loadConfig())?.apiKey;
159
+ if (!apiKey)
160
+ return;
161
+ client = new ApiClient(apiKey);
162
+ }
163
+ const filePath = join(process.cwd(), YAML_FILE);
164
+ if (!existsSync(filePath))
165
+ return;
166
+ const content = await readFile(filePath, 'utf8');
167
+ const blob = new Blob([content], { type: 'application/yaml' });
168
+ const formData = new FormData();
169
+ formData.append('yaml', blob, YAML_FILE);
170
+ await client.postFormData(API_ENDPOINTS.ASSETS_YAML, formData);
171
+ }
172
+ catch {
173
+ // Silently ignore — local file remains authoritative
88
174
  }
89
- return config;
90
175
  }
91
176
  /**
92
177
  * Handles a failed API response by mapping HTTP status codes to
@@ -276,6 +361,7 @@ export class BaseCommand extends Command {
276
361
  if (inputs.replyTo)
277
362
  yamlConfig.project.replyTo = inputs.replyTo;
278
363
  await saveYaml(yamlConfig);
364
+ await this.syncYamlToServer();
279
365
  return {
280
366
  dnsGuideUrl: response.data?.dnsGuideUrl,
281
367
  dnsRecords: response.data?.dnsRecords || [],
@@ -0,0 +1,3 @@
1
+ import type { BillingCtx } from './types.js';
2
+ export declare function startCheckout(ctx: BillingCtx, jsonOutput: boolean): Promise<void>;
3
+ export declare function showStatus(ctx: BillingCtx, jsonOutput: boolean, statusOnly: boolean): Promise<void>;