@mailmodo/cli 0.0.53 → 0.0.54-beta.pr56.87

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.
@@ -68,12 +68,14 @@ export default class Billing extends BaseCommand {
68
68
  }
69
69
  this.log(`\n ${chalk.bold('Stripe Checkout')} — add or update your payment method.`);
70
70
  this.log(` ${chalk.dim(checkoutUrl)}\n`);
71
- try {
72
- await open(checkoutUrl);
73
- this.log(` ${INFO.BROWSER_OPENING}\n`);
74
- }
75
- catch {
76
- this.log(` ${INFO.BROWSER_OPEN_FAILED}\n`);
71
+ if (!process.env.CI) {
72
+ try {
73
+ await open(checkoutUrl);
74
+ this.log(` ${INFO.BROWSER_OPENING}\n`);
75
+ }
76
+ catch {
77
+ this.log(` ${INFO.BROWSER_OPEN_FAILED}\n`);
78
+ }
77
79
  }
78
80
  }
79
81
  async showStatus(jsonOutput, statusOnly) {
@@ -176,6 +178,7 @@ export default class Billing extends BaseCommand {
176
178
  return;
177
179
  yamlConfig.project.monthlyCap = capBlocks;
178
180
  await saveYaml(yamlConfig);
181
+ await this.syncYamlToServer();
179
182
  }
180
183
  pluralize(word, count) {
181
184
  return count === 1 ? word : `${word}s`;
@@ -8,37 +8,6 @@ export default class Deploy extends BaseCommand {
8
8
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
9
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  };
11
- private fetchDomainVerifyForDeploy;
12
11
  run(): Promise<void>;
13
- private validateSequence;
14
- /**
15
- * Calls `POST /sequences/{id}/status` with `{ status: "paused" }` and prints
16
- * a confirmation-aware success/no-op message. Skips the prompt entirely when
17
- * `--yes` is set so the command stays scriptable. `--json` always emits the
18
- * raw server payload (sequenceId, status, alreadyInStatus).
19
- */
20
- private pauseSequence;
21
- /**
22
- * Calls `POST /sequences/{id}/status` with `{ status: "active" }`. No prompt
23
- * — resuming is the safe direction (it does not start sends that weren't
24
- * already queued). `--json` always emits the raw server payload (sequenceId,
25
- * status, alreadyInStatus).
26
- */
27
- private resumeSequence;
28
- private updateSequenceStatus;
29
- private sequenceStatusPath;
30
- private buildDeployPayload;
31
- private resolveMonthlyCapForDeploy;
32
- private mapEmailToPayload;
33
- private buildBrandSection;
34
- private buildProductSection;
35
- private buildSenderSection;
36
- private buildProjectPayload;
37
- private confirmDeploy;
38
- private ensureDomainReady;
39
- private logPreDeploySummary;
40
- private logDiff;
41
- private logDeploySuccessInstructions;
42
- private runDomainSetup;
43
- private verifyDomain;
12
+ private makeCtx;
44
13
  }
@@ -1,10 +1,12 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { confirm, input } from '@inquirer/prompts';
3
- import chalk from 'chalk';
2
+ import { confirm } from '@inquirer/prompts';
4
3
  import { BaseCommand } from '../../lib/base-command.js';
5
- import { API_ENDPOINTS, DEFAULT_BRAND_COLOR } from '../../lib/constants.js';
6
- import { ERRORS, INFO, pauseAlready, pauseSuccess, PROMPTS, resumeAlready, resumeSuccess, SEPARATOR, } from '../../lib/messages.js';
7
- import { loadTemplate, } from '../../lib/yaml-config.js';
4
+ import { API_ENDPOINTS } from '../../lib/constants.js';
5
+ import { buildDeployPayload } from '../../lib/deploy/payload.js';
6
+ import { logDeploySuccessInstructions, logPreDeploySummary, } from '../../lib/deploy/output.js';
7
+ import { pauseSequence, resumeSequence, } from '../../lib/deploy/sequence-status.js';
8
+ import { ensureDomainReady, validateDeploySequence, } from '../../lib/deploy/domain-setup.js';
9
+ import { getMissingTemplateIds, handleMissingTemplates, } from '../../lib/deploy/missing-templates.js';
8
10
  export default class Deploy extends BaseCommand {
9
11
  static description = 'Deploy, pause, or resume an email sequence';
10
12
  static examples = [
@@ -24,37 +26,45 @@ export default class Deploy extends BaseCommand {
24
26
  exclusive: ['pause'],
25
27
  }),
26
28
  };
27
- fetchDomainVerifyForDeploy(jsonOutput, domain) {
28
- return this.withApiSpinner({ json: jsonOutput, text: ' Checking domain verification...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY, {
29
- domain: domain || '',
30
- }));
31
- }
32
29
  async run() {
33
30
  const { flags } = await this.parse(Deploy);
34
31
  await this.ensureAuth();
32
+ const ctx = this.makeCtx();
35
33
  const baseFlags = { json: flags.json, yes: flags.yes };
36
34
  if (flags.pause) {
37
- await this.pauseSequence(flags.pause, baseFlags);
35
+ await pauseSequence(ctx, flags.pause, baseFlags);
38
36
  return;
39
37
  }
40
38
  if (flags.resume) {
41
- await this.resumeSequence(flags.resume, baseFlags);
39
+ await resumeSequence(ctx, flags.resume, baseFlags);
42
40
  return;
43
41
  }
44
42
  const yamlConfig = await this.ensureYaml();
45
- const domainReady = await this.ensureDomainReady(yamlConfig, flags);
46
- if (!domainReady)
43
+ const missingIds = getMissingTemplateIds(yamlConfig);
44
+ if (missingIds.length > 0) {
45
+ await handleMissingTemplates(ctx, yamlConfig, missingIds, baseFlags);
47
46
  return;
48
- const payload = await this.buildDeployPayload(yamlConfig);
49
- const validateResult = await this.validateSequence(payload, flags);
50
- this.logPreDeploySummary(yamlConfig, validateResult, flags.json);
51
- const confirmed = await this.confirmDeploy(yamlConfig, flags);
52
- if (!confirmed)
47
+ }
48
+ const domainReady = await ensureDomainReady(ctx, yamlConfig, baseFlags);
49
+ if (!domainReady)
53
50
  return;
54
- const response = await this.withApiSpinner({ json: flags.json, text: ' Deploying email sequences...' }, () => this.apiClient.post(API_ENDPOINTS.SEQUENCES_DEPLOY, payload));
55
- if (!response.ok) {
56
- this.handleApiError(response);
51
+ const payload = await buildDeployPayload(ctx, yamlConfig);
52
+ const validateResult = await validateDeploySequence(ctx, payload, baseFlags);
53
+ logPreDeploySummary(ctx, yamlConfig, validateResult, flags.json);
54
+ if (!flags.yes) {
55
+ const proceed = await confirm({
56
+ default: true,
57
+ message: `Deploy ${yamlConfig.emails.length} emails?`,
58
+ });
59
+ if (!proceed) {
60
+ this.log('\n Deploy cancelled.\n');
61
+ return;
62
+ }
57
63
  }
64
+ const response = await ctx.spinner(' Deploying email sequences...', flags.json, () => ctx.post(API_ENDPOINTS.SEQUENCES_DEPLOY, payload));
65
+ if (!response.ok)
66
+ ctx.onApiError(response);
67
+ await ctx.syncYaml();
58
68
  if (flags.json) {
59
69
  this.log(JSON.stringify({
60
70
  deployed: response.data.deployed,
@@ -65,290 +75,25 @@ export default class Deploy extends BaseCommand {
65
75
  }, null, 2));
66
76
  return;
67
77
  }
68
- this.logDeploySuccessInstructions(response.data.sdkSnippet);
78
+ logDeploySuccessInstructions(ctx, response.data.sdkSnippet);
69
79
  }
70
- async validateSequence(payload, flags) {
71
- const response = await this.withApiSpinner({ json: flags.json, text: ' Validating sequence...' }, () => this.apiClient.post(API_ENDPOINTS.SEQUENCES_VALIDATE, payload));
72
- if (!response.ok) {
73
- if (response.data.error === 'senderDomainNotFound') {
74
- this.error(ERRORS.DOMAIN_NOT_REGISTERED);
75
- }
76
- if (response.data.error === 'senderDomainNotVerified') {
77
- this.error(ERRORS.DOMAIN_NOT_VERIFIED);
78
- }
79
- this.handleApiError(response);
80
- }
81
- return response.data;
82
- }
83
- /**
84
- * Calls `POST /sequences/{id}/status` with `{ status: "paused" }` and prints
85
- * a confirmation-aware success/no-op message. Skips the prompt entirely when
86
- * `--yes` is set so the command stays scriptable. `--json` always emits the
87
- * raw server payload (sequenceId, status, alreadyInStatus).
88
- */
89
- async pauseSequence(sequenceId, flags) {
90
- if (!flags.yes) {
91
- const confirmed = await confirm({
92
- default: false,
93
- message: PROMPTS.PAUSE_CONFIRM,
94
- });
95
- if (!confirmed) {
96
- this.log(`\n ${INFO.PAUSE_CANCELLED}\n`);
97
- return;
98
- }
99
- }
100
- const data = await this.updateSequenceStatus(sequenceId, 'paused', flags, ' Pausing sequence...');
101
- if (flags.json) {
102
- this.log(JSON.stringify(data, null, 2));
103
- return;
104
- }
105
- const message = data.alreadyInStatus
106
- ? pauseAlready(data.sequenceId || sequenceId)
107
- : pauseSuccess(data.sequenceId || sequenceId);
108
- this.log(`\n ${message}\n`);
109
- }
110
- /**
111
- * Calls `POST /sequences/{id}/status` with `{ status: "active" }`. No prompt
112
- * — resuming is the safe direction (it does not start sends that weren't
113
- * already queued). `--json` always emits the raw server payload (sequenceId,
114
- * status, alreadyInStatus).
115
- */
116
- async resumeSequence(sequenceId, flags) {
117
- const data = await this.updateSequenceStatus(sequenceId, 'active', flags, ' Resuming sequence...');
118
- if (flags.json) {
119
- this.log(JSON.stringify(data, null, 2));
120
- return;
121
- }
122
- const message = data.alreadyInStatus
123
- ? resumeAlready(data.sequenceId || sequenceId)
124
- : resumeSuccess(data.sequenceId || sequenceId);
125
- this.log(`\n ${message}\n`);
126
- }
127
- async updateSequenceStatus(sequenceId, status, flags, spinnerText) {
128
- const response = await this.withApiSpinner({ json: flags.json, text: spinnerText }, () => this.apiClient.post(this.sequenceStatusPath(sequenceId), { status }));
129
- if (!response.ok) {
130
- this.handleApiError(response);
131
- }
132
- return response.data;
133
- }
134
- sequenceStatusPath(sequenceId) {
135
- return `${API_ENDPOINTS.SEQUENCES}/${encodeURIComponent(sequenceId)}/status`;
136
- }
137
- async buildDeployPayload(yamlConfig) {
138
- const [emailsWithHtml, monthlyCap] = await Promise.all([
139
- Promise.all(yamlConfig.emails.map(async (email) => {
140
- const html = (await loadTemplate(`${email.id}.html`)) || '';
141
- const plainHtml = (await loadTemplate(`${email.id}_plain.html`)) || html;
142
- return { ...this.mapEmailToPayload(email), html, plainHtml };
143
- })),
144
- this.resolveMonthlyCapForDeploy(yamlConfig.project.monthlyCap),
145
- ]);
146
- return {
147
- ...this.buildProjectPayload(yamlConfig.project, monthlyCap),
148
- emails: emailsWithHtml,
149
- };
150
- }
151
- async resolveMonthlyCapForDeploy(yamlMonthlyCap) {
152
- if (yamlMonthlyCap !== undefined)
153
- return yamlMonthlyCap;
154
- const billingStatus = await this.fetchBillingStatus();
155
- return billingStatus?.cap?.inBlocks ?? undefined;
156
- }
157
- mapEmailToPayload(email) {
158
- return {
159
- condition: email.condition || null,
160
- ctaText: email.ctaText || '',
161
- delay: email.delay,
162
- goal: email.goal || '',
163
- id: email.id,
164
- isReminder: false,
165
- previewText: email.previewText || '',
166
- priority: 'medium',
167
- subject: email.subject,
168
- trigger: email.trigger,
169
- };
170
- }
171
- buildBrandSection(project) {
80
+ makeCtx() {
172
81
  return {
173
- colors: [project?.brandColor || DEFAULT_BRAND_COLOR],
174
- logoUrl: project?.logoUrl || '',
82
+ collectDomainInputs: (yaml, skip) => this.collectDomainSetupInputs(yaml, skip),
83
+ error: (msg) => this.error(msg),
84
+ exit: (code) => this.exit(code),
85
+ get: (path, params) => this.apiClient.get(path, params),
86
+ getBillingCap: async () => {
87
+ const s = await this.fetchBillingStatus();
88
+ return s?.cap?.inBlocks ?? undefined;
89
+ },
90
+ log: (msg) => this.log(msg),
91
+ onApiError: (r) => this.handleApiError(r),
92
+ post: (path, body) => this.apiClient.post(path, body),
93
+ registerDomainAndSave: (yaml, inputs, json) => this.registerDomain(yaml, inputs, json),
94
+ showDnsRecords: (records, url, json) => this.logDnsRecords(records, url, json),
95
+ spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
96
+ syncYaml: () => this.syncYamlToServer(),
175
97
  };
176
98
  }
177
- buildProductSection(project) {
178
- return {
179
- businessType: project?.type || '',
180
- description: project?.description || '',
181
- pricingModel: project?.pricingModel || '',
182
- productName: project?.name || '',
183
- saasModel: project?.saasModel || '',
184
- targetUser: project?.targetUser || '',
185
- url: project?.url || '',
186
- };
187
- }
188
- buildSenderSection(project) {
189
- return {
190
- address: project?.address || '',
191
- domain: project?.domain || '',
192
- fromEmail: project?.fromEmail || '',
193
- fromName: project?.fromName || '',
194
- replyTo: project?.replyTo || project?.fromEmail || '',
195
- };
196
- }
197
- buildProjectPayload(project, monthlyCap) {
198
- return {
199
- brand: this.buildBrandSection(project),
200
- emailStyle: project?.emailStyle || 'branded',
201
- ...(monthlyCap === undefined ? {} : { monthlyCap }),
202
- product: this.buildProductSection(project),
203
- senderDetails: this.buildSenderSection(project),
204
- ...(project?.webhookUrl ? { webhookUrl: project.webhookUrl } : {}),
205
- };
206
- }
207
- async confirmDeploy(yamlConfig, flags) {
208
- if (flags.yes)
209
- return true;
210
- const proceed = await confirm({
211
- default: true,
212
- message: `Deploy ${yamlConfig.emails.length} emails?`,
213
- });
214
- if (!proceed) {
215
- this.log('\n Deploy cancelled.\n');
216
- }
217
- return proceed;
218
- }
219
- async ensureDomainReady(yamlConfig, flags) {
220
- const domainVerify = await this.fetchDomainVerifyForDeploy(flags.json, yamlConfig.project?.domain);
221
- if (domainVerify.ok && domainVerify.data?.domainStatus === 'VERIFIED') {
222
- return true;
223
- }
224
- if (yamlConfig.project?.domain) {
225
- if (!flags.json) {
226
- this.log(`\n ${INFO.DOMAIN_PENDING_VERIFICATION}\n`);
227
- }
228
- return false;
229
- }
230
- if (!flags.json) {
231
- this.log(`\n No sending domain set up yet.`);
232
- this.log(` You need a verified domain before sending emails.`);
233
- this.log(` This is a one-time setup. Takes about 5 minutes.\n`);
234
- }
235
- if (!flags.yes) {
236
- const setupNow = await confirm({
237
- default: true,
238
- message: 'Set up your sending domain now?',
239
- });
240
- if (!setupNow) {
241
- this.log(`\n ${INFO.SEQUENCES_NOT_DEPLOYED}`);
242
- this.log(` Emails will not send until your domain is verified.`);
243
- this.log(` ${INFO.DOMAIN_NOT_DEPLOYED_HINT}\n`);
244
- return false;
245
- }
246
- }
247
- return this.runDomainSetup(yamlConfig, flags);
248
- }
249
- logPreDeploySummary(yamlConfig, validateResult, jsonOutput) {
250
- if (jsonOutput)
251
- return;
252
- this.log(`\n ${chalk.green('✓')} Domain: ${yamlConfig.project?.domain || 'verified'}\n`);
253
- if (!validateResult.existingDeployment || !validateResult.diff) {
254
- this.log(` Deploying:`);
255
- for (const email of yamlConfig.emails) {
256
- this.log(` ${chalk.green('+')} ${email.id.padEnd(24)} ${email.trigger}`);
257
- }
258
- }
259
- else {
260
- this.logDiff(validateResult.diff);
261
- }
262
- this.log('');
263
- }
264
- logDiff(diff) {
265
- if (!diff.hasChanges) {
266
- this.log(` No changes from last deployment.`);
267
- return;
268
- }
269
- this.log(` Changes vs. last deployment:`);
270
- for (const email of diff.added) {
271
- this.log(` ${chalk.green('+')} ${email.id.padEnd(24)} ${email.trigger || ''}`);
272
- }
273
- for (const email of diff.removed) {
274
- this.log(` ${chalk.red('-')} ${email.id.padEnd(24)} ${email.trigger || ''}`);
275
- }
276
- for (const email of diff.modified) {
277
- const fields = email.changedFields?.join(', ') || '';
278
- this.log(` ${chalk.yellow('~')} ${email.id.padEnd(24)} ${fields}`);
279
- }
280
- if (diff.unchanged.length > 0) {
281
- this.log(` ${chalk.dim(`∙ ${diff.unchanged.length} unchanged`)}`);
282
- }
283
- }
284
- logDeploySuccessInstructions(sdkSnippet) {
285
- this.log(` ${chalk.green('Deployed.')} Emails are live.\n`);
286
- this.log(` ${SEPARATOR}`);
287
- this.log(` ${chalk.bold('ADD THIS TO YOUR APP (one-time only):')}`);
288
- this.log(` ${SEPARATOR}\n`);
289
- this.log(` ${chalk.cyan(sdkSnippet.install ?? 'npm install @mailmodo/sdk')}\n`);
290
- this.log(` ${chalk.dim("import { track, identify } from '@mailmodo/sdk'")}\n`);
291
- if (sdkSnippet.examples) {
292
- this.log(` ${chalk.dim('// Example usage:')}`);
293
- this.log(` ${chalk.dim(sdkSnippet.examples.track)}`);
294
- this.log(` ${chalk.dim(sdkSnippet.examples.identify)}\n`);
295
- }
296
- const trackCalls = [...new Set(sdkSnippet.trackCalls ?? [])];
297
- for (const call of trackCalls) {
298
- this.log(` ${chalk.dim(call)}`);
299
- }
300
- if (trackCalls.length > 0)
301
- this.log('');
302
- const identifyCalls = [...new Set(sdkSnippet.identifyCalls ?? [])];
303
- for (const call of identifyCalls) {
304
- this.log(` ${chalk.dim(call)}`);
305
- }
306
- if (identifyCalls.length > 0)
307
- this.log('');
308
- this.log(` Full SDK docs: ${chalk.cyan('mailmodo.com/docs/sdk')}\n`);
309
- this.log(` ${SEPARATOR}\n`);
310
- }
311
- async runDomainSetup(yamlConfig, flags) {
312
- const inputs = await this.collectDomainSetupInputs(yamlConfig, flags.yes);
313
- const { dnsRecords, dnsGuideUrl } = await this.registerDomain(yamlConfig, inputs, flags.json);
314
- this.logDnsRecords(dnsRecords, dnsGuideUrl, flags.json);
315
- if (flags.yes) {
316
- return this.verifyDomain(flags.json, inputs.domain);
317
- }
318
- const action = await input({
319
- default: '',
320
- message: PROMPTS.ENTER_AFTER_RECORDS,
321
- });
322
- if (action.toLowerCase() === 'skip') {
323
- this.log(`\n ${INFO.SEQUENCES_NOT_DEPLOYED}`);
324
- this.log(` ${INFO.DOMAIN_NOT_DEPLOYED_HINT}\n`);
325
- return false;
326
- }
327
- return this.verifyDomain(flags.json, inputs.domain);
328
- }
329
- async verifyDomain(jsonOutput, domain) {
330
- const verify = await this.withApiSpinner({ json: jsonOutput, text: ' Checking DNS...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY, {
331
- domain,
332
- }));
333
- if (!verify.ok) {
334
- this.handleApiError(verify);
335
- }
336
- const { dkim, dmarc, dnsGuideUrl, domainStatus, returnPath } = verify.data;
337
- const allPassed = domainStatus === 'VERIFIED';
338
- if (!jsonOutput) {
339
- this.log(` DKIM ${dkim ? chalk.green('✓') : chalk.red('✗')}`);
340
- this.log(` DMARC ${dmarc ? chalk.green('✓') : chalk.red('✗')}`);
341
- this.log(` Return-Path ${returnPath ? chalk.green('✓') : chalk.red('✗')}`);
342
- if (allPassed) {
343
- this.log(`\n ${chalk.green('Domain verified.')} Continuing deploy...\n`);
344
- }
345
- else {
346
- this.log(`\n ${INFO.DNS_RECORDS_FAILED}`);
347
- this.log(`\n ${INFO.DNS_FIX_AND_VERIFY}`);
348
- if (dnsGuideUrl)
349
- this.log(` Help: ${chalk.cyan(dnsGuideUrl)}\n`);
350
- }
351
- }
352
- return allPassed;
353
- }
354
99
  }
@@ -125,6 +125,7 @@ export default class Edit extends BaseCommand {
125
125
  if (updated.html) {
126
126
  await saveTemplate(ctx.templateFilename, updated.html);
127
127
  }
128
+ await this.syncYamlToServer();
128
129
  }
129
130
  logJsonResult(email, updated, oldSubject) {
130
131
  this.log(JSON.stringify({
@@ -161,6 +161,7 @@ export default class Init extends BaseCommand {
161
161
  return saves;
162
162
  });
163
163
  await Promise.all(templateSaves);
164
+ await this.syncYamlToServer();
164
165
  if (flags.json) {
165
166
  this.log(JSON.stringify({
166
167
  brandDetected: analysisPayload.brand,
@@ -27,11 +27,14 @@ export default class Login extends BaseCommand {
27
27
  if (!envKey) {
28
28
  const existing = await loadConfig();
29
29
  if (existing?.apiKey) {
30
+ const existingClient = new ApiClient(existing.apiKey);
31
+ const yamlRestored = await this.recoverYamlAfterLogin(existingClient);
30
32
  if (flags.json) {
31
33
  this.log(JSON.stringify({
32
34
  email: existing.email ?? null,
33
35
  status: 'already_logged_in',
34
36
  totalFreeRemaining: existing.totalFreeRemaining ?? null,
37
+ yamlRestored,
35
38
  }, null, 2));
36
39
  return;
37
40
  }
@@ -40,7 +43,12 @@ export default class Login extends BaseCommand {
40
43
  ? chalk.green(existing.email.trim())
41
44
  : chalk.dim('(unknown)');
42
45
  this.log(` Email: ${emailDisplay}\n`);
43
- this.log(` ${chalk.dim('1.')} Run ${chalk.cyan('mailmodo init')} to generate an email sequence.`);
46
+ if (yamlRestored) {
47
+ this.log(` ${INFO.YAML_RESTORED_ON_LOGIN}`);
48
+ }
49
+ else {
50
+ this.log(` ${chalk.dim('1.')} Run ${chalk.cyan('mailmodo init')} to generate an email sequence.`);
51
+ }
44
52
  this.log(` ${chalk.dim('2.')} Run ${chalk.cyan('mailmodo logout')} to log in with another account.\n`);
45
53
  return;
46
54
  }
@@ -79,6 +87,7 @@ export default class Login extends BaseCommand {
79
87
  email,
80
88
  totalFreeRemaining,
81
89
  });
90
+ const yamlRestored = await this.recoverYamlAfterLogin(client);
82
91
  if (flags.json) {
83
92
  this.log(JSON.stringify({
84
93
  email,
@@ -86,6 +95,7 @@ export default class Login extends BaseCommand {
86
95
  totalFreeRemaining,
87
96
  paidEmailsRemaining,
88
97
  status: 'authenticated',
98
+ yamlRestored,
89
99
  }, null, 2));
90
100
  return;
91
101
  }
@@ -97,6 +107,11 @@ export default class Login extends BaseCommand {
97
107
  if (plan === 'paid') {
98
108
  this.log(` Current paid block: ${chalk.cyan(String(paidEmailsRemaining))} emails remaining\n`);
99
109
  }
100
- this.log(` Next: Run ${chalk.cyan("'mailmodo init'")} to generate your email sequences.\n`);
110
+ if (yamlRestored) {
111
+ this.log(` ${INFO.YAML_RESTORED_ON_LOGIN}\n`);
112
+ }
113
+ else {
114
+ this.log(` Next: Run ${chalk.cyan("'mailmodo init'")} to generate your email sequences.\n`);
115
+ }
101
116
  }
102
117
  }
@@ -0,0 +1,14 @@
1
+ import { BaseCommand } from '../../lib/base-command.js';
2
+ export default class Sdk extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'sequence-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ };
10
+ run(): Promise<void>;
11
+ private renderSnippets;
12
+ private renderSequenceBlock;
13
+ private renderCallBlock;
14
+ }
@@ -0,0 +1,74 @@
1
+ import { Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { BaseCommand } from '../../lib/base-command.js';
4
+ import { API_ENDPOINTS, SDK_IMPORT_SNIPPET, SDK_INSTALL_COMMAND, } from '../../lib/constants.js';
5
+ import { SEPARATOR } from '../../lib/messages.js';
6
+ export default class Sdk extends BaseCommand {
7
+ static description = 'Show the SDK track() / identify() reference for deployed sequences';
8
+ static examples = [
9
+ '<%= config.bin %> sdk',
10
+ '<%= config.bin %> sdk --sequence-id a1b2c3d4',
11
+ '<%= config.bin %> sdk --json',
12
+ ];
13
+ static flags = {
14
+ ...BaseCommand.baseFlags,
15
+ 'sequence-id': Flags.string({
16
+ description: 'Limit output to a single active sequence by ID (default: all active sequences)',
17
+ }),
18
+ };
19
+ async run() {
20
+ const { flags } = await this.parse(Sdk);
21
+ await this.ensureAuth();
22
+ const params = flags['sequence-id']
23
+ ? { sequenceId: flags['sequence-id'] }
24
+ : undefined;
25
+ const response = await this.withApiSpinner({ json: flags.json, text: ' Loading SDK reference...' }, () => this.apiClient.get(API_ENDPOINTS.SEQUENCES_SDK, params));
26
+ if (!response.ok) {
27
+ this.handleApiError(response);
28
+ }
29
+ if (flags.json) {
30
+ this.log(JSON.stringify(response.data, null, 2));
31
+ return;
32
+ }
33
+ this.renderSnippets(response.data);
34
+ }
35
+ renderSnippets(data) {
36
+ const snippets = data.sdkSnippets ?? [];
37
+ if (snippets.length === 0) {
38
+ this.log(`\n ${chalk.dim('No active deployed sequences.')}`);
39
+ this.log(` Run ${chalk.cyan('mailmodo deploy')} to deploy one.\n`);
40
+ return;
41
+ }
42
+ this.log(`\n ${chalk.bold(String(snippets.length))} active ${snippets.length === 1 ? 'sequence' : 'sequences'}:\n`);
43
+ this.log(` ${SEPARATOR}`);
44
+ this.log(` ${chalk.bold('SDK EVENT REFERENCE')}`);
45
+ this.log(` ${SEPARATOR}\n`);
46
+ this.log(` ${chalk.cyan(SDK_INSTALL_COMMAND)}\n`);
47
+ this.log(` ${chalk.dim(SDK_IMPORT_SNIPPET)}\n`);
48
+ for (const [index, snippet] of snippets.entries()) {
49
+ this.renderSequenceBlock(snippet);
50
+ if (index < snippets.length - 1)
51
+ this.log('');
52
+ }
53
+ this.log(` ${SEPARATOR}\n`);
54
+ }
55
+ renderSequenceBlock(snippet) {
56
+ const productName = snippet.productName || 'Unnamed sequence';
57
+ this.log(` ${chalk.bold(productName)} ${chalk.dim(`(${snippet.sequenceId})`)}`);
58
+ const trackCalls = [...new Set(snippet.sdkSnippet?.trackCalls ?? [])];
59
+ const identifyCalls = [...new Set(snippet.sdkSnippet?.identifyCalls ?? [])];
60
+ this.renderCallBlock('// track() calls', trackCalls);
61
+ this.renderCallBlock('// identify() calls', identifyCalls);
62
+ if (trackCalls.length === 0 && identifyCalls.length === 0) {
63
+ this.log(` ${chalk.dim('No track() or identify() calls available.')}`);
64
+ }
65
+ }
66
+ renderCallBlock(label, calls) {
67
+ if (calls.length === 0)
68
+ return;
69
+ this.log(` ${chalk.dim(label)}`);
70
+ for (const call of calls) {
71
+ this.log(` ${chalk.dim(call)}`);
72
+ }
73
+ }
74
+ }
@@ -50,6 +50,7 @@ export default class Settings extends BaseCommand {
50
50
  if (capFromApi !== null) {
51
51
  yamlConfig.project.monthlyCap = capFromApi;
52
52
  await saveYaml(yamlConfig);
53
+ await this.syncYamlToServer();
53
54
  }
54
55
  if (flags.json) {
55
56
  this.log(JSON.stringify({ settings: yamlConfig.project }, null, 2));
@@ -83,6 +84,7 @@ export default class Settings extends BaseCommand {
83
84
  }
84
85
  project[propKey] = value;
85
86
  await saveYaml(yamlConfig);
87
+ await this.syncYamlToServer();
86
88
  if (isJson) {
87
89
  this.log(JSON.stringify({ [propKey]: value, status: 'updated' }, null, 2));
88
90
  return;
@@ -104,6 +106,7 @@ export default class Settings extends BaseCommand {
104
106
  const data = await this.applyBillingCap({ cap: parsed, json: isJson });
105
107
  yamlConfig.project.monthlyCap = data.capBlocks;
106
108
  await saveYaml(yamlConfig);
109
+ await this.syncYamlToServer();
107
110
  if (isJson) {
108
111
  this.log(JSON.stringify({ monthlyCap: data.capBlocks, status: 'updated' }, null, 2));
109
112
  return;
@@ -207,6 +210,7 @@ export default class Settings extends BaseCommand {
207
210
  });
208
211
  project.emailStyle = style;
209
212
  await saveYaml(yamlConfig);
213
+ await this.syncYamlToServer();
210
214
  this.log(`\n ${chalk.green('✓')} email_style updated to ${chalk.cyan(style)}`);
211
215
  this.log(` ${INFO.DEPLOY_TO_APPLY}\n`);
212
216
  return;
@@ -216,6 +220,7 @@ export default class Settings extends BaseCommand {
216
220
  });
217
221
  project[editPropKey] = newValue;
218
222
  await saveYaml(yamlConfig);
223
+ await this.syncYamlToServer();
219
224
  this.log(`\n ${chalk.green('✓')} Updated. ${INFO.DEPLOY_TO_APPLY}\n`);
220
225
  }
221
226
  /**
@@ -281,6 +286,7 @@ export default class Settings extends BaseCommand {
281
286
  yamlConfig.project.logoUrl = response.data?.url || '';
282
287
  yamlConfig.project.logoFile = logoPath;
283
288
  await saveYaml(yamlConfig);
289
+ await this.syncYamlToServer();
284
290
  this.log(`\n Logo uploaded and hosted at:`);
285
291
  this.log(` ${chalk.cyan(String(response.data?.url))}`);
286
292
  this.log(` Run ${chalk.cyan("'mailmodo deploy'")} to apply to all branded emails.\n`);