@mailmodo/cli 0.0.55 → 0.0.56-beta.pr58.101
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 +51 -200
- 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 +38 -10
- package/dist/lib/base-command.js +171 -18
- 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/dist/lib/yaml-config.d.ts +1 -0
- package/dist/lib/yaml-config.js +8 -0
- package/oclif.manifest.json +100 -100
- 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
package/dist/lib/base-command.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { input } from '@inquirer/prompts';
|
|
4
|
+
import { input, select } from '@inquirer/prompts';
|
|
5
5
|
import { Command, Flags } from '@oclif/core';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import ora from 'ora';
|
|
8
8
|
import { ApiClient } from './api-client.js';
|
|
9
9
|
import { loadConfig } from './config.js';
|
|
10
|
-
import { API_ENDPOINTS, IS_DEV_MODE, YAML_FILE } from './constants.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { API_ENDPOINTS, IS_DEV_MODE, TEMPLATES_DIR, YAML_FILE, } from './constants.js';
|
|
11
|
+
import { syncTemplatesToServer, syncTemplateToServer, fetchTemplatesFromServer, fetchTemplateFromServer, } from './templates/sync.js';
|
|
12
|
+
import { getMissingTemplateIds } from './templates/missing-templates.js';
|
|
13
|
+
import { BLANK_DIR, ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
|
|
14
|
+
import { loadYaml, parseYamlText, saveYaml, } from './yaml-config.js';
|
|
13
15
|
export const FREE_TIER = 'free';
|
|
14
16
|
/**
|
|
15
17
|
* Abstract base command providing shared functionality for all Mailmodo CLI commands.
|
|
@@ -90,11 +92,90 @@ export class BaseCommand extends Command {
|
|
|
90
92
|
const config = await loadYaml();
|
|
91
93
|
if (config)
|
|
92
94
|
return config;
|
|
95
|
+
if (this.isBlankDirectory())
|
|
96
|
+
return this.promptBlankDirRestore();
|
|
93
97
|
const restored = await this.restoreYamlFromServer();
|
|
94
98
|
if (restored)
|
|
95
99
|
return restored;
|
|
96
100
|
this.error(ERRORS.NO_YAML);
|
|
97
101
|
}
|
|
102
|
+
isBlankDirectory() {
|
|
103
|
+
return (!existsSync(join(process.cwd(), YAML_FILE)) &&
|
|
104
|
+
!existsSync(join(process.cwd(), TEMPLATES_DIR)));
|
|
105
|
+
}
|
|
106
|
+
async fetchYamlText() {
|
|
107
|
+
try {
|
|
108
|
+
let client = this.apiClient;
|
|
109
|
+
if (!client) {
|
|
110
|
+
const apiKey = process.env.MAILMODO_API_KEY ?? (await loadConfig())?.apiKey;
|
|
111
|
+
if (!apiKey)
|
|
112
|
+
return null;
|
|
113
|
+
client = new ApiClient(apiKey);
|
|
114
|
+
}
|
|
115
|
+
const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
|
|
116
|
+
return response.ok && response.data ? response.data : null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async promptBlankDirRestore() {
|
|
123
|
+
const yamlText = await this.fetchYamlText();
|
|
124
|
+
if (!yamlText)
|
|
125
|
+
this.error(ERRORS.NO_YAML);
|
|
126
|
+
const autoRestore = this.argv.includes('--yes') || this.argv.includes('--json');
|
|
127
|
+
if (!autoRestore) {
|
|
128
|
+
this.log(`\n ${BLANK_DIR.PROMPT}\n`);
|
|
129
|
+
const choice = await select({
|
|
130
|
+
choices: [
|
|
131
|
+
{ name: BLANK_DIR.CHOICE_RESTORE, value: 'restore' },
|
|
132
|
+
{ name: BLANK_DIR.CHOICE_SKIP, value: 'skip' },
|
|
133
|
+
],
|
|
134
|
+
message: 'How would you like to proceed?',
|
|
135
|
+
});
|
|
136
|
+
if (choice === 'skip') {
|
|
137
|
+
this.log(`\n ${BLANK_DIR.SKIP_HINT}\n`);
|
|
138
|
+
this.exit(0);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await writeFile(join(process.cwd(), YAML_FILE), yamlText);
|
|
142
|
+
this.logToStderr(INFO.YAML_RESTORED_FROM_SERVER);
|
|
143
|
+
const yaml = await loadYaml();
|
|
144
|
+
if (!yaml)
|
|
145
|
+
this.error(ERRORS.NO_YAML);
|
|
146
|
+
const client = this.apiClient;
|
|
147
|
+
if (client)
|
|
148
|
+
await this.fetchMissingTemplates(client, yaml);
|
|
149
|
+
return yaml;
|
|
150
|
+
}
|
|
151
|
+
async promptInitServerRestore(flags) {
|
|
152
|
+
const yamlText = await this.fetchYamlText();
|
|
153
|
+
if (!yamlText)
|
|
154
|
+
return { restored: false, serverYaml: null };
|
|
155
|
+
let shouldRestore = Boolean(flags.yes || flags.json);
|
|
156
|
+
if (!shouldRestore) {
|
|
157
|
+
const choice = await select({
|
|
158
|
+
choices: [
|
|
159
|
+
{ name: BLANK_DIR.CHOICE_RESTORE_INIT, value: 'restore' },
|
|
160
|
+
{ name: BLANK_DIR.CHOICE_FRESH, value: 'fresh' },
|
|
161
|
+
],
|
|
162
|
+
message: BLANK_DIR.PROMPT_INIT,
|
|
163
|
+
});
|
|
164
|
+
shouldRestore = choice === 'restore';
|
|
165
|
+
}
|
|
166
|
+
if (!shouldRestore) {
|
|
167
|
+
return { restored: false, serverYaml: parseYamlText(yamlText) };
|
|
168
|
+
}
|
|
169
|
+
await writeFile(join(process.cwd(), YAML_FILE), yamlText);
|
|
170
|
+
const yaml = await loadYaml();
|
|
171
|
+
if (!yaml)
|
|
172
|
+
return { restored: false, serverYaml: null };
|
|
173
|
+
const client = this.apiClient;
|
|
174
|
+
if (client)
|
|
175
|
+
await this.fetchMissingTemplates(client, yaml);
|
|
176
|
+
this.log(`\n ${BLANK_DIR.RESTORED_INIT}\n`);
|
|
177
|
+
return { restored: true, serverYaml: yaml };
|
|
178
|
+
}
|
|
98
179
|
async fetchAndWriteYaml(client) {
|
|
99
180
|
try {
|
|
100
181
|
const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
|
|
@@ -131,20 +212,6 @@ export class BaseCommand extends Command {
|
|
|
131
212
|
return null;
|
|
132
213
|
}
|
|
133
214
|
}
|
|
134
|
-
/**
|
|
135
|
-
* If `mailmodo.yaml` is absent from the current directory, attempts to restore
|
|
136
|
-
* it from the server using the given client. Returns `true` if the file was
|
|
137
|
-
* successfully written, `false` otherwise (file already present, server 404,
|
|
138
|
-
* or any network error). Silent — never throws.
|
|
139
|
-
*
|
|
140
|
-
* Used by `mailmodo login` right after the API key is validated so a returning
|
|
141
|
-
* user automatically gets their config back without having to run `init` again.
|
|
142
|
-
*/
|
|
143
|
-
async recoverYamlAfterLogin(client) {
|
|
144
|
-
if (existsSync(join(process.cwd(), YAML_FILE)))
|
|
145
|
-
return false;
|
|
146
|
-
return this.fetchAndWriteYaml(client);
|
|
147
|
-
}
|
|
148
215
|
/**
|
|
149
216
|
* Uploads the current local mailmodo.yaml to the server as a backup.
|
|
150
217
|
* Best-effort: silently ignores all errors so the originating command
|
|
@@ -173,6 +240,92 @@ export class BaseCommand extends Command {
|
|
|
173
240
|
// Silently ignore — local file remains authoritative
|
|
174
241
|
}
|
|
175
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Bulk-uploads all template HTML files referenced in the YAML to the server
|
|
245
|
+
* as a backup. Best-effort: silently ignores all errors so the originating
|
|
246
|
+
* command always succeeds regardless of sync failures.
|
|
247
|
+
* Called after init, deploy, and AI regeneration.
|
|
248
|
+
*/
|
|
249
|
+
async syncTemplatesToServer(yaml) {
|
|
250
|
+
try {
|
|
251
|
+
let client = this.apiClient;
|
|
252
|
+
if (!client) {
|
|
253
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
254
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
255
|
+
if (!apiKey)
|
|
256
|
+
return;
|
|
257
|
+
client = new ApiClient(apiKey);
|
|
258
|
+
}
|
|
259
|
+
await syncTemplatesToServer(client, yaml);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Silently ignore — local files remain authoritative
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Uploads a single template's HTML files to the server for incremental sync.
|
|
267
|
+
* Best-effort: silently ignores all errors. Called after the edit command
|
|
268
|
+
* applies changes to a specific template.
|
|
269
|
+
*/
|
|
270
|
+
async syncTemplateToServer(emailId) {
|
|
271
|
+
try {
|
|
272
|
+
let client = this.apiClient;
|
|
273
|
+
if (!client) {
|
|
274
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
275
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
276
|
+
if (!apiKey)
|
|
277
|
+
return;
|
|
278
|
+
client = new ApiClient(apiKey);
|
|
279
|
+
}
|
|
280
|
+
await syncTemplateToServer(client, emailId);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// Silently ignore — local files remain authoritative
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Fetches a single backed-up template from the server and writes it to the
|
|
288
|
+
* local mailmodo/ folder. Returns true if the template was successfully
|
|
289
|
+
* restored, false if it is not backed up or on any error.
|
|
290
|
+
* Used as the `ctx.fetchTemplate` bridge passed to handleMissingTemplates.
|
|
291
|
+
*/
|
|
292
|
+
async getTemplateFromServer(emailId) {
|
|
293
|
+
try {
|
|
294
|
+
let client = this.apiClient;
|
|
295
|
+
if (!client) {
|
|
296
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
297
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
298
|
+
if (!apiKey)
|
|
299
|
+
return false;
|
|
300
|
+
client = new ApiClient(apiKey);
|
|
301
|
+
}
|
|
302
|
+
return fetchTemplateFromServer(client, emailId);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Silently restores any template files missing from disk by fetching them
|
|
310
|
+
* from the server. Uses the bulk endpoint when all templates are missing
|
|
311
|
+
* (single round-trip), or individual per-ID requests when only a subset is
|
|
312
|
+
* missing to avoid overwriting files that are already present. Best-effort —
|
|
313
|
+
* never throws.
|
|
314
|
+
*/
|
|
315
|
+
async fetchMissingTemplates(client, yaml) {
|
|
316
|
+
try {
|
|
317
|
+
const missingIds = getMissingTemplateIds(yaml);
|
|
318
|
+
if (missingIds.length === 0)
|
|
319
|
+
return;
|
|
320
|
+
// Use bulk endpoint when every template is missing; per-ID otherwise
|
|
321
|
+
await (missingIds.length === yaml.emails.length
|
|
322
|
+
? fetchTemplatesFromServer(client, yaml)
|
|
323
|
+
: Promise.all(missingIds.map((id) => fetchTemplateFromServer(client, id))));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// best-effort, silently ignore
|
|
327
|
+
}
|
|
328
|
+
}
|
|
176
329
|
/**
|
|
177
330
|
* Handles a failed API response by mapping HTTP status codes to
|
|
178
331
|
* user-friendly error messages and exiting the process.
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { API_ENDPOINTS } from '../../constants.js';
|
|
3
|
+
export async function showSummary(ctx, json) {
|
|
4
|
+
const response = await ctx.spinner(' Loading contacts...', json, () => ctx.get(API_ENDPOINTS.CONTACTS));
|
|
5
|
+
if (!response.ok) {
|
|
6
|
+
ctx.onApiError(response);
|
|
7
|
+
}
|
|
8
|
+
const { data } = response;
|
|
9
|
+
if (json) {
|
|
10
|
+
ctx.log(JSON.stringify(data, null, 2));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
ctx.log(`\n Total: ${chalk.bold(String(data.total ?? 0))} ` +
|
|
14
|
+
`Active: ${chalk.green(String(data.active ?? 0))} ` +
|
|
15
|
+
`Unsubscribed: ${chalk.yellow(String(data.unsubscribed ?? 0))} ` +
|
|
16
|
+
`Bounced: ${chalk.red(String(data.bounced ?? 0))}\n`);
|
|
17
|
+
}
|
|
18
|
+
export async function searchContact(ctx, email, json) {
|
|
19
|
+
const encodedEmail = encodeURIComponent(email);
|
|
20
|
+
const response = await ctx.spinner(' Looking up contact...', json, () => ctx.get(`${API_ENDPOINTS.CONTACTS}/${encodedEmail}`));
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
if (response.status === 404) {
|
|
23
|
+
if (json) {
|
|
24
|
+
ctx.log(JSON.stringify({ email, error: 'Contact not found' }, null, 2));
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
ctx.log(`\n Contact '${email}' not found.\n`);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
ctx.onApiError(response);
|
|
32
|
+
}
|
|
33
|
+
const contact = response.data;
|
|
34
|
+
if (json) {
|
|
35
|
+
ctx.log(JSON.stringify(contact, null, 2));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ctx.log(`\n ${chalk.bold('Email:')} ${contact.email}`);
|
|
39
|
+
ctx.log(` ${chalk.bold('Status:')} ${contact.status}`);
|
|
40
|
+
ctx.log(` ${chalk.bold('Added:')} ${contact.added}`);
|
|
41
|
+
ctx.log(` ${chalk.bold('Next email:')} ${contact.nextEmail || 'None'}`);
|
|
42
|
+
if (contact.properties && Object.keys(contact.properties).length > 0) {
|
|
43
|
+
ctx.log(` ${chalk.bold('Properties:')}`);
|
|
44
|
+
for (const [key, value] of Object.entries(contact.properties)) {
|
|
45
|
+
ctx.log(` ${key}: ${String(value)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
ctx.log('');
|
|
49
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ContactsCtx } from './types.js';
|
|
2
|
+
export declare function exportContacts(ctx: ContactsCtx, opts: {
|
|
3
|
+
json: boolean;
|
|
4
|
+
}): Promise<void>;
|
|
5
|
+
export declare function deleteContact(ctx: ContactsCtx, opts: {
|
|
6
|
+
email: string;
|
|
7
|
+
json: boolean;
|
|
8
|
+
skipConfirm: boolean;
|
|
9
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { API_ENDPOINTS } from '../../constants.js';
|
|
6
|
+
export async function exportContacts(ctx, opts) {
|
|
7
|
+
const { json } = opts;
|
|
8
|
+
const response = await ctx.spinner(' Preparing contact export...', json, () => ctx.get(API_ENDPOINTS.CONTACTS_EXPORT));
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
ctx.onApiError(response);
|
|
11
|
+
}
|
|
12
|
+
if (json) {
|
|
13
|
+
ctx.log(JSON.stringify(response.data, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
ctx.log(`\n ${chalk.green('✓')} Contact export started.`);
|
|
17
|
+
const { downloadUrl, status } = response.data;
|
|
18
|
+
if (!downloadUrl) {
|
|
19
|
+
ctx.log(`\n Export status: ${status ?? 'unknown'}. No download URL yet.\n`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const fileResult = await ctx.spinner(' Downloading CSV file...', json, () => ctx.getPublicFile(downloadUrl.trim()));
|
|
23
|
+
if (!fileResult.ok) {
|
|
24
|
+
ctx.error(`Download failed: ${fileResult.status} ${fileResult.error ?? ''}\n URL: ${fileResult.debug.fullUrl}`);
|
|
25
|
+
}
|
|
26
|
+
const outputPath = resolve('contacts.csv');
|
|
27
|
+
await writeFile(outputPath, Buffer.from(fileResult.body));
|
|
28
|
+
ctx.log(`\n ${chalk.green('✓')} Contact export saved to ${chalk.cyan(outputPath)}\n`);
|
|
29
|
+
}
|
|
30
|
+
export async function deleteContact(ctx, opts) {
|
|
31
|
+
const { email, json, skipConfirm } = opts;
|
|
32
|
+
if (!skipConfirm) {
|
|
33
|
+
const confirmed = await confirm({
|
|
34
|
+
default: false,
|
|
35
|
+
message: `Permanently delete ${email} and all their data? This cannot be undone.`,
|
|
36
|
+
});
|
|
37
|
+
if (!confirmed) {
|
|
38
|
+
ctx.log('\n Delete cancelled.\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const response = await ctx.spinner(' Deleting contact...', json, () => ctx.delete(`${API_ENDPOINTS.CONTACTS}/${encodeURIComponent(email)}`));
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
ctx.onApiError(response);
|
|
45
|
+
}
|
|
46
|
+
if (json) {
|
|
47
|
+
ctx.log(JSON.stringify({ deleted: true, email }, null, 2));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
ctx.log(`\n ${chalk.green('✓')} Contact ${email} permanently deleted.\n`);
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ApiResponse } from '../../api-client.js';
|
|
2
|
+
import type { FileFetchResult } from '../../fetch-file.js';
|
|
3
|
+
export interface ContactSummaryResponse {
|
|
4
|
+
active: number;
|
|
5
|
+
bounced: number;
|
|
6
|
+
total: number;
|
|
7
|
+
unsubscribed: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ContactDetailResponse {
|
|
10
|
+
added: string;
|
|
11
|
+
email: string;
|
|
12
|
+
nextEmail: null | string;
|
|
13
|
+
properties: Record<string, unknown>;
|
|
14
|
+
status: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ContactExportResponse {
|
|
17
|
+
downloadUrl: string;
|
|
18
|
+
status: string;
|
|
19
|
+
}
|
|
20
|
+
export type ContactsFlags = {
|
|
21
|
+
json: boolean;
|
|
22
|
+
yes: boolean;
|
|
23
|
+
};
|
|
24
|
+
export type ContactsCtx = {
|
|
25
|
+
delete<T = Record<string, unknown>>(path: string): Promise<ApiResponse<T>>;
|
|
26
|
+
error(msg: string): never;
|
|
27
|
+
get<T>(path: string, params?: Record<string, string>): Promise<ApiResponse<T>>;
|
|
28
|
+
getPublicFile(url: string): Promise<FileFetchResult>;
|
|
29
|
+
log(msg?: string): void;
|
|
30
|
+
onApiError(resp: {
|
|
31
|
+
error?: string;
|
|
32
|
+
status: number;
|
|
33
|
+
}): never;
|
|
34
|
+
spinner<T>(text: string, json: boolean, work: () => Promise<T>): Promise<T>;
|
|
35
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MailmodoYaml } from '
|
|
1
|
+
import type { MailmodoYaml } from '../../yaml-config.js';
|
|
2
2
|
import type { DeployCtx, DeployFlags, ValidateResponse } from './types.js';
|
|
3
3
|
export declare function validateDeploySequence(ctx: DeployCtx, payload: object, flags: {
|
|
4
4
|
json: boolean;
|