@velaro/cli 0.4.0 → 0.7.0

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.
@@ -1,47 +1,47 @@
1
- import { execSync } from 'child_process';
2
- import { createRequire } from 'module';
3
-
4
- const require = createRequire(import.meta.url);
5
- const { version: currentVersion } = require('../../package.json');
6
-
7
- const NPM_REGISTRY = 'https://registry.npmjs.org/@velaro/cli/latest';
8
-
9
- export const updateCommand = {
10
- command: 'update',
11
- describe: 'Update the Velaro CLI to the latest version',
12
- handler: async () => {
13
- process.stdout.write('Checking for updates... ');
14
-
15
- let latest;
16
- try {
17
- const res = await fetch(NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
18
- const data = await res.json();
19
- latest = data?.version;
20
- } catch {
21
- console.error('Could not reach npm registry. Check your connection and try again.');
22
- process.exit(1);
23
- }
24
-
25
- if (!latest) {
26
- console.error('Could not determine the latest version.');
27
- process.exit(1);
28
- }
29
-
30
- if (latest === currentVersion) {
31
- console.log(`already up to date (${currentVersion}).`);
32
- return;
33
- }
34
-
35
- console.log(`${currentVersion} → ${latest}`);
36
- console.log('Running: npm install -g @velaro/cli@latest\n');
37
-
38
- try {
39
- execSync('npm install -g @velaro/cli@latest', { stdio: 'inherit' });
40
- console.log('\nUpdated successfully. Run velaro --version to confirm.');
41
- } catch {
42
- console.error('\nUpdate failed. Try running manually:');
43
- console.error(' npm install -g @velaro/cli@latest');
44
- process.exit(1);
45
- }
46
- },
47
- };
1
+ import { execSync } from 'child_process';
2
+ import { createRequire } from 'module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const { version: currentVersion } = require('../../package.json');
6
+
7
+ const NPM_REGISTRY = 'https://registry.npmjs.org/@velaro/cli/latest';
8
+
9
+ export const updateCommand = {
10
+ command: 'update',
11
+ describe: 'Update the Velaro CLI to the latest version',
12
+ handler: async () => {
13
+ process.stdout.write('Checking for updates... ');
14
+
15
+ let latest;
16
+ try {
17
+ const res = await fetch(NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
18
+ const data = await res.json();
19
+ latest = data?.version;
20
+ } catch {
21
+ console.error('Could not reach npm registry. Check your connection and try again.');
22
+ process.exit(1);
23
+ }
24
+
25
+ if (!latest) {
26
+ console.error('Could not determine the latest version.');
27
+ process.exit(1);
28
+ }
29
+
30
+ if (latest === currentVersion) {
31
+ console.log(`already up to date (${currentVersion}).`);
32
+ return;
33
+ }
34
+
35
+ console.log(`${currentVersion} → ${latest}`);
36
+ console.log('Running: npm install -g @velaro/cli@latest\n');
37
+
38
+ try {
39
+ execSync('npm install -g @velaro/cli@latest', { stdio: 'inherit' });
40
+ console.log('\nUpdated successfully. Run velaro --version to confirm.');
41
+ } catch {
42
+ console.error('\nUpdate failed. Try running manually:');
43
+ console.error(' npm install -g @velaro/cli@latest');
44
+ process.exit(1);
45
+ }
46
+ },
47
+ };
@@ -1,22 +1,22 @@
1
- import { readConfig } from '../config.js';
2
-
3
- export const whoamiCommand = {
4
- command: 'whoami',
5
- describe: 'Show current login and site info',
6
- handler: () => {
7
- const cfg = readConfig();
8
-
9
- if (!cfg?.velaroToken) {
10
- console.error('Not logged in. Run: velaro login');
11
- process.exit(1);
12
- }
13
-
14
- const expires = new Date(cfg.velaroExpires);
15
- const minLeft = Math.round((expires - Date.now()) / 60000);
16
-
17
- console.log(`User: ${cfg.userName ?? '(unknown)'}`);
18
- console.log(`Site ID: ${cfg.siteId ?? '(unknown)'}`);
19
- console.log(`API: ${cfg.apiBase}`);
20
- console.log(`Token: ${expires > new Date() ? `valid (expires in ${minLeft}m)` : 'EXPIRED — run velaro login'}`);
21
- },
22
- };
1
+ import { readConfig } from '../config.js';
2
+
3
+ export const whoamiCommand = {
4
+ command: 'whoami',
5
+ describe: 'Show current login and site info',
6
+ handler: () => {
7
+ const cfg = readConfig();
8
+
9
+ if (!cfg?.velaroToken) {
10
+ console.error('Not logged in. Run: velaro login');
11
+ process.exit(1);
12
+ }
13
+
14
+ const expires = new Date(cfg.velaroExpires);
15
+ const minLeft = Math.round((expires - Date.now()) / 60000);
16
+
17
+ console.log(`User: ${cfg.userName ?? '(unknown)'}`);
18
+ console.log(`Site ID: ${cfg.siteId ?? '(unknown)'}`);
19
+ console.log(`API: ${cfg.apiBase}`);
20
+ console.log(`Token: ${expires > new Date() ? `valid (expires in ${minLeft}m)` : 'EXPIRED — run velaro login'}`);
21
+ },
22
+ };
package/lib/config.js CHANGED
@@ -5,24 +5,79 @@ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
5
5
  const CONFIG_DIR = join(homedir(), '.velaro');
6
6
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
7
 
8
- // Production API URL — update when production messaging API is deployed.
9
- // messaging.velaro.com is the chat widget host (Azure SWA), not the API.
10
- export const DEFAULT_API_BASE = process.env.VELARO_API_BASE || 'https://velaro-messaging-api-staging.azurewebsites.net';
11
- export const STAGING_API_BASE = process.env.VELARO_STAGING_API || 'https://velaro-messaging-api-staging.azurewebsites.net';
8
+ export const ENVS = {
9
+ prod: {
10
+ adminApiBase: 'https://api-admin-us-east.velaro.com',
11
+ messagingApiBase: 'https://velaro-messaging-api-staging.azurewebsites.net',
12
+ },
13
+ staging: {
14
+ adminApiBase: 'https://velaro-admin-staging.azurewebsites.net',
15
+ messagingApiBase: 'https://velaro-messaging-api-staging.azurewebsites.net',
16
+ },
17
+ };
18
+
19
+ // Legacy single-entry config — migrate transparently on first read.
20
+ function migrate(raw) {
21
+ if (raw.envs) return raw; // already multi-env
22
+ const apiBase = raw.apiBase || '';
23
+ const env = apiBase.includes('staging') ? 'staging' : 'prod';
24
+ return {
25
+ activeEnv: env,
26
+ envs: {
27
+ [env]: {
28
+ adminApiBase: ENVS[env].adminApiBase,
29
+ messagingApiBase: ENVS[env].messagingApiBase,
30
+ velaroToken: raw.velaroToken,
31
+ velaroExpires: raw.velaroExpires,
32
+ entraRefreshToken: raw.entraRefreshToken,
33
+ siteId: raw.siteId,
34
+ userName: raw.userName,
35
+ },
36
+ },
37
+ _lastUpdateCheck: raw._lastUpdateCheck,
38
+ _latestVersion: raw._latestVersion,
39
+ };
40
+ }
12
41
 
13
42
  export function readConfig() {
14
43
  try {
15
- return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
44
+ const raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
45
+ return migrate(raw);
16
46
  } catch {
17
- return {};
47
+ return { activeEnv: 'prod', envs: {} };
18
48
  }
19
49
  }
20
50
 
21
51
  export function writeConfig(data) {
22
- mkdirSync(CONFIG_DIR, { recursive: true }); // no-op if already exists
52
+ mkdirSync(CONFIG_DIR, { recursive: true });
23
53
  writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
24
54
  }
25
55
 
56
+ export function getActiveEnv() {
57
+ return readConfig().activeEnv || 'prod';
58
+ }
59
+
60
+ export function setActiveEnv(env) {
61
+ if (!ENVS[env]) throw new Error(`Unknown environment "${env}". Use: prod, staging`);
62
+ const cfg = readConfig();
63
+ cfg.activeEnv = env;
64
+ writeConfig(cfg);
65
+ }
66
+
67
+ export function getEnvCredentials(env) {
68
+ const cfg = readConfig();
69
+ return cfg.envs?.[env] || null;
70
+ }
71
+
72
+ export function setEnvCredentials(env, creds) {
73
+ if (!ENVS[env]) throw new Error(`Unknown environment "${env}". Use: prod, staging`);
74
+ const cfg = readConfig();
75
+ cfg.envs = cfg.envs || {};
76
+ cfg.envs[env] = { ...ENVS[env], ...creds };
77
+ cfg.activeEnv = cfg.activeEnv || env;
78
+ writeConfig(cfg);
79
+ }
80
+
26
81
  export function clearConfig() {
27
- writeConfig({});
82
+ writeConfig({ activeEnv: 'prod', envs: {} });
28
83
  }
package/lib/oauth.js CHANGED
@@ -1,135 +1,135 @@
1
- /**
2
- * OAuth 2.0 device authorization flow (RFC 8628) against the Velaro Entra CIAM tenant.
3
- *
4
- * Flow:
5
- * 1. POST /devicecode → get user_code + verification_uri
6
- * 2. Show the user the code; poll /token until granted
7
- * 3. Exchange the Entra access_token for a Velaro JWT via /auth/entra/token
8
- * 4. Store { velaroToken, velaroExpires, entraRefreshToken } in ~/.velaro/config.json
9
- *
10
- * Refresh:
11
- * When the Velaro JWT is within 5 minutes of expiring, silently use the stored
12
- * Entra refresh_token to get a new access_token and re-exchange for a new Velaro JWT.
13
- *
14
- * One-time setup (run once in the Velaro CIAM tenant):
15
- * az login --tenant 61de45b3-458d-49a5-913c-501247a6fe4f --allow-no-subscriptions
16
- * az ad app create --display-name "Velaro CLI" --sign-in-audience AzureADMyOrg \
17
- * --public-client-redirect-uris "https://login.microsoftonline.com/common/oauth2/nativeclient"
18
- * az ad app update --id <appId> --set isFallbackPublicClient=true
19
- * # Grant admin consent in Azure Portal:
20
- * # api://89bd2fb7-7020-4cfe-a153-1c1ee37903c2/access_as_user (Delegated)
21
- * # Then set VELARO_CLI_CLIENT_ID=<appId> or update the constant below.
22
- */
23
-
24
- const TENANT_ID = '61de45b3-458d-49a5-913c-501247a6fe4f';
25
- // CIAM tenant uses login.velaro.com, not login.microsoftonline.com
26
- const AUTHORITY = `https://login.velaro.com/${TENANT_ID}`;
27
- const CLIENT_ID = process.env.VELARO_CLI_CLIENT_ID || 'c0fdce54-e9b4-4427-a021-b2605855ff8b';
28
-
29
- const SCOPE = [
30
- 'api://89bd2fb7-7020-4cfe-a153-1c1ee37903c2/access_as_user',
31
- 'offline_access',
32
- 'openid',
33
- 'profile',
34
- 'email',
35
- ].join(' ');
36
-
37
- const DEVICE_CODE_URL = `${AUTHORITY}/oauth2/v2.0/devicecode`;
38
- const TOKEN_URL = `${AUTHORITY}/oauth2/v2.0/token`;
39
- const FETCH_TIMEOUT = 10_000;
40
-
41
- export async function requestDeviceCode() {
42
- const res = await fetch(DEVICE_CODE_URL, {
43
- method: 'POST',
44
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
45
- body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE }),
46
- signal: AbortSignal.timeout(FETCH_TIMEOUT),
47
- });
48
-
49
- const data = await res.json();
50
- if (data.error) throw new Error(`Device code request failed: ${data.error_description || data.error}`);
51
- return data; // { device_code, user_code, verification_uri, expires_in, interval, message }
52
- }
53
-
54
- export async function pollForToken(deviceCode, intervalSeconds) {
55
- let interval = intervalSeconds;
56
-
57
- while (true) {
58
- const res = await fetch(TOKEN_URL, {
59
- method: 'POST',
60
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
61
- body: new URLSearchParams({
62
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
63
- client_id: CLIENT_ID,
64
- device_code: deviceCode,
65
- }),
66
- signal: AbortSignal.timeout(FETCH_TIMEOUT),
67
- });
68
-
69
- const data = await res.json();
70
-
71
- if (data.access_token) return data; // { access_token, refresh_token, expires_in }
72
-
73
- if (data.error === 'authorization_pending') {
74
- // normal — keep waiting
75
- } else if (data.error === 'slow_down') {
76
- interval += 5 + Math.random() * 2; // RFC 8628 §3.5 — add jitter to avoid lockstep
77
- } else if (data.error === 'expired_token') {
78
- throw new Error('Login timed out — please run velaro login again.');
79
- } else if (data.error === 'access_denied') {
80
- throw new Error('Login cancelled.');
81
- } else {
82
- throw new Error(data.error_description || data.error);
83
- }
84
-
85
- await sleep(interval * 1000); // sleep at end so first attempt fires immediately
86
- }
87
- }
88
-
89
- export async function exchangeForVelaroToken(entraAccessToken, apiBase) {
90
- const res = await fetch(`${apiBase}/auth/entra/token`, {
91
- method: 'POST',
92
- headers: { Authorization: `Bearer ${entraAccessToken}` },
93
- signal: AbortSignal.timeout(FETCH_TIMEOUT),
94
- });
95
-
96
- if (!res.ok) {
97
- const body = await res.text().catch(() => res.status.toString());
98
- throw new Error(`Velaro token exchange failed (${res.status}): ${body}`);
99
- }
100
-
101
- return res.json(); // { token: { token, expires }, profile: { SiteId, Name, UserName, ... } }
102
- }
103
-
104
- export async function refreshVelaroToken(credentials) {
105
- const res = await fetch(TOKEN_URL, {
106
- method: 'POST',
107
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
- body: new URLSearchParams({
109
- grant_type: 'refresh_token',
110
- client_id: CLIENT_ID,
111
- refresh_token: credentials.entraRefreshToken,
112
- }),
113
- signal: AbortSignal.timeout(FETCH_TIMEOUT),
114
- });
115
-
116
- const data = await res.json();
117
- if (!data.access_token) {
118
- throw new Error(`Token refresh failed: ${data.error_description || data.error}`);
119
- }
120
-
121
- const velaro = await exchangeForVelaroToken(data.access_token, credentials.apiBase);
122
-
123
- return {
124
- ...credentials,
125
- velaroToken: velaro.token.token,
126
- velaroExpires: velaro.token.expires,
127
- entraRefreshToken: data.refresh_token || credentials.entraRefreshToken,
128
- siteId: velaro.profile?.SiteId ?? credentials.siteId,
129
- userName: velaro.profile?.Name ?? credentials.userName,
130
- };
131
- }
132
-
133
- function sleep(ms) {
134
- return new Promise((resolve) => setTimeout(resolve, ms));
135
- }
1
+ /**
2
+ * OAuth 2.0 device authorization flow (RFC 8628) against the Velaro Entra CIAM tenant.
3
+ *
4
+ * Flow:
5
+ * 1. POST /devicecode → get user_code + verification_uri
6
+ * 2. Show the user the code; poll /token until granted
7
+ * 3. Exchange the Entra access_token for a Velaro JWT via /auth/entra/token
8
+ * 4. Store { velaroToken, velaroExpires, entraRefreshToken } in ~/.velaro/config.json
9
+ *
10
+ * Refresh:
11
+ * When the Velaro JWT is within 5 minutes of expiring, silently use the stored
12
+ * Entra refresh_token to get a new access_token and re-exchange for a new Velaro JWT.
13
+ *
14
+ * One-time setup (run once in the Velaro CIAM tenant):
15
+ * az login --tenant 61de45b3-458d-49a5-913c-501247a6fe4f --allow-no-subscriptions
16
+ * az ad app create --display-name "Velaro CLI" --sign-in-audience AzureADMyOrg \
17
+ * --public-client-redirect-uris "https://login.microsoftonline.com/common/oauth2/nativeclient"
18
+ * az ad app update --id <appId> --set isFallbackPublicClient=true
19
+ * # Grant admin consent in Azure Portal:
20
+ * # api://89bd2fb7-7020-4cfe-a153-1c1ee37903c2/access_as_user (Delegated)
21
+ * # Then set VELARO_CLI_CLIENT_ID=<appId> or update the constant below.
22
+ */
23
+
24
+ const TENANT_ID = '61de45b3-458d-49a5-913c-501247a6fe4f';
25
+ // CIAM tenant uses login.velaro.com, not login.microsoftonline.com
26
+ const AUTHORITY = `https://login.velaro.com/${TENANT_ID}`;
27
+ const CLIENT_ID = process.env.VELARO_CLI_CLIENT_ID || 'c0fdce54-e9b4-4427-a021-b2605855ff8b';
28
+
29
+ const SCOPE = [
30
+ 'api://89bd2fb7-7020-4cfe-a153-1c1ee37903c2/access_as_user',
31
+ 'offline_access',
32
+ 'openid',
33
+ 'profile',
34
+ 'email',
35
+ ].join(' ');
36
+
37
+ const DEVICE_CODE_URL = `${AUTHORITY}/oauth2/v2.0/devicecode`;
38
+ const TOKEN_URL = `${AUTHORITY}/oauth2/v2.0/token`;
39
+ const FETCH_TIMEOUT = 10_000;
40
+
41
+ export async function requestDeviceCode() {
42
+ const res = await fetch(DEVICE_CODE_URL, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
45
+ body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE }),
46
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
47
+ });
48
+
49
+ const data = await res.json();
50
+ if (data.error) throw new Error(`Device code request failed: ${data.error_description || data.error}`);
51
+ return data; // { device_code, user_code, verification_uri, expires_in, interval, message }
52
+ }
53
+
54
+ export async function pollForToken(deviceCode, intervalSeconds) {
55
+ let interval = intervalSeconds;
56
+
57
+ while (true) {
58
+ const res = await fetch(TOKEN_URL, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
61
+ body: new URLSearchParams({
62
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
63
+ client_id: CLIENT_ID,
64
+ device_code: deviceCode,
65
+ }),
66
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
67
+ });
68
+
69
+ const data = await res.json();
70
+
71
+ if (data.access_token) return data; // { access_token, refresh_token, expires_in }
72
+
73
+ if (data.error === 'authorization_pending') {
74
+ // normal — keep waiting
75
+ } else if (data.error === 'slow_down') {
76
+ interval += 5 + Math.random() * 2; // RFC 8628 §3.5 — add jitter to avoid lockstep
77
+ } else if (data.error === 'expired_token') {
78
+ throw new Error('Login timed out — please run velaro login again.');
79
+ } else if (data.error === 'access_denied') {
80
+ throw new Error('Login cancelled.');
81
+ } else {
82
+ throw new Error(data.error_description || data.error);
83
+ }
84
+
85
+ await sleep(interval * 1000); // sleep at end so first attempt fires immediately
86
+ }
87
+ }
88
+
89
+ export async function exchangeForVelaroToken(entraAccessToken, apiBase) {
90
+ const res = await fetch(`${apiBase}/auth/entra/token`, {
91
+ method: 'POST',
92
+ headers: { Authorization: `Bearer ${entraAccessToken}` },
93
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
94
+ });
95
+
96
+ if (!res.ok) {
97
+ const body = await res.text().catch(() => res.status.toString());
98
+ throw new Error(`Velaro token exchange failed (${res.status}): ${body}`);
99
+ }
100
+
101
+ return res.json(); // { token: { token, expires }, profile: { SiteId, Name, UserName, ... } }
102
+ }
103
+
104
+ export async function refreshVelaroToken(credentials) {
105
+ const res = await fetch(TOKEN_URL, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
+ body: new URLSearchParams({
109
+ grant_type: 'refresh_token',
110
+ client_id: CLIENT_ID,
111
+ refresh_token: credentials.entraRefreshToken,
112
+ }),
113
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
114
+ });
115
+
116
+ const data = await res.json();
117
+ if (!data.access_token) {
118
+ throw new Error(`Token refresh failed: ${data.error_description || data.error}`);
119
+ }
120
+
121
+ const velaro = await exchangeForVelaroToken(data.access_token, credentials.apiBase);
122
+
123
+ return {
124
+ ...credentials,
125
+ velaroToken: velaro.token.token,
126
+ velaroExpires: velaro.token.expires,
127
+ entraRefreshToken: data.refresh_token || credentials.entraRefreshToken,
128
+ siteId: velaro.profile?.SiteId ?? credentials.siteId,
129
+ userName: velaro.profile?.Name ?? credentials.userName,
130
+ };
131
+ }
132
+
133
+ function sleep(ms) {
134
+ return new Promise((resolve) => setTimeout(resolve, ms));
135
+ }
package/lib/run.js CHANGED
@@ -1,16 +1,16 @@
1
- import { track } from './track.js';
2
-
3
- /** Wraps a yargs command handler with consistent error reporting and usage tracking. */
4
- export function runCommand(fn) {
5
- return async (argv) => {
6
- try {
7
- await fn(argv);
8
- // Fire-and-forget tracking after successful command — never awaited, never blocks
9
- const action = argv._.join('.') || 'unknown';
10
- track(action);
11
- } catch (err) {
12
- console.error(`Error: ${err.message}`);
13
- process.exit(1);
14
- }
15
- };
16
- }
1
+ import { track } from './track.js';
2
+
3
+ /** Wraps a yargs command handler with consistent error reporting and usage tracking. */
4
+ export function runCommand(fn) {
5
+ return async (argv) => {
6
+ try {
7
+ await fn(argv);
8
+ // Fire-and-forget tracking after successful command — never awaited, never blocks
9
+ const action = argv._.join('.') || 'unknown';
10
+ track(action);
11
+ } catch (err) {
12
+ console.error(`Error: ${err.message}`);
13
+ process.exit(1);
14
+ }
15
+ };
16
+ }
@@ -1,39 +1,39 @@
1
- /**
2
- * Subscription helpers for CLI commands.
3
- * Provides a clean gate that prints a plan upgrade message instead of
4
- * throwing a confusing 403/400 from the API.
5
- */
6
-
7
- import { get } from './api.js';
8
-
9
- let _cachedSub = null;
10
-
11
- export async function getSubscription() {
12
- if (!_cachedSub) {
13
- _cachedSub = await get('/Subscription');
14
- }
15
- return _cachedSub;
16
- }
17
-
18
- /**
19
- * Throws an Error with a friendly upgrade message if the feature flag is off.
20
- * Usage: await requireFeature('enableAI', 'AI Bots');
21
- */
22
- export async function requireFeature(flag, featureName) {
23
- const sub = await getSubscription();
24
- if (!sub[flag]) {
25
- throw new Error(
26
- `${featureName} is not enabled on your plan.\n` +
27
- ` Upgrade at https://velaro.com/pricing or contact sales@velaro.com.`
28
- );
29
- }
30
- return sub;
31
- }
32
-
33
- /**
34
- * Returns true/false without throwing — for check/status commands.
35
- */
36
- export async function hasFeature(flag) {
37
- const sub = await getSubscription();
38
- return sub[flag] === true;
39
- }
1
+ /**
2
+ * Subscription helpers for CLI commands.
3
+ * Provides a clean gate that prints a plan upgrade message instead of
4
+ * throwing a confusing 403/400 from the API.
5
+ */
6
+
7
+ import { get } from './api.js';
8
+
9
+ let _cachedSub = null;
10
+
11
+ export async function getSubscription() {
12
+ if (!_cachedSub) {
13
+ _cachedSub = await get('/Subscription');
14
+ }
15
+ return _cachedSub;
16
+ }
17
+
18
+ /**
19
+ * Throws an Error with a friendly upgrade message if the feature flag is off.
20
+ * Usage: await requireFeature('enableAI', 'AI Bots');
21
+ */
22
+ export async function requireFeature(flag, featureName) {
23
+ const sub = await getSubscription();
24
+ if (!sub[flag]) {
25
+ throw new Error(
26
+ `${featureName} is not enabled on your plan.\n` +
27
+ ` Upgrade at https://velaro.com/pricing or contact sales@velaro.com.`
28
+ );
29
+ }
30
+ return sub;
31
+ }
32
+
33
+ /**
34
+ * Returns true/false without throwing — for check/status commands.
35
+ */
36
+ export async function hasFeature(flag) {
37
+ const sub = await getSubscription();
38
+ return sub[flag] === true;
39
+ }