@4ge/cli 0.1.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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +228 -0
  3. package/bin/dev.js +3 -0
  4. package/bin/run.js +3 -0
  5. package/dist/base.js +398 -0
  6. package/dist/commands/auth/login.js +197 -0
  7. package/dist/commands/auth/logout.js +31 -0
  8. package/dist/commands/auth/profile/delete.js +34 -0
  9. package/dist/commands/auth/profile/list.js +32 -0
  10. package/dist/commands/auth/profile/use.js +30 -0
  11. package/dist/commands/auth/status.js +107 -0
  12. package/dist/commands/config/get.js +37 -0
  13. package/dist/commands/config/index.js +9 -0
  14. package/dist/commands/config/manage.js +165 -0
  15. package/dist/commands/config/set.js +42 -0
  16. package/dist/commands/config/show.js +46 -0
  17. package/dist/commands/epic/index.js +10 -0
  18. package/dist/commands/epic/list.js +91 -0
  19. package/dist/commands/epic/pull.js +137 -0
  20. package/dist/commands/epic/show.js +67 -0
  21. package/dist/commands/epic/update.js +104 -0
  22. package/dist/commands/feature/index.js +2 -0
  23. package/dist/commands/feature/list.js +93 -0
  24. package/dist/commands/feature/pull.js +122 -0
  25. package/dist/commands/feature/show.js +60 -0
  26. package/dist/commands/feature/update.js +103 -0
  27. package/dist/commands/idea/create.js +88 -0
  28. package/dist/commands/idea/graduate.js +89 -0
  29. package/dist/commands/idea/index.js +5 -0
  30. package/dist/commands/idea/list.js +83 -0
  31. package/dist/commands/idea/show.js +86 -0
  32. package/dist/commands/idea/update.js +108 -0
  33. package/dist/commands/init.js +354 -0
  34. package/dist/commands/login.js +5 -0
  35. package/dist/commands/plan/index.js +10 -0
  36. package/dist/commands/plan/pull.js +104 -0
  37. package/dist/commands/plan/versions.js +64 -0
  38. package/dist/commands/project/create.js +84 -0
  39. package/dist/commands/project/index.js +9 -0
  40. package/dist/commands/project/info.js +74 -0
  41. package/dist/commands/project/list.js +78 -0
  42. package/dist/commands/project/pull.js +127 -0
  43. package/dist/commands/project/update.js +104 -0
  44. package/dist/commands/root.js +13 -0
  45. package/dist/commands/skill/index.js +8 -0
  46. package/dist/commands/skill/install.js +71 -0
  47. package/dist/commands/story/index.js +10 -0
  48. package/dist/commands/story/list.js +81 -0
  49. package/dist/commands/story/pull.js +125 -0
  50. package/dist/commands/story/show.js +57 -0
  51. package/dist/commands/story/update.js +103 -0
  52. package/dist/commands/template/create.js +72 -0
  53. package/dist/commands/template/delete.js +62 -0
  54. package/dist/commands/template/feature/create.js +70 -0
  55. package/dist/commands/template/feature/delete.js +75 -0
  56. package/dist/commands/template/feature/list.js +69 -0
  57. package/dist/commands/template/feature/update.js +82 -0
  58. package/dist/commands/template/info.js +52 -0
  59. package/dist/commands/template/list.js +76 -0
  60. package/dist/commands/template/pull.js +99 -0
  61. package/dist/commands/template/update.js +71 -0
  62. package/dist/commands/whoami.js +5 -0
  63. package/dist/core/api/action-hints.js +17 -0
  64. package/dist/core/api/index.js +3 -0
  65. package/dist/core/api/retry.js +95 -0
  66. package/dist/core/api/validate-response.js +41 -0
  67. package/dist/core/auth/api-client.js +156 -0
  68. package/dist/core/auth/config.js +3 -0
  69. package/dist/core/auth/credentials.js +143 -0
  70. package/dist/core/auth/email-auth.js +30 -0
  71. package/dist/core/auth/errors.js +20 -0
  72. package/dist/core/auth/index.js +6 -0
  73. package/dist/core/auth/realtime.js +82 -0
  74. package/dist/core/auth/resolution.js +63 -0
  75. package/dist/core/auth/state.js +9 -0
  76. package/dist/core/auth/token-refresh.js +100 -0
  77. package/dist/core/config/defaults.js +31 -0
  78. package/dist/core/config/index.js +3 -0
  79. package/dist/core/config/parser.js +213 -0
  80. package/dist/core/config/schema.js +29 -0
  81. package/dist/core/project/markdown.js +37 -0
  82. package/dist/core/skill/installer.js +59 -0
  83. package/dist/core/skill/paths.js +52 -0
  84. package/dist/generated/api/client/client.gen.js +217 -0
  85. package/dist/generated/api/client/index.js +6 -0
  86. package/dist/generated/api/client/types.gen.js +2 -0
  87. package/dist/generated/api/client/utils.gen.js +231 -0
  88. package/dist/generated/api/client.gen.js +3 -0
  89. package/dist/generated/api/core/auth.gen.js +14 -0
  90. package/dist/generated/api/core/bodySerializer.gen.js +57 -0
  91. package/dist/generated/api/core/params.gen.js +103 -0
  92. package/dist/generated/api/core/pathSerializer.gen.js +114 -0
  93. package/dist/generated/api/core/queryKeySerializer.gen.js +99 -0
  94. package/dist/generated/api/core/serverSentEvents.gen.js +136 -0
  95. package/dist/generated/api/core/types.gen.js +2 -0
  96. package/dist/generated/api/core/utils.gen.js +87 -0
  97. package/dist/generated/api/index.js +2 -0
  98. package/dist/generated/api/sdk.gen.js +556 -0
  99. package/dist/generated/api/types.gen.js +2 -0
  100. package/dist/generated/api/zod.gen.js +1262 -0
  101. package/dist/ui/components/action-hints.js +33 -0
  102. package/dist/ui/components/cli-header.js +91 -0
  103. package/dist/ui/components/confirm-prompt.js +28 -0
  104. package/dist/ui/components/data-table.js +35 -0
  105. package/dist/ui/components/detail-view.js +5 -0
  106. package/dist/ui/components/error-display.js +5 -0
  107. package/dist/ui/components/prompt-layout.js +6 -0
  108. package/dist/ui/components/select-prompt.js +28 -0
  109. package/dist/ui/components/spinner.js +14 -0
  110. package/dist/ui/components/success-message.js +5 -0
  111. package/dist/ui/components/text-input.js +29 -0
  112. package/dist/ui/hooks/use-action-hints.js +21 -0
  113. package/dist/ui/hooks/use-status.js +22 -0
  114. package/dist/ui/index.js +15 -0
  115. package/dist/ui/prompt-helpers.js +55 -0
  116. package/dist/ui/render.js +9 -0
  117. package/package.json +89 -0
  118. package/skills/4ge-cli.md +113 -0
  119. package/usage.md +320 -0
@@ -0,0 +1,156 @@
1
+ import { client } from '../../generated/api/client.gen.js';
2
+ import { getApiBaseUrl } from './config.js';
3
+ import { ensureFreshToken } from './token-refresh.js';
4
+ import { ConnectivityError } from './errors.js';
5
+ export { ConnectivityError } from './errors.js';
6
+ /**
7
+ * Check if an error is an authentication-related error.
8
+ * Works with error objects returned by the API client when throwOnError is false.
9
+ */
10
+ export function isAuthError(error) {
11
+ return error?.code === 'TOKEN_EXPIRED' || error?.code === 'INVALID_API_KEY';
12
+ }
13
+ /**
14
+ * Get actionable guidance for an authentication error.
15
+ * Returns null if the error is not an auth error.
16
+ */
17
+ export function getAuthErrorHint(error) {
18
+ if (error?.code === 'TOKEN_EXPIRED') {
19
+ return "Run '4ge auth login' to re-authenticate";
20
+ }
21
+ if (error?.code === 'INVALID_API_KEY') {
22
+ return 'Check your 4GE_API_KEY environment variable';
23
+ }
24
+ return null;
25
+ }
26
+ /**
27
+ * Check if the API server is reachable.
28
+ * Performs a lightweight HEAD request with a 5-second timeout.
29
+ */
30
+ export async function checkConnectivity(baseUrl) {
31
+ const controller = new AbortController();
32
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
33
+ try {
34
+ await fetch(baseUrl, {
35
+ method: 'HEAD',
36
+ signal: controller.signal,
37
+ });
38
+ }
39
+ catch (error) {
40
+ let message = `Cannot connect to the API server at ${baseUrl}. Please check that the server is running and accessible.`;
41
+ if (error.name === 'AbortError') {
42
+ message = `Connection to API server at ${baseUrl} timed out after 5 seconds.`;
43
+ }
44
+ throw new ConnectivityError(baseUrl, message);
45
+ }
46
+ finally {
47
+ clearTimeout(timeoutId);
48
+ }
49
+ }
50
+ /**
51
+ * Create an authenticated API client wrapper.
52
+ * This wraps the generated API client from src/generated/api/
53
+ * and injects authentication headers automatically.
54
+ *
55
+ * Headers added:
56
+ * - Authorization: Bearer <api_key> (PAT or OAuth access token)
57
+ * - X-Client-Type: 4ge-agent (when isJsonMode is true)
58
+ *
59
+ * Interceptors added:
60
+ * - Request: injects Authorization + X-Client-Type headers
61
+ * - Response: intercepts 401s and attempts token refresh + retry (once)
62
+ * - Error: attaches structured auth error info (code + hint) for 401s
63
+ *
64
+ * NOTE: This implementation currently uses a singleton client.
65
+ * Interceptors are cleared and re-added to ensure the correct credentials
66
+ * are used for the current call. This is safe for CLI usage patterns.
67
+ */
68
+ export function createApiClient(opts) {
69
+ const { credential, isJsonMode, baseUrl = getApiBaseUrl() } = opts;
70
+ client.setConfig({
71
+ baseUrl,
72
+ });
73
+ // Clear existing interceptors to avoid duplication if called multiple times
74
+ client.interceptors.request.clear();
75
+ client.interceptors.response.clear();
76
+ client.interceptors.error.clear();
77
+ client.interceptors.request.use((request) => {
78
+ request.headers.set('Authorization', `Bearer ${credential.api_key}`);
79
+ if (isJsonMode) {
80
+ request.headers.set('X-Client-Type', '4ge-agent');
81
+ }
82
+ return request;
83
+ });
84
+ // Response interceptor: handle 401 with token refresh + retry (once)
85
+ client.interceptors.response.use(async (response, request, options) => {
86
+ if (response.status !== 401) {
87
+ return response;
88
+ }
89
+ // Already retried once — don't retry again to prevent infinite loops
90
+ if (request.headers.has('X-Auth-Retry')) {
91
+ return response;
92
+ }
93
+ // PAT auth (environment source) cannot be refreshed
94
+ if (credential.source === 'environment') {
95
+ return response;
96
+ }
97
+ // No refresh token available — can't refresh
98
+ if (!credential.refresh_token) {
99
+ return response;
100
+ }
101
+ try {
102
+ const freshCredential = await ensureFreshToken();
103
+ // Only retry if refresh produced a new access token
104
+ if (freshCredential && freshCredential.api_key !== credential.api_key) {
105
+ const retryHeaders = new Headers(request.headers);
106
+ retryHeaders.set('Authorization', `Bearer ${freshCredential.api_key}`);
107
+ retryHeaders.set('X-Auth-Retry', '1');
108
+ const _fetch = options.fetch ?? globalThis.fetch;
109
+ const retryBody = options.serializedBody || undefined;
110
+ const retryRequest = new Request(request.url, {
111
+ method: request.method,
112
+ headers: retryHeaders,
113
+ body: retryBody,
114
+ redirect: request.redirect,
115
+ credentials: request.credentials,
116
+ });
117
+ return await _fetch(retryRequest);
118
+ }
119
+ }
120
+ catch (err) {
121
+ // Refresh failed. If the server is unreachable, ensureFreshToken throws
122
+ // a ConnectivityError — re-throw it so callers can distinguish a dead
123
+ // server (CONNECTION_ERROR) from a dead token (TOKEN_EXPIRED). The
124
+ // error interceptor preserves it instead of forcing TOKEN_EXPIRED.
125
+ if (err instanceof ConnectivityError) {
126
+ throw err;
127
+ }
128
+ // Other refresh failures (rejected refresh token) fall through to the
129
+ // original 401, which the error interceptor labels TOKEN_EXPIRED.
130
+ }
131
+ return response;
132
+ });
133
+ // Error interceptor: attach structured auth error info for 401s
134
+ client.interceptors.error.use((error, response, _request, _options) => {
135
+ // A ConnectivityError re-thrown from the 401-retry path means the server
136
+ // is unreachable — preserve its code so callers don't mistake it for a
137
+ // session-expiry (the 401 response is still attached, which would
138
+ // otherwise force TOKEN_EXPIRED below).
139
+ if (error instanceof ConnectivityError) {
140
+ return error;
141
+ }
142
+ if (response?.status === 401) {
143
+ const isPatAuth = credential.source === 'environment' || !credential.refresh_token;
144
+ const authError = {
145
+ ...(typeof error === 'object' && error !== null ? error : { message: String(error) }),
146
+ code: isPatAuth ? 'INVALID_API_KEY' : 'TOKEN_EXPIRED',
147
+ hint: isPatAuth
148
+ ? 'Check your 4GE_API_KEY environment variable'
149
+ : "Run '4ge auth login' to re-authenticate",
150
+ };
151
+ return authError;
152
+ }
153
+ return error;
154
+ });
155
+ return client;
156
+ }
@@ -0,0 +1,3 @@
1
+ export function getApiBaseUrl() {
2
+ return process.env['4GE_API_URL'] || process.env['CORE_API_URL'] || 'https://app.4ge.dev';
3
+ }
@@ -0,0 +1,143 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ // Zod schema for credentials file
6
+ const ProfileCredentialSchema = z.object({
7
+ // PAT authentication
8
+ api_key: z.string().min(1).optional(),
9
+ // OAuth / email authentication
10
+ access_token: z.string().min(1).optional(),
11
+ refresh_token: z.string().min(1).optional(),
12
+ expires_at: z.number().optional(),
13
+ user: z.object({
14
+ id: z.string(),
15
+ email: z.string(),
16
+ }).optional(),
17
+ }).refine((data) => data.api_key || data.access_token, { message: 'Profile must have either api_key or access_token' });
18
+ export const CredentialsFileSchema = z.object({
19
+ default: ProfileCredentialSchema.optional(),
20
+ profiles: z.record(z.string(), ProfileCredentialSchema).default({}),
21
+ active_profile: z.string().default('default'),
22
+ });
23
+ // Paths
24
+ export function getGlobalCredentialsPath() {
25
+ return join(homedir(), '.4ge', 'credentials.json');
26
+ }
27
+ export function getLocalCredentialsPath() {
28
+ return join(process.cwd(), '.4ge', 'credentials.json');
29
+ }
30
+ // Read credentials file (returns null if doesn't exist or invalid)
31
+ export async function readCredentialsFile(filePath) {
32
+ try {
33
+ const content = await readFile(filePath, 'utf-8');
34
+ const result = CredentialsFileSchema.safeParse(JSON.parse(content));
35
+ return result.success ? result.data : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ // Write credentials file (creates directory if needed)
42
+ export async function writeCredentialsFile(filePath, data) {
43
+ const dir = dirname(filePath);
44
+ await mkdir(dir, { recursive: true, mode: 0o700 });
45
+ await writeFile(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
46
+ }
47
+ // Save a credential to a specific location and profile
48
+ export async function saveCredential(opts) {
49
+ const hasToken = opts.apiKey || opts.accessToken;
50
+ if (!hasToken) {
51
+ throw new Error('Either apiKey or accessToken must be provided');
52
+ }
53
+ const filePath = opts.local ? getLocalCredentialsPath() : getGlobalCredentialsPath();
54
+ const existing = (await readCredentialsFile(filePath)) ?? {
55
+ profiles: {},
56
+ active_profile: 'default',
57
+ };
58
+ const profile = opts.profile ?? 'default';
59
+ const profileData = {};
60
+ if (opts.apiKey)
61
+ profileData.api_key = opts.apiKey;
62
+ if (opts.accessToken)
63
+ profileData.access_token = opts.accessToken;
64
+ if (opts.refreshToken)
65
+ profileData.refresh_token = opts.refreshToken;
66
+ if (opts.expiresAt !== undefined)
67
+ profileData.expires_at = opts.expiresAt;
68
+ if (opts.user)
69
+ profileData.user = opts.user;
70
+ if (profile === 'default') {
71
+ existing.default = profileData;
72
+ }
73
+ else {
74
+ existing.profiles[profile] = profileData;
75
+ }
76
+ await writeCredentialsFile(filePath, existing);
77
+ }
78
+ // Clear a credential from a specific location
79
+ export async function clearCredential(opts) {
80
+ const filePath = opts.local ? getLocalCredentialsPath() : getGlobalCredentialsPath();
81
+ const existing = await readCredentialsFile(filePath);
82
+ if (!existing)
83
+ return;
84
+ const profileToClear = opts.profile ?? existing.active_profile;
85
+ if (profileToClear === 'default') {
86
+ delete existing.default;
87
+ }
88
+ else {
89
+ delete existing.profiles[profileToClear];
90
+ }
91
+ if (existing.active_profile === profileToClear) {
92
+ existing.active_profile = 'default';
93
+ }
94
+ await writeCredentialsFile(filePath, existing);
95
+ }
96
+ // List all profiles in the global credentials file
97
+ export async function listProfiles() {
98
+ const filePath = getGlobalCredentialsPath();
99
+ const data = (await readCredentialsFile(filePath)) ?? {
100
+ profiles: {},
101
+ active_profile: 'default',
102
+ };
103
+ const profiles = [];
104
+ if (data.default?.api_key || data.default?.access_token) {
105
+ profiles.push({ name: 'default', is_active: data.active_profile === 'default' });
106
+ }
107
+ profiles.push(...Object.keys(data.profiles).map((name) => ({
108
+ name,
109
+ is_active: data.active_profile === name,
110
+ })));
111
+ return {
112
+ profiles,
113
+ active_profile: data.active_profile,
114
+ };
115
+ }
116
+ // Set the active profile in global credentials
117
+ export async function setActiveProfile(name) {
118
+ const filePath = getGlobalCredentialsPath();
119
+ const existing = await readCredentialsFile(filePath);
120
+ if (!existing) {
121
+ throw new Error('No credentials file found. Please login first.');
122
+ }
123
+ if (name !== 'default' && !existing.profiles[name]) {
124
+ throw new Error(`Profile "${name}" not found.`);
125
+ }
126
+ existing.active_profile = name;
127
+ await writeCredentialsFile(filePath, existing);
128
+ }
129
+ // Delete a named profile from global credentials
130
+ export async function deleteProfile(name) {
131
+ if (name === 'default') {
132
+ throw new Error('Cannot delete the default profile.');
133
+ }
134
+ const filePath = getGlobalCredentialsPath();
135
+ const existing = await readCredentialsFile(filePath);
136
+ if (!existing)
137
+ return;
138
+ delete existing.profiles[name];
139
+ if (existing.active_profile === name) {
140
+ existing.active_profile = 'default';
141
+ }
142
+ await writeCredentialsFile(filePath, existing);
143
+ }
@@ -0,0 +1,30 @@
1
+ import { cliAuthEmailLogin } from '../../generated/api/sdk.gen.js';
2
+ /**
3
+ * Authenticate with email and password via the Core API.
4
+ * Uses the generated SDK function cliAuthEmailLogin.
5
+ */
6
+ export async function authenticateWithEmail(opts) {
7
+ const { email, password, baseUrl } = opts;
8
+ // Build the client options - we don't need auth for this endpoint (security: [])
9
+ const clientOptions = {};
10
+ if (baseUrl) {
11
+ // We need to set the baseUrl on the client before calling
12
+ // This will be handled by the caller setting up the client
13
+ }
14
+ const { data, error } = await cliAuthEmailLogin({
15
+ body: { email, password },
16
+ ...(baseUrl ? { baseUrl } : {}),
17
+ });
18
+ if (error) {
19
+ const errorMsg = error instanceof Error ? error.message
20
+ : (typeof error === 'object' && error !== null && 'message' in error) ? String(error.message)
21
+ : String(error);
22
+ throw new Error(`Email authentication failed: ${errorMsg}`);
23
+ }
24
+ // Extract the inner data payload from the SDK envelope
25
+ const payload = data?.data;
26
+ if (!payload?.access_token) {
27
+ throw new Error('Email authentication failed: invalid response from server');
28
+ }
29
+ return payload;
30
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * ConnectivityError — raised when the 4ge Core API server is unreachable
3
+ * (network failure, DNS, timeout, abort, or non-JSON response).
4
+ *
5
+ * Kept in its own module so that low-level auth helpers (e.g. token-refresh)
6
+ * can throw ConnectivityError without importing the full api-client (which
7
+ * would create a circular dependency: api-client imports ensureFreshToken
8
+ * from token-refresh).
9
+ *
10
+ * `code` is always 'CONNECTION_ERROR' for machine-readable consumers.
11
+ */
12
+ export class ConnectivityError extends Error {
13
+ url;
14
+ code = 'CONNECTION_ERROR';
15
+ constructor(url, message) {
16
+ super(message);
17
+ this.url = url;
18
+ this.name = 'ConnectivityError';
19
+ }
20
+ }
@@ -0,0 +1,6 @@
1
+ export * from './credentials.js';
2
+ export * from './resolution.js';
3
+ export * from './errors.js';
4
+ export * from './api-client.js';
5
+ export * from './token-refresh.js';
6
+ export * from './config.js';
@@ -0,0 +1,82 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ /**
3
+ * Subscribe to a Supabase Realtime broadcast channel to receive OAuth tokens.
4
+ * Channel pattern: oauth:${state}
5
+ * Event: 'tokens'
6
+ *
7
+ * Returns a promise that resolves with the tokens when the broadcast event is received,
8
+ * or rejects on timeout.
9
+ */
10
+ export async function subscribeForTokens(opts) {
11
+ const { supabaseUrl, supabaseAnonKey, state, timeoutMs = 5 * 60 * 1000 } = opts;
12
+ const supabase = createClient(supabaseUrl, supabaseAnonKey);
13
+ const channel = supabase.channel(`oauth:${state}`);
14
+ let timeoutHandle = null;
15
+ let resolveFunc = null;
16
+ let rejectFunc = null;
17
+ const tokensPromise = new Promise((resolve, reject) => {
18
+ resolveFunc = resolve;
19
+ rejectFunc = reject;
20
+ // Set timeout for handshake
21
+ timeoutHandle = setTimeout(() => {
22
+ cleanup();
23
+ reject(new Error('OAuth handshake timed out after 5 minutes. Please try again.'));
24
+ }, timeoutMs);
25
+ });
26
+ const cleanup = () => {
27
+ if (timeoutHandle) {
28
+ clearTimeout(timeoutHandle);
29
+ timeoutHandle = null;
30
+ }
31
+ supabase.removeChannel(channel);
32
+ };
33
+ // Subscribe to the broadcast channel and listen for 'tokens' event
34
+ channel.on('broadcast', { event: 'tokens' }, (payload) => {
35
+ const tokens = payload.payload;
36
+ // Validate the tokens have all required fields (securely receive)
37
+ if (!tokens?.access_token ||
38
+ !tokens?.refresh_token ||
39
+ typeof tokens?.expires_at !== 'number' ||
40
+ typeof tokens?.expires_in !== 'number' ||
41
+ !tokens?.user?.id ||
42
+ !tokens?.user?.email) {
43
+ return; // Ignore invalid payloads
44
+ }
45
+ if (timeoutHandle) {
46
+ clearTimeout(timeoutHandle);
47
+ timeoutHandle = null;
48
+ }
49
+ resolveFunc(tokens);
50
+ });
51
+ // Subscribe and wait for connection
52
+ await new Promise((resolve, reject) => {
53
+ channel.subscribe((status, err) => {
54
+ if (status === 'SUBSCRIBED') {
55
+ resolve();
56
+ }
57
+ else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
58
+ cleanup();
59
+ reject(new Error(`Realtime subscription failed: ${status}${err ? ` - ${err.message}` : ''}`));
60
+ }
61
+ });
62
+ });
63
+ return {
64
+ tokens: await tokensPromise,
65
+ cleanup,
66
+ };
67
+ }
68
+ /**
69
+ * Get the Supabase URL and anon key from environment variables.
70
+ * Throws if either is missing.
71
+ */
72
+ export function getSupabaseConfig() {
73
+ const supabaseUrl = process.env.SUPABASE_URL;
74
+ const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
75
+ if (!supabaseUrl) {
76
+ throw new Error('SUPABASE_URL environment variable is required for OAuth login. Set it in your .env file or environment.');
77
+ }
78
+ if (!supabaseAnonKey) {
79
+ throw new Error('SUPABASE_ANON_KEY environment variable is required for OAuth login. Set it in your .env file or environment.');
80
+ }
81
+ return { supabaseUrl, supabaseAnonKey };
82
+ }
@@ -0,0 +1,63 @@
1
+ import { getGlobalCredentialsPath, getLocalCredentialsPath, readCredentialsFile } from './credentials.js';
2
+ /**
3
+ * Resolve credentials in priority order:
4
+ * 1. 4GE_API_KEY environment variable → { source: 'environment' }
5
+ * 2. ./.4ge/credentials.json (local) → { source: 'local' }
6
+ * 3. ~/.4ge/credentials.json active profile → { source: 'global', profile: '...' }
7
+ *
8
+ * Returns null if no credentials found.
9
+ */
10
+ export async function resolveCredential() {
11
+ // 1. Environment variable
12
+ const envKey = process.env['4GE_API_KEY'];
13
+ if (envKey && envKey.length > 0) {
14
+ return {
15
+ api_key: envKey,
16
+ source: 'environment',
17
+ profile: 'default',
18
+ };
19
+ }
20
+ // 2. Local credentials
21
+ const localData = await readCredentialsFile(getLocalCredentialsPath());
22
+ if (localData) {
23
+ const { active_profile } = localData;
24
+ const profile = active_profile === 'default'
25
+ ? localData.default
26
+ : localData.profiles[active_profile];
27
+ if (profile) {
28
+ const api_key = profile.access_token || profile.api_key;
29
+ if (api_key && api_key.length > 0) {
30
+ return {
31
+ api_key,
32
+ source: 'local',
33
+ profile: active_profile,
34
+ refresh_token: profile.refresh_token,
35
+ expires_at: profile.expires_at,
36
+ user: profile.user,
37
+ };
38
+ }
39
+ }
40
+ }
41
+ // 3. Global credentials
42
+ const globalData = await readCredentialsFile(getGlobalCredentialsPath());
43
+ if (globalData) {
44
+ const { active_profile } = globalData;
45
+ const profile = active_profile === 'default'
46
+ ? globalData.default
47
+ : globalData.profiles[active_profile];
48
+ if (profile) {
49
+ const api_key = profile.access_token || profile.api_key;
50
+ if (api_key && api_key.length > 0) {
51
+ return {
52
+ api_key,
53
+ source: 'global',
54
+ profile: active_profile,
55
+ refresh_token: profile.refresh_token,
56
+ expires_at: profile.expires_at,
57
+ user: profile.user,
58
+ };
59
+ }
60
+ }
61
+ }
62
+ return null;
63
+ }
@@ -0,0 +1,9 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /**
3
+ * Generate a cryptographically secure state token for OAuth Realtime flow.
4
+ * Used as the channel identifier (oauth:${state}) and CSRF protection.
5
+ * @param bytes - Number of random bytes (default 32 = 64 hex chars)
6
+ */
7
+ export function generateState(bytes = 32) {
8
+ return randomBytes(bytes).toString('hex');
9
+ }
@@ -0,0 +1,100 @@
1
+ import { resolveCredential } from './resolution.js';
2
+ import { saveCredential } from './credentials.js';
3
+ import { getApiBaseUrl } from './config.js';
4
+ import { ConnectivityError } from './errors.js';
5
+ /**
6
+ * Ensures the current credential has a fresh access token.
7
+ * If the token is expired or expiring soon, it performs a refresh.
8
+ *
9
+ * Returns the fresh credential.
10
+ */
11
+ export async function ensureFreshToken() {
12
+ const credential = await resolveCredential();
13
+ if (!credential || (!credential.api_key && !credential.refresh_token)) {
14
+ return null;
15
+ }
16
+ // Environment-sourced credentials should never be refreshed or persisted
17
+ if (credential.source === 'environment') {
18
+ return credential;
19
+ }
20
+ // Skip refresh for PAT-based auth (no refresh_token)
21
+ if (!credential.refresh_token) {
22
+ return credential;
23
+ }
24
+ // Check if token is expired or expiring soon
25
+ const now = Math.floor(Date.now() / 1000);
26
+ const isFresh = credential.expires_at && credential.expires_at > (now + 30);
27
+ if (isFresh) {
28
+ return credential;
29
+ }
30
+ // Token is expired or expiring soon — refresh it.
31
+ //
32
+ // Two failure modes must be distinguished:
33
+ // 1. Network/connectivity failure (server unreachable, DNS, timeout):
34
+ // the fetch throws (TypeError) or aborts. Classify as CONNECTION_ERROR
35
+ // so callers can tell the user "the server is down" rather than
36
+ // "your session expired".
37
+ // 2. Session genuinely expired: the server is reachable and explicitly
38
+ // rejects the refresh token (401 / error envelope). Classify as
39
+ // SESSION_EXPIRED so the user is prompted to re-authenticate.
40
+ const baseUrl = getApiBaseUrl();
41
+ const refreshUrl = `${baseUrl}/api/cli/v1/auth/token/refresh`;
42
+ let response;
43
+ try {
44
+ response = await fetch(refreshUrl, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({ refresh_token: credential.refresh_token }),
48
+ });
49
+ }
50
+ catch (networkError) {
51
+ // fetch threw — server unreachable / DNS / timeout / abort.
52
+ // This is NOT a session-expiry; do not conflate them. Reuse the
53
+ // canonical ConnectivityError class so instanceof checks and code
54
+ // consumers stay consistent across the auth module.
55
+ throw new ConnectivityError(refreshUrl, `Cannot connect to the API server to refresh the token: ${networkError.message}. ` +
56
+ `Check that the server is running and reachable at ${baseUrl}.`);
57
+ }
58
+ let result;
59
+ try {
60
+ result = await response.json();
61
+ }
62
+ catch (parseError) {
63
+ // Server responded but body wasn't JSON — treat as a connectivity/server error,
64
+ // not session expiry.
65
+ throw new ConnectivityError(refreshUrl, `Token refresh endpoint returned a non-JSON response (status ${response.status}): ${parseError.message}.`);
66
+ }
67
+ if (result.error || !result.data) {
68
+ // Server is reachable and explicitly rejected the refresh token.
69
+ throw new Error(`Session expired. Please re-authenticate: run '4ge auth login'\n` +
70
+ `Details: ${result.error?.message || 'Refresh token is invalid or expired'}`);
71
+ }
72
+ try {
73
+ // Update the credential and persist it
74
+ await saveCredential({
75
+ accessToken: result.data.access_token,
76
+ refreshToken: result.data.refresh_token,
77
+ expiresAt: result.data.expires_at,
78
+ user: result.data.user || credential.user,
79
+ profile: credential.profile,
80
+ local: credential.source === 'local',
81
+ });
82
+ return {
83
+ ...credential,
84
+ api_key: result.data.access_token,
85
+ refresh_token: result.data.refresh_token,
86
+ expires_at: result.data.expires_at,
87
+ user: result.data.user || credential.user,
88
+ };
89
+ }
90
+ catch (error) {
91
+ // Re-throw errors we've already classified (ConnectivityError or the
92
+ // "Session expired" Error above) without wrapping them — their message
93
+ // and code must be preserved for callers to act on.
94
+ if (error instanceof ConnectivityError || error.message?.includes('Session expired')) {
95
+ throw error;
96
+ }
97
+ // Unexpected error (e.g. saveCredential failure) — surface it.
98
+ throw new Error(`Failed to refresh token: ${error.message}`);
99
+ }
100
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Resolve the roadmap query value for a hierarchy pull (plan:pull + the
3
+ * recursive tree path of epic/story/feature:pull).
4
+ *
5
+ * Precedence: explicit `--roadmap` flag > `config.hierarchy.default_roadmap_filter`
6
+ * > omit the filter entirely (pull every roadmap status).
7
+ *
8
+ * Returns the roadmap literal to send, or `undefined` to omit the filter.
9
+ */
10
+ export function resolveRoadmapFilter(flagValue, config) {
11
+ if (flagValue)
12
+ return flagValue;
13
+ const configured = config?.hierarchy?.default_roadmap_filter;
14
+ if (configured && configured !== 'all')
15
+ return configured;
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Resolve whether a hierarchy pull should descend into the tree.
20
+ *
21
+ * Precedence: explicit `--recursive`/`--no-recursive` flag >
22
+ * `config.hierarchy.recursive_pull` > `false`.
23
+ *
24
+ * `flagValue` is `undefined` when neither `--recursive` nor `--no-recursive`
25
+ * was passed (oclif boolean with `allowNo` and no default).
26
+ */
27
+ export function resolveRecursive(flagValue, config) {
28
+ if (flagValue !== undefined)
29
+ return flagValue;
30
+ return config?.hierarchy?.recursive_pull ?? false;
31
+ }
@@ -0,0 +1,3 @@
1
+ export * from './schema.js';
2
+ export * from './parser.js';
3
+ export * from './defaults.js';