@funnelsgrove/cli 0.1.142 → 0.1.146

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
@@ -7,6 +7,17 @@ npm install -g @funnelsgrove/cli
7
7
  fgrove login
8
8
  ```
9
9
 
10
+ Update every global installation visible on `PATH` (including separate NVM and
11
+ Homebrew prefixes) with one command:
12
+
13
+ ```bash
14
+ fgrove update
15
+ ```
16
+
17
+ Use `fgrove update --dry-run` to inspect the installations first. The updater
18
+ runs each installation's sibling `npm`, so duplicate global installs cannot
19
+ silently remain on different CLI versions.
20
+
10
21
  Sync a funnel into its own local folder:
11
22
 
12
23
  ```bash
@@ -29,14 +40,27 @@ fgrove publish --env preview
29
40
 
30
41
  Inside a synced folder, `fgrove` reads `.funnelsgrove-sync.json` first, so you do not need to run `fgrove use` when switching between local funnel directories. Use `fgrove use` only when you want a global fallback context for commands outside a synced folder.
31
42
 
43
+ `fgrove funnels clone` copies the draft and the funnel's offer-set mapping. The
44
+ clone reuses the same project-level offer sets and payment profiles; custom-domain
45
+ configuration is not copied into the new preview.
46
+
32
47
  `fgrove sync down` refuses to overwrite local changes in an existing synced
33
- folder. If `fgrove sync up` says the remote draft changed since your local sync,
34
- run `fgrove sync rebase`: it performs a three-way merge from the recorded base,
35
- preserves CLI-managed files, and leaves the working tree untouched when it finds
36
- conflicts. Review the merged diff, rerun `fgrove validate`, then sync up again.
48
+ folder. `fgrove sync up` sends only changed and deleted paths in one atomic
49
+ draft patch. Disjoint concurrent edits are rebased automatically; if the same
50
+ path changed remotely, run `fgrove sync rebase`: it performs a three-way merge
51
+ from the recorded base, preserves CLI-managed files, and leaves the working tree
52
+ untouched when it finds conflicts. Review the merged diff, rerun `fgrove
53
+ validate`, then sync up again.
37
54
  `sync down --force` is the explicit discard path and writes an automatic source
38
55
  backup under `.funnelsgrove/rebase-backups/` before replacing hosted paths.
39
56
 
57
+ Changed images below `public/` are uploaded directly to asset storage, then the
58
+ small immutable asset references are included in the atomic patch. Sync retries
59
+ use a deterministic durable idempotency key, so a lost response cannot create a
60
+ second draft version. The CLI automatically falls back to candidate sync when
61
+ the compatible deployed API lacks the fast procedures or a change set exceeds
62
+ patch limits.
63
+
40
64
  `fgrove sync up` is for funnels without GitHub source sync. When GitHub is
41
65
  connected, commit and push source changes with normal git, then run `fgrove
42
66
  github pull` to sync GitHub into the hosted draft. Do not run `fgrove sync up`
@@ -78,6 +102,8 @@ fgrove publish --funnel claimbee-general --env preview
78
102
  The GitHub commands use the FunnelsGrove API only. `fgrove github pull` pulls
79
103
  the repository into the hosted draft after you push normal git commits.
80
104
  `fgrove publish` waits for the current draft to reach GitHub before publishing.
105
+ It writes validation, GitHub sync, queue, build, image optimization, and upload
106
+ progress to stderr while keeping the final URL/version row alone on stdout.
81
107
  If the exact job is still pending or running when that wait times out, publish
82
108
  continues with a warning on stderr and asks you to check the eventual result
83
109
  with `fgrove github status`. Terminal job failures, missing jobs, and API
@@ -18,6 +18,47 @@ export type FunnelSourceCandidateApiContext = {
18
18
  fetchFn?: FetchFn;
19
19
  signal?: AbortSignal;
20
20
  };
21
+ export type DraftPatchFile = {
22
+ path: string;
23
+ content: string;
24
+ contentType?: string;
25
+ };
26
+ export type ApplyDraftPatchInput = {
27
+ workspaceId: string;
28
+ funnelId: string;
29
+ baseDraftVersionId: string | null;
30
+ idempotencyKey: string;
31
+ message?: string;
32
+ changedFiles: DraftPatchFile[];
33
+ deletedPaths: string[];
34
+ };
35
+ export type ApplyDraftPatchResult = {
36
+ funnelId: string;
37
+ previousDraftVersionId: string | null;
38
+ draftVersionId: string;
39
+ draftVersionSeq: number;
40
+ rebasedFromVersionId: string | null;
41
+ changedPaths: string[];
42
+ deletedPaths: string[];
43
+ changedContentBytes: number;
44
+ idempotent: boolean;
45
+ };
46
+ export type DirectAssetUploadDescription = {
47
+ workspaceId: string;
48
+ funnelId: string;
49
+ path: string;
50
+ contentType: string;
51
+ sizeBytes: number;
52
+ sha256: string;
53
+ };
54
+ export type PreparedDirectAssetUpload = Omit<DirectAssetUploadDescription, 'workspaceId' | 'funnelId'> & {
55
+ uploadKey: string;
56
+ uploadUrl: string;
57
+ uploadHeaders: Record<string, string>;
58
+ };
59
+ export type FinalizeDirectAssetUploadInput = DirectAssetUploadDescription & {
60
+ uploadKey: string;
61
+ };
21
62
  export type CreateFunnelExperimentFromSpecInput = {
22
63
  workspaceId: string;
23
64
  funnelId: string;
@@ -96,6 +137,12 @@ export declare class FunnelContractApiError extends Error {
96
137
  readonly funnelContract: FunnelContractApiErrorData;
97
138
  constructor(message: string, funnelContract: FunnelContractApiErrorData);
98
139
  }
140
+ export declare class TrpcApiError extends Error {
141
+ readonly code: string | null;
142
+ readonly httpStatus: number | null;
143
+ constructor(message: string, code: string | null, httpStatus: number | null);
144
+ }
145
+ export declare const isMissingTrpcProcedureError: (error: unknown) => boolean;
99
146
  export declare const parseFunnelContractApiErrorData: (value: unknown) => FunnelContractApiErrorData | null;
100
147
  export declare function parseTrpcJsonResponse<T = unknown>(response: unknown): T;
101
148
  export declare function callTrpcProcedure<T = unknown>(input: TrpcCallInput): Promise<T>;
@@ -103,5 +150,8 @@ export declare const beginFunnelSourceCandidate: (context: FunnelSourceCandidate
103
150
  export declare const stageFunnelSourceCandidate: (context: FunnelSourceCandidateApiContext, input: StageFunnelSourceCandidateInput) => Promise<StageFunnelSourceCandidateResult>;
104
151
  export declare const finalizeFunnelSourceCandidate: (context: FunnelSourceCandidateApiContext, input: FinalizeFunnelSourceCandidateInput) => Promise<FinalizeFunnelSourceCandidateResult>;
105
152
  export declare const abortFunnelSourceCandidate: (context: FunnelSourceCandidateApiContext, input: FinishFunnelSourceCandidateInput) => Promise<FunnelSourceCandidateSummary>;
153
+ export declare const applyFunnelDraftPatch: (context: FunnelSourceCandidateApiContext, input: ApplyDraftPatchInput) => Promise<ApplyDraftPatchResult>;
154
+ export declare const prepareFunnelDirectAssetUpload: (context: FunnelSourceCandidateApiContext, input: DirectAssetUploadDescription) => Promise<PreparedDirectAssetUpload>;
155
+ export declare const finalizeFunnelDirectAssetUpload: (context: FunnelSourceCandidateApiContext, input: FinalizeDirectAssetUploadInput) => Promise<DraftPatchFile>;
106
156
  export declare const createFunnelExperimentFromSpec: (context: FunnelSourceCandidateApiContext, input: CreateFunnelExperimentFromSpecInput) => Promise<ExperimentCreateResponse>;
107
157
  export {};
package/dist/apiClient.js CHANGED
@@ -48,6 +48,19 @@ export class FunnelContractApiError extends Error {
48
48
  this.funnelContract = funnelContract;
49
49
  }
50
50
  }
51
+ export class TrpcApiError extends Error {
52
+ code;
53
+ httpStatus;
54
+ constructor(message, code, httpStatus) {
55
+ super(`tRPC request failed: ${message}`);
56
+ this.code = code;
57
+ this.httpStatus = httpStatus;
58
+ this.name = 'TrpcApiError';
59
+ }
60
+ }
61
+ export const isMissingTrpcProcedureError = (error) => (error instanceof TrpcApiError
62
+ && error.code === 'NOT_FOUND'
63
+ && /no procedure found on path/i.test(error.message));
51
64
  const ENVELOPE_KEYS = Object.freeze([
52
65
  'schemaVersion',
53
66
  'contractVersion',
@@ -238,7 +251,14 @@ export function parseTrpcJsonResponse(response) {
238
251
  if (funnelContract) {
239
252
  throw new FunnelContractApiError(message, funnelContract);
240
253
  }
241
- throw new Error(`tRPC request failed: ${message}`);
254
+ const data = ownDataValue(errorValue, 'data');
255
+ const code = isObject(data) && typeof ownDataValue(data, 'code') === 'string'
256
+ ? ownDataValue(data, 'code')
257
+ : null;
258
+ const httpStatus = isObject(data) && typeof ownDataValue(data, 'httpStatus') === 'number'
259
+ ? ownDataValue(data, 'httpStatus')
260
+ : null;
261
+ throw new TrpcApiError(message, code, httpStatus);
242
262
  }
243
263
  const result = ownDataValue(response, 'result');
244
264
  if (!isObject(result)) {
@@ -316,6 +336,9 @@ export const beginFunnelSourceCandidate = (context, input) => callFunnelSourceCa
316
336
  export const stageFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.stageSourceCandidate', input);
317
337
  export const finalizeFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.finalizeSourceCandidate', input);
318
338
  export const abortFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.abortSourceCandidate', input);
339
+ export const applyFunnelDraftPatch = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.applyDraftPatch', input);
340
+ export const prepareFunnelDirectAssetUpload = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.prepareDirectAssetUpload', input);
341
+ export const finalizeFunnelDirectAssetUpload = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.finalizeDirectAssetUpload', input);
319
342
  export const createFunnelExperimentFromSpec = (context, input) => callTrpcProcedure({
320
343
  ...context,
321
344
  path: 'funnelExperiments.createFromSpec',
package/dist/cli.d.ts CHANGED
@@ -239,6 +239,8 @@ type PublishCommandBackend = {
239
239
  funnelId: string;
240
240
  }) => Promise<FunnelDetailResponse>;
241
241
  };
242
+ export type PublishProgressReporter = (message: string) => void;
243
+ export declare const formatPublishProgress: (deployment: PublishDeploymentStatus) => string;
242
244
  export declare const executePublishAndWait: (input: {
243
245
  token: string;
244
246
  workspaceId: string;
@@ -246,6 +248,7 @@ export declare const executePublishAndWait: (input: {
246
248
  message?: string;
247
249
  domains?: string[];
248
250
  backend?: PublishCommandBackend;
251
+ onProgress?: PublishProgressReporter;
249
252
  }) => Promise<string>;
250
253
  export declare const buildCloneFunnelMutationInput: (input: {
251
254
  workspaceId: string;
package/dist/cli.js CHANGED
@@ -8,13 +8,15 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { Command } from 'commander';
10
10
  import { CURRENT_STEP_CONTRACT_VERSION, FUNNEL_CONTRACT_DIAGNOSTIC_CODES, PREVIOUS_STEP_CONTRACT_VERSION, createFunnelContractDiagnostic, } from '@funnelsgrove/runtime';
11
- import { abortFunnelSourceCandidate, beginFunnelSourceCandidate, callTrpcProcedure, createFunnelExperimentFromSpec, finalizeFunnelSourceCandidate, stageFunnelSourceCandidate, } from './apiClient.js';
11
+ import { abortFunnelSourceCandidate, applyFunnelDraftPatch, beginFunnelSourceCandidate, callTrpcProcedure, createFunnelExperimentFromSpec, finalizeFunnelDirectAssetUpload, finalizeFunnelSourceCandidate, prepareFunnelDirectAssetUpload, stageFunnelSourceCandidate, } from './apiClient.js';
12
12
  import { cliReleaseIdentity } from './cliIdentity.js';
13
13
  import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
14
14
  import { buildCommittedSyncManifest, buildDownloadedSyncManifest, buildSourceCandidateOperations, collectChangedSourceFiles, collectSourceFiles, collectSourceSnapshot, ensureGitignore, formatSyncUploadSummary, hasLocalSourceChanges, rebaseSourceFiles, readSyncManifest, replaceSourceFiles, runSyncDownLocalLifecycle, runSourceCandidateSync, writeLocalEnvFile, writeSourceFiles, writeSourceRebaseBackup, writeSyncManifest, } from './localSync.js';
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 { executeFastDraftPatchSync, uploadDirectAssetWithFetch } from './draftPatchSync.js';
19
+ import { updateCliInstallations } from './selfUpdate.js';
18
20
  import { executeExpensePublish, executeExpensePull } from './expenseCommands.js';
19
21
  import { executeEmailPull, executeEmailPush, executeEmailSend, executeEmailUpdateVariables, executeEmailSequenceAddStep, executeEmailSequenceCancel, executeEmailSequenceCreate, executeEmailSequencePublish, executeEmailSequenceSetActive, executeEmailTemplatePublish, executeEmailValidate, parseEmailVariablesJson, } from './emailCommands.js';
20
22
  import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
@@ -1200,6 +1202,39 @@ export const formatPublishTerminalSuccess = (input) => {
1200
1202
  || input.acknowledgement.deploymentUrl;
1201
1203
  return `${deploymentUrl}\tv${publishedVersionSeq}\t${versionId}`;
1202
1204
  };
1205
+ const PUBLISH_BUILD_STAGE_LABELS = {
1206
+ sandboxCreate: 'starting build environment',
1207
+ writeFiles: 'writing source files',
1208
+ install: 'installing dependencies',
1209
+ nextBuild: 'building funnel',
1210
+ readExport: 'reading build output',
1211
+ imageVariants: 'optimizing images',
1212
+ r2Upload: 'uploading publish artifact',
1213
+ };
1214
+ export const formatPublishProgress = (deployment) => {
1215
+ const state = deployment.state.trim().toLowerCase();
1216
+ const metadata = isRecord(deployment.metadata) ? deployment.metadata : {};
1217
+ const publishBuild = isRecord(metadata.publishBuild) ? metadata.publishBuild : null;
1218
+ const stageName = publishBuild ? nonEmptyString(publishBuild.stageName) : null;
1219
+ const stageStatus = publishBuild ? nonEmptyString(publishBuild.status) : null;
1220
+ if (stageName) {
1221
+ const label = PUBLISH_BUILD_STAGE_LABELS[stageName] || stageName;
1222
+ if (stageStatus === 'completed')
1223
+ return `Publish: ${label} completed.`;
1224
+ if (stageStatus === 'failed')
1225
+ return `Publish: ${label} failed.`;
1226
+ return `Publish: ${label}...`;
1227
+ }
1228
+ if (state === 'queued')
1229
+ return 'Publish: queued...';
1230
+ if (state === 'building')
1231
+ return 'Publish: preparing build...';
1232
+ if (state === 'syncing')
1233
+ return 'Publish: uploading publish artifact...';
1234
+ if (state === 'ready')
1235
+ return 'Publish: ready.';
1236
+ return `Publish: ${state}...`;
1237
+ };
1203
1238
  const getApiUrl = () => {
1204
1239
  const options = program.opts();
1205
1240
  return options.apiUrl || process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL;
@@ -1234,6 +1269,7 @@ const isTerminalDeploymentState = (state) => {
1234
1269
  };
1235
1270
  const waitForPublishDeployment = async (input) => {
1236
1271
  const deadline = Date.now() + PUBLISH_WAIT_TIMEOUT_MS;
1272
+ let lastProgressMessage = null;
1237
1273
  while (Date.now() <= deadline) {
1238
1274
  const detail = input.detail
1239
1275
  ? await input.detail(input)
@@ -1247,6 +1283,13 @@ const waitForPublishDeployment = async (input) => {
1247
1283
  },
1248
1284
  });
1249
1285
  const deployment = detail.deployments.find((item) => item.id === input.deploymentId);
1286
+ if (deployment && input.onProgress) {
1287
+ const progressMessage = formatPublishProgress(deployment);
1288
+ if (progressMessage !== lastProgressMessage) {
1289
+ input.onProgress(progressMessage);
1290
+ lastProgressMessage = progressMessage;
1291
+ }
1292
+ }
1250
1293
  if (deployment && isTerminalDeploymentState(deployment.state)) {
1251
1294
  if (deployment.state === 'failed') {
1252
1295
  throw new Error(formatPublishTerminalFailure(deployment));
@@ -1283,6 +1326,7 @@ export const executePublishAndWait = async (input) => {
1283
1326
  },
1284
1327
  }),
1285
1328
  };
1329
+ input.onProgress?.('Publish: requesting deployment...');
1286
1330
  const acknowledgement = await backend.publish({
1287
1331
  token: input.token,
1288
1332
  workspaceId: input.workspaceId,
@@ -1290,12 +1334,14 @@ export const executePublishAndWait = async (input) => {
1290
1334
  message: input.message,
1291
1335
  domains: input.domains,
1292
1336
  });
1337
+ input.onProgress?.(`Publish: deployment ${acknowledgement.deploymentId} accepted.`);
1293
1338
  const deployment = await waitForPublishDeployment({
1294
1339
  token: input.token,
1295
1340
  workspaceId: input.workspaceId,
1296
1341
  funnelId: input.funnelId,
1297
1342
  deploymentId: acknowledgement.deploymentId,
1298
1343
  detail: backend.detail,
1344
+ onProgress: input.onProgress,
1299
1345
  });
1300
1346
  return formatPublishTerminalSuccess({ acknowledgement, deployment });
1301
1347
  };
@@ -1618,10 +1664,28 @@ program
1618
1664
  .option('--config <path>', 'Auth config path', process.env.FUNNELSGROVE_CONFIG || getDefaultAuthConfigPath());
1619
1665
  addExamples(program, [
1620
1666
  'fgrove login',
1667
+ 'fgrove update',
1621
1668
  'fgrove sync down --funnel claimbee-ios --dir ./claimbee-ios',
1622
1669
  'cd ./claimbee-ios && fgrove status',
1623
1670
  'fgrove publish --env preview',
1624
1671
  ]);
1672
+ addExamples(program
1673
+ .command('update')
1674
+ .description('Update every global fgrove installation found on PATH')
1675
+ .option('--dry-run', 'List installations without changing them'), [
1676
+ 'fgrove update',
1677
+ 'fgrove update --dry-run',
1678
+ ])
1679
+ .action(async (options) => {
1680
+ const installations = await updateCliInstallations({
1681
+ dryRun: options.dryRun,
1682
+ entrypointPath: process.argv[1],
1683
+ report: (message) => console.error(message),
1684
+ });
1685
+ console.log(options.dryRun
1686
+ ? `Found ${installations.length} global fgrove installation${installations.length === 1 ? '' : 's'}.`
1687
+ : `Updated ${installations.length} global fgrove installation${installations.length === 1 ? '' : 's'} to the latest release.`);
1688
+ });
1625
1689
  addExamples(program
1626
1690
  .command('login')
1627
1691
  .description('Authorize this CLI with your FunnelsGrove account')
@@ -2666,7 +2730,7 @@ addExamples(envCommand
2666
2730
  });
2667
2731
  addExamples(syncCommand
2668
2732
  .command('up')
2669
- .description('Upload local source into a new funnel draft version for funnels without GitHub source sync')
2733
+ .description('Atomically upload changed local paths into the hosted draft for funnels without GitHub source sync')
2670
2734
  .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
2671
2735
  .option('--funnel <id-or-slug>', 'Funnel id or slug')
2672
2736
  .option('--dir <path>', 'Local source directory', '.')
@@ -2719,40 +2783,59 @@ addExamples(syncCommand
2719
2783
  process.once('SIGINT', cancel);
2720
2784
  process.once('SIGTERM', cancel);
2721
2785
  try {
2722
- const candidateResult = await runSourceCandidateSync({
2786
+ const apiContext = {
2787
+ apiUrl: getApiUrl(),
2788
+ token,
2789
+ signal: cancellation.signal,
2790
+ };
2791
+ const patchResult = await executeFastDraftPatchSync({
2723
2792
  workspaceId: target.workspaceId,
2724
2793
  funnelId: target.funnelId,
2725
2794
  baseDraftVersionId: changes.currentManifest.draftVersionId,
2726
2795
  message: options.message,
2727
- operations: buildSourceCandidateOperations({
2728
- previousFiles: target.manifest?.files ?? [],
2729
- files: changes.files,
2730
- deletedPaths: changes.deletedPaths,
2731
- }),
2796
+ files: changes.files,
2797
+ deletedPaths: changes.deletedPaths,
2732
2798
  signal: cancellation.signal,
2733
2799
  api: {
2734
- begin: (input) => beginFunnelSourceCandidate({
2735
- apiUrl: getApiUrl(),
2736
- token,
2737
- signal: cancellation.signal,
2738
- }, input),
2739
- stage: (input) => stageFunnelSourceCandidate({
2740
- apiUrl: getApiUrl(),
2741
- token,
2742
- signal: cancellation.signal,
2743
- }, input),
2744
- finalize: (input) => finalizeFunnelSourceCandidate({
2745
- apiUrl: getApiUrl(),
2746
- token,
2747
- signal: cancellation.signal,
2748
- }, input),
2749
- abort: (input) => abortFunnelSourceCandidate({
2750
- apiUrl: getApiUrl(),
2751
- token,
2752
- }, input),
2800
+ apply: (input) => applyFunnelDraftPatch(apiContext, input),
2801
+ prepareAsset: (input) => prepareFunnelDirectAssetUpload(apiContext, input),
2802
+ uploadAsset: uploadDirectAssetWithFetch,
2803
+ finalizeAsset: (input) => finalizeFunnelDirectAssetUpload(apiContext, input),
2753
2804
  },
2754
2805
  });
2755
- result = { versionId: candidateResult.versionId };
2806
+ if (patchResult.kind === 'committed') {
2807
+ result = { versionId: patchResult.result.draftVersionId };
2808
+ if (patchResult.directAssetCount > 0) {
2809
+ console.log(`Uploaded ${patchResult.directAssetCount} changed image${patchResult.directAssetCount === 1 ? '' : 's'} directly to asset storage.`);
2810
+ }
2811
+ }
2812
+ else {
2813
+ console.warn(patchResult.reason === 'legacy-api'
2814
+ ? 'The API does not support fast draft patches yet; using compatible source sync.'
2815
+ : 'This change set exceeds fast draft patch limits; using compatible source sync.');
2816
+ const candidateResult = await runSourceCandidateSync({
2817
+ workspaceId: target.workspaceId,
2818
+ funnelId: target.funnelId,
2819
+ baseDraftVersionId: changes.currentManifest.draftVersionId,
2820
+ message: options.message,
2821
+ operations: buildSourceCandidateOperations({
2822
+ previousFiles: target.manifest?.files ?? [],
2823
+ files: changes.files,
2824
+ deletedPaths: changes.deletedPaths,
2825
+ }),
2826
+ signal: cancellation.signal,
2827
+ api: {
2828
+ begin: (input) => beginFunnelSourceCandidate(apiContext, input),
2829
+ stage: (input) => stageFunnelSourceCandidate(apiContext, input),
2830
+ finalize: (input) => finalizeFunnelSourceCandidate(apiContext, input),
2831
+ abort: (input) => abortFunnelSourceCandidate({
2832
+ apiUrl: getApiUrl(),
2833
+ token,
2834
+ }, input),
2835
+ },
2836
+ });
2837
+ result = { versionId: candidateResult.versionId };
2838
+ }
2756
2839
  }
2757
2840
  finally {
2758
2841
  process.off('SIGINT', cancel);
@@ -2826,8 +2909,10 @@ addExamples(program
2826
2909
  throw new Error('--domain is required when publishing production');
2827
2910
  }
2828
2911
  const sourceDir = path.resolve(process.cwd(), options.dir);
2912
+ console.error('Publish: validating local funnel...');
2829
2913
  await assertFunnelValidForMutation(sourceDir);
2830
2914
  const token = await readAuthToken();
2915
+ console.error('Publish: resolving funnel target...');
2831
2916
  const target = await resolveSyncTarget({
2832
2917
  token,
2833
2918
  workspace: options.workspace,
@@ -2835,6 +2920,7 @@ addExamples(program
2835
2920
  dir: options.dir,
2836
2921
  });
2837
2922
  if (shouldSyncGitHubDraft(options)) {
2923
+ console.error('Publish: checking GitHub draft sync...');
2838
2924
  await syncGitHubDraftForCli({
2839
2925
  token,
2840
2926
  workspaceId: target.workspaceId,
@@ -2848,6 +2934,7 @@ addExamples(program
2848
2934
  funnelId: target.funnelId,
2849
2935
  message: options.message,
2850
2936
  domains: publishEnv === 'production' && options.domain ? [options.domain] : undefined,
2937
+ onProgress: (message) => console.error(message),
2851
2938
  }));
2852
2939
  });
2853
2940
  const githubCommand = addExamples(program.command('github').description('Manage GitHub funnel sync'), [
@@ -13,7 +13,7 @@ export class ContractCompatibilityError extends Error {
13
13
  }
14
14
  const SHA256 = /^[a-f0-9]{64}$/;
15
15
  const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
16
- const UPDATE_COMMAND = 'npm install -g @funnelsgrove/cli@latest';
16
+ const UPDATE_COMMAND = 'fgrove update';
17
17
  const DEFAULT_TIMEOUT_MS = 3_000;
18
18
  const MAX_TIMEOUT_MS = 10_000;
19
19
  const MAX_HEALTH_RESPONSE_BYTES = 64 * 1_024;
@@ -0,0 +1,39 @@
1
+ import type { ApplyDraftPatchInput, ApplyDraftPatchResult, DirectAssetUploadDescription, DraftPatchFile, FinalizeDirectAssetUploadInput, PreparedDirectAssetUpload } from './apiClient.js';
2
+ import { type SourceFile } from './localSync.js';
3
+ export type DraftPatchSyncApi = {
4
+ apply: (input: ApplyDraftPatchInput) => Promise<ApplyDraftPatchResult>;
5
+ prepareAsset: (input: DirectAssetUploadDescription) => Promise<PreparedDirectAssetUpload>;
6
+ uploadAsset: (input: {
7
+ uploadUrl: string;
8
+ uploadHeaders: Record<string, string>;
9
+ body: Uint8Array;
10
+ signal?: AbortSignal;
11
+ }) => Promise<void>;
12
+ finalizeAsset: (input: FinalizeDirectAssetUploadInput) => Promise<DraftPatchFile>;
13
+ };
14
+ export type DraftPatchSyncResult = {
15
+ kind: 'committed';
16
+ result: ApplyDraftPatchResult;
17
+ directAssetCount: number;
18
+ } | {
19
+ kind: 'fallback';
20
+ reason: 'unsupported-patch' | 'legacy-api';
21
+ };
22
+ export declare const uploadDirectAssetWithFetch: (input: {
23
+ uploadUrl: string;
24
+ uploadHeaders: Record<string, string>;
25
+ body: Uint8Array;
26
+ signal?: AbortSignal;
27
+ fetchFn?: typeof fetch;
28
+ }) => Promise<void>;
29
+ export declare const executeFastDraftPatchSync: (input: {
30
+ workspaceId: string;
31
+ funnelId: string;
32
+ baseDraftVersionId: string | null;
33
+ message?: string;
34
+ files: readonly SourceFile[];
35
+ deletedPaths: readonly string[];
36
+ signal?: AbortSignal;
37
+ uploadConcurrency?: number;
38
+ api: DraftPatchSyncApi;
39
+ }) => Promise<DraftPatchSyncResult>;
@@ -0,0 +1,132 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isMissingTrpcProcedureError } from './apiClient.js';
3
+ import { sourceFileBytes } from './localSync.js';
4
+ const MAX_PATCH_FILES = 600;
5
+ const MAX_PATCH_FILE_CONTENT_CHARS = 3_000_000;
6
+ const MAX_PATCH_CONTENT_BYTES = 65_000_000;
7
+ const MAX_DIRECT_ASSET_BYTES = 25_000_000;
8
+ const MAX_DIRECT_ASSET_REF_BYTES = 1_024;
9
+ const DEFAULT_UPLOAD_CONCURRENCY = 4;
10
+ const isDirectImage = (file) => (file.path.startsWith('public/')
11
+ && Boolean(file.contentType?.toLowerCase().startsWith('image/')));
12
+ const hash = (value) => (createHash('sha256').update(value).digest('hex'));
13
+ const mapWithConcurrency = async (values, concurrency, worker) => {
14
+ const results = new Array(values.length);
15
+ let cursor = 0;
16
+ const run = async () => {
17
+ while (cursor < values.length) {
18
+ const index = cursor;
19
+ cursor += 1;
20
+ results[index] = await worker(values[index]);
21
+ }
22
+ };
23
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => run()));
24
+ return results;
25
+ };
26
+ const createIdempotencyKey = (input) => {
27
+ const payload = JSON.stringify({
28
+ ...input,
29
+ changedFiles: [...input.changedFiles]
30
+ .map((file) => ({
31
+ path: file.path,
32
+ contentType: file.contentType || 'text/plain',
33
+ contentHash: hash(file.content),
34
+ }))
35
+ .sort((left, right) => left.path.localeCompare(right.path)),
36
+ deletedPaths: [...input.deletedPaths].sort((left, right) => left.localeCompare(right)),
37
+ });
38
+ return `cli-sync-patch:${hash(payload)}`;
39
+ };
40
+ const patchIsSupported = (files, deletedPaths) => {
41
+ if (files.length > MAX_PATCH_FILES || deletedPaths.length > MAX_PATCH_FILES)
42
+ return false;
43
+ let projectedBytes = 0;
44
+ for (const file of files) {
45
+ if (isDirectImage(file)) {
46
+ const sizeBytes = sourceFileBytes(file).byteLength;
47
+ if (sizeBytes < 1 || sizeBytes > MAX_DIRECT_ASSET_BYTES)
48
+ return false;
49
+ projectedBytes += MAX_DIRECT_ASSET_REF_BYTES;
50
+ continue;
51
+ }
52
+ if (file.content.length > MAX_PATCH_FILE_CONTENT_CHARS)
53
+ return false;
54
+ projectedBytes += Buffer.byteLength(file.content, 'utf8');
55
+ }
56
+ return projectedBytes <= MAX_PATCH_CONTENT_BYTES;
57
+ };
58
+ export const uploadDirectAssetWithFetch = async (input) => {
59
+ const response = await (input.fetchFn || fetch)(input.uploadUrl, {
60
+ method: 'PUT',
61
+ headers: input.uploadHeaders,
62
+ body: new Uint8Array(input.body).buffer,
63
+ signal: input.signal,
64
+ });
65
+ if (!response.ok) {
66
+ throw new Error(`Direct asset upload failed with HTTP ${response.status}.`);
67
+ }
68
+ };
69
+ export const executeFastDraftPatchSync = async (input) => {
70
+ if (!patchIsSupported(input.files, input.deletedPaths)) {
71
+ return { kind: 'fallback', reason: 'unsupported-patch' };
72
+ }
73
+ const directFiles = input.files.filter(isDirectImage);
74
+ let uploadedAssets;
75
+ try {
76
+ const uploadConcurrency = Number.isSafeInteger(input.uploadConcurrency)
77
+ ? Math.min(16, Math.max(1, input.uploadConcurrency))
78
+ : DEFAULT_UPLOAD_CONCURRENCY;
79
+ uploadedAssets = await mapWithConcurrency(directFiles, uploadConcurrency, async (file) => {
80
+ const body = sourceFileBytes(file);
81
+ const description = {
82
+ workspaceId: input.workspaceId,
83
+ funnelId: input.funnelId,
84
+ path: file.path,
85
+ contentType: file.contentType,
86
+ sizeBytes: body.byteLength,
87
+ sha256: hash(body),
88
+ };
89
+ const prepared = await input.api.prepareAsset(description);
90
+ await input.api.uploadAsset({
91
+ uploadUrl: prepared.uploadUrl,
92
+ uploadHeaders: prepared.uploadHeaders,
93
+ body,
94
+ signal: input.signal,
95
+ });
96
+ return input.api.finalizeAsset({ ...description, uploadKey: prepared.uploadKey });
97
+ });
98
+ }
99
+ catch (error) {
100
+ if (isMissingTrpcProcedureError(error)) {
101
+ return { kind: 'fallback', reason: 'legacy-api' };
102
+ }
103
+ throw error;
104
+ }
105
+ const uploadedByPath = new Map(uploadedAssets.map((file) => [file.path, file]));
106
+ const changedFiles = input.files.map((file) => (uploadedByPath.get(file.path) ?? {
107
+ path: file.path,
108
+ content: file.content,
109
+ contentType: file.contentType,
110
+ }));
111
+ const requestWithoutKey = {
112
+ workspaceId: input.workspaceId,
113
+ funnelId: input.funnelId,
114
+ baseDraftVersionId: input.baseDraftVersionId,
115
+ message: input.message,
116
+ changedFiles,
117
+ deletedPaths: [...input.deletedPaths],
118
+ };
119
+ try {
120
+ const result = await input.api.apply({
121
+ ...requestWithoutKey,
122
+ idempotencyKey: createIdempotencyKey(requestWithoutKey),
123
+ });
124
+ return { kind: 'committed', result, directAssetCount: directFiles.length };
125
+ }
126
+ catch (error) {
127
+ if (isMissingTrpcProcedureError(error)) {
128
+ return { kind: 'fallback', reason: 'legacy-api' };
129
+ }
130
+ throw error;
131
+ }
132
+ };
@@ -113,7 +113,7 @@ export async function executeEmailSequenceCancel(input, dependencies = {}) {
113
113
  const remote = await emailApiRequest({
114
114
  apiUrl: input.apiUrl,
115
115
  privateToken: input.privateToken,
116
- path: `/sdk/private/sequences/${encodeURIComponent(sequence.id)}`,
116
+ path: `/integration/v1/email/sequences/${encodeURIComponent(sequence.id)}`,
117
117
  fetchImpl: dependencies.fetchImpl || fetch,
118
118
  });
119
119
  if (!isRecord(remote.sequence)
@@ -124,7 +124,7 @@ export async function executeEmailSequenceCancel(input, dependencies = {}) {
124
124
  const response = await emailApiRequest({
125
125
  apiUrl: input.apiUrl,
126
126
  privateToken: input.privateToken,
127
- path: `/sdk/private/sequences/${encodeURIComponent(sequence.id)}/enrollments/${encodeURIComponent(input.funnelEndUserId)}/cancel`,
127
+ path: `/integration/v1/email/sequences/${encodeURIComponent(sequence.id)}/enrollments/${encodeURIComponent(input.funnelEndUserId)}/cancel`,
128
128
  method: 'POST',
129
129
  body: { reason: input.reason },
130
130
  fetchImpl: dependencies.fetchImpl || fetch,
@@ -128,6 +128,7 @@ export declare function collectSourceSnapshot(rootDir: string): Promise<{
128
128
  sourceFiles: SourceFile[];
129
129
  }>;
130
130
  export declare function collectSourceFiles(rootDir: string): Promise<SourceFile[]>;
131
+ export declare const sourceFileBytes: (file: SourceFile) => Buffer;
131
132
  export declare function rebaseSourceFiles(input: {
132
133
  baseFiles: readonly SourceFile[];
133
134
  localFiles: readonly SourceFile[];
package/dist/localSync.js CHANGED
@@ -382,7 +382,7 @@ export async function collectSourceSnapshot(rootDir) {
382
382
  export async function collectSourceFiles(rootDir) {
383
383
  return (await collectSourceSnapshot(rootDir)).sourceFiles;
384
384
  }
385
- const sourceFileBytes = (file) => (decodeBinarySourceContent(file) ?? Buffer.from(file.content, 'utf8'));
385
+ export const sourceFileBytes = (file) => (decodeBinarySourceContent(file) ?? Buffer.from(file.content, 'utf8'));
386
386
  const sourceFilesEqual = (left, right) => {
387
387
  if (!left || !right)
388
388
  return left === right;