@mailmodo/cli 0.0.54-beta.pr56.91 → 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 (125) hide show
  1. package/dist/commands/billing/index.d.ts +1 -11
  2. package/dist/commands/billing/index.js +28 -184
  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.js +4 -4
  6. package/dist/commands/deployments/index.d.ts +1 -4
  7. package/dist/commands/deployments/index.js +11 -52
  8. package/dist/commands/domain/index.d.ts +1 -14
  9. package/dist/commands/domain/index.js +19 -100
  10. package/dist/commands/edit/index.d.ts +2 -20
  11. package/dist/commands/edit/index.js +30 -258
  12. package/dist/commands/emails/index.d.ts +1 -2
  13. package/dist/commands/emails/index.js +26 -91
  14. package/dist/commands/init/index.d.ts +1 -3
  15. package/dist/commands/init/index.js +41 -199
  16. package/dist/commands/login/index.d.ts +2 -0
  17. package/dist/commands/login/index.js +32 -76
  18. package/dist/commands/logs/index.d.ts +1 -8
  19. package/dist/commands/logs/index.js +12 -55
  20. package/dist/commands/preview/index.d.ts +1 -19
  21. package/dist/commands/preview/index.js +30 -212
  22. package/dist/commands/sdk/index.d.ts +1 -3
  23. package/dist/commands/sdk/index.js +14 -46
  24. package/dist/commands/settings/index.d.ts +1 -22
  25. package/dist/commands/settings/index.js +34 -246
  26. package/dist/commands/status/index.d.ts +1 -0
  27. package/dist/commands/status/index.js +13 -39
  28. package/dist/lib/commands/billing/checkout-status.d.ts +3 -0
  29. package/dist/lib/commands/billing/checkout-status.js +63 -0
  30. package/dist/lib/commands/billing/format.d.ts +7 -0
  31. package/dist/lib/commands/billing/format.js +63 -0
  32. package/dist/lib/commands/billing/purchase-cap.d.ts +7 -0
  33. package/dist/lib/commands/billing/purchase-cap.js +57 -0
  34. package/dist/lib/commands/billing/types.d.ts +72 -0
  35. package/dist/lib/commands/contacts/actions.d.ts +3 -0
  36. package/dist/lib/commands/contacts/actions.js +49 -0
  37. package/dist/lib/commands/contacts/export-delete.d.ts +9 -0
  38. package/dist/lib/commands/contacts/export-delete.js +51 -0
  39. package/dist/lib/commands/contacts/types.d.ts +35 -0
  40. package/dist/lib/commands/contacts/types.js +1 -0
  41. package/dist/lib/{deploy → commands/deploy}/domain-setup.d.ts +1 -1
  42. package/dist/lib/{deploy → commands/deploy}/domain-setup.js +2 -2
  43. package/dist/lib/{deploy → commands/deploy}/output.d.ts +1 -1
  44. package/dist/lib/{deploy → commands/deploy}/output.js +2 -2
  45. package/dist/lib/{deploy → commands/deploy}/payload.d.ts +1 -1
  46. package/dist/lib/{deploy → commands/deploy}/payload.js +2 -2
  47. package/dist/lib/{deploy → commands/deploy}/sequence-status.js +2 -2
  48. package/dist/lib/{deploy → commands/deploy}/types.d.ts +4 -4
  49. package/dist/lib/commands/deploy/types.js +1 -0
  50. package/dist/lib/commands/deployments/output.d.ts +2 -0
  51. package/dist/lib/commands/deployments/output.js +68 -0
  52. package/dist/lib/commands/deployments/types.d.ts +24 -0
  53. package/dist/lib/commands/deployments/types.js +1 -0
  54. package/dist/lib/commands/domain/setup.d.ts +8 -0
  55. package/dist/lib/commands/domain/setup.js +53 -0
  56. package/dist/lib/commands/domain/types.d.ts +56 -0
  57. package/dist/lib/commands/domain/types.js +1 -0
  58. package/dist/lib/commands/domain/verify.d.ts +5 -0
  59. package/dist/lib/commands/domain/verify.js +50 -0
  60. package/dist/lib/commands/edit/diff.d.ts +7 -0
  61. package/dist/lib/commands/edit/diff.js +65 -0
  62. package/dist/lib/commands/edit/display.d.ts +5 -0
  63. package/dist/lib/commands/edit/display.js +53 -0
  64. package/dist/lib/commands/edit/flow.d.ts +8 -0
  65. package/dist/lib/commands/edit/flow.js +70 -0
  66. package/dist/lib/commands/edit/persist.d.ts +5 -0
  67. package/dist/lib/commands/edit/persist.js +65 -0
  68. package/dist/lib/commands/edit/types.d.ts +37 -0
  69. package/dist/lib/commands/edit/types.js +1 -0
  70. package/dist/lib/commands/emails/editor.d.ts +2 -0
  71. package/dist/lib/commands/emails/editor.js +43 -0
  72. package/dist/lib/commands/emails/output.d.ts +4 -0
  73. package/dist/lib/commands/emails/output.js +36 -0
  74. package/dist/lib/commands/emails/types.d.ts +3 -0
  75. package/dist/lib/commands/emails/types.js +1 -0
  76. package/dist/lib/commands/init/analysis.d.ts +3 -0
  77. package/dist/lib/commands/init/analysis.js +69 -0
  78. package/dist/lib/commands/init/output.d.ts +12 -0
  79. package/dist/lib/commands/init/output.js +39 -0
  80. package/dist/lib/commands/init/payload.d.ts +8 -0
  81. package/dist/lib/commands/init/payload.js +78 -0
  82. package/dist/lib/commands/init/types.d.ts +57 -0
  83. package/dist/lib/commands/init/types.js +1 -0
  84. package/dist/lib/commands/login/output.d.ts +8 -0
  85. package/dist/lib/commands/login/output.js +53 -0
  86. package/dist/lib/commands/login/types.d.ts +19 -0
  87. package/dist/lib/commands/login/types.js +1 -0
  88. package/dist/lib/commands/logs/output.d.ts +2 -0
  89. package/dist/lib/commands/logs/output.js +52 -0
  90. package/dist/lib/commands/logs/types.d.ts +23 -0
  91. package/dist/lib/commands/logs/types.js +1 -0
  92. package/dist/lib/commands/preview/actions.d.ts +11 -0
  93. package/dist/lib/commands/preview/actions.js +43 -0
  94. package/dist/lib/commands/preview/render.d.ts +3 -0
  95. package/dist/lib/commands/preview/render.js +30 -0
  96. package/dist/lib/commands/preview/server.d.ts +8 -0
  97. package/dist/lib/commands/preview/server.js +63 -0
  98. package/dist/lib/commands/preview/types.d.ts +19 -0
  99. package/dist/lib/commands/preview/types.js +1 -0
  100. package/dist/lib/commands/preview/wrapper-html.d.ts +2 -0
  101. package/dist/lib/commands/preview/wrapper-html.js +35 -0
  102. package/dist/lib/commands/sdk/output.d.ts +2 -0
  103. package/dist/lib/commands/sdk/output.js +42 -0
  104. package/dist/lib/commands/sdk/types.d.ts +21 -0
  105. package/dist/lib/commands/sdk/types.js +1 -0
  106. package/dist/lib/commands/settings/actions.d.ts +10 -0
  107. package/dist/lib/commands/settings/actions.js +56 -0
  108. package/dist/lib/commands/settings/display.d.ts +15 -0
  109. package/dist/lib/commands/settings/display.js +69 -0
  110. package/dist/lib/commands/settings/logo-domain.d.ts +3 -0
  111. package/dist/lib/commands/settings/logo-domain.js +47 -0
  112. package/dist/lib/commands/settings/prompt.d.ts +2 -0
  113. package/dist/lib/commands/settings/prompt.js +82 -0
  114. package/dist/lib/commands/settings/types.d.ts +65 -0
  115. package/dist/lib/commands/settings/types.js +1 -0
  116. package/dist/lib/commands/status/output.d.ts +2 -0
  117. package/dist/lib/commands/status/output.js +49 -0
  118. package/dist/lib/commands/status/types.d.ts +28 -0
  119. package/dist/lib/commands/status/types.js +1 -0
  120. package/dist/lib/templates/missing-templates.d.ts +1 -1
  121. package/dist/lib/templates/missing-templates.js +1 -1
  122. package/oclif.manifest.json +48 -48
  123. package/package.json +1 -1
  124. /package/dist/lib/{deploy → commands/billing}/types.js +0 -0
  125. /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
- 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;
@@ -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
- this.displaySettingsGroup(group, keys, yamlConfig.project, domainVerified);
64
- }
65
- if (!flags.yes) {
66
- await this.promptEditSetting(yamlConfig, tier);
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
- catch {
243
- this.log(` ${chalk.dim('Could not reach API for domain status. Skipping verification check.')}`);
244
- return null;
57
+ if (!flags.yes) {
58
+ await promptEditSetting(ctx, yamlConfig, tier);
245
59
  }
246
60
  }
247
- async handleDomainChange(yamlConfig) {
248
- await this.ensureAuth();
249
- const inputs = await this.collectDomainSetupInputs(yamlConfig, false);
250
- const { dnsRecords, dnsGuideUrl } = await this.registerDomain(yamlConfig, inputs, false);
251
- this.log(`\n Domain and sender details updated. You will need to re-verify.`);
252
- this.logDnsRecords(dnsRecords, dnsGuideUrl, false);
253
- this.log(` Run ${chalk.cyan("'mailmodo domain --verify'")} once records are added.`);
254
- this.log(` Emails will not send until the new domain is verified.`);
255
- }
256
- /**
257
- * Handles the logo file upload flow: validates the local file exists,
258
- * reads it, uploads to Mailmodo CDN via API, and updates both logoFile
259
- * and logoUrl in the project config.
260
- *
261
- * @param {import('../../lib/yaml-config.js').MailmodoYaml} yamlConfig - The full YAML config to update and save.
262
- */
263
- async handleLogoUpload(yamlConfig) {
264
- const logoPath = await input({ message: 'Path to logo file:' });
265
- const resolvedPath = resolve(logoPath);
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
  }
@@ -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
  }
@@ -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>;
@@ -0,0 +1,63 @@
1
+ import chalk from 'chalk';
2
+ import open from 'open';
3
+ import { FREE_TIER } from '../../base-command.js';
4
+ import { API_ENDPOINTS } from '../../constants.js';
5
+ import { INFO } from '../../messages.js';
6
+ import { formatAutoCharge, formatCap, formatCurrency, formatPaymentMethod, formatUsageBlock, } from './format.js';
7
+ export async function startCheckout(ctx, jsonOutput) {
8
+ const response = await ctx.spinner(' Creating checkout session...', jsonOutput, () => ctx.post(API_ENDPOINTS.BILLING_CHECKOUT));
9
+ if (!response.ok) {
10
+ ctx.onApiError(response);
11
+ }
12
+ const { checkoutUrl } = response.data;
13
+ if (jsonOutput) {
14
+ ctx.log(JSON.stringify({ checkoutUrl }, null, 2));
15
+ return;
16
+ }
17
+ ctx.log(`\n ${chalk.bold('Stripe Checkout')} — add or update your payment method.`);
18
+ ctx.log(` ${chalk.dim(checkoutUrl)}\n`);
19
+ if (!process.env.CI) {
20
+ try {
21
+ await open(checkoutUrl);
22
+ ctx.log(` ${INFO.BROWSER_OPENING}\n`);
23
+ }
24
+ catch {
25
+ ctx.log(` ${INFO.BROWSER_OPEN_FAILED}\n`);
26
+ }
27
+ }
28
+ }
29
+ function logStatusFields(ctx, data) {
30
+ ctx.log('');
31
+ ctx.log(` Tier: ${data.tier}`);
32
+ ctx.log(` Payment: ${formatPaymentMethod(data)}`);
33
+ ctx.log(` Auto-charge: ${formatAutoCharge(data)}`);
34
+ if (data.tier !== FREE_TIER) {
35
+ ctx.log(` Monthly cap: ${formatCap(data.cap)}`);
36
+ }
37
+ ctx.log(` Total spent: ${formatCurrency(data.totalSpent, data.spentCurrency)}`);
38
+ if (data.activeBlocks.length === 0) {
39
+ ctx.log(' Active blocks: none');
40
+ }
41
+ else {
42
+ ctx.log(' Active blocks:');
43
+ for (const block of data.activeBlocks) {
44
+ ctx.log(` - ${formatUsageBlock(block)}`);
45
+ }
46
+ }
47
+ ctx.log('');
48
+ }
49
+ export async function showStatus(ctx, jsonOutput, statusOnly) {
50
+ const response = await ctx.spinner(' Loading billing status...', jsonOutput, () => ctx.get(API_ENDPOINTS.BILLING_STATUS));
51
+ if (!response.ok) {
52
+ ctx.onApiError(response);
53
+ }
54
+ const { data } = response;
55
+ if (jsonOutput) {
56
+ ctx.log(JSON.stringify(data, null, 2));
57
+ return;
58
+ }
59
+ logStatusFields(ctx, data);
60
+ if (!data.hasPaymentMethod && !statusOnly) {
61
+ await startCheckout(ctx, jsonOutput);
62
+ }
63
+ }
@@ -0,0 +1,7 @@
1
+ import type { BillingCap, BillingStatusResponse, BillingUsageBlock } from './types.js';
2
+ export declare function pluralize(word: string, count: number): string;
3
+ export declare function formatAutoCharge(data: BillingStatusResponse): string;
4
+ export declare function formatCap(cap: BillingCap): string;
5
+ export declare function formatCurrency(amount: number | string, currency: string): string;
6
+ export declare function formatPaymentMethod(data: BillingStatusResponse): string;
7
+ export declare function formatUsageBlock(block: BillingUsageBlock): string;
@@ -0,0 +1,63 @@
1
+ export function pluralize(word, count) {
2
+ return count === 1 ? word : `${word}s`;
3
+ }
4
+ export function formatAutoCharge(data) {
5
+ if (!data.autoChargeEnabled) {
6
+ return 'disabled';
7
+ }
8
+ if (typeof data.autoChargeBlockCount !== 'number') {
9
+ return 'enabled';
10
+ }
11
+ return `enabled (${data.autoChargeBlockCount} ${pluralize('block', data.autoChargeBlockCount)})`;
12
+ }
13
+ export function formatCap(cap) {
14
+ const hasBlocks = typeof cap.inBlocks === 'number';
15
+ const hasEmails = typeof cap.inEmails === 'number';
16
+ if (hasBlocks && hasEmails) {
17
+ return `${cap.inBlocks} ${pluralize('block', cap.inBlocks)} (${cap.inEmails.toLocaleString()} ${pluralize('email', cap.inEmails)})`;
18
+ }
19
+ if (hasBlocks) {
20
+ return `${cap.inBlocks} ${pluralize('block', cap.inBlocks)}`;
21
+ }
22
+ if (hasEmails) {
23
+ return `${cap.inEmails.toLocaleString()} ${pluralize('email', cap.inEmails)}`;
24
+ }
25
+ return 'not set';
26
+ }
27
+ export function formatCurrency(amount, currency) {
28
+ const numericAmount = typeof amount === 'number' ? amount : Number.parseFloat(amount);
29
+ const normalizedCurrency = currency.toUpperCase();
30
+ if (Number.isFinite(numericAmount)) {
31
+ try {
32
+ return new Intl.NumberFormat('en-US', {
33
+ currency: normalizedCurrency,
34
+ style: 'currency',
35
+ }).format(numericAmount);
36
+ }
37
+ catch {
38
+ return `${numericAmount.toFixed(2)} ${normalizedCurrency}`;
39
+ }
40
+ }
41
+ return `${String(amount)} ${normalizedCurrency}`;
42
+ }
43
+ export function formatPaymentMethod(data) {
44
+ if (!data.hasPaymentMethod) {
45
+ return 'No payment method on file';
46
+ }
47
+ const primaryMethod = data.paymentMethod[0];
48
+ if (!primaryMethod) {
49
+ return 'Payment method on file';
50
+ }
51
+ const brand = primaryMethod.brand || 'Card';
52
+ return primaryMethod.last4
53
+ ? `${brand} ending ${primaryMethod.last4}`
54
+ : `${brand} on file`;
55
+ }
56
+ export function formatUsageBlock(block) {
57
+ const allowance = block.blockAllowance ?? Math.max(block.blocksCount * block.blockSize, 0);
58
+ const used = Math.max(block.emailsSent ?? 0, 0);
59
+ const activationSuffix = block.activatedAt
60
+ ? `, activated at ${new Date(block.activatedAt).toLocaleDateString('en-US')}`
61
+ : '';
62
+ return `${block.type} block (${used + allowance}): ${used} ${pluralize('email', used)} sent, ${allowance} ${pluralize('email', allowance)} remaining${activationSuffix}`;
63
+ }
@@ -0,0 +1,7 @@
1
+ import type { BillingCtx } from './types.js';
2
+ export declare function purchaseBlocks(ctx: BillingCtx, blocksCount: number, jsonOutput: boolean): Promise<void>;
3
+ export declare function setCap(ctx: BillingCtx, opts: {
4
+ autoChargeBlockCount?: number;
5
+ cap: number;
6
+ json: boolean;
7
+ }): Promise<void>;
@@ -0,0 +1,57 @@
1
+ import chalk from 'chalk';
2
+ import { FREE_TIER } from '../../base-command.js';
3
+ import { API_ENDPOINTS } from '../../constants.js';
4
+ import { loadYaml, saveYaml } from '../../yaml-config.js';
5
+ import { pluralize } from './format.js';
6
+ async function persistMonthlyCap(ctx, capBlocks) {
7
+ const yamlConfig = await loadYaml();
8
+ if (!yamlConfig)
9
+ return;
10
+ if (yamlConfig.project.monthlyCap === capBlocks)
11
+ return;
12
+ yamlConfig.project.monthlyCap = capBlocks;
13
+ await saveYaml(yamlConfig);
14
+ await ctx.syncYaml();
15
+ }
16
+ export async function purchaseBlocks(ctx, blocksCount, jsonOutput) {
17
+ if (blocksCount < 1) {
18
+ ctx.error('Purchase block count must be at least 1 block.');
19
+ }
20
+ const response = await ctx.spinner(' Initiating block purchase...', jsonOutput, () => ctx.post(API_ENDPOINTS.BILLING_PURCHASE, {
21
+ blocksCount,
22
+ }));
23
+ if (!response.ok) {
24
+ ctx.onApiError(response);
25
+ }
26
+ if (jsonOutput) {
27
+ ctx.log(JSON.stringify(response.data, null, 2));
28
+ return;
29
+ }
30
+ ctx.log(`\n ${chalk.green('✓')} ${response.data.message}`);
31
+ ctx.log(` Blocks: ${blocksCount} ${pluralize('block', blocksCount)}`);
32
+ ctx.log(` Payment intent:${` ${chalk.dim(response.data.paymentIntentId)}`}`);
33
+ ctx.log('');
34
+ }
35
+ export async function setCap(ctx, opts) {
36
+ ctx.validateBillingCapInputs({
37
+ autoChargeBlockCount: opts.autoChargeBlockCount,
38
+ cap: opts.cap,
39
+ });
40
+ const tier = await ctx.fetchBillingTier();
41
+ if (tier === FREE_TIER) {
42
+ ctx.warnFreeTierCapBlocked(opts.json);
43
+ return;
44
+ }
45
+ const data = await ctx.applyBillingCap(opts);
46
+ await persistMonthlyCap(ctx, data.capBlocks);
47
+ if (opts.json) {
48
+ ctx.log(JSON.stringify(data, null, 2));
49
+ return;
50
+ }
51
+ ctx.log(`\n ${chalk.green('✓')} ${data.message}`);
52
+ ctx.log(` Monthly cap: ${chalk.bold(String(data.capBlocks))} ${pluralize('block', data.capBlocks)} (${data.capEmails.toLocaleString()} emails)`);
53
+ if (data.autoChargeBlockCount !== undefined) {
54
+ ctx.log(` Auto-charge: ${data.autoChargeBlockCount} ${pluralize('block', data.autoChargeBlockCount)}`);
55
+ }
56
+ ctx.log('');
57
+ }
@@ -0,0 +1,72 @@
1
+ import type { ApiResponse } from '../../api-client.js';
2
+ import type { BillingCapUpdateResult } from '../../base-command.js';
3
+ export type { BillingCapUpdateResult } from '../../base-command.js';
4
+ export interface BillingCap {
5
+ capUpdatedAt: null | string;
6
+ inBlocks: null | number;
7
+ inEmails: null | number;
8
+ }
9
+ export interface BillingPaymentMethod {
10
+ brand?: string;
11
+ last4?: string;
12
+ type?: string;
13
+ }
14
+ export interface BillingUsageBlock {
15
+ activatedAt?: string;
16
+ blockAllowance?: number;
17
+ blockSize: number;
18
+ blocksCount: number;
19
+ emailsSent: number;
20
+ id: string;
21
+ status: string;
22
+ type: string;
23
+ }
24
+ export interface BillingStatusResponse {
25
+ activeBlocks: BillingUsageBlock[];
26
+ autoChargeBlockCount: null | number;
27
+ autoChargeEnabled: boolean;
28
+ cap: BillingCap;
29
+ hasPaymentMethod: boolean;
30
+ paymentMethod: BillingPaymentMethod[];
31
+ spentCurrency: string;
32
+ tier: string;
33
+ totalSpent: number | string;
34
+ }
35
+ export interface CheckoutResponse {
36
+ checkoutUrl: string;
37
+ }
38
+ export interface BillingPurchaseResponse {
39
+ message: string;
40
+ paymentIntentId: string;
41
+ }
42
+ export type BillingFlags = {
43
+ 'auto-charge-block-count'?: number;
44
+ cap?: number;
45
+ checkout: boolean;
46
+ json: boolean;
47
+ purchase?: number;
48
+ status: boolean;
49
+ };
50
+ export type BillingCtx = {
51
+ applyBillingCap(opts: {
52
+ autoChargeBlockCount?: number;
53
+ cap: number;
54
+ json: boolean;
55
+ }): Promise<BillingCapUpdateResult>;
56
+ error(msg: string): never;
57
+ fetchBillingTier(): Promise<null | string>;
58
+ get<T>(path: string, params?: Record<string, string>): Promise<ApiResponse<T>>;
59
+ log(msg?: string): void;
60
+ onApiError(resp: {
61
+ error?: string;
62
+ status: number;
63
+ }): never;
64
+ post<T>(path: string, body?: unknown): Promise<ApiResponse<T>>;
65
+ spinner<T>(text: string, json: boolean, work: () => Promise<T>): Promise<T>;
66
+ syncYaml(): Promise<void>;
67
+ validateBillingCapInputs(opts: {
68
+ autoChargeBlockCount?: number;
69
+ cap: number;
70
+ }): void;
71
+ warnFreeTierCapBlocked(json: boolean): void;
72
+ };