@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 +30 -4
- package/dist/apiClient.d.ts +50 -0
- package/dist/apiClient.js +24 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +115 -28
- package/dist/contractCompatibility.js +1 -1
- package/dist/draftPatchSync.d.ts +39 -0
- package/dist/draftPatchSync.js +132 -0
- package/dist/emailCommands.js +2 -2
- package/dist/localSync.d.ts +1 -0
- package/dist/localSync.js +1 -1
- package/dist/selfUpdate.d.ts +24 -0
- package/dist/selfUpdate.js +82 -0
- package/funnel-contract-compatibility.json +34 -34
- package/package.json +3 -3
- package/template_docs/.funnelsgrove-docs.json +5 -5
- package/template_docs/AGENTS.md +1 -1
- package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_docs/docs/funnelsgrove/qa/publish.md +1 -0
- package/template_docs/funnel-docs.config.json +1 -1
- package/template_scaffold/.funnelsgrove-docs.json +5 -5
- package/template_scaffold/.funnelsgrove-scaffold.json +9 -9
- package/template_scaffold/AGENTS.md +1 -1
- package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_scaffold/docs/funnelsgrove/qa/publish.md +1 -0
- package/template_scaffold/funnel-docs.config.json +1 -1
- package/template_scaffold/package-lock.json +9 -9
- package/template_scaffold/package.json +2 -2
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.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
package/dist/apiClient.d.ts
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
2728
|
-
|
|
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
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
+
};
|
package/dist/emailCommands.js
CHANGED
|
@@ -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: `/
|
|
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: `/
|
|
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,
|
package/dist/localSync.d.ts
CHANGED
|
@@ -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;
|