@mailmodo/cli 0.0.35 → 0.0.36-beta.pr38.61

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.
@@ -19,6 +19,7 @@ export default class Billing extends BaseCommand {
19
19
  private formatCurrency;
20
20
  private formatPaymentMethod;
21
21
  private formatUsageBlock;
22
+ private persistMonthlyCap;
22
23
  private pluralize;
23
24
  private purchaseBlocks;
24
25
  private setCap;
@@ -4,6 +4,7 @@ import open from 'open';
4
4
  import { BaseCommand } from '../../lib/base-command.js';
5
5
  import { API_ENDPOINTS } from '../../lib/constants.js';
6
6
  import { INFO } from '../../lib/messages.js';
7
+ import { loadYaml, saveYaml } from '../../lib/yaml-config.js';
7
8
  export default class Billing extends BaseCommand {
8
9
  static description = 'View billing status, purchase blocks, set cap, or add a payment method';
9
10
  static examples = [
@@ -89,18 +90,23 @@ export default class Billing extends BaseCommand {
89
90
  this.log(` Tier: ${data.tier}`);
90
91
  this.log(` Payment: ${this.formatPaymentMethod(data)}`);
91
92
  this.log(` Auto-charge: ${this.formatAutoCharge(data)}`);
92
- this.log(` Monthly cap: ${this.formatCap(data.cap)}`);
93
+ if (data.tier !== 'free') {
94
+ this.log(` Monthly cap: ${this.formatCap(data.cap)}`);
95
+ }
93
96
  this.log(` Total spent: ${this.formatCurrency(data.totalSpent, data.spentCurrency)}`);
94
97
  if (data.activeBlocks.length === 0) {
95
98
  this.log(' Active blocks: none');
96
- this.log('');
97
- return;
98
99
  }
99
- this.log(' Active blocks:');
100
- for (const block of data.activeBlocks) {
101
- this.log(` - ${this.formatUsageBlock(block)}`);
100
+ else {
101
+ this.log(' Active blocks:');
102
+ for (const block of data.activeBlocks) {
103
+ this.log(` - ${this.formatUsageBlock(block)}`);
104
+ }
102
105
  }
103
106
  this.log('');
107
+ if (!data.hasPaymentMethod) {
108
+ await this.startCheckout(jsonOutput);
109
+ }
104
110
  }
105
111
  formatAutoCharge(data) {
106
112
  if (!data.autoChargeEnabled) {
@@ -112,14 +118,18 @@ export default class Billing extends BaseCommand {
112
118
  return `enabled (${data.autoChargeBlockCount} ${this.pluralize('block', data.autoChargeBlockCount)})`;
113
119
  }
114
120
  formatCap(cap) {
115
- const parts = [];
116
- if (typeof cap.inBlocks === 'number') {
117
- parts.push(`${cap.inBlocks} ${this.pluralize('block', cap.inBlocks)}`);
121
+ const hasBlocks = typeof cap.inBlocks === 'number';
122
+ const hasEmails = typeof cap.inEmails === 'number';
123
+ if (hasBlocks && hasEmails) {
124
+ return `${cap.inBlocks} ${this.pluralize('block', cap.inBlocks)} (${cap.inEmails.toLocaleString()} ${this.pluralize('email', cap.inEmails)})`;
118
125
  }
119
- if (typeof cap.inEmails === 'number') {
120
- parts.push(`${cap.inEmails.toLocaleString()} emails`);
126
+ if (hasBlocks) {
127
+ return `${cap.inBlocks} ${this.pluralize('block', cap.inBlocks)}`;
121
128
  }
122
- return parts.length > 0 ? parts.join(' / ') : 'not set';
129
+ if (hasEmails) {
130
+ return `${cap.inEmails.toLocaleString()} ${this.pluralize('email', cap.inEmails)}`;
131
+ }
132
+ return 'not set';
123
133
  }
124
134
  formatCurrency(amount, currency) {
125
135
  const numericAmount = typeof amount === 'number' ? amount : Number.parseFloat(amount);
@@ -154,9 +164,18 @@ export default class Billing extends BaseCommand {
154
164
  const allowance = block.blockAllowance ?? Math.max(block.blocksCount * block.blockSize, 0);
155
165
  const used = Math.max(block.emailsSent ?? 0, 0);
156
166
  const activationSuffix = block.activatedAt
157
- ? `, activated ${new Date(block.activatedAt).toLocaleDateString('en-US')}`
167
+ ? `, activated at ${new Date(block.activatedAt).toLocaleDateString('en-US')}`
158
168
  : '';
159
- return `${block.type} block: ${used.toLocaleString()} / ${allowance.toLocaleString()} used (${block.status}${activationSuffix})`;
169
+ return `${block.type} block (${used + allowance}): ${used} ${this.pluralize('email', used)} sent, ${allowance} ${this.pluralize('email', allowance)} remaining${activationSuffix}`;
170
+ }
171
+ async persistMonthlyCap(capBlocks) {
172
+ const yamlConfig = await loadYaml();
173
+ if (!yamlConfig)
174
+ return;
175
+ if (yamlConfig.project.monthlyCap === capBlocks)
176
+ return;
177
+ yamlConfig.project.monthlyCap = capBlocks;
178
+ await saveYaml(yamlConfig);
160
179
  }
161
180
  pluralize(word, count) {
162
181
  return count === 1 ? word : `${word}s`;
@@ -181,29 +200,20 @@ export default class Billing extends BaseCommand {
181
200
  this.log('');
182
201
  }
183
202
  async setCap(cap, autoChargeBlockCount, jsonOutput) {
184
- if (cap < 1) {
185
- this.error('Cap must be at least 1 block.');
186
- }
187
- if (autoChargeBlockCount !== undefined && autoChargeBlockCount < 1) {
188
- this.error('Auto-charge block count must be at least 1 block.');
189
- }
190
- const response = await this.withApiSpinner({ json: jsonOutput, text: ' Updating spending cap...' }, () => {
191
- const payload = autoChargeBlockCount === undefined
192
- ? { cap }
193
- : { autoChargeBlockCount, cap };
194
- return this.apiClient.post(API_ENDPOINTS.BILLING_CAP, payload);
203
+ const data = await this.applyBillingCap({
204
+ autoChargeBlockCount,
205
+ cap,
206
+ json: jsonOutput,
195
207
  });
196
- if (!response.ok) {
197
- this.handleApiError(response);
198
- }
208
+ await this.persistMonthlyCap(data.capBlocks);
199
209
  if (jsonOutput) {
200
- this.log(JSON.stringify(response.data, null, 2));
210
+ this.log(JSON.stringify(data, null, 2));
201
211
  return;
202
212
  }
203
- this.log(`\n ${chalk.green('✓')} ${response.data.message}`);
204
- this.log(` Monthly cap: ${chalk.bold(String(response.data.capBlocks))} ${this.pluralize('block', response.data.capBlocks)} (${response.data.capEmails.toLocaleString()} emails)`);
205
- if (response.data.autoChargeBlockCount !== undefined) {
206
- this.log(` Auto-charge: ${response.data.autoChargeBlockCount} ${this.pluralize('block', response.data.autoChargeBlockCount)}`);
213
+ this.log(`\n ${chalk.green('✓')} ${data.message}`);
214
+ this.log(` Monthly cap: ${chalk.bold(String(data.capBlocks))} ${this.pluralize('block', data.capBlocks)} (${data.capEmails.toLocaleString()} emails)`);
215
+ if (data.autoChargeBlockCount !== undefined) {
216
+ this.log(` Auto-charge: ${data.autoChargeBlockCount} ${this.pluralize('block', data.autoChargeBlockCount)}`);
207
217
  }
208
218
  this.log('');
209
219
  }
@@ -9,6 +9,7 @@ export default class Settings extends BaseCommand {
9
9
  };
10
10
  run(): Promise<void>;
11
11
  private applySetFlag;
12
+ private applyMonthlyCapChange;
12
13
  private displaySettingsGroup;
13
14
  /**
14
15
  * Prompts the user to pick a setting key to edit and dispatches
@@ -73,8 +73,11 @@ export default class Settings extends BaseCommand {
73
73
  if (!(propKey in project) && key !== 'logo_file') {
74
74
  this.error(`Unknown setting: ${key}`);
75
75
  }
76
- project[propKey] =
77
- propKey === 'monthlyCap' ? Number(value) : value;
76
+ if (propKey === 'monthlyCap') {
77
+ await this.applyMonthlyCapChange(yamlConfig, value, isJson);
78
+ return;
79
+ }
80
+ project[propKey] = value;
78
81
  await saveYaml(yamlConfig);
79
82
  if (isJson) {
80
83
  this.log(JSON.stringify({ [propKey]: value, status: 'updated' }, null, 2));
@@ -83,6 +86,21 @@ export default class Settings extends BaseCommand {
83
86
  this.log(`\n ${chalk.green('✓')} ${key} updated to ${chalk.cyan(value)}`);
84
87
  this.log(` ${INFO.DEPLOY_TO_APPLY}\n`);
85
88
  }
89
+ async applyMonthlyCapChange(yamlConfig, rawValue, isJson) {
90
+ const parsed = Number(rawValue);
91
+ if (!Number.isInteger(parsed) || parsed < 1) {
92
+ this.error('monthly_cap must be a positive integer (blocks).');
93
+ }
94
+ await this.ensureAuth();
95
+ const data = await this.applyBillingCap({ cap: parsed, json: isJson });
96
+ yamlConfig.project.monthlyCap = data.capBlocks;
97
+ await saveYaml(yamlConfig);
98
+ if (isJson) {
99
+ this.log(JSON.stringify({ monthlyCap: data.capBlocks, status: 'updated' }, null, 2));
100
+ return;
101
+ }
102
+ this.log(`\n ${chalk.green('✓')} monthly_cap updated to ${chalk.cyan(String(data.capBlocks))} (${data.capEmails.toLocaleString()} emails)\n`);
103
+ }
86
104
  displaySettingsGroup(group, keys, project, domainVerified) {
87
105
  const availableKeys = keys.filter((key) => {
88
106
  if (group === 'brand' && key === 'logo_file')
@@ -159,6 +177,13 @@ export default class Settings extends BaseCommand {
159
177
  await this.handleDomainChange(yamlConfig);
160
178
  return;
161
179
  }
180
+ if (editKey === 'monthly_cap') {
181
+ const newValue = await input({
182
+ message: 'New monthly cap (blocks):',
183
+ });
184
+ await this.applyMonthlyCapChange(yamlConfig, newValue, false);
185
+ return;
186
+ }
162
187
  if (editKey === 'email_style') {
163
188
  const style = await select({
164
189
  choices: [
@@ -176,8 +201,7 @@ export default class Settings extends BaseCommand {
176
201
  const newValue = await input({
177
202
  message: `New value for ${editKey}:`,
178
203
  });
179
- project[editPropKey] =
180
- editPropKey === 'monthlyCap' ? Number(newValue) : newValue;
204
+ project[editPropKey] = newValue;
181
205
  await saveYaml(yamlConfig);
182
206
  this.log(`\n ${chalk.green('✓')} Updated. ${INFO.DEPLOY_TO_APPLY}\n`);
183
207
  }
@@ -42,15 +42,18 @@ export default class Status extends BaseCommand {
42
42
  this.log(` Emails sent this month: ${chalk.bold(String(monthlySent))}`);
43
43
  }
44
44
  if (quota) {
45
- if (quota.freeRemaining > 0) {
46
- this.log(` Free tier remaining: ${chalk.cyan(String(quota.freeRemaining))}`);
45
+ if (quota.plan === 'free') {
46
+ this.log(` Free tier remaining: ${chalk.cyan(String(quota.freeRemaining))} emails`);
47
47
  }
48
- else if (quota.blocksUsed !== undefined) {
49
- if (quota.currentBlockRemaining !== null &&
50
- quota.currentBlockRemaining !== undefined) {
51
- this.log(` Current paid block: ${chalk.cyan(`${quota.currentBlockRemaining} / 10,000 used`)}`);
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`)}`);
52
55
  }
53
- this.log(` Blocks purchased: ${quota.blocksUsed}`);
56
+ this.log(` Blocks used: ${quota.blocksUsed}`);
54
57
  }
55
58
  }
56
59
  this.log('');
@@ -2,6 +2,12 @@ import { Command } from '@oclif/core';
2
2
  import { ApiClient, type ApiRequestDebugInfo } from './api-client.js';
3
3
  import { type MailmodoConfig } from './config.js';
4
4
  import { type MailmodoYaml } from './yaml-config.js';
5
+ export interface BillingCapUpdateResult {
6
+ autoChargeBlockCount?: number;
7
+ capBlocks: number;
8
+ capEmails: number;
9
+ message: string;
10
+ }
5
11
  /**
6
12
  * Abstract base command providing shared functionality for all Mailmodo CLI commands.
7
13
  * Subclasses inherit --json and --yes base flags, authentication enforcement,
@@ -68,6 +74,17 @@ export declare abstract class BaseCommand extends Command {
68
74
  fromName: string;
69
75
  replyTo: string;
70
76
  }>;
77
+ /**
78
+ * Updates the account's monthly sending cap on the server.
79
+ * Validates inputs, wraps the POST /billing/cap call in a spinner, and
80
+ * surfaces errors via handleApiError. The caller is responsible for any
81
+ * local persistence (e.g. writing `monthlyCap` to mailmodo.yaml).
82
+ */
83
+ protected applyBillingCap(options: {
84
+ autoChargeBlockCount?: number;
85
+ cap: number;
86
+ json: boolean;
87
+ }): Promise<BillingCapUpdateResult>;
71
88
  protected registerDomain(yamlConfig: MailmodoYaml, inputs: {
72
89
  address: string;
73
90
  domain: string;
@@ -145,6 +145,32 @@ export class BaseCommand extends Command {
145
145
  });
146
146
  return { address, domain, fromEmail, fromName, replyTo };
147
147
  }
148
+ /**
149
+ * Updates the account's monthly sending cap on the server.
150
+ * Validates inputs, wraps the POST /billing/cap call in a spinner, and
151
+ * surfaces errors via handleApiError. The caller is responsible for any
152
+ * local persistence (e.g. writing `monthlyCap` to mailmodo.yaml).
153
+ */
154
+ async applyBillingCap(options) {
155
+ if (options.cap < 1) {
156
+ this.error('Cap must be at least 1 block.');
157
+ }
158
+ if (options.autoChargeBlockCount !== undefined &&
159
+ options.autoChargeBlockCount < 1) {
160
+ this.error('Auto-charge block count must be at least 1 block.');
161
+ }
162
+ const payload = options.autoChargeBlockCount === undefined
163
+ ? { cap: options.cap }
164
+ : {
165
+ autoChargeBlockCount: options.autoChargeBlockCount,
166
+ cap: options.cap,
167
+ };
168
+ const response = await this.withApiSpinner({ json: options.json, text: ' Updating spending cap...' }, () => this.apiClient.post(API_ENDPOINTS.BILLING_CAP, payload));
169
+ if (!response.ok) {
170
+ this.handleApiError(response);
171
+ }
172
+ return response.data;
173
+ }
148
174
  async registerDomain(yamlConfig, inputs, json) {
149
175
  const apiPayload = {
150
176
  address: inputs.address,
@@ -657,5 +657,5 @@
657
657
  ]
658
658
  }
659
659
  },
660
- "version": "0.0.35"
660
+ "version": "0.0.36-beta.pr38.61"
661
661
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mailmodo/cli",
3
3
  "description": "Email lifecycle automation for the AI-native builder generation.",
4
- "version": "0.0.35",
4
+ "version": "0.0.36-beta.pr38.61",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"