@funnelsgrove/cli 0.1.73 → 0.1.76

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/cli.js CHANGED
@@ -15,6 +15,7 @@ import { buildCommittedSyncManifest, buildDownloadedSyncManifest, buildSourceCan
15
15
  import { mergeTextSourceWithGit } from './sourceRebase.js';
16
16
  import { executeExperimentCreate, formatExperimentCreateSuccess, recoverExperimentCreateTransaction, } from './experimentCreate.js';
17
17
  import { pullEnvFile } from './envSync.js';
18
+ import { executeEmailPull, executeEmailPush, executeEmailSequencePublish, executeEmailTemplatePublish, executeEmailValidate, } from './emailCommands.js';
18
19
  import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
19
20
  import { GitHubSyncTimeoutError, syncGitHubDraftIfConnected, } from './githubSyncFlow.js';
20
21
  import { reskinFunnel } from './reskin.js';
@@ -1440,6 +1441,22 @@ const resolveAnalyticsProject = async (input) => {
1440
1441
  }
1441
1442
  return resolveProject(input.token, input.workspaceId, project);
1442
1443
  };
1444
+ const resolveEmailCommandScope = async (options) => {
1445
+ const token = await readAuthToken();
1446
+ const workspaceId = await resolveWorkspaceId(token, options.workspace);
1447
+ const project = await resolveAnalyticsProject({
1448
+ token,
1449
+ workspaceId,
1450
+ project: options.project,
1451
+ });
1452
+ return {
1453
+ callApi,
1454
+ token,
1455
+ workspaceId,
1456
+ projectId: project.id,
1457
+ sourceDir: path.resolve(process.cwd(), options.dir),
1458
+ };
1459
+ };
1443
1460
  const resolveAnalyticsFunnel = async (input) => {
1444
1461
  const active = await loadActiveContext(getConfigPath());
1445
1462
  const funnel = input.funnel || (active?.workspaceId === input.workspaceId ? active.funnelId : undefined);
@@ -1682,6 +1699,69 @@ addExamples(program
1682
1699
  console.log(`${legacyDiagnostic.code}\t${legacyDiagnostic.message}`);
1683
1700
  }
1684
1701
  });
1702
+ const addEmailScopeOptions = (command) => command
1703
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
1704
+ .option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
1705
+ .option('--dir <path>', 'Directory containing the emails folder', '.');
1706
+ const emailCommand = addExamples(program.command('email').description('Manage project email drafts'), [
1707
+ 'fgrove email pull',
1708
+ 'fgrove email validate',
1709
+ 'fgrove email push',
1710
+ 'fgrove email template publish welcome',
1711
+ ]);
1712
+ addEmailScopeOptions(emailCommand
1713
+ .command('pull')
1714
+ .description('Pull project email drafts into local files'))
1715
+ .action(async (options) => {
1716
+ const result = await executeEmailPull(await resolveEmailCommandScope(options));
1717
+ console.log(`Pulled ${result.templates} templates and ${result.sequences} sequences.`);
1718
+ });
1719
+ emailCommand
1720
+ .command('validate')
1721
+ .description('Validate local email draft files')
1722
+ .option('--dir <path>', 'Directory containing the emails folder', '.')
1723
+ .action(async (options) => {
1724
+ const result = await executeEmailValidate({
1725
+ sourceDir: path.resolve(process.cwd(), options.dir),
1726
+ });
1727
+ if (result.valid) {
1728
+ console.log('Email files are valid.');
1729
+ return;
1730
+ }
1731
+ for (const item of result.diagnostics) {
1732
+ console.error(`[${item.code}] ${item.file || 'emails'}: ${item.reason}`);
1733
+ }
1734
+ process.exitCode = 1;
1735
+ });
1736
+ addEmailScopeOptions(emailCommand
1737
+ .command('push')
1738
+ .description('Push local email drafts without publishing'))
1739
+ .action(async (options) => {
1740
+ const result = await executeEmailPush(await resolveEmailCommandScope(options));
1741
+ console.log(`Pushed ${result.templates} templates and ${result.sequences} sequences.`);
1742
+ });
1743
+ const emailTemplateCommand = emailCommand.command('template').description('Manage email templates');
1744
+ addEmailScopeOptions(emailTemplateCommand
1745
+ .command('publish <slug>')
1746
+ .description('Publish one email template explicitly'))
1747
+ .action(async (slug, options) => {
1748
+ await executeEmailTemplatePublish({
1749
+ ...await resolveEmailCommandScope(options),
1750
+ slug,
1751
+ });
1752
+ console.log(`Published email template ${slug}.`);
1753
+ });
1754
+ const emailSequenceCommand = emailCommand.command('sequence').description('Manage email sequences');
1755
+ addEmailScopeOptions(emailSequenceCommand
1756
+ .command('publish <slug>')
1757
+ .description('Publish one email sequence explicitly'))
1758
+ .action(async (slug, options) => {
1759
+ await executeEmailSequencePublish({
1760
+ ...await resolveEmailCommandScope(options),
1761
+ slug,
1762
+ });
1763
+ console.log(`Published email sequence ${slug}.`);
1764
+ });
1685
1765
  const projectsCommand = addExamples(program.command('projects').description('Manage projects'), [
1686
1766
  'fgrove projects list',
1687
1767
  'fgrove projects list --workspace acme',
@@ -0,0 +1,32 @@
1
+ import { type EmailFilesResult } from './emailFiles.js';
2
+ export type EmailCallApi = <T>(input: {
3
+ path: string;
4
+ type: 'query' | 'mutation';
5
+ data?: unknown;
6
+ token?: string | null;
7
+ }) => Promise<T>;
8
+ type EmailCommandScope = {
9
+ callApi: EmailCallApi;
10
+ token: string;
11
+ workspaceId: string;
12
+ projectId: string;
13
+ sourceDir: string;
14
+ };
15
+ export declare function executeEmailPull(scope: EmailCommandScope): Promise<{
16
+ templates: number;
17
+ sequences: number;
18
+ }>;
19
+ export declare const executeEmailValidate: (input: {
20
+ sourceDir: string;
21
+ }) => Promise<EmailFilesResult>;
22
+ export declare function executeEmailPush(scope: EmailCommandScope): Promise<{
23
+ templates: number;
24
+ sequences: number;
25
+ }>;
26
+ export declare function executeEmailTemplatePublish(scope: EmailCommandScope & {
27
+ slug: string;
28
+ }): Promise<unknown>;
29
+ export declare function executeEmailSequencePublish(scope: EmailCommandScope & {
30
+ slug: string;
31
+ }): Promise<unknown>;
32
+ export {};
@@ -0,0 +1,269 @@
1
+ import { readEmailFiles, replaceEmailFiles, writeEmailFiles, writeEmailSequenceFile, writeEmailTemplateFile, } from './emailFiles.js';
2
+ const isRecord = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value));
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
+ const hasDraft = (value) => (isRecord(value) && isRecord(value.draft));
5
+ const assertRemoteResource = (value, resource) => {
6
+ if (!isRecord(value)
7
+ || typeof value.id !== 'string'
8
+ || !UUID.test(value.id)
9
+ || typeof value.slug !== 'string'
10
+ || typeof value.name !== 'string'
11
+ || !hasDraft(value)) {
12
+ throw new Error(`Invalid remote ${resource} resource`);
13
+ }
14
+ return value;
15
+ };
16
+ const assertCreatedId = (value, resource) => {
17
+ if (!isRecord(value) || typeof value.id !== 'string' || !UUID.test(value.id)) {
18
+ throw new Error(`Invalid created ${resource} ID`);
19
+ }
20
+ return value.id;
21
+ };
22
+ const assertRemoteIdentity = (value, resource) => {
23
+ if (!isRecord(value)
24
+ || typeof value.id !== 'string'
25
+ || !UUID.test(value.id)
26
+ || typeof value.slug !== 'string') {
27
+ throw new Error(`Invalid remote ${resource} identity`);
28
+ }
29
+ return value;
30
+ };
31
+ const assertValidFiles = (result) => {
32
+ if (result.valid)
33
+ return;
34
+ const summary = result.diagnostics
35
+ .map((item) => `[${item.code}] ${item.file || 'emails'}: ${item.reason}`)
36
+ .join('\n');
37
+ throw new Error(`Email files are invalid. Run \`fgrove email validate\`.\n${summary}`);
38
+ };
39
+ const projectData = (scope) => ({
40
+ workspaceId: scope.workspaceId,
41
+ projectId: scope.projectId,
42
+ });
43
+ const assertRemoteIdentities = async (scope, input) => {
44
+ const localWithIds = input.local.filter((item) => item.id !== null);
45
+ if (localWithIds.length === 0)
46
+ return new Map();
47
+ const remote = await scope.callApi({
48
+ path: `${input.resource}.list`,
49
+ type: 'query',
50
+ token: scope.token,
51
+ data: projectData(scope),
52
+ });
53
+ const parsed = remote.map((item) => assertRemoteIdentity(item, input.resource));
54
+ const remoteSlugs = new Map(parsed.map((item) => [item.id, item.slug]));
55
+ for (const local of localWithIds) {
56
+ const remoteSlug = remoteSlugs.get(local.id);
57
+ if (remoteSlug !== local.slug) {
58
+ throw new Error(`Email identity mismatch: ID ${local.id} has local slug "${local.slug}" but remote slug ${remoteSlug ? `"${remoteSlug}"` : 'is missing'}.`);
59
+ }
60
+ }
61
+ return new Map(parsed.map((item) => [item.id, item]));
62
+ };
63
+ const canonicalResource = (kind, value) => {
64
+ if (kind === 'template') {
65
+ const variables = isRecord(value.draft.variables) ? value.draft.variables : {};
66
+ return JSON.stringify({
67
+ name: value.name,
68
+ subject: value.draft.subject,
69
+ previewText: value.draft.previewText,
70
+ html: value.draft.html,
71
+ text: value.draft.text,
72
+ variables: Object.fromEntries(Object.entries(variables).sort(([left], [right]) => left.localeCompare(right))),
73
+ });
74
+ }
75
+ const steps = Array.isArray(value.draft.steps) ? value.draft.steps : [];
76
+ return JSON.stringify({
77
+ name: value.name,
78
+ triggerEventType: value.draft.triggerEventType,
79
+ funnelIds: Array.isArray(value.draft.funnelIds) ? [...new Set(value.draft.funnelIds)].sort() : [],
80
+ steps: steps.map((step) => isRecord(step) ? {
81
+ key: step.key,
82
+ delaySeconds: step.delaySeconds,
83
+ templateVersionId: step.templateVersionId,
84
+ } : step),
85
+ exitEventTypes: Array.isArray(value.draft.exitEventTypes)
86
+ ? [...new Set(value.draft.exitEventTypes)].sort()
87
+ : [],
88
+ });
89
+ };
90
+ const loadCompleteItems = async (scope, input) => {
91
+ const listed = await scope.callApi({
92
+ path: `${input.resource}.list`,
93
+ type: 'query',
94
+ token: scope.token,
95
+ data: projectData(scope),
96
+ });
97
+ return Promise.all(listed.map(async (item) => {
98
+ if (hasDraft(item))
99
+ return assertRemoteResource(item, input.resource);
100
+ if (!isRecord(item) || typeof item.id !== 'string') {
101
+ throw new Error(`Invalid ${input.resource}.list response`);
102
+ }
103
+ const detail = await scope.callApi({
104
+ path: `${input.resource}.get`,
105
+ type: 'query',
106
+ token: scope.token,
107
+ data: {
108
+ ...projectData(scope),
109
+ [input.idKey]: item.id,
110
+ },
111
+ });
112
+ return assertRemoteResource(detail[input.detailKey], input.resource);
113
+ }));
114
+ };
115
+ export async function executeEmailPull(scope) {
116
+ const [templates, sequences] = await Promise.all([
117
+ loadCompleteItems(scope, {
118
+ resource: 'emailTemplates',
119
+ idKey: 'templateId',
120
+ detailKey: 'template',
121
+ }),
122
+ loadCompleteItems(scope, {
123
+ resource: 'emailSequences',
124
+ idKey: 'sequenceId',
125
+ detailKey: 'sequence',
126
+ }),
127
+ ]);
128
+ await replaceEmailFiles({
129
+ sourceDir: scope.sourceDir,
130
+ templates,
131
+ sequences,
132
+ });
133
+ return { templates: templates.length, sequences: sequences.length };
134
+ }
135
+ export const executeEmailValidate = (input) => readEmailFiles(input.sourceDir);
136
+ export async function executeEmailPush(scope) {
137
+ const files = await readEmailFiles(scope.sourceDir);
138
+ assertValidFiles(files);
139
+ await Promise.all([
140
+ assertRemoteIdentities(scope, {
141
+ resource: 'emailTemplates',
142
+ local: files.templates,
143
+ }),
144
+ assertRemoteIdentities(scope, {
145
+ resource: 'emailSequences',
146
+ local: files.sequences,
147
+ }),
148
+ ]);
149
+ for (const template of files.templates) {
150
+ if (template.id) {
151
+ await scope.callApi({
152
+ path: 'emailTemplates.updateDraft',
153
+ type: 'mutation',
154
+ token: scope.token,
155
+ data: {
156
+ ...projectData(scope),
157
+ templateId: template.id,
158
+ name: template.name,
159
+ draft: template.draft,
160
+ },
161
+ });
162
+ continue;
163
+ }
164
+ const created = await scope.callApi({
165
+ path: 'emailTemplates.create',
166
+ type: 'mutation',
167
+ token: scope.token,
168
+ data: {
169
+ ...projectData(scope),
170
+ slug: template.slug,
171
+ name: template.name,
172
+ draft: template.draft,
173
+ },
174
+ });
175
+ template.id = assertCreatedId(created, 'email template');
176
+ await writeEmailTemplateFile(scope.sourceDir, template);
177
+ }
178
+ for (const sequence of files.sequences) {
179
+ if (sequence.id) {
180
+ await scope.callApi({
181
+ path: 'emailSequences.updateDraft',
182
+ type: 'mutation',
183
+ token: scope.token,
184
+ data: {
185
+ ...projectData(scope),
186
+ sequenceId: sequence.id,
187
+ name: sequence.name,
188
+ draft: sequence.draft,
189
+ },
190
+ });
191
+ continue;
192
+ }
193
+ const created = await scope.callApi({
194
+ path: 'emailSequences.create',
195
+ type: 'mutation',
196
+ token: scope.token,
197
+ data: {
198
+ ...projectData(scope),
199
+ slug: sequence.slug,
200
+ name: sequence.name,
201
+ draft: sequence.draft,
202
+ },
203
+ });
204
+ sequence.id = assertCreatedId(created, 'email sequence');
205
+ await writeEmailSequenceFile(scope.sourceDir, sequence);
206
+ }
207
+ await writeEmailFiles({
208
+ sourceDir: scope.sourceDir,
209
+ templates: files.templates,
210
+ sequences: files.sequences,
211
+ });
212
+ return { templates: files.templates.length, sequences: files.sequences.length };
213
+ }
214
+ export async function executeEmailTemplatePublish(scope) {
215
+ return executeEmailPublish(scope, {
216
+ kind: 'template',
217
+ resource: 'emailTemplates',
218
+ idKey: 'templateId',
219
+ });
220
+ }
221
+ export async function executeEmailSequencePublish(scope) {
222
+ return executeEmailPublish(scope, {
223
+ kind: 'sequence',
224
+ resource: 'emailSequences',
225
+ idKey: 'sequenceId',
226
+ });
227
+ }
228
+ const executeEmailPublish = async (scope, input) => {
229
+ const files = await readEmailFiles(scope.sourceDir);
230
+ assertValidFiles(files);
231
+ const resources = input.kind === 'template' ? files.templates : files.sequences;
232
+ const resource = resources.find((item) => item.slug === scope.slug);
233
+ if (!resource)
234
+ throw new Error(`Email ${input.kind} "${scope.slug}" was not found locally.`);
235
+ if (!resource.id)
236
+ throw new Error(`Push email ${input.kind} "${scope.slug}" before publishing it.`);
237
+ const remote = await assertRemoteIdentities(scope, {
238
+ resource: input.resource,
239
+ local: [resource],
240
+ });
241
+ let remoteResource = remote.get(resource.id);
242
+ if (remoteResource && !hasDraft(remoteResource)) {
243
+ const detailKey = input.kind;
244
+ const detail = await scope.callApi({
245
+ path: `${input.resource}.get`,
246
+ type: 'query',
247
+ token: scope.token,
248
+ data: {
249
+ ...projectData(scope),
250
+ [input.idKey]: resource.id,
251
+ },
252
+ });
253
+ remoteResource = assertRemoteResource(detail[detailKey], input.resource);
254
+ }
255
+ if (!remoteResource
256
+ || canonicalResource(input.kind, resource)
257
+ !== canonicalResource(input.kind, remoteResource)) {
258
+ throw new Error(`Run \`fgrove email push\` before publishing ${input.kind} "${scope.slug}".`);
259
+ }
260
+ return scope.callApi({
261
+ path: `${input.resource}.publish`,
262
+ type: 'mutation',
263
+ token: scope.token,
264
+ data: {
265
+ ...projectData(scope),
266
+ [input.idKey]: resource.id,
267
+ },
268
+ });
269
+ };
@@ -0,0 +1,52 @@
1
+ import { type FunnelValidationDiagnostic } from './diagnosticOutput.js';
2
+ export type EmailTemplateVariableType = 'string' | 'number' | 'boolean';
3
+ export type EmailEventType = 'email_captured' | 'purchase_completed' | 'registration_completed';
4
+ export type EmailTemplateDraft = {
5
+ subject: string;
6
+ previewText: string | null;
7
+ html: string;
8
+ text: string;
9
+ variables: Record<string, EmailTemplateVariableType>;
10
+ };
11
+ export type EmailTemplateFile = {
12
+ id: string | null;
13
+ slug: string;
14
+ name: string;
15
+ draft: EmailTemplateDraft;
16
+ };
17
+ export type EmailSequenceStep = {
18
+ key: string;
19
+ delaySeconds: number;
20
+ templateVersionId: string;
21
+ };
22
+ export type EmailSequenceDraft = {
23
+ triggerEventType: EmailEventType;
24
+ funnelIds: string[];
25
+ steps: EmailSequenceStep[];
26
+ exitEventTypes: EmailEventType[];
27
+ };
28
+ export type EmailSequenceFile = {
29
+ id: string | null;
30
+ slug: string;
31
+ name: string;
32
+ draft: EmailSequenceDraft;
33
+ };
34
+ export type EmailFilesResult = {
35
+ valid: boolean;
36
+ diagnostics: FunnelValidationDiagnostic[];
37
+ templates: EmailTemplateFile[];
38
+ sequences: EmailSequenceFile[];
39
+ };
40
+ export declare function readEmailFiles(sourceDir: string): Promise<EmailFilesResult>;
41
+ export declare function writeEmailTemplateFile(sourceDir: string, template: EmailTemplateFile): Promise<void>;
42
+ export declare function writeEmailSequenceFile(sourceDir: string, sequence: EmailSequenceFile): Promise<void>;
43
+ export declare function writeEmailFiles(input: {
44
+ sourceDir: string;
45
+ templates: EmailTemplateFile[];
46
+ sequences: EmailSequenceFile[];
47
+ }): Promise<void>;
48
+ export declare function replaceEmailFiles(input: {
49
+ sourceDir: string;
50
+ templates: EmailTemplateFile[];
51
+ sequences: EmailSequenceFile[];
52
+ }): Promise<void>;