@funnelsgrove/cli 0.1.2 → 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/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')
@@ -205,7 +218,7 @@ const program = new Command();
205
218
  program
206
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, [
@@ -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')
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.2",
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": {
@@ -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
  },