Skip to content

Commit

Permalink
feat(novu): move sync command (#5852)
Browse files Browse the repository at this point in the history
* feat(cli): move sync command

Add sync command to novu cli
Add deprecation warning to legacy command
Remove unused command
Add tests and related packages

* feat(novu): move sync command

PR recommendations

* feat(novu): move sync command

PR recommendations

* feat(novu): move sync command

Remove debug console statement

* feat(novu): move sync command

Make description more generic

* Update packages/cli/src/index.ts

* Update packages/cli/src/index.ts

* feat(cli): move sync command

Exchange warn for log

* feat(cli): move sync command

Fix typo
Add linting script

---------

Co-authored-by: Richard Fontein <32132657+rifont@users.noreply.github.com>
  • Loading branch information
denis-kralj-novu and rifont authored Jun 28, 2024
1 parent 2ca6610 commit 3dde98b
Show file tree
Hide file tree
Showing 7 changed files with 525 additions and 271 deletions.
26 changes: 23 additions & 3 deletions packages/cli-next/src/services/program/program.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env node

import chalk from 'chalk';
import { Command } from 'commander';
import { v4 as uuidv4 } from 'uuid';
import { randomUUID } from 'crypto';

import { echo } from '../../commands/echo';
import { sync } from '../../commands/sync';
Expand All @@ -15,7 +16,7 @@ if (process.env.NODE_ENV === 'development') {
}

const anonymousIdLocalState = config.getValue('anonymousId');
const anonymousId = anonymousIdLocalState || uuidv4();
const anonymousId = anonymousIdLocalState || randomUUID();

if (!anonymousIdLocalState) {
config.setValue('anonymousId', anonymousId);
Expand Down Expand Up @@ -47,8 +48,9 @@ export const buildProgram = () => {
.option('-b, --backend-url <backendUrl>', 'The backend url to use', 'https://api.novu.co')
.requiredOption('--echo-url <echoUrl>', 'The cho url to use')
.requiredOption('--api-key <apiKey>', 'The Novu api key to use')
.description('Sync your Echo state with Novu Cloud')
.description('Sync your Novu Framework state with Novu Cloud')
.action(async (options) => {
printSyncDeprecationWarning();
analytics.track({
identity: {
anonymousId: anonymousId,
Expand All @@ -62,3 +64,21 @@ export const buildProgram = () => {

return program;
};

function printSyncDeprecationWarning() {
console.log('');
console.log(
chalk.yellowBright(chalk.bold('############################# DEPRECATION WARNING ##############################'))
);
console.log(
chalk.yellowBright(chalk.bold('# The `novu-labs` package is deprecated, please install `novu` #'))
);
console.log(
chalk.yellowBright(chalk.bold('# and use use `novu sync` instead #'))
);

console.log(
chalk.yellowBright(chalk.bold('################################################################################'))
);
console.log('');
}
4 changes: 4 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"format": "prettier --write \"src/**/*.ts\"",
"precommit": "lint-staged",
"start": "pnpm start:dev",
"test": "vitest",
"test:watch": "vitest --watch",
"lint": "eslint src --ext .ts",
"start:dev": "cross-env NODE_ENV=dev NOVU_EMBED_PATH=http://127.0.0.1:4701/embed.umd.min.js NOVU_API_ADDRESS=http://127.0.0.1:3000 NOVU_CLIENT_LOGIN=http://127.0.0.1:4200/auth/login CLI_SEGMENT_WRITE_KEY=GdQ594CEBj4pU6RFldDOjKJwZjxZOsIj TZ=UTC nodemon init",
"start:dev:mode": "cross-env NODE_ENV=dev NOVU_EMBED_PATH=http://127.0.0.1:4701/embed.umd.min.js NOVU_API_ADDRESS=http://127.0.0.1:3000 NOVU_CLIENT_LOGIN=http://127.0.0.1:4200/auth/login CLI_SEGMENT_WRITE_KEY=GdQ594CEBj4pU6RFldDOjKJwZjxZOsIj TZ=UTC nodemon dev --studio-remote-origin http://localhost:4200",
"start:test": "cross-env NODE_ENV=test PORT=1336 TZ=UTC nodemon init",
Expand Down Expand Up @@ -49,6 +52,7 @@
"ora": "^5.4.1",
"ts-node": "~10.9.1",
"uuid": "^9.0.0",
"vitest": "^1.2.1",
"ws": "^8.11.0"
}
}
96 changes: 96 additions & 0 deletions packages/cli/src/commands/sync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { expect, it, describe, afterEach, vi, MockedFunction } from 'vitest';
import axios from 'axios';

import { sync, buildSignature } from './sync';

vi.mock('axios', () => {
return {
default: {
post: vi.fn(),
get: vi.fn(),
},
};
});

describe('sync command', () => {
describe('sync function', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('happy case of execute sync functions', async () => {
const bridgeUrl = 'https://bridge.novu.co';
const secretKey = 'your-api-key';
const apiUrl = 'https://api.novu.co';
const syncData = { someData: 'from sync' };

const syncRestCallSpy = vi.spyOn(axios, 'post');

(axios.post as MockedFunction<typeof axios.post>).mockResolvedValueOnce({
data: syncData,
});

const response = await sync(bridgeUrl, secretKey, apiUrl);

const expectBackendUrl = `${apiUrl}/v1/bridge/sync?source=cli`;
expect(syncRestCallSpy).toHaveBeenCalledWith(
expectBackendUrl,
expect.objectContaining({ bridgeUrl: bridgeUrl }),
expect.objectContaining({ headers: { Authorization: expect.any(String), 'Content-Type': 'application/json' } })
);
expect(response).toEqual(syncData);
});

it('syncState - network error on sync', async () => {
const bridgeUrl = 'https://bridge.novu.co';
const secretKey = 'your-api-key';
const apiUrl = 'https://api.novu.co';

(axios.post as MockedFunction<typeof axios.post>).mockRejectedValueOnce(new Error('Network error'));

try {
await sync(bridgeUrl, secretKey, apiUrl);
} catch (error) {
expect(error.message).toBe('Network error');
}
});

it('syncState - unexpected error', async () => {
const bridgeUrl = 'https://bridge.novu.co';
const secretKey = 'your-api-key';
const apiUrl = 'https://api.novu.co';

(axios.get as MockedFunction<typeof axios.get>).mockResolvedValueOnce({ data: {} });
(axios.post as MockedFunction<typeof axios.post>).mockImplementationOnce(() => {
throw new Error('Unexpected error');
});

try {
await sync(bridgeUrl, secretKey, apiUrl);
} catch (error) {
expect(error.message).toBe('Unexpected error');
}
});
});

describe('buildSignature function', () => {
it('buildSignature - generates valid signature format', () => {
const secretKey = 'your-api-key';
const signature = buildSignature(secretKey);

expect(signature).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/); // Matches format: t=<timestamp>,v1=<hex hash>
});

it('buildSignature - generates different signatures for different timestamps', async () => {
const secretKey = 'your-api-key';
const signature1 = buildSignature(secretKey);

// make sure we have different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));

const signature2 = buildSignature(secretKey);

expect(signature1).not.toEqual(signature2); // Check for different hashes with different timestamps
});
});
});
55 changes: 55 additions & 0 deletions packages/cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import axios from 'axios';
import { createHmac } from 'crypto';

export async function sync(bridgeUrl: string, secretKey: string, apiUrl: string) {
if (!bridgeUrl) {
throw new Error('A bridge URL is required for the sync command, please supply it when running the command');
}

if (!secretKey) {
throw new Error('A secret key is required for the sync command, please supply it when running the command');
}

if (!apiUrl) {
throw new Error(
'An API url is required for the sync command, please omit the configuration option entirely or supply a valid API url when running the command'
);
}
const syncResult = await executeSync(apiUrl, bridgeUrl, secretKey);

if (syncResult.status >= 400) {
console.error(new Error(JSON.stringify(syncResult.data)));
process.exit(1);
}

return syncResult.data;
}

export async function executeSync(apiUrl: string, bridgeUrl: string, secretKey: string) {
const url = apiUrl + '/v1/bridge/sync?source=cli';

return await axios.post(
url,
{
bridgeUrl,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: 'ApiKey ' + secretKey,
} as any,
}
);
}

export function buildSignature(secretKey: string) {
const timestamp = Date.now();

return `t=${timestamp},v1=${buildHmac(secretKey, timestamp)}`;
}

export function buildHmac(secretKey: string, timestamp: number) {
return createHmac('sha256', secretKey)
.update(timestamp + '.' + JSON.stringify({}))
.digest('hex');
}
32 changes: 0 additions & 32 deletions packages/cli/src/commands/tunnel.ts

This file was deleted.

36 changes: 34 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@

import { Command } from 'commander';
import { initCommand, devCommand, DevCommandOptions } from './commands';
import { sync } from './commands/sync';

import { v4 as uuidv4 } from 'uuid';
import { AnalyticService, ConfigService } from './services';

const analytics = new AnalyticService();
const config = new ConfigService();
if (process.env.NODE_ENV === 'development') {
config.clearStore();
}
const anonymousIdLocalState = config.getValue('anonymousId');
const anonymousId = anonymousIdLocalState || uuidv4();
const program = new Command();

program.name('novu').description('A CLI tool to interact with the novu API');
Expand All @@ -14,6 +25,26 @@ program
initCommand();
});

program
.command('sync')
.option('-a, --api-url <apiUrl>', 'The Novu Cloud API URL', 'https://api.novu.co')
.requiredOption(
'-b, --bridge-url <bridgeUrl>',
'The Novu endpoint URL hosted in the Bridge application, by convention ends in /api/novu'
)
.requiredOption('-s, --secret-key <secretKey>', 'The Novu secret key')
.description('Sync your state with Novu Cloud')
.action(async (options) => {
analytics.track({
identity: {
anonymousId: anonymousId,
},
data: {},
event: 'Sync Novu Endpoint State',
});
await sync(options.bridgeUrl, options.secretKey, options.apiUrl);
});

program
.command('dev')
.description('Start a Novu Dev Studio and a localtunnel')
Expand All @@ -31,5 +62,6 @@ program
'Studio Origin SPA, used for staging environment and local development, defaults to us',
'us'
)
.action((options: DevCommandOptions) => devCommand(options))
.parse(process.argv);
.action((options: DevCommandOptions) => devCommand(options));

program.parse(process.argv);
Loading

0 comments on commit 3dde98b

Please sign in to comment.