@funnelsgrove/cli 0.1.4 → 0.1.5

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
@@ -24,6 +24,18 @@ fgrove sync up --message 'Update funnel copy'
24
24
  fgrove publish --env preview
25
25
  ```
26
26
 
27
+ GitHub sync workflow:
28
+
29
+ ```bash
30
+ fgrove github status --funnel claimbee-general
31
+ fgrove github connect --funnel claimbee-general --account The-Solid-Grove --repo claimbee-funnel
32
+ fgrove github push --funnel claimbee-general
33
+ fgrove github pull --funnel claimbee-general
34
+ fgrove publish --funnel claimbee-general --env preview
35
+ ```
36
+
37
+ The GitHub commands use the FunnelsGrove API only. When GitHub is connected, `fgrove sync up` pushes the resulting draft to GitHub before returning, and `fgrove publish` waits for the current draft to reach GitHub before publishing. Local `.env*` files remain CLI-local runtime material from `sync down`; they are not sent to GitHub sync.
38
+
27
39
  The package also keeps the longer `funnelsgrove` command as a compatibility alias.
28
40
  Use `--api-url` or `FUNNELSGROVE_API_URL` for non-production APIs.
29
41
  Use `--config` or `FUNNELSGROVE_CONFIG` to keep test credentials separate from the default `~/.funnelsgrove/config.json`.
package/dist/cli.js CHANGED
@@ -8,6 +8,8 @@ import { Command } from 'commander';
8
8
  import { callTrpcProcedure } from './apiClient.js';
9
9
  import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
10
10
  import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, readSyncManifest, writeSourceFiles, writeSyncManifest, } from './localSync.js';
11
+ import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
12
+ import { syncGitHubDraftIfConnected } from './githubSyncFlow.js';
11
13
  import { isKnownTemplate, KNOWN_TEMPLATE_SLUGS, reskinFunnel } from './reskin.js';
12
14
  import { syncTemplateDocs, TEMPLATE_DOCS_DIR } from './templateDocs.js';
13
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -213,6 +215,34 @@ const printRows = (rows, columns) => {
213
215
  console.log(columns.map((column) => row[column] || '').join('\t'));
214
216
  }
215
217
  };
218
+ const printValueRows = (rows) => {
219
+ for (const row of rows) {
220
+ console.log(row.join('\t'));
221
+ }
222
+ };
223
+ const summarizeGitHubSyncResult = (result) => {
224
+ if (result.status === 'not_connected') {
225
+ return null;
226
+ }
227
+ if (result.status === 'draft_missing') {
228
+ throw new Error('Cannot sync GitHub before publish because this funnel has no draft version.');
229
+ }
230
+ const remoteSha = result.remoteSha ? ` ${result.remoteSha}` : '';
231
+ return result.status === 'completed'
232
+ ? `GitHub synced${remoteSha}`
233
+ : `GitHub already synced${remoteSha}`;
234
+ };
235
+ const syncGitHubDraftForCli = async (input) => {
236
+ const summary = summarizeGitHubSyncResult(await syncGitHubDraftIfConnected({
237
+ callApi,
238
+ token: input.token,
239
+ workspaceId: input.workspaceId,
240
+ funnelId: input.funnelId,
241
+ }));
242
+ if (summary) {
243
+ console.log(summary);
244
+ }
245
+ };
216
246
  const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
217
247
  const program = new Command();
218
248
  program
@@ -452,6 +482,11 @@ addExamples(syncCommand
452
482
  : null;
453
483
  if (changes && changes.files.length === 0 && changes.deletedPaths.length === 0) {
454
484
  console.log('No local changes to sync.');
485
+ await syncGitHubDraftForCli({
486
+ token,
487
+ workspaceId: target.workspaceId,
488
+ funnelId: target.funnelId,
489
+ });
455
490
  return;
456
491
  }
457
492
  let result = null;
@@ -499,6 +534,11 @@ addExamples(syncCommand
499
534
  funnelId: target.funnelId,
500
535
  draftVersionId: result.versionId,
501
536
  }));
537
+ await syncGitHubDraftForCli({
538
+ token,
539
+ workspaceId: target.workspaceId,
540
+ funnelId: target.funnelId,
541
+ });
502
542
  const deletedSummary = deletedFileCount > 0 ? ` and removed ${deletedFileCount} files` : '';
503
543
  console.log(`Synced ${syncedFileCount} files${deletedSummary} to draft v${result.versionSeq} (${result.versionId})`);
504
544
  });
@@ -529,6 +569,11 @@ addExamples(program
529
569
  if (publishEnv === 'production' && !options.domain?.trim()) {
530
570
  throw new Error('--domain is required when publishing production');
531
571
  }
572
+ await syncGitHubDraftForCli({
573
+ token,
574
+ workspaceId: target.workspaceId,
575
+ funnelId: target.funnelId,
576
+ });
532
577
  const result = await callApi({
533
578
  path: 'funnels.publish',
534
579
  type: 'mutation',
@@ -542,6 +587,130 @@ addExamples(program
542
587
  });
543
588
  console.log(`${result.deploymentUrl}\tv${result.publishedVersionSeq}\t${result.publishedVersionId}`);
544
589
  });
590
+ const githubCommand = addExamples(program.command('github').description('Manage GitHub funnel sync'), [
591
+ 'fgrove github status --funnel claimbee-general',
592
+ 'fgrove github connect --funnel claimbee-general --account The-Solid-Grove --repo claimbee-funnel',
593
+ 'fgrove github push --funnel claimbee-general',
594
+ 'fgrove github pull --funnel claimbee-general',
595
+ ]);
596
+ addExamples(githubCommand
597
+ .command('status')
598
+ .description('Show GitHub sync status for a funnel')
599
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
600
+ .option('--funnel <id-or-slug>', 'Funnel id or slug')
601
+ .option('--dir <path>', 'Local source directory for reading sync manifest', '.'), [
602
+ 'fgrove github status --funnel claimbee-general',
603
+ 'fgrove github status --dir ./claimbee-funnel',
604
+ ])
605
+ .action(async (options) => {
606
+ const token = await readAuthToken();
607
+ const target = await resolveSyncTarget({
608
+ token,
609
+ workspace: options.workspace,
610
+ funnel: options.funnel,
611
+ dir: options.dir,
612
+ });
613
+ const result = await callApi({
614
+ path: 'github.status',
615
+ type: 'query',
616
+ token,
617
+ data: {
618
+ workspaceId: target.workspaceId,
619
+ funnelId: target.funnelId,
620
+ },
621
+ });
622
+ printValueRows(formatGitHubStatusRows(result));
623
+ });
624
+ addExamples(githubCommand
625
+ .command('connect')
626
+ .description('Connect a funnel to a GitHub repository and queue the initial push')
627
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
628
+ .option('--funnel <id-or-slug>', 'Funnel id or slug')
629
+ .option('--dir <path>', 'Local source directory for reading sync manifest', '.')
630
+ .option('--installation <id>', 'Workspace GitHub installation row id')
631
+ .option('--account <login>', 'GitHub organization or user login')
632
+ .option('--repo <name>', 'GitHub repository name'), [
633
+ 'fgrove github connect --funnel claimbee-general --account The-Solid-Grove --repo claimbee-funnel',
634
+ 'fgrove github connect --dir ./claimbee-funnel --account The-Solid-Grove --repo claimbee-funnel',
635
+ ])
636
+ .action(async (options) => {
637
+ const token = await readAuthToken();
638
+ const target = await resolveSyncTarget({
639
+ token,
640
+ workspace: options.workspace,
641
+ funnel: options.funnel,
642
+ dir: options.dir,
643
+ });
644
+ const result = await callApi({
645
+ path: 'github.connectFunnel',
646
+ type: 'mutation',
647
+ token,
648
+ data: {
649
+ workspaceId: target.workspaceId,
650
+ funnelId: target.funnelId,
651
+ installationId: options.installation,
652
+ accountLogin: options.account,
653
+ repoName: options.repo,
654
+ },
655
+ });
656
+ printValueRows(formatGitHubConnectRows(result));
657
+ });
658
+ addExamples(githubCommand
659
+ .command('push')
660
+ .description('Queue a push of the current FunnelsGrove draft to GitHub')
661
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
662
+ .option('--funnel <id-or-slug>', 'Funnel id or slug')
663
+ .option('--dir <path>', 'Local source directory for reading sync manifest', '.'), [
664
+ 'fgrove github push --funnel claimbee-general',
665
+ 'fgrove github push --dir ./claimbee-funnel',
666
+ ])
667
+ .action(async (options) => {
668
+ const token = await readAuthToken();
669
+ const target = await resolveSyncTarget({
670
+ token,
671
+ workspace: options.workspace,
672
+ funnel: options.funnel,
673
+ dir: options.dir,
674
+ });
675
+ const result = await callApi({
676
+ path: 'github.pushNow',
677
+ type: 'mutation',
678
+ token,
679
+ data: {
680
+ workspaceId: target.workspaceId,
681
+ funnelId: target.funnelId,
682
+ },
683
+ });
684
+ printValueRows(formatGitHubJobRows(result));
685
+ });
686
+ addExamples(githubCommand
687
+ .command('pull')
688
+ .description('Queue a pull from GitHub into the FunnelsGrove draft')
689
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
690
+ .option('--funnel <id-or-slug>', 'Funnel id or slug')
691
+ .option('--dir <path>', 'Local source directory for reading sync manifest', '.'), [
692
+ 'fgrove github pull --funnel claimbee-general',
693
+ 'fgrove github pull --dir ./claimbee-funnel',
694
+ ])
695
+ .action(async (options) => {
696
+ const token = await readAuthToken();
697
+ const target = await resolveSyncTarget({
698
+ token,
699
+ workspace: options.workspace,
700
+ funnel: options.funnel,
701
+ dir: options.dir,
702
+ });
703
+ const result = await callApi({
704
+ path: 'github.pullLatest',
705
+ type: 'mutation',
706
+ token,
707
+ data: {
708
+ workspaceId: target.workspaceId,
709
+ funnelId: target.funnelId,
710
+ },
711
+ });
712
+ printValueRows(formatGitHubJobRows(result));
713
+ });
545
714
  addExamples(program
546
715
  .command('docs')
547
716
  .description('Install or refresh local funnel editing docs in a funnel directory')
@@ -0,0 +1,65 @@
1
+ type Row = string[];
2
+ export type GitHubStatusResponse = {
3
+ connected: boolean;
4
+ installUrl: string | null;
5
+ account: {
6
+ id: string;
7
+ accountLogin: string;
8
+ accountType: string;
9
+ } | null;
10
+ installations: Array<{
11
+ id: string;
12
+ githubInstallationId: number;
13
+ accountLogin: string;
14
+ accountType: string;
15
+ }>;
16
+ connection: {
17
+ id: string;
18
+ repoOwner: string;
19
+ repoName: string;
20
+ repoUrl: string;
21
+ defaultBranch: string;
22
+ status: string;
23
+ lastSyncedRemoteSha: string | null;
24
+ lastSyncedVersionId: string | null;
25
+ lastSyncedVersionSeq: number | null;
26
+ } | null;
27
+ draftVersionId: string | null;
28
+ draftVersionSeq: number | null;
29
+ publishedVersionId: string | null;
30
+ publishedVersionSeq: number | null;
31
+ syncJobs: Array<{
32
+ id: string;
33
+ direction: string;
34
+ status: string;
35
+ triggerSource: string;
36
+ errorMessage: string | null;
37
+ targetRemoteSha: string | null;
38
+ appliedVersionId: string | null;
39
+ appliedVersionSeq: number | null;
40
+ createdAt: string;
41
+ completedAt: string | null;
42
+ }>;
43
+ };
44
+ export type GitHubConnectResponse = {
45
+ connection: {
46
+ repo_owner: string;
47
+ repo_name: string;
48
+ repo_url: string;
49
+ default_branch: string;
50
+ status: string;
51
+ };
52
+ syncJob: {
53
+ id: string;
54
+ direction: string;
55
+ status: string;
56
+ };
57
+ };
58
+ export type GitHubJobResponse = {
59
+ status: string;
60
+ jobId: string | null;
61
+ };
62
+ export declare const formatGitHubStatusRows: (status: GitHubStatusResponse) => Row[];
63
+ export declare const formatGitHubConnectRows: (result: GitHubConnectResponse) => Row[];
64
+ export declare const formatGitHubJobRows: (result: GitHubJobResponse) => Row[];
65
+ export {};
@@ -0,0 +1,67 @@
1
+ const formatVersion = (seq, id) => {
2
+ if (!id) {
3
+ return ['Not published'];
4
+ }
5
+ return [seq ? `v${seq}` : 'version', id];
6
+ };
7
+ export const formatGitHubStatusRows = (status) => {
8
+ const rows = [['connected', String(status.connected)]];
9
+ if (status.account) {
10
+ rows.push(['account', status.account.accountLogin, status.account.accountType]);
11
+ }
12
+ if (status.connection) {
13
+ rows.push([
14
+ 'repo',
15
+ `${status.connection.repoOwner}/${status.connection.repoName}`,
16
+ status.connection.repoUrl,
17
+ ]);
18
+ rows.push(['branch', status.connection.defaultBranch]);
19
+ rows.push(['status', status.connection.status]);
20
+ if (status.connection.lastSyncedRemoteSha) {
21
+ rows.push(['lastRemoteSha', status.connection.lastSyncedRemoteSha]);
22
+ }
23
+ if (status.connection.lastSyncedVersionId) {
24
+ rows.push([
25
+ 'lastVersion',
26
+ ...formatVersion(status.connection.lastSyncedVersionSeq, status.connection.lastSyncedVersionId),
27
+ ]);
28
+ }
29
+ }
30
+ else if (status.installUrl) {
31
+ rows.push(['installUrl', status.installUrl]);
32
+ }
33
+ if (status.draftVersionId) {
34
+ rows.push(['draft', ...formatVersion(status.draftVersionSeq, status.draftVersionId)]);
35
+ }
36
+ rows.push(['published', ...formatVersion(status.publishedVersionSeq, status.publishedVersionId)]);
37
+ for (const job of status.syncJobs) {
38
+ rows.push([
39
+ 'job',
40
+ job.id,
41
+ job.direction,
42
+ job.status,
43
+ job.triggerSource,
44
+ job.targetRemoteSha || '',
45
+ job.appliedVersionSeq ? `v${job.appliedVersionSeq}` : '',
46
+ job.errorMessage || '',
47
+ ].filter((value, index, values) => value || index < values.length - 1));
48
+ }
49
+ return rows;
50
+ };
51
+ export const formatGitHubConnectRows = (result) => [
52
+ [
53
+ 'connected',
54
+ `${result.connection.repo_owner}/${result.connection.repo_name}`,
55
+ result.connection.repo_url,
56
+ ],
57
+ ['branch', result.connection.default_branch],
58
+ ['status', result.connection.status],
59
+ ['syncJob', result.syncJob.id, result.syncJob.direction, result.syncJob.status],
60
+ ];
61
+ export const formatGitHubJobRows = (result) => {
62
+ const rows = [['status', result.status]];
63
+ if (result.jobId) {
64
+ rows.push(['job', result.jobId]);
65
+ }
66
+ return rows;
67
+ };
@@ -0,0 +1,27 @@
1
+ type ApiCallInput = {
2
+ path: string;
3
+ type: 'query' | 'mutation';
4
+ data?: unknown;
5
+ token?: string | null;
6
+ };
7
+ export type GitHubFlowCallApi = <T>(input: ApiCallInput) => Promise<T>;
8
+ export type GitHubSyncFlowResult = {
9
+ status: 'not_connected';
10
+ } | {
11
+ status: 'draft_missing';
12
+ } | {
13
+ status: 'completed' | 'skipped';
14
+ jobId: string;
15
+ remoteSha: string | null;
16
+ versionId: string | null;
17
+ };
18
+ export declare const syncGitHubDraftIfConnected: (input: {
19
+ callApi: GitHubFlowCallApi;
20
+ token: string;
21
+ workspaceId: string;
22
+ funnelId: string;
23
+ pollIntervalMs?: number;
24
+ timeoutMs?: number;
25
+ sleep?: (ms: number) => Promise<void>;
26
+ }) => Promise<GitHubSyncFlowResult>;
27
+ export {};
@@ -0,0 +1,75 @@
1
+ const TERMINAL_JOB_STATUSES = new Set(['completed', 'skipped', 'failed']);
2
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
3
+ const DEFAULT_TIMEOUT_MS = 600_000;
4
+ const sleepDefault = async (ms) => {
5
+ await new Promise((resolve) => {
6
+ setTimeout(resolve, ms);
7
+ });
8
+ };
9
+ const findJob = (status, jobId) => {
10
+ return status.syncJobs.find((job) => job.id === jobId) || null;
11
+ };
12
+ const assertSuccessfulTerminalJob = (job) => {
13
+ if (job.status === 'failed' || (job.status === 'skipped' && job.errorMessage)) {
14
+ throw new Error(job.errorMessage || 'GitHub sync failed.');
15
+ }
16
+ };
17
+ export const syncGitHubDraftIfConnected = async (input) => {
18
+ const status = await input.callApi({
19
+ path: 'github.status',
20
+ type: 'query',
21
+ token: input.token,
22
+ data: {
23
+ workspaceId: input.workspaceId,
24
+ funnelId: input.funnelId,
25
+ },
26
+ });
27
+ if (!status.connection || status.connection.status === 'disconnected') {
28
+ return { status: 'not_connected' };
29
+ }
30
+ const pushResult = await input.callApi({
31
+ path: 'github.pushNow',
32
+ type: 'mutation',
33
+ token: input.token,
34
+ data: {
35
+ workspaceId: input.workspaceId,
36
+ funnelId: input.funnelId,
37
+ },
38
+ });
39
+ if (pushResult.status === 'not_connected') {
40
+ return { status: 'not_connected' };
41
+ }
42
+ if (pushResult.status === 'draft_missing') {
43
+ return { status: 'draft_missing' };
44
+ }
45
+ if (!pushResult.jobId) {
46
+ throw new Error(`GitHub sync did not return a job id (status: ${pushResult.status}).`);
47
+ }
48
+ const pollIntervalMs = input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
49
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
50
+ const sleep = input.sleep || sleepDefault;
51
+ const deadline = Date.now() + timeoutMs;
52
+ while (Date.now() <= deadline) {
53
+ const nextStatus = await input.callApi({
54
+ path: 'github.status',
55
+ type: 'query',
56
+ token: input.token,
57
+ data: {
58
+ workspaceId: input.workspaceId,
59
+ funnelId: input.funnelId,
60
+ },
61
+ });
62
+ const job = findJob(nextStatus, pushResult.jobId);
63
+ if (job && TERMINAL_JOB_STATUSES.has(job.status)) {
64
+ assertSuccessfulTerminalJob(job);
65
+ return {
66
+ status: job.status,
67
+ jobId: job.id,
68
+ remoteSha: job.targetRemoteSha,
69
+ versionId: job.appliedVersionId,
70
+ };
71
+ }
72
+ await sleep(pollIntervalMs);
73
+ }
74
+ throw new Error(`Timed out waiting for GitHub sync job ${pushResult.jobId}.`);
75
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
5
5
  "type": "module",
6
6
  "bin": {