@funnelsgrove/cli 0.1.1 → 0.1.3

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 CHANGED
@@ -4,24 +4,24 @@ Install:
4
4
 
5
5
  ```bash
6
6
  npm install -g @funnelsgrove/cli
7
- fg login
7
+ fgrove login
8
8
  ```
9
9
 
10
10
  Set the active project and funnel:
11
11
 
12
12
  ```bash
13
- fg use --project claimbee --funnel claimbee-ios
14
- fg status
13
+ fgrove use --project claimbee --funnel claimbee-ios
14
+ fgrove status
15
15
  ```
16
16
 
17
17
  Common workflow:
18
18
 
19
19
  ```bash
20
- fg sync down --dir ./claimbee-ios
20
+ fgrove sync down --dir ./claimbee-ios
21
21
  cd ./claimbee-ios
22
- fg docs
23
- fg sync up --message 'Update funnel copy'
24
- fg publish --env preview
22
+ fgrove docs
23
+ fgrove sync up --message 'Update funnel copy'
24
+ fgrove publish --env preview
25
25
  ```
26
26
 
27
27
  The package also keeps the longer `funnelsgrove` command as a compatibility alias.
package/dist/apiClient.js CHANGED
@@ -46,6 +46,16 @@ export async function callTrpcProcedure(input) {
46
46
  ? `${baseUrl}?input=${encodeURIComponent(JSON.stringify(input.input))}`
47
47
  : baseUrl;
48
48
  const response = await fetchFn(url, init);
49
- const json = await response.json();
49
+ const responseText = await response.text();
50
+ let json;
51
+ try {
52
+ json = JSON.parse(responseText);
53
+ }
54
+ catch {
55
+ const contentType = response.headers.get('content-type') || 'unknown content type';
56
+ const snippet = responseText.replace(/\s+/g, ' ').trim().slice(0, 500);
57
+ const details = snippet ? `: ${snippet}` : '';
58
+ throw new Error(`FunnelsGrove API returned ${response.status} ${response.statusText || 'Unknown status'} with ${contentType}${details}`);
59
+ }
50
60
  return parseTrpcJsonResponse(json);
51
61
  }
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
3
4
  import { createInterface } from 'node:readline/promises';
4
5
  import path from 'node:path';
@@ -6,10 +7,22 @@ import { fileURLToPath } from 'node:url';
6
7
  import { Command } from 'commander';
7
8
  import { callTrpcProcedure } from './apiClient.js';
8
9
  import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
9
- import { buildSyncManifest, collectSourceFiles, readSyncManifest, writeSourceFiles, writeSyncManifest, } from './localSync.js';
10
+ import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, readSyncManifest, writeSourceFiles, writeSyncManifest, } from './localSync.js';
10
11
  import { isKnownTemplate, KNOWN_TEMPLATE_SLUGS, reskinFunnel } from './reskin.js';
11
12
  import { syncTemplateDocs, TEMPLATE_DOCS_DIR } from './templateDocs.js';
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const readCliVersion = () => {
15
+ try {
16
+ const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
17
+ if (typeof packageJson.version === 'string' && packageJson.version.trim()) {
18
+ return packageJson.version;
19
+ }
20
+ }
21
+ catch {
22
+ // Fall through to the packaged fallback below.
23
+ }
24
+ return '0.1.3';
25
+ };
13
26
  const toKebabCase = (value) => value
14
27
  .trim()
15
28
  .replace(/([a-z])([A-Z])/g, '$1-$2')
@@ -30,7 +43,7 @@ const getConfigPath = () => {
30
43
  const readAuthToken = async () => {
31
44
  const token = await loadAuthToken(getConfigPath());
32
45
  if (!token) {
33
- throw new Error('Not logged in. Run `fg login` first.');
46
+ throw new Error('Not logged in. Run `fgrove login` first.');
34
47
  }
35
48
  return token;
36
49
  };
@@ -173,7 +186,7 @@ const resolveSyncTarget = async (input) => {
173
186
  ? await resolveFunnelId(input.token, workspaceId, input.funnel)
174
187
  : manifest?.funnelId || active?.funnelId;
175
188
  if (!funnelId) {
176
- throw new Error('No active funnel. Run `fg use --funnel <id-or-slug>` or pass `--funnel`.');
189
+ throw new Error('No active funnel. Run `fgrove use --funnel <id-or-slug>` or pass `--funnel`.');
177
190
  }
178
191
  return {
179
192
  workspaceId,
@@ -203,23 +216,23 @@ const printRows = (rows, columns) => {
203
216
  const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
204
217
  const program = new Command();
205
218
  program
206
- .name('fg')
219
+ .name('fgrove')
207
220
  .description('FunnelsGrove CLI for editing, syncing, and publishing funnels')
208
- .version('0.1.0')
221
+ .version(readCliVersion())
209
222
  .option('--api-url <url>', 'FunnelsGrove tRPC API URL', process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL)
210
223
  .option('--config <path>', 'Auth config path', process.env.FUNNELSGROVE_CONFIG || getDefaultAuthConfigPath());
211
224
  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',
225
+ 'fgrove login',
226
+ 'fgrove use --project claimbee --funnel claimbee-ios',
227
+ 'fgrove sync down --dir ./claimbee-ios',
228
+ 'fgrove publish --env preview',
216
229
  ]);
217
230
  addExamples(program
218
231
  .command('login')
219
232
  .description('Authorize this CLI with your FunnelsGrove account')
220
233
  .option('--code <code>', '8 digit code from the authorization page'), [
221
- 'fg login',
222
- 'fg login --api-url http://localhost:3001/trpc',
234
+ 'fgrove login',
235
+ 'fgrove login --api-url http://localhost:3001/trpc',
223
236
  ])
224
237
  .action(async (options) => {
225
238
  const request = await callApi({
@@ -244,7 +257,7 @@ addExamples(program
244
257
  addExamples(program
245
258
  .command('whoami')
246
259
  .description('Show the current authenticated user'), [
247
- 'fg whoami',
260
+ 'fgrove whoami',
248
261
  ])
249
262
  .action(async () => {
250
263
  const token = await readAuthToken();
@@ -258,9 +271,9 @@ addExamples(program
258
271
  .option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
259
272
  .option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
260
273
  .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',
274
+ 'fgrove use --project claimbee --funnel claimbee-ios',
275
+ 'fgrove use --workspace acme --project claimbee',
276
+ 'fgrove use --clear',
264
277
  ])
265
278
  .action(async (options) => {
266
279
  if (options.clear) {
@@ -292,8 +305,8 @@ addExamples(program
292
305
  .command('status')
293
306
  .description('Show the authenticated user and active CLI context')
294
307
  .option('--dir <path>', 'Local source directory for reading sync manifest', '.'), [
295
- 'fg status',
296
- 'fg status --dir ./claimbee-ios',
308
+ 'fgrove status',
309
+ 'fgrove status --dir ./claimbee-ios',
297
310
  ])
298
311
  .action(async (options) => {
299
312
  const token = await readAuthToken();
@@ -315,15 +328,15 @@ addExamples(program
315
328
  }
316
329
  });
317
330
  const projectsCommand = addExamples(program.command('projects').description('Manage projects'), [
318
- 'fg projects list',
319
- 'fg projects list --workspace acme',
331
+ 'fgrove projects list',
332
+ 'fgrove projects list --workspace acme',
320
333
  ]);
321
334
  addExamples(projectsCommand
322
335
  .command('list')
323
336
  .description('List projects in the active or provided workspace')
324
337
  .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name'), [
325
- 'fg projects list',
326
- 'fg projects list --workspace acme',
338
+ 'fgrove projects list',
339
+ 'fgrove projects list --workspace acme',
327
340
  ])
328
341
  .action(async (options) => {
329
342
  const token = await readAuthToken();
@@ -332,15 +345,15 @@ addExamples(projectsCommand
332
345
  printRows(projects, ['id', 'name', 'slug']);
333
346
  });
334
347
  const funnelsCommand = addExamples(program.command('funnels').description('Manage funnels'), [
335
- 'fg funnels list',
336
- 'fg funnels clone --funnel claimbee --name claimbee-ios',
348
+ 'fgrove funnels list',
349
+ 'fgrove funnels clone --funnel claimbee --name claimbee-ios',
337
350
  ]);
338
351
  addExamples(funnelsCommand
339
352
  .command('list')
340
353
  .description('List funnels in a workspace')
341
354
  .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name'), [
342
- 'fg funnels list',
343
- 'fg funnels list --workspace acme',
355
+ 'fgrove funnels list',
356
+ 'fgrove funnels list --workspace acme',
344
357
  ])
345
358
  .action(async (options) => {
346
359
  const token = await readAuthToken();
@@ -354,8 +367,8 @@ addExamples(funnelsCommand
354
367
  .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
355
368
  .requiredOption('--funnel <id-or-slug>', 'Source funnel id or slug')
356
369
  .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',
370
+ 'fgrove funnels clone --funnel claimbee --name claimbee-ios',
371
+ 'fgrove funnels clone --workspace acme --funnel claimbee --name claimbee-ios',
359
372
  ])
360
373
  .action(async (options) => {
361
374
  const token = await readAuthToken();
@@ -374,8 +387,8 @@ addExamples(funnelsCommand
374
387
  console.log(`${result.funnel.id}\t${result.funnel.name}\t${result.funnel.slug}`);
375
388
  });
376
389
  const syncCommand = addExamples(program.command('sync').description('Sync funnel source'), [
377
- 'fg sync down --dir ./claimbee-ios',
378
- 'fg sync up --message "Update copy"',
390
+ 'fgrove sync down --dir ./claimbee-ios',
391
+ 'fgrove sync up --message "Update copy"',
379
392
  ]);
380
393
  addExamples(syncCommand
381
394
  .command('down')
@@ -383,8 +396,8 @@ addExamples(syncCommand
383
396
  .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
384
397
  .option('--funnel <id-or-slug>', 'Funnel id or slug')
385
398
  .requiredOption('--dir <path>', 'Local target directory'), [
386
- 'fg sync down --dir ./claimbee-ios',
387
- 'fg sync down --funnel claimbee-ios --dir ./claimbee-ios',
399
+ 'fgrove sync down --dir ./claimbee-ios',
400
+ 'fgrove sync down --funnel claimbee-ios --dir ./claimbee-ios',
388
401
  ])
389
402
  .action(async (options) => {
390
403
  const token = await readAuthToken();
@@ -423,8 +436,8 @@ addExamples(syncCommand
423
436
  .option('--funnel <id-or-slug>', 'Funnel id or slug')
424
437
  .option('--dir <path>', 'Local source directory', '.')
425
438
  .option('--message <message>', 'Draft version message'), [
426
- 'fg sync up --message "Update hero copy"',
427
- 'fg sync up --dir ./claimbee-ios --message "Update paywall"',
439
+ 'fgrove sync up --message "Update hero copy"',
440
+ 'fgrove sync up --dir ./claimbee-ios --message "Update paywall"',
428
441
  ])
429
442
  .action(async (options) => {
430
443
  const token = await readAuthToken();
@@ -434,24 +447,60 @@ addExamples(syncCommand
434
447
  funnel: options.funnel,
435
448
  dir: options.dir,
436
449
  });
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
- });
450
+ const changes = target.manifest
451
+ ? await collectChangedSourceFiles(target.sourceDir, target.manifest)
452
+ : null;
453
+ if (changes && changes.files.length === 0 && changes.deletedPaths.length === 0) {
454
+ console.log('No local changes to sync.');
455
+ return;
456
+ }
457
+ let result = null;
458
+ let syncedFileCount = 0;
459
+ let deletedFileCount = 0;
460
+ if (changes) {
461
+ const batches = chunkChangedSourceFiles(changes);
462
+ for (const batch of batches) {
463
+ result = await callApi({
464
+ path: 'funnels.patchSource',
465
+ type: 'mutation',
466
+ token,
467
+ data: {
468
+ workspaceId: target.workspaceId,
469
+ funnelId: target.funnelId,
470
+ message: options.message,
471
+ files: batch.files,
472
+ deletedPaths: batch.deletedPaths,
473
+ },
474
+ });
475
+ syncedFileCount += result.syncedFiles.length;
476
+ deletedFileCount += result.deletedFiles?.length || 0;
477
+ }
478
+ }
479
+ else {
480
+ result = await callApi({
481
+ path: 'funnels.importSource',
482
+ type: 'mutation',
483
+ token,
484
+ data: {
485
+ workspaceId: target.workspaceId,
486
+ funnelId: target.funnelId,
487
+ message: options.message,
488
+ files: await collectSourceFiles(target.sourceDir),
489
+ },
490
+ });
491
+ syncedFileCount = result.syncedFiles.length;
492
+ deletedFileCount = result.deletedFiles?.length || 0;
493
+ }
494
+ if (!result) {
495
+ throw new Error('No sync result returned.');
496
+ }
449
497
  await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
450
498
  workspaceId: target.workspaceId,
451
499
  funnelId: target.funnelId,
452
500
  draftVersionId: result.versionId,
453
501
  }));
454
- console.log(`Synced ${result.syncedFiles.length} files to draft v${result.versionSeq} (${result.versionId})`);
502
+ const deletedSummary = deletedFileCount > 0 ? ` and removed ${deletedFileCount} files` : '';
503
+ console.log(`Synced ${syncedFileCount} files${deletedSummary} to draft v${result.versionSeq} (${result.versionId})`);
455
504
  });
456
505
  addExamples(program
457
506
  .command('publish')
@@ -462,8 +511,8 @@ addExamples(program
462
511
  .option('--env <preview-or-production>', 'Publish environment', 'preview')
463
512
  .option('--domain <domain>', 'Production custom domain')
464
513
  .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"',
514
+ 'fgrove publish --env preview --message "Preview copy updates"',
515
+ 'fgrove publish --env production --domain claimbee.example.com --message "Launch"',
467
516
  ])
468
517
  .action(async (options) => {
469
518
  const token = await readAuthToken();
@@ -497,8 +546,8 @@ addExamples(program
497
546
  .command('docs')
498
547
  .description('Install or refresh local funnel editing docs in a funnel directory')
499
548
  .option('--dir <path>', 'Local funnel directory', '.'), [
500
- 'fg docs',
501
- 'fg docs --dir ./claimbee-ios',
549
+ 'fgrove docs',
550
+ 'fgrove docs --dir ./claimbee-ios',
502
551
  ])
503
552
  .action(async (options) => {
504
553
  const targetDir = path.resolve(process.cwd(), options.dir);
@@ -511,8 +560,8 @@ addExamples(program
511
560
  .requiredOption('--from <template>', 'Template slug from rag-catalog')
512
561
  .requiredOption('--app <name>', 'Your app name')
513
562
  .option('--output <dir>', 'Output directory'), [
514
- 'fg create --from headway-funnel --app ClaimBee',
515
- 'fg create --from promova --app ClaimBee --output ./claimbee-funnel',
563
+ 'fgrove create --from headway-funnel --app ClaimBee',
564
+ 'fgrove create --from promova --app ClaimBee --output ./claimbee-funnel',
516
565
  ])
517
566
  .action(async (options) => {
518
567
  const { from: templateSlug, app: appName, output } = options;
@@ -16,10 +16,22 @@ export type SourceFile = {
16
16
  content: string;
17
17
  contentType?: string;
18
18
  };
19
+ export type ChangedSourceFiles = {
20
+ currentManifest: SyncManifest;
21
+ deletedPaths: string[];
22
+ files: SourceFile[];
23
+ };
24
+ export type SourceFilePatchBatch = {
25
+ deletedPaths: string[];
26
+ files: SourceFile[];
27
+ };
28
+ export declare const DEFAULT_PATCH_BATCH_CONTENT_CHARS = 12000000;
19
29
  export declare function normalizeSyncPath(filePath: string): string;
20
30
  export declare function shouldSyncFile(filePath: string): boolean;
21
31
  export declare function buildSyncManifest(rootDir: string, input: SyncManifestInput): Promise<SyncManifest>;
22
32
  export declare function writeSyncManifest(rootDir: string, manifest: SyncManifest): Promise<void>;
23
33
  export declare function readSyncManifest(rootDir: string): Promise<SyncManifest | null>;
24
34
  export declare function collectSourceFiles(rootDir: string): Promise<SourceFile[]>;
35
+ export declare function collectChangedSourceFiles(rootDir: string, previousManifest: SyncManifest): Promise<ChangedSourceFiles>;
36
+ export declare function chunkChangedSourceFiles(changes: Pick<ChangedSourceFiles, 'deletedPaths' | 'files'>, maxContentChars?: number): SourceFilePatchBatch[];
25
37
  export declare function writeSourceFiles(rootDir: string, files: SourceFile[]): Promise<void>;
package/dist/localSync.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  export const SYNC_MANIFEST_FILE = '.funnelsgrove-sync.json';
5
+ export const DEFAULT_PATCH_BATCH_CONTENT_CHARS = 12_000_000;
5
6
  const EXCLUDED_PATH_PARTS = new Set(['node_modules', '.next', 'out']);
6
7
  export function normalizeSyncPath(filePath) {
7
8
  const normalized = path.posix.normalize(filePath.replaceAll('\\', '/'));
@@ -76,7 +77,58 @@ export async function collectSourceFiles(rootDir) {
76
77
  funnelId: '',
77
78
  draftVersionId: '',
78
79
  });
79
- const files = await Promise.all(manifest.files.map(async (file) => {
80
+ return readSourceFiles(rootDir, manifest.files);
81
+ }
82
+ export async function collectChangedSourceFiles(rootDir, previousManifest) {
83
+ const currentManifest = await buildSyncManifest(rootDir, {
84
+ workspaceId: previousManifest.workspaceId,
85
+ funnelId: previousManifest.funnelId,
86
+ draftVersionId: previousManifest.draftVersionId,
87
+ });
88
+ const previousHashByPath = new Map(previousManifest.files.map((file) => [file.path, file.hash]));
89
+ const currentHashByPath = new Map(currentManifest.files.map((file) => [file.path, file.hash]));
90
+ const changedManifestFiles = currentManifest.files.filter((file) => previousHashByPath.get(file.path) !== file.hash);
91
+ const deletedPaths = previousManifest.files
92
+ .filter((file) => !currentHashByPath.has(file.path))
93
+ .map((file) => file.path)
94
+ .sort((left, right) => left.localeCompare(right));
95
+ return {
96
+ currentManifest,
97
+ deletedPaths,
98
+ files: await readSourceFiles(rootDir, changedManifestFiles),
99
+ };
100
+ }
101
+ export function chunkChangedSourceFiles(changes, maxContentChars = DEFAULT_PATCH_BATCH_CONTENT_CHARS) {
102
+ const batches = [];
103
+ let currentBatch = {
104
+ deletedPaths: changes.deletedPaths,
105
+ files: [],
106
+ };
107
+ let currentContentChars = 0;
108
+ const pushCurrentBatch = () => {
109
+ if (currentBatch.files.length === 0 && currentBatch.deletedPaths.length === 0) {
110
+ return;
111
+ }
112
+ batches.push(currentBatch);
113
+ currentBatch = {
114
+ deletedPaths: [],
115
+ files: [],
116
+ };
117
+ currentContentChars = 0;
118
+ };
119
+ for (const file of changes.files) {
120
+ if (currentBatch.files.length > 0 &&
121
+ currentContentChars + file.content.length > maxContentChars) {
122
+ pushCurrentBatch();
123
+ }
124
+ currentBatch.files.push(file);
125
+ currentContentChars += file.content.length;
126
+ }
127
+ pushCurrentBatch();
128
+ return batches;
129
+ }
130
+ async function readSourceFiles(rootDir, manifestFiles) {
131
+ const files = await Promise.all(manifestFiles.map(async (file) => {
80
132
  const absolutePath = path.join(rootDir, assertSafeSyncPath(file.path));
81
133
  const imageContentType = inferImageContentType(file.path);
82
134
  const buffer = await readFile(absolutePath);
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
5
5
  "type": "module",
6
6
  "bin": {
7
- "fg": "dist/cli.js",
7
+ "fgrove": "dist/cli.js",
8
8
  "funnelsgrove": "dist/cli.js"
9
9
  },
10
10
  "files": [
@@ -16,7 +16,7 @@
16
16
  "access": "public"
17
17
  },
18
18
  "scripts": {
19
- "build": "rm -rf dist && tsc",
19
+ "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
20
20
  "prepack": "npm run build",
21
21
  "test": "vitest run"
22
22
  },
@@ -4,11 +4,11 @@ Use this folder as the local source-of-truth guide when editing a synced Funnels
4
4
 
5
5
  ## Workflow
6
6
 
7
- 1. Run `fg status` and confirm the active project and funnel.
7
+ 1. Run `fgrove status` and confirm the active project and funnel.
8
8
  2. Keep edits inside the synced funnel tree.
9
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.
10
+ 4. Run `fgrove sync up --message '<summary>'`.
11
+ 5. Run `fgrove publish --env preview --message '<summary>'` and verify the preview.
12
12
  6. Publish production only when explicitly requested.
13
13
 
14
14
  ## Topics