@funnelsgrove/cli 0.1.0
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/README.md +29 -0
- package/dist/apiClient.d.ts +13 -0
- package/dist/apiClient.js +51 -0
- package/dist/authStore.d.ts +18 -0
- package/dist/authStore.js +85 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +550 -0
- package/dist/localSync.d.ts +25 -0
- package/dist/localSync.js +152 -0
- package/dist/reskin.d.ts +4 -0
- package/dist/reskin.js +76 -0
- package/dist/templateDocs.d.ts +2 -0
- package/dist/templateDocs.js +28 -0
- package/package.json +30 -0
- package/template_docs/agent.md +24 -0
- package/template_docs/docs/ab-experiments.md +10 -0
- package/template_docs/docs/analytics.md +10 -0
- package/template_docs/docs/editing-flow.md +10 -0
- package/template_docs/docs/editing-step.md +10 -0
- package/template_docs/docs/editor-and-content.md +10 -0
- package/template_docs/docs/payment-plans-and-discounts.md +10 -0
- package/template_docs/docs/publishing-and-versioning.md +10 -0
- package/template_docs/docs/sdk-api-endpoints.md +10 -0
- package/template_docs/docs/theme.md +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# FunnelsGrove CLI
|
|
2
|
+
|
|
3
|
+
Install:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @funnelsgrove/cli
|
|
7
|
+
fg login
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Set the active project and funnel:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
fg use --project claimbee --funnel claimbee-ios
|
|
14
|
+
fg status
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Common workflow:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
fg sync down --dir ./claimbee-ios
|
|
21
|
+
cd ./claimbee-ios
|
|
22
|
+
fg docs
|
|
23
|
+
fg sync up --message 'Update funnel copy'
|
|
24
|
+
fg publish --env preview
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package also keeps the longer `funnelsgrove` command as a compatibility alias.
|
|
28
|
+
Use `--api-url` or `FUNNELSGROVE_API_URL` for non-production APIs.
|
|
29
|
+
Use `--config` or `FUNNELSGROVE_CONFIG` to keep test credentials separate from the default `~/.funnelsgrove/config.json`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type FetchFn = typeof fetch;
|
|
2
|
+
type TrpcProcedureType = 'query' | 'mutation';
|
|
3
|
+
export type TrpcCallInput = {
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
path: string;
|
|
6
|
+
type: TrpcProcedureType;
|
|
7
|
+
input?: unknown;
|
|
8
|
+
token?: string | null;
|
|
9
|
+
fetchFn?: FetchFn;
|
|
10
|
+
};
|
|
11
|
+
export declare function parseTrpcJsonResponse<T = unknown>(response: unknown): T;
|
|
12
|
+
export declare function callTrpcProcedure<T = unknown>(input: TrpcCallInput): Promise<T>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function isObject(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function parseTrpcJsonResponse(response) {
|
|
5
|
+
if (!isObject(response)) {
|
|
6
|
+
throw new Error('Invalid tRPC response');
|
|
7
|
+
}
|
|
8
|
+
if (isObject(response.error)) {
|
|
9
|
+
const message = typeof response.error.message === 'string' ? response.error.message : 'Unknown error';
|
|
10
|
+
throw new Error(`tRPC request failed: ${message}`);
|
|
11
|
+
}
|
|
12
|
+
const result = response.result;
|
|
13
|
+
if (!isObject(result) || !('data' in result)) {
|
|
14
|
+
throw new Error('Invalid tRPC response');
|
|
15
|
+
}
|
|
16
|
+
if (isObject(result.data) && 'json' in result.data) {
|
|
17
|
+
return result.data.json;
|
|
18
|
+
}
|
|
19
|
+
return result.data;
|
|
20
|
+
}
|
|
21
|
+
const trimTrailingSlash = (value) => value.trim().replace(/\/+$/, '');
|
|
22
|
+
const buildHeaders = (input) => {
|
|
23
|
+
const headers = {};
|
|
24
|
+
if (input.token) {
|
|
25
|
+
headers.authorization = `Bearer ${input.token}`;
|
|
26
|
+
}
|
|
27
|
+
if (input.contentType) {
|
|
28
|
+
headers['content-type'] = input.contentType;
|
|
29
|
+
}
|
|
30
|
+
return headers;
|
|
31
|
+
};
|
|
32
|
+
export async function callTrpcProcedure(input) {
|
|
33
|
+
const fetchFn = input.fetchFn || fetch;
|
|
34
|
+
const baseUrl = `${trimTrailingSlash(input.apiUrl)}/${input.path}`;
|
|
35
|
+
const init = input.type === 'query'
|
|
36
|
+
? {
|
|
37
|
+
method: 'GET',
|
|
38
|
+
headers: buildHeaders({ token: input.token }),
|
|
39
|
+
}
|
|
40
|
+
: {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: buildHeaders({ token: input.token, contentType: 'application/json' }),
|
|
43
|
+
body: JSON.stringify(input.input ?? {}),
|
|
44
|
+
};
|
|
45
|
+
const url = input.type === 'query' && input.input !== undefined
|
|
46
|
+
? `${baseUrl}?input=${encodeURIComponent(JSON.stringify(input.input))}`
|
|
47
|
+
: baseUrl;
|
|
48
|
+
const response = await fetchFn(url, init);
|
|
49
|
+
const json = await response.json();
|
|
50
|
+
return parseTrpcJsonResponse(json);
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ActiveContext = {
|
|
2
|
+
workspaceId?: string;
|
|
3
|
+
workspaceName?: string;
|
|
4
|
+
workspaceSlug?: string;
|
|
5
|
+
projectId?: string;
|
|
6
|
+
projectName?: string;
|
|
7
|
+
projectSlug?: string;
|
|
8
|
+
funnelId?: string;
|
|
9
|
+
funnelName?: string;
|
|
10
|
+
funnelSlug?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function saveAuthToken(token: string, configPath: string): Promise<void>;
|
|
13
|
+
export declare function loadAuthToken(configPath: string): Promise<string | null>;
|
|
14
|
+
export declare function clearAuthToken(configPath: string): Promise<void>;
|
|
15
|
+
export declare function saveActiveContext(configPath: string, active: ActiveContext): Promise<void>;
|
|
16
|
+
export declare function loadActiveContext(configPath: string): Promise<ActiveContext | null>;
|
|
17
|
+
export declare function clearActiveContext(configPath: string): Promise<void>;
|
|
18
|
+
export declare function getDefaultAuthConfigPath(): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function normalizeString(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
10
|
+
}
|
|
11
|
+
function normalizeActiveContext(value) {
|
|
12
|
+
if (!isRecord(value)) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const active = {
|
|
16
|
+
workspaceId: normalizeString(value.workspaceId),
|
|
17
|
+
workspaceName: normalizeString(value.workspaceName),
|
|
18
|
+
workspaceSlug: normalizeString(value.workspaceSlug),
|
|
19
|
+
projectId: normalizeString(value.projectId),
|
|
20
|
+
projectName: normalizeString(value.projectName),
|
|
21
|
+
projectSlug: normalizeString(value.projectSlug),
|
|
22
|
+
funnelId: normalizeString(value.funnelId),
|
|
23
|
+
funnelName: normalizeString(value.funnelName),
|
|
24
|
+
funnelSlug: normalizeString(value.funnelSlug),
|
|
25
|
+
};
|
|
26
|
+
return Object.values(active).some(Boolean) ? active : undefined;
|
|
27
|
+
}
|
|
28
|
+
function pruneActiveContext(active) {
|
|
29
|
+
return Object.fromEntries(Object.entries(active).filter(([, value]) => typeof value === 'string' && value.trim()));
|
|
30
|
+
}
|
|
31
|
+
async function loadConfig(configPath) {
|
|
32
|
+
try {
|
|
33
|
+
const config = JSON.parse(await readFile(configPath, 'utf8'));
|
|
34
|
+
return {
|
|
35
|
+
token: typeof config.token === 'string' ? config.token : undefined,
|
|
36
|
+
active: normalizeActiveContext(config.active),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function saveConfig(configPath, config) {
|
|
47
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
48
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
export async function saveAuthToken(token, configPath) {
|
|
51
|
+
const config = await loadConfig(configPath);
|
|
52
|
+
await saveConfig(configPath, {
|
|
53
|
+
...config,
|
|
54
|
+
token,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function loadAuthToken(configPath) {
|
|
58
|
+
return (await loadConfig(configPath)).token || null;
|
|
59
|
+
}
|
|
60
|
+
export async function clearAuthToken(configPath) {
|
|
61
|
+
await rm(configPath, { force: true });
|
|
62
|
+
}
|
|
63
|
+
export async function saveActiveContext(configPath, active) {
|
|
64
|
+
const config = await loadConfig(configPath);
|
|
65
|
+
await saveConfig(configPath, {
|
|
66
|
+
...config,
|
|
67
|
+
active: pruneActiveContext(active),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export async function loadActiveContext(configPath) {
|
|
71
|
+
return (await loadConfig(configPath)).active || null;
|
|
72
|
+
}
|
|
73
|
+
export async function clearActiveContext(configPath) {
|
|
74
|
+
const config = await loadConfig(configPath);
|
|
75
|
+
if (!config.token) {
|
|
76
|
+
await rm(configPath, { force: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await saveConfig(configPath, {
|
|
80
|
+
token: config.token,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function getDefaultAuthConfigPath() {
|
|
84
|
+
return path.join(os.homedir(), '.funnelsgrove', 'config.json');
|
|
85
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { callTrpcProcedure } from './apiClient.js';
|
|
8
|
+
import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
|
|
9
|
+
import { buildSyncManifest, collectSourceFiles, readSyncManifest, writeSourceFiles, writeSyncManifest, } from './localSync.js';
|
|
10
|
+
import { isKnownTemplate, KNOWN_TEMPLATE_SLUGS, reskinFunnel } from './reskin.js';
|
|
11
|
+
import { syncTemplateDocs, TEMPLATE_DOCS_DIR } from './templateDocs.js';
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const toKebabCase = (value) => value
|
|
14
|
+
.trim()
|
|
15
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
16
|
+
.replace(/[\s_]+/g, '-')
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
const resolveTemplatePath = (slug) => path.resolve(__dirname, '..', '..', '..', 'funnels', 'rag-catalog', slug);
|
|
19
|
+
const resolveTemplateDocsPath = () => path.resolve(__dirname, '..', TEMPLATE_DOCS_DIR);
|
|
20
|
+
const DEFAULT_API_URL = 'https://api.funnelsgrove.com/trpc';
|
|
21
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
22
|
+
const getApiUrl = () => {
|
|
23
|
+
const options = program.opts();
|
|
24
|
+
return options.apiUrl || process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL;
|
|
25
|
+
};
|
|
26
|
+
const getConfigPath = () => {
|
|
27
|
+
const options = program.opts();
|
|
28
|
+
return options.config || process.env.FUNNELSGROVE_CONFIG || getDefaultAuthConfigPath();
|
|
29
|
+
};
|
|
30
|
+
const readAuthToken = async () => {
|
|
31
|
+
const token = await loadAuthToken(getConfigPath());
|
|
32
|
+
if (!token) {
|
|
33
|
+
throw new Error('Not logged in. Run `fg login` first.');
|
|
34
|
+
}
|
|
35
|
+
return token;
|
|
36
|
+
};
|
|
37
|
+
const callApi = async (input) => {
|
|
38
|
+
return callTrpcProcedure({
|
|
39
|
+
apiUrl: getApiUrl(),
|
|
40
|
+
path: input.path,
|
|
41
|
+
type: input.type,
|
|
42
|
+
input: input.data,
|
|
43
|
+
token: input.token,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
const readCodeFromStdin = async () => {
|
|
47
|
+
const rl = createInterface({
|
|
48
|
+
input: process.stdin,
|
|
49
|
+
output: process.stdout,
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
return (await rl.question('Enter 8 digit code: ')).trim();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
rl.close();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const getCurrentUser = async (token) => {
|
|
59
|
+
return callApi({
|
|
60
|
+
path: 'auth.me',
|
|
61
|
+
type: 'query',
|
|
62
|
+
token,
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const matchesResource = (resource, value) => {
|
|
66
|
+
const normalized = value.trim().toLowerCase();
|
|
67
|
+
return (resource.id === value ||
|
|
68
|
+
resource.slug?.toLowerCase() === normalized ||
|
|
69
|
+
resource.name?.toLowerCase() === normalized);
|
|
70
|
+
};
|
|
71
|
+
const resolveWorkspace = async (token, workspace) => {
|
|
72
|
+
const me = await getCurrentUser(token);
|
|
73
|
+
if (workspace) {
|
|
74
|
+
const matched = me.workspaces.find((item) => matchesResource(item, workspace));
|
|
75
|
+
if (!matched) {
|
|
76
|
+
throw new Error(`Unable to resolve workspace "${workspace}"`);
|
|
77
|
+
}
|
|
78
|
+
return matched;
|
|
79
|
+
}
|
|
80
|
+
const active = await loadActiveContext(getConfigPath());
|
|
81
|
+
if (active?.workspaceId) {
|
|
82
|
+
const matched = me.workspaces.find((item) => item.id === active.workspaceId);
|
|
83
|
+
if (matched) {
|
|
84
|
+
return matched;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const defaultWorkspaceId = me.workspace?.id || me.workspaces[0]?.id;
|
|
88
|
+
if (!defaultWorkspaceId) {
|
|
89
|
+
throw new Error('No workspace found for this account.');
|
|
90
|
+
}
|
|
91
|
+
const defaultWorkspace = me.workspaces.find((item) => item.id === defaultWorkspaceId);
|
|
92
|
+
if (!defaultWorkspace) {
|
|
93
|
+
throw new Error('Default workspace is unavailable.');
|
|
94
|
+
}
|
|
95
|
+
return defaultWorkspace;
|
|
96
|
+
};
|
|
97
|
+
const resolveWorkspaceId = async (token, workspace) => {
|
|
98
|
+
return (await resolveWorkspace(token, workspace)).id;
|
|
99
|
+
};
|
|
100
|
+
const listProjects = async (token, workspaceId) => {
|
|
101
|
+
const result = await callApi({
|
|
102
|
+
path: 'projects.list',
|
|
103
|
+
type: 'query',
|
|
104
|
+
token,
|
|
105
|
+
data: {
|
|
106
|
+
workspaceId,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
return result.projects;
|
|
110
|
+
};
|
|
111
|
+
const resolveProject = async (token, workspaceId, project) => {
|
|
112
|
+
const projects = await listProjects(token, workspaceId);
|
|
113
|
+
const matched = projects.find((item) => matchesResource(item, project));
|
|
114
|
+
if (!matched) {
|
|
115
|
+
throw new Error(`Unable to resolve project "${project}" in workspace ${workspaceId}`);
|
|
116
|
+
}
|
|
117
|
+
return matched;
|
|
118
|
+
};
|
|
119
|
+
const listFunnels = async (token, workspaceId) => {
|
|
120
|
+
const result = await callApi({
|
|
121
|
+
path: 'funnels.list',
|
|
122
|
+
type: 'query',
|
|
123
|
+
token,
|
|
124
|
+
data: {
|
|
125
|
+
workspaceId,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return result.funnels;
|
|
129
|
+
};
|
|
130
|
+
const resolveFunnel = async (token, workspaceId, funnel) => {
|
|
131
|
+
if (UUID_PATTERN.test(funnel)) {
|
|
132
|
+
return {
|
|
133
|
+
id: funnel,
|
|
134
|
+
name: funnel,
|
|
135
|
+
slug: funnel,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const funnels = await listFunnels(token, workspaceId);
|
|
139
|
+
const matched = funnels.find((item) => matchesResource(item, funnel));
|
|
140
|
+
if (!matched) {
|
|
141
|
+
throw new Error(`Unable to resolve funnel "${funnel}" in workspace ${workspaceId}`);
|
|
142
|
+
}
|
|
143
|
+
return matched;
|
|
144
|
+
};
|
|
145
|
+
const resolveFunnelId = async (token, workspaceId, funnel) => {
|
|
146
|
+
return (await resolveFunnel(token, workspaceId, funnel)).id;
|
|
147
|
+
};
|
|
148
|
+
const contextFromResources = (input) => {
|
|
149
|
+
const sameWorkspace = input.current?.workspaceId === input.workspace.id;
|
|
150
|
+
return {
|
|
151
|
+
workspaceId: input.workspace.id,
|
|
152
|
+
workspaceName: input.workspace.name,
|
|
153
|
+
workspaceSlug: input.workspace.slug,
|
|
154
|
+
projectId: input.project?.id || (sameWorkspace ? input.current?.projectId : undefined),
|
|
155
|
+
projectName: input.project?.name || (sameWorkspace ? input.current?.projectName : undefined),
|
|
156
|
+
projectSlug: input.project?.slug || (sameWorkspace ? input.current?.projectSlug : undefined),
|
|
157
|
+
funnelId: input.funnel?.id || (sameWorkspace ? input.current?.funnelId : undefined),
|
|
158
|
+
funnelName: input.funnel?.name || (sameWorkspace ? input.current?.funnelName : undefined),
|
|
159
|
+
funnelSlug: input.funnel?.slug || (sameWorkspace ? input.current?.funnelSlug : undefined),
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
const loadManifestForDir = async (dir) => {
|
|
163
|
+
return readSyncManifest(path.resolve(process.cwd(), dir));
|
|
164
|
+
};
|
|
165
|
+
const resolveSyncTarget = async (input) => {
|
|
166
|
+
const sourceDir = path.resolve(process.cwd(), input.dir || '.');
|
|
167
|
+
const manifest = await readSyncManifest(sourceDir);
|
|
168
|
+
const active = await loadActiveContext(getConfigPath());
|
|
169
|
+
const workspaceId = input.workspace
|
|
170
|
+
? await resolveWorkspaceId(input.token, input.workspace)
|
|
171
|
+
: manifest?.workspaceId || active?.workspaceId || await resolveWorkspaceId(input.token);
|
|
172
|
+
const funnelId = input.funnel
|
|
173
|
+
? await resolveFunnelId(input.token, workspaceId, input.funnel)
|
|
174
|
+
: manifest?.funnelId || active?.funnelId;
|
|
175
|
+
if (!funnelId) {
|
|
176
|
+
throw new Error('No active funnel. Run `fg use --funnel <id-or-slug>` or pass `--funnel`.');
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
workspaceId,
|
|
180
|
+
funnelId,
|
|
181
|
+
sourceDir,
|
|
182
|
+
manifest,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const ensureGitignore = async (dir) => {
|
|
186
|
+
const gitignorePath = path.join(dir, '.gitignore');
|
|
187
|
+
const existing = await readFile(gitignorePath, 'utf8').catch(() => '');
|
|
188
|
+
const lines = existing.split(/\r?\n/g).filter(Boolean);
|
|
189
|
+
const required = ['.env', '.env.local', '.env.*', '!.env.example', '.funnelsgrove-sync.json', 'node_modules', '.next', 'out'];
|
|
190
|
+
const seen = new Set(lines);
|
|
191
|
+
for (const line of required) {
|
|
192
|
+
if (!seen.has(line)) {
|
|
193
|
+
lines.push(line);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
await writeFile(gitignorePath, `${lines.join('\n')}\n`, 'utf8');
|
|
197
|
+
};
|
|
198
|
+
const printRows = (rows, columns) => {
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
console.log(columns.map((column) => row[column] || '').join('\t'));
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
|
|
204
|
+
const program = new Command();
|
|
205
|
+
program
|
|
206
|
+
.name('fg')
|
|
207
|
+
.description('FunnelsGrove CLI for editing, syncing, and publishing funnels')
|
|
208
|
+
.version('0.1.0')
|
|
209
|
+
.option('--api-url <url>', 'FunnelsGrove tRPC API URL', process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL)
|
|
210
|
+
.option('--config <path>', 'Auth config path', process.env.FUNNELSGROVE_CONFIG || getDefaultAuthConfigPath());
|
|
211
|
+
addExamples(program, [
|
|
212
|
+
'fg login',
|
|
213
|
+
'fg use --project claimbee --funnel claimbee-ios',
|
|
214
|
+
'fg sync down --dir ./claimbee-ios',
|
|
215
|
+
'fg publish --env preview',
|
|
216
|
+
]);
|
|
217
|
+
addExamples(program
|
|
218
|
+
.command('login')
|
|
219
|
+
.description('Authorize this CLI with your FunnelsGrove account')
|
|
220
|
+
.option('--code <code>', '8 digit code from the authorization page'), [
|
|
221
|
+
'fg login',
|
|
222
|
+
'fg login --api-url http://localhost:3001/trpc',
|
|
223
|
+
])
|
|
224
|
+
.action(async (options) => {
|
|
225
|
+
const request = await callApi({
|
|
226
|
+
path: 'cliAuth.createDeviceLogin',
|
|
227
|
+
type: 'mutation',
|
|
228
|
+
});
|
|
229
|
+
console.log('Open this URL to authorize the CLI:');
|
|
230
|
+
console.log(request.authorizeUrl);
|
|
231
|
+
console.log(`This request expires at ${request.expiresAt}.`);
|
|
232
|
+
const userCode = options.code?.trim() || await readCodeFromStdin();
|
|
233
|
+
const result = await callApi({
|
|
234
|
+
path: 'cliAuth.exchangeDeviceCode',
|
|
235
|
+
type: 'mutation',
|
|
236
|
+
data: {
|
|
237
|
+
deviceId: request.deviceId,
|
|
238
|
+
userCode,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
await saveAuthToken(result.token, getConfigPath());
|
|
242
|
+
console.log(`Logged in. Token expires at ${result.expiresAt}.`);
|
|
243
|
+
});
|
|
244
|
+
addExamples(program
|
|
245
|
+
.command('whoami')
|
|
246
|
+
.description('Show the current authenticated user'), [
|
|
247
|
+
'fg whoami',
|
|
248
|
+
])
|
|
249
|
+
.action(async () => {
|
|
250
|
+
const token = await readAuthToken();
|
|
251
|
+
const me = await getCurrentUser(token);
|
|
252
|
+
console.log(`${me.user.email || me.user.id}\t${me.workspace?.name || 'No workspace'}`);
|
|
253
|
+
});
|
|
254
|
+
addExamples(program
|
|
255
|
+
.command('use')
|
|
256
|
+
.description('Set the active workspace, project, and funnel for future commands')
|
|
257
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
258
|
+
.option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
|
|
259
|
+
.option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
|
|
260
|
+
.option('--clear', 'Clear the active CLI context'), [
|
|
261
|
+
'fg use --project claimbee --funnel claimbee-ios',
|
|
262
|
+
'fg use --workspace acme --project claimbee',
|
|
263
|
+
'fg use --clear',
|
|
264
|
+
])
|
|
265
|
+
.action(async (options) => {
|
|
266
|
+
if (options.clear) {
|
|
267
|
+
await clearActiveContext(getConfigPath());
|
|
268
|
+
console.log('Cleared active context.');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const token = await readAuthToken();
|
|
272
|
+
const current = await loadActiveContext(getConfigPath());
|
|
273
|
+
const workspace = await resolveWorkspace(token, options.workspace || current?.workspaceId);
|
|
274
|
+
const project = options.project ? await resolveProject(token, workspace.id, options.project) : null;
|
|
275
|
+
const funnel = options.funnel ? await resolveFunnel(token, workspace.id, options.funnel) : null;
|
|
276
|
+
const nextContext = contextFromResources({
|
|
277
|
+
workspace,
|
|
278
|
+
project,
|
|
279
|
+
funnel,
|
|
280
|
+
current,
|
|
281
|
+
});
|
|
282
|
+
await saveActiveContext(getConfigPath(), nextContext);
|
|
283
|
+
console.log(`workspace\t${nextContext.workspaceName || nextContext.workspaceId}`);
|
|
284
|
+
if (nextContext.projectId) {
|
|
285
|
+
console.log(`project\t${nextContext.projectName || nextContext.projectSlug || nextContext.projectId}`);
|
|
286
|
+
}
|
|
287
|
+
if (nextContext.funnelId) {
|
|
288
|
+
console.log(`funnel\t${nextContext.funnelName || nextContext.funnelSlug || nextContext.funnelId}`);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
addExamples(program
|
|
292
|
+
.command('status')
|
|
293
|
+
.description('Show the authenticated user and active CLI context')
|
|
294
|
+
.option('--dir <path>', 'Local source directory for reading sync manifest', '.'), [
|
|
295
|
+
'fg status',
|
|
296
|
+
'fg status --dir ./claimbee-ios',
|
|
297
|
+
])
|
|
298
|
+
.action(async (options) => {
|
|
299
|
+
const token = await readAuthToken();
|
|
300
|
+
const [me, active, manifest] = await Promise.all([
|
|
301
|
+
getCurrentUser(token),
|
|
302
|
+
loadActiveContext(getConfigPath()),
|
|
303
|
+
loadManifestForDir(options.dir),
|
|
304
|
+
]);
|
|
305
|
+
console.log(`user\t${me.user.email || me.user.id}`);
|
|
306
|
+
console.log(`api\t${getApiUrl()}`);
|
|
307
|
+
console.log(`config\t${getConfigPath()}`);
|
|
308
|
+
console.log(`workspace\t${active?.workspaceName || active?.workspaceSlug || active?.workspaceId || me.workspace?.name || 'Not set'}`);
|
|
309
|
+
console.log(`project\t${active?.projectName || active?.projectSlug || active?.projectId || 'Not set'}`);
|
|
310
|
+
console.log(`funnel\t${active?.funnelName || active?.funnelSlug || active?.funnelId || 'Not set'}`);
|
|
311
|
+
if (manifest) {
|
|
312
|
+
console.log(`localWorkspace\t${manifest.workspaceId}`);
|
|
313
|
+
console.log(`localFunnel\t${manifest.funnelId}`);
|
|
314
|
+
console.log(`localDraft\t${manifest.draftVersionId}`);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
const projectsCommand = addExamples(program.command('projects').description('Manage projects'), [
|
|
318
|
+
'fg projects list',
|
|
319
|
+
'fg projects list --workspace acme',
|
|
320
|
+
]);
|
|
321
|
+
addExamples(projectsCommand
|
|
322
|
+
.command('list')
|
|
323
|
+
.description('List projects in the active or provided workspace')
|
|
324
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name'), [
|
|
325
|
+
'fg projects list',
|
|
326
|
+
'fg projects list --workspace acme',
|
|
327
|
+
])
|
|
328
|
+
.action(async (options) => {
|
|
329
|
+
const token = await readAuthToken();
|
|
330
|
+
const workspaceId = await resolveWorkspaceId(token, options.workspace);
|
|
331
|
+
const projects = await listProjects(token, workspaceId);
|
|
332
|
+
printRows(projects, ['id', 'name', 'slug']);
|
|
333
|
+
});
|
|
334
|
+
const funnelsCommand = addExamples(program.command('funnels').description('Manage funnels'), [
|
|
335
|
+
'fg funnels list',
|
|
336
|
+
'fg funnels clone --funnel claimbee --name claimbee-ios',
|
|
337
|
+
]);
|
|
338
|
+
addExamples(funnelsCommand
|
|
339
|
+
.command('list')
|
|
340
|
+
.description('List funnels in a workspace')
|
|
341
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name'), [
|
|
342
|
+
'fg funnels list',
|
|
343
|
+
'fg funnels list --workspace acme',
|
|
344
|
+
])
|
|
345
|
+
.action(async (options) => {
|
|
346
|
+
const token = await readAuthToken();
|
|
347
|
+
const workspaceId = await resolveWorkspaceId(token, options.workspace);
|
|
348
|
+
const funnels = await listFunnels(token, workspaceId);
|
|
349
|
+
printRows(funnels, ['id', 'name', 'slug']);
|
|
350
|
+
});
|
|
351
|
+
addExamples(funnelsCommand
|
|
352
|
+
.command('clone')
|
|
353
|
+
.description('Copy an existing funnel')
|
|
354
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
355
|
+
.requiredOption('--funnel <id-or-slug>', 'Source funnel id or slug')
|
|
356
|
+
.requiredOption('--name <name>', 'New funnel name'), [
|
|
357
|
+
'fg funnels clone --funnel claimbee --name claimbee-ios',
|
|
358
|
+
'fg funnels clone --workspace acme --funnel claimbee --name claimbee-ios',
|
|
359
|
+
])
|
|
360
|
+
.action(async (options) => {
|
|
361
|
+
const token = await readAuthToken();
|
|
362
|
+
const workspaceId = await resolveWorkspaceId(token, options.workspace);
|
|
363
|
+
const funnelId = await resolveFunnelId(token, workspaceId, options.funnel);
|
|
364
|
+
const result = await callApi({
|
|
365
|
+
path: 'funnels.clone',
|
|
366
|
+
type: 'mutation',
|
|
367
|
+
token,
|
|
368
|
+
data: {
|
|
369
|
+
workspaceId,
|
|
370
|
+
funnelId,
|
|
371
|
+
name: options.name,
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
console.log(`${result.funnel.id}\t${result.funnel.name}\t${result.funnel.slug}`);
|
|
375
|
+
});
|
|
376
|
+
const syncCommand = addExamples(program.command('sync').description('Sync funnel source'), [
|
|
377
|
+
'fg sync down --dir ./claimbee-ios',
|
|
378
|
+
'fg sync up --message "Update copy"',
|
|
379
|
+
]);
|
|
380
|
+
addExamples(syncCommand
|
|
381
|
+
.command('down')
|
|
382
|
+
.description('Download funnel draft source to a local directory')
|
|
383
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
384
|
+
.option('--funnel <id-or-slug>', 'Funnel id or slug')
|
|
385
|
+
.requiredOption('--dir <path>', 'Local target directory'), [
|
|
386
|
+
'fg sync down --dir ./claimbee-ios',
|
|
387
|
+
'fg sync down --funnel claimbee-ios --dir ./claimbee-ios',
|
|
388
|
+
])
|
|
389
|
+
.action(async (options) => {
|
|
390
|
+
const token = await readAuthToken();
|
|
391
|
+
const target = await resolveSyncTarget({
|
|
392
|
+
token,
|
|
393
|
+
workspace: options.workspace,
|
|
394
|
+
funnel: options.funnel,
|
|
395
|
+
dir: options.dir,
|
|
396
|
+
});
|
|
397
|
+
const result = await callApi({
|
|
398
|
+
path: 'funnels.exportSource',
|
|
399
|
+
type: 'query',
|
|
400
|
+
token,
|
|
401
|
+
data: {
|
|
402
|
+
workspaceId: target.workspaceId,
|
|
403
|
+
funnelId: target.funnelId,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
await mkdir(target.sourceDir, { recursive: true });
|
|
407
|
+
await writeSourceFiles(target.sourceDir, result.files);
|
|
408
|
+
if (result.envFile) {
|
|
409
|
+
await writeFile(path.join(target.sourceDir, '.env'), result.envFile, 'utf8');
|
|
410
|
+
}
|
|
411
|
+
await ensureGitignore(target.sourceDir);
|
|
412
|
+
await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
|
|
413
|
+
workspaceId: target.workspaceId,
|
|
414
|
+
funnelId: target.funnelId,
|
|
415
|
+
draftVersionId: result.draftVersionId,
|
|
416
|
+
}));
|
|
417
|
+
console.log(`Synced ${result.files.length} files to ${target.sourceDir}`);
|
|
418
|
+
});
|
|
419
|
+
addExamples(syncCommand
|
|
420
|
+
.command('up')
|
|
421
|
+
.description('Upload local source into a new funnel draft version')
|
|
422
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
423
|
+
.option('--funnel <id-or-slug>', 'Funnel id or slug')
|
|
424
|
+
.option('--dir <path>', 'Local source directory', '.')
|
|
425
|
+
.option('--message <message>', 'Draft version message'), [
|
|
426
|
+
'fg sync up --message "Update hero copy"',
|
|
427
|
+
'fg sync up --dir ./claimbee-ios --message "Update paywall"',
|
|
428
|
+
])
|
|
429
|
+
.action(async (options) => {
|
|
430
|
+
const token = await readAuthToken();
|
|
431
|
+
const target = await resolveSyncTarget({
|
|
432
|
+
token,
|
|
433
|
+
workspace: options.workspace,
|
|
434
|
+
funnel: options.funnel,
|
|
435
|
+
dir: options.dir,
|
|
436
|
+
});
|
|
437
|
+
const files = await collectSourceFiles(target.sourceDir);
|
|
438
|
+
const result = await callApi({
|
|
439
|
+
path: 'funnels.importSource',
|
|
440
|
+
type: 'mutation',
|
|
441
|
+
token,
|
|
442
|
+
data: {
|
|
443
|
+
workspaceId: target.workspaceId,
|
|
444
|
+
funnelId: target.funnelId,
|
|
445
|
+
message: options.message,
|
|
446
|
+
files,
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
|
|
450
|
+
workspaceId: target.workspaceId,
|
|
451
|
+
funnelId: target.funnelId,
|
|
452
|
+
draftVersionId: result.versionId,
|
|
453
|
+
}));
|
|
454
|
+
console.log(`Synced ${result.syncedFiles.length} files to draft v${result.versionSeq} (${result.versionId})`);
|
|
455
|
+
});
|
|
456
|
+
addExamples(program
|
|
457
|
+
.command('publish')
|
|
458
|
+
.description('Publish a funnel preview or production deployment')
|
|
459
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
460
|
+
.option('--funnel <id-or-slug>', 'Funnel id or slug')
|
|
461
|
+
.option('--dir <path>', 'Local source directory for reading sync manifest', '.')
|
|
462
|
+
.option('--env <preview-or-production>', 'Publish environment', 'preview')
|
|
463
|
+
.option('--domain <domain>', 'Production custom domain')
|
|
464
|
+
.option('--message <message>', 'Publish version message'), [
|
|
465
|
+
'fg publish --env preview --message "Preview copy updates"',
|
|
466
|
+
'fg publish --env production --domain claimbee.example.com --message "Launch"',
|
|
467
|
+
])
|
|
468
|
+
.action(async (options) => {
|
|
469
|
+
const token = await readAuthToken();
|
|
470
|
+
const target = await resolveSyncTarget({
|
|
471
|
+
token,
|
|
472
|
+
workspace: options.workspace,
|
|
473
|
+
funnel: options.funnel,
|
|
474
|
+
dir: options.dir,
|
|
475
|
+
});
|
|
476
|
+
const publishEnv = options.env.trim().toLowerCase();
|
|
477
|
+
if (publishEnv !== 'preview' && publishEnv !== 'production') {
|
|
478
|
+
throw new Error('--env must be preview or production');
|
|
479
|
+
}
|
|
480
|
+
if (publishEnv === 'production' && !options.domain?.trim()) {
|
|
481
|
+
throw new Error('--domain is required when publishing production');
|
|
482
|
+
}
|
|
483
|
+
const result = await callApi({
|
|
484
|
+
path: 'funnels.publish',
|
|
485
|
+
type: 'mutation',
|
|
486
|
+
token,
|
|
487
|
+
data: {
|
|
488
|
+
workspaceId: target.workspaceId,
|
|
489
|
+
funnelId: target.funnelId,
|
|
490
|
+
message: options.message,
|
|
491
|
+
domains: publishEnv === 'production' && options.domain ? [options.domain] : undefined,
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
console.log(`${result.deploymentUrl}\tv${result.publishedVersionSeq}\t${result.publishedVersionId}`);
|
|
495
|
+
});
|
|
496
|
+
addExamples(program
|
|
497
|
+
.command('docs')
|
|
498
|
+
.description('Install or refresh local funnel editing docs in a funnel directory')
|
|
499
|
+
.option('--dir <path>', 'Local funnel directory', '.'), [
|
|
500
|
+
'fg docs',
|
|
501
|
+
'fg docs --dir ./claimbee-ios',
|
|
502
|
+
])
|
|
503
|
+
.action(async (options) => {
|
|
504
|
+
const targetDir = path.resolve(process.cwd(), options.dir);
|
|
505
|
+
const copiedFiles = await syncTemplateDocs(resolveTemplateDocsPath(), targetDir);
|
|
506
|
+
console.log(`Synced ${copiedFiles.length} docs files to ${targetDir}`);
|
|
507
|
+
});
|
|
508
|
+
addExamples(program
|
|
509
|
+
.command('create')
|
|
510
|
+
.description('Create a new funnel from a template')
|
|
511
|
+
.requiredOption('--from <template>', 'Template slug from rag-catalog')
|
|
512
|
+
.requiredOption('--app <name>', 'Your app name')
|
|
513
|
+
.option('--output <dir>', 'Output directory'), [
|
|
514
|
+
'fg create --from headway-funnel --app ClaimBee',
|
|
515
|
+
'fg create --from promova --app ClaimBee --output ./claimbee-funnel',
|
|
516
|
+
])
|
|
517
|
+
.action(async (options) => {
|
|
518
|
+
const { from: templateSlug, app: appName, output } = options;
|
|
519
|
+
if (!isKnownTemplate(templateSlug)) {
|
|
520
|
+
console.error(`Unknown template "${templateSlug}". Available templates:\n${KNOWN_TEMPLATE_SLUGS.map((s) => ` - ${s}`).join('\n')}`);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
const templatePath = resolveTemplatePath(templateSlug);
|
|
524
|
+
const outputDir = output ?? `./${toKebabCase(appName)}-funnel`;
|
|
525
|
+
const outputPath = path.resolve(process.cwd(), outputDir);
|
|
526
|
+
console.log(`Creating funnel from "${templateSlug}" for "${appName}"...`);
|
|
527
|
+
try {
|
|
528
|
+
await cp(templatePath, outputPath, { recursive: true });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
console.error(`Failed to copy template: ${err instanceof Error ? err.message : String(err)}`);
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
const updated = await reskinFunnel(outputPath, appName);
|
|
535
|
+
console.log(`\nCreated funnel at ${outputDir}`);
|
|
536
|
+
if (updated.length > 0) {
|
|
537
|
+
console.log(`Updated ${updated.length} files with "${appName}" branding:`);
|
|
538
|
+
for (const file of updated) {
|
|
539
|
+
console.log(` - ${file}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
console.log(`\nNext steps:`);
|
|
543
|
+
console.log(` cd ${outputDir}`);
|
|
544
|
+
console.log(' npm install');
|
|
545
|
+
console.log(' npm run dev');
|
|
546
|
+
});
|
|
547
|
+
program.parseAsync().catch((error) => {
|
|
548
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
549
|
+
process.exitCode = 1;
|
|
550
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const SYNC_MANIFEST_FILE = ".funnelsgrove-sync.json";
|
|
2
|
+
export type SyncManifestFile = {
|
|
3
|
+
path: string;
|
|
4
|
+
hash: string;
|
|
5
|
+
};
|
|
6
|
+
export type SyncManifest = {
|
|
7
|
+
version: 1;
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
funnelId: string;
|
|
10
|
+
draftVersionId: string;
|
|
11
|
+
files: SyncManifestFile[];
|
|
12
|
+
};
|
|
13
|
+
export type SyncManifestInput = Omit<SyncManifest, 'version' | 'files'>;
|
|
14
|
+
export type SourceFile = {
|
|
15
|
+
path: string;
|
|
16
|
+
content: string;
|
|
17
|
+
contentType?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function normalizeSyncPath(filePath: string): string;
|
|
20
|
+
export declare function shouldSyncFile(filePath: string): boolean;
|
|
21
|
+
export declare function buildSyncManifest(rootDir: string, input: SyncManifestInput): Promise<SyncManifest>;
|
|
22
|
+
export declare function writeSyncManifest(rootDir: string, manifest: SyncManifest): Promise<void>;
|
|
23
|
+
export declare function readSyncManifest(rootDir: string): Promise<SyncManifest | null>;
|
|
24
|
+
export declare function collectSourceFiles(rootDir: string): Promise<SourceFile[]>;
|
|
25
|
+
export declare function writeSourceFiles(rootDir: string, files: SourceFile[]): Promise<void>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const SYNC_MANIFEST_FILE = '.funnelsgrove-sync.json';
|
|
5
|
+
const EXCLUDED_PATH_PARTS = new Set(['node_modules', '.next', 'out']);
|
|
6
|
+
export function normalizeSyncPath(filePath) {
|
|
7
|
+
const normalized = path.posix.normalize(filePath.replaceAll('\\', '/'));
|
|
8
|
+
return normalized.replace(/^(\.\/|\/)+/, '');
|
|
9
|
+
}
|
|
10
|
+
function assertSafeSyncPath(filePath) {
|
|
11
|
+
const normalized = normalizeSyncPath(filePath);
|
|
12
|
+
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
13
|
+
throw new Error(`Unsafe sync path "${filePath}"`);
|
|
14
|
+
}
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
export function shouldSyncFile(filePath) {
|
|
18
|
+
const normalized = normalizeSyncPath(filePath);
|
|
19
|
+
const parts = normalized.split('/');
|
|
20
|
+
const fileName = parts.at(-1) ?? '';
|
|
21
|
+
if (parts.some((part) => EXCLUDED_PATH_PARTS.has(part))) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (fileName === SYNC_MANIFEST_FILE) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (fileName === '.env' || (fileName.startsWith('.env.') && fileName !== '.env.example')) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
export async function buildSyncManifest(rootDir, input) {
|
|
33
|
+
const files = await collectSyncFiles(rootDir, rootDir);
|
|
34
|
+
return {
|
|
35
|
+
version: 1,
|
|
36
|
+
workspaceId: input.workspaceId,
|
|
37
|
+
funnelId: input.funnelId,
|
|
38
|
+
draftVersionId: input.draftVersionId,
|
|
39
|
+
files: files.sort((left, right) => left.path.localeCompare(right.path)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function writeSyncManifest(rootDir, manifest) {
|
|
43
|
+
await writeFile(path.join(rootDir, SYNC_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
export async function readSyncManifest(rootDir) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(await readFile(path.join(rootDir, SYNC_MANIFEST_FILE), 'utf8'));
|
|
48
|
+
if (raw.version !== 1 ||
|
|
49
|
+
typeof raw.workspaceId !== 'string' ||
|
|
50
|
+
typeof raw.funnelId !== 'string' ||
|
|
51
|
+
typeof raw.draftVersionId !== 'string' ||
|
|
52
|
+
!Array.isArray(raw.files)) {
|
|
53
|
+
throw new Error(`${SYNC_MANIFEST_FILE} is invalid`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
workspaceId: raw.workspaceId,
|
|
58
|
+
funnelId: raw.funnelId,
|
|
59
|
+
draftVersionId: raw.draftVersionId,
|
|
60
|
+
files: raw.files.map((file) => ({
|
|
61
|
+
path: String(file.path || ''),
|
|
62
|
+
hash: String(file.hash || ''),
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function collectSourceFiles(rootDir) {
|
|
74
|
+
const manifest = await buildSyncManifest(rootDir, {
|
|
75
|
+
workspaceId: '',
|
|
76
|
+
funnelId: '',
|
|
77
|
+
draftVersionId: '',
|
|
78
|
+
});
|
|
79
|
+
const files = await Promise.all(manifest.files.map(async (file) => {
|
|
80
|
+
const absolutePath = path.join(rootDir, assertSafeSyncPath(file.path));
|
|
81
|
+
const imageContentType = inferImageContentType(file.path);
|
|
82
|
+
const buffer = await readFile(absolutePath);
|
|
83
|
+
return {
|
|
84
|
+
path: file.path,
|
|
85
|
+
content: imageContentType ? `data:${imageContentType};base64,${buffer.toString('base64')}` : buffer.toString('utf8'),
|
|
86
|
+
contentType: imageContentType || 'text/plain',
|
|
87
|
+
};
|
|
88
|
+
}));
|
|
89
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
90
|
+
}
|
|
91
|
+
export async function writeSourceFiles(rootDir, files) {
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
const relativePath = assertSafeSyncPath(file.path);
|
|
94
|
+
const absolutePath = path.join(rootDir, relativePath);
|
|
95
|
+
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
96
|
+
await writeFile(absolutePath, decodeDataUrl(file.content) || file.content, decodeDataUrl(file.content) ? undefined : 'utf8');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function collectSyncFiles(rootDir, dir) {
|
|
100
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
101
|
+
const files = [];
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const absolutePath = path.join(dir, entry.name);
|
|
104
|
+
const relativePath = normalizeSyncPath(path.relative(rootDir, absolutePath));
|
|
105
|
+
if (!shouldSyncFile(relativePath)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (entry.isDirectory()) {
|
|
109
|
+
files.push(...(await collectSyncFiles(rootDir, absolutePath)));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (entry.isFile() || (entry.isSymbolicLink() && (await stat(absolutePath)).isFile())) {
|
|
113
|
+
files.push({
|
|
114
|
+
path: relativePath,
|
|
115
|
+
hash: await hashFile(absolutePath),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
async function hashFile(filePath) {
|
|
122
|
+
return createHash('sha256').update(await readFile(filePath)).digest('hex');
|
|
123
|
+
}
|
|
124
|
+
function inferImageContentType(filePath) {
|
|
125
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
126
|
+
if (extension === '.png') {
|
|
127
|
+
return 'image/png';
|
|
128
|
+
}
|
|
129
|
+
if (extension === '.jpg' || extension === '.jpeg') {
|
|
130
|
+
return 'image/jpeg';
|
|
131
|
+
}
|
|
132
|
+
if (extension === '.webp') {
|
|
133
|
+
return 'image/webp';
|
|
134
|
+
}
|
|
135
|
+
if (extension === '.gif') {
|
|
136
|
+
return 'image/gif';
|
|
137
|
+
}
|
|
138
|
+
if (extension === '.svg') {
|
|
139
|
+
return 'image/svg+xml';
|
|
140
|
+
}
|
|
141
|
+
if (extension === '.avif') {
|
|
142
|
+
return 'image/avif';
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function decodeDataUrl(content) {
|
|
147
|
+
const match = content.trim().match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s_-]+)$/);
|
|
148
|
+
if (!match) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return Buffer.from(match[2].replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
152
|
+
}
|
package/dist/reskin.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const reskinFunnel: (dir: string, appName: string) => Promise<string[]>;
|
|
2
|
+
export declare const KNOWN_TEMPLATE_SLUGS: readonly ["12min", "addmile", "astroline", "betterme-chair-yoga", "blesse", "claimbee-funnel", "headway-funnel", "keiki", "monivate", "promova"];
|
|
3
|
+
export type TemplateSlugs = typeof KNOWN_TEMPLATE_SLUGS;
|
|
4
|
+
export declare const isKnownTemplate: (slug: string) => boolean;
|
package/dist/reskin.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const toKebabCase = (value) => value
|
|
4
|
+
.trim()
|
|
5
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
6
|
+
.replace(/[\s_]+/g, '-')
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
const replaceInFile = async (filePath, replacements) => {
|
|
9
|
+
let content;
|
|
10
|
+
try {
|
|
11
|
+
content = await readFile(filePath, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
let changed = false;
|
|
17
|
+
for (const { pattern, replacement } of replacements) {
|
|
18
|
+
const next = content.replace(pattern, replacement);
|
|
19
|
+
if (next !== content) {
|
|
20
|
+
content = next;
|
|
21
|
+
changed = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (changed) {
|
|
25
|
+
await writeFile(filePath, content, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
return changed;
|
|
28
|
+
};
|
|
29
|
+
export const reskinFunnel = async (dir, appName) => {
|
|
30
|
+
const kebabName = toKebabCase(appName);
|
|
31
|
+
const updated = [];
|
|
32
|
+
// 1. Update package.json name
|
|
33
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
34
|
+
if (await replaceInFile(pkgPath, [
|
|
35
|
+
{ pattern: /"name":\s*"[^"]*"/, replacement: `"name": "${kebabName}-funnel"` },
|
|
36
|
+
])) {
|
|
37
|
+
updated.push('package.json');
|
|
38
|
+
}
|
|
39
|
+
// 2. Update funnel.manifest.ts inline meta (12min pattern)
|
|
40
|
+
const manifestPath = path.join(dir, 'src', 'config', 'funnel.manifest.ts');
|
|
41
|
+
if (await replaceInFile(manifestPath, [
|
|
42
|
+
{ pattern: /(title:\s*')[^']*(')/g, replacement: `$1${appName}$2` },
|
|
43
|
+
{ pattern: /(description:\s*')[^']*(')/g, replacement: `$1${appName} web funnel$2` },
|
|
44
|
+
])) {
|
|
45
|
+
updated.push('src/config/funnel.manifest.ts');
|
|
46
|
+
}
|
|
47
|
+
// 3. Update funnel.config.ts meta (most other funnels)
|
|
48
|
+
const configPath = path.join(dir, 'src', 'config', 'funnel.config.ts');
|
|
49
|
+
if (await replaceInFile(configPath, [
|
|
50
|
+
{ pattern: /(title:\s*')[^']*(')/g, replacement: `$1${appName}$2` },
|
|
51
|
+
{ pattern: /(description:\s*')[^']*(')/g, replacement: `$1${appName} web funnel$2` },
|
|
52
|
+
])) {
|
|
53
|
+
updated.push('src/config/funnel.config.ts');
|
|
54
|
+
}
|
|
55
|
+
// 4. Update HTML title if index.html exists
|
|
56
|
+
const indexHtmlPath = path.join(dir, 'index.html');
|
|
57
|
+
if (await replaceInFile(indexHtmlPath, [
|
|
58
|
+
{ pattern: /<title>[^<]*<\/title>/, replacement: `<title>${appName}</title>` },
|
|
59
|
+
])) {
|
|
60
|
+
updated.push('index.html');
|
|
61
|
+
}
|
|
62
|
+
return updated;
|
|
63
|
+
};
|
|
64
|
+
export const KNOWN_TEMPLATE_SLUGS = [
|
|
65
|
+
'12min',
|
|
66
|
+
'addmile',
|
|
67
|
+
'astroline',
|
|
68
|
+
'betterme-chair-yoga',
|
|
69
|
+
'blesse',
|
|
70
|
+
'claimbee-funnel',
|
|
71
|
+
'headway-funnel',
|
|
72
|
+
'keiki',
|
|
73
|
+
'monivate',
|
|
74
|
+
'promova',
|
|
75
|
+
];
|
|
76
|
+
export const isKnownTemplate = (slug) => KNOWN_TEMPLATE_SLUGS.includes(slug);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cp, mkdir, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const TEMPLATE_DOCS_DIR = 'template_docs';
|
|
4
|
+
export async function syncTemplateDocs(sourceDir, targetDir) {
|
|
5
|
+
const targetRoot = path.resolve(targetDir);
|
|
6
|
+
const copiedFiles = await listTemplateFiles(sourceDir, sourceDir);
|
|
7
|
+
await mkdir(targetRoot, { recursive: true });
|
|
8
|
+
await cp(sourceDir, targetRoot, {
|
|
9
|
+
recursive: true,
|
|
10
|
+
force: true,
|
|
11
|
+
});
|
|
12
|
+
return copiedFiles.sort((left, right) => left.localeCompare(right));
|
|
13
|
+
}
|
|
14
|
+
async function listTemplateFiles(rootDir, dir) {
|
|
15
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
16
|
+
const files = [];
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const absolutePath = path.join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
files.push(...(await listTemplateFiles(rootDir, absolutePath)));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (entry.isFile()) {
|
|
24
|
+
files.push(path.relative(rootDir, absolutePath).replaceAll(path.sep, '/'));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return files;
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@funnelsgrove/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fg": "dist/cli.js",
|
|
8
|
+
"funnelsgrove": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"template_docs"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "rm -rf dist && tsc",
|
|
20
|
+
"prepack": "npm run build",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^12.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.0.0",
|
|
28
|
+
"vitest": "^3.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Funnel Editing Guide
|
|
2
|
+
|
|
3
|
+
Use this folder as the local source-of-truth guide when editing a synced FunnelsGrove funnel.
|
|
4
|
+
|
|
5
|
+
## Workflow
|
|
6
|
+
|
|
7
|
+
1. Run `fg status` and confirm the active project and funnel.
|
|
8
|
+
2. Keep edits inside the synced funnel tree.
|
|
9
|
+
3. Run the funnel's local checks before syncing.
|
|
10
|
+
4. Run `fg sync up --message '<summary>'`.
|
|
11
|
+
5. Run `fg publish --env preview --message '<summary>'` and verify the preview.
|
|
12
|
+
6. Publish production only when explicitly requested.
|
|
13
|
+
|
|
14
|
+
## Topics
|
|
15
|
+
|
|
16
|
+
- [Editing a Step](docs/editing-step.md)
|
|
17
|
+
- [Editing Flow](docs/editing-flow.md)
|
|
18
|
+
- [Editor and Content](docs/editor-and-content.md)
|
|
19
|
+
- [Payment Plans and Discounts](docs/payment-plans-and-discounts.md)
|
|
20
|
+
- [SDK API Endpoints](docs/sdk-api-endpoints.md)
|
|
21
|
+
- [Analytics](docs/analytics.md)
|
|
22
|
+
- [A/B Experiments](docs/ab-experiments.md)
|
|
23
|
+
- [Theme](docs/theme.md)
|
|
24
|
+
- [Publishing and Versioning](docs/publishing-and-versioning.md)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# A/B Experiments
|
|
2
|
+
|
|
3
|
+
Experiments should isolate one decision and make results trustworthy.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Define the hypothesis before changing variants.
|
|
7
|
+
- Keep assignment stable for a user during the experiment.
|
|
8
|
+
- Change one major variable per experiment when possible.
|
|
9
|
+
- Make variant names clear and durable.
|
|
10
|
+
- Do not remove the control until results are reviewed and the rollout is approved.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Analytics
|
|
2
|
+
|
|
3
|
+
Analytics should describe meaningful user behavior, not implementation details.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Keep event names stable unless a migration is planned.
|
|
7
|
+
- Track step views, key choices, checkout starts, purchases, and major errors.
|
|
8
|
+
- Include only dimensions needed for analysis.
|
|
9
|
+
- Do not send personal data or secrets.
|
|
10
|
+
- When changing flow or offers, verify analytics still fires on the new path.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Editing Flow
|
|
2
|
+
|
|
3
|
+
The flow controls how users move between steps. Treat flow edits as product logic changes, not only UI changes.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Keep the happy path short and obvious.
|
|
7
|
+
- Make every conditional branch easy to explain.
|
|
8
|
+
- Preserve back/forward behavior when the funnel supports it.
|
|
9
|
+
- Confirm that skipped steps do not leave required state missing.
|
|
10
|
+
- Update analytics events when a branch, gate, or conversion path changes.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Editing a Step
|
|
2
|
+
|
|
3
|
+
A step is one screen or decision point in the funnel. Keep step changes focused: copy, layout, input behavior, validation, and navigation for that screen.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Preserve existing step ids and route keys unless the change is explicitly a migration.
|
|
7
|
+
- Reuse existing UI components, tokens, and helper functions.
|
|
8
|
+
- Keep step state local unless another step needs it.
|
|
9
|
+
- Make button labels, validation messages, and analytics events match the changed user action.
|
|
10
|
+
- Test the edited step directly and also test the step before and after it.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Editor and Content
|
|
2
|
+
|
|
3
|
+
Content should be editable without making the runtime fragile. Prefer existing content structures over hardcoded one-off strings.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Keep user-facing copy in the same content pattern the funnel already uses.
|
|
7
|
+
- Use plain, direct copy for actions and error states.
|
|
8
|
+
- Keep legal, pricing, and disclaimer text exact.
|
|
9
|
+
- Do not add unused content fields.
|
|
10
|
+
- If content is repeated across steps, centralize it only when that matches the current funnel pattern.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Payment Plans and Discounts
|
|
2
|
+
|
|
3
|
+
Payment changes affect conversion, billing, and support. Keep them explicit and easy to review.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Treat plan ids, price ids, trial lengths, and discount codes as stable contracts.
|
|
7
|
+
- Do not rename or remove a live plan unless the rollout calls for it.
|
|
8
|
+
- Keep displayed price, billing period, trial text, and checkout payload in sync.
|
|
9
|
+
- Test free trials, discounted offers, and default paid plans separately.
|
|
10
|
+
- Update paywall copy and analytics when an offer changes.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Publishing and Versioning
|
|
2
|
+
|
|
3
|
+
Syncing creates drafts. Publishing makes a draft available to users.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Use clear sync and publish messages so versions are reviewable.
|
|
7
|
+
- Publish preview first and verify the returned URL.
|
|
8
|
+
- Production publish requires an explicit production request and target domain.
|
|
9
|
+
- Keep `.funnelsgrove-sync.json` in the local tree; it records the synced draft.
|
|
10
|
+
- If preview verification fails, fix locally, sync up again, and publish a new preview.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SDK API Endpoints
|
|
2
|
+
|
|
3
|
+
SDK calls should be thin, typed boundaries between the funnel and FunnelsGrove services.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Reuse existing SDK helpers instead of calling raw endpoints directly.
|
|
7
|
+
- Keep request payloads minimal and typed.
|
|
8
|
+
- Handle loading, retryable errors, and final failure states in the UI.
|
|
9
|
+
- Avoid logging personal data, payment data, or secrets.
|
|
10
|
+
- When an endpoint contract changes, update the caller, tests, and docs together.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Theme
|
|
2
|
+
|
|
3
|
+
Theme changes should make the funnel feel consistent, not create one-off styling.
|
|
4
|
+
|
|
5
|
+
Principles:
|
|
6
|
+
- Use existing tokens for colors, spacing, typography, and radius.
|
|
7
|
+
- Keep contrast readable across all important states.
|
|
8
|
+
- Check mobile and desktop layouts after theme edits.
|
|
9
|
+
- Avoid changing component internals for a theme-only request.
|
|
10
|
+
- Update shared theme values when the same style appears in multiple places.
|