subconscious-cli 4.2.0 → 5.0.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.
- package/README.md +3 -3
- package/bin/auth.js +131 -263
- package/bin/cli.js +2 -0
- package/bin/native/subc-tui-darwin-amd64 +0 -0
- package/bin/native/subc-tui-darwin-arm64 +0 -0
- package/bin/native/subc-tui-linux-amd64 +0 -0
- package/bin/native/subc-tui-linux-arm64 +0 -0
- package/bin/native/subc-tui-windows-amd64.exe +0 -0
- package/bin/native/subc-tui-windows-arm64.exe +0 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -378,9 +378,9 @@ passed through the same way and override Subconscious-injected defaults.
|
|
|
378
378
|
|
|
379
379
|
## Authentication
|
|
380
380
|
|
|
381
|
-
`subc login` opens a browser,
|
|
382
|
-
|
|
383
|
-
also
|
|
381
|
+
`subc login` opens a browser to a one-time login link, waits for you to sign in, and saves the API key to the selected runbook profile with mode `600`. For the default profile it also maintains `~/.subconscious/config.json`. The same flow works on a laptop, over SSH, and inside a VM: the browser does not need to be on the machine running the CLI.
|
|
382
|
+
|
|
383
|
+
If a window does not open, open the login URL printed in the terminal. That URL includes your code. You can also open the printed `/cli/device` link and type the code. To skip the browser, copy an API key from the dashboard and run `subc update-key`.
|
|
384
384
|
|
|
385
385
|
```bash
|
|
386
386
|
subc login
|
package/bin/auth.js
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authentication + credential storage for the Subconscious CLI.
|
|
3
3
|
*
|
|
4
|
-
* Login flow (
|
|
5
|
-
* 1. CLI
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* delivers it back to the CLI via a cross-origin fetch to
|
|
10
|
-
* localhost:{port}/callback?token=...&state=...
|
|
11
|
-
* 4. CLI verifies the `state`, saves the key to ~/.subconscious/config.json,
|
|
12
|
-
* and creates a coding-agent profile under ~/.subconscious/profiles/.
|
|
4
|
+
* Login flow (device code):
|
|
5
|
+
* 1. CLI asks the platform for a device code and a short user code.
|
|
6
|
+
* 2. Opens {platformUrl}/cli/device?code=... in any browser.
|
|
7
|
+
* 3. Polls the platform until the signed-in browser approves the code.
|
|
8
|
+
* 4. Saves the key to ~/.subconscious/config.json and a runbook profile.
|
|
13
9
|
*
|
|
14
10
|
* Override SUBCONSCIOUS_URL env var for local development
|
|
15
11
|
* (e.g. http://localhost:3000). Production defaults to platform.subconscious.dev.
|
|
16
12
|
*/
|
|
17
13
|
|
|
18
|
-
import http from 'node:http';
|
|
19
|
-
import crypto from 'node:crypto';
|
|
20
14
|
import { exec } from 'node:child_process';
|
|
21
15
|
import { openWindowsBrowser } from './windows/process.js';
|
|
22
16
|
import fs from 'node:fs/promises';
|
|
@@ -26,12 +20,18 @@ import { c } from './colors.js';
|
|
|
26
20
|
import { clearProfileApiKey, DEFAULT_PROFILE, ensureProfile } from './profiles.js';
|
|
27
21
|
import { printLoginUpgradeWarning } from './upgrade.js';
|
|
28
22
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
function configDir() {
|
|
24
|
+
return process.env.SUBC_CONFIG_DIR?.trim() || path.join(os.homedir(), '.subconscious');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function configFile() {
|
|
28
|
+
return path.join(configDir(), 'config.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function legacyConfigFile() {
|
|
32
|
+
if (process.env.SUBC_CONFIG_DIR?.trim()) return null;
|
|
33
|
+
return path.join(os.homedir(), '.subcon', 'config.json');
|
|
34
|
+
}
|
|
35
35
|
// Defaults to the platform host. Developers set SUBCONSCIOUS_URL for local dev.
|
|
36
36
|
export const DEFAULT_PLATFORM_URL = 'https://platform.subconscious.dev';
|
|
37
37
|
|
|
@@ -43,41 +43,16 @@ export function getPlatformUrl(profile) {
|
|
|
43
43
|
return DEFAULT_PLATFORM_URL;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Login callback CORS. After the marketing/platform split, /cli/auth lives on
|
|
47
|
-
// platform. www may still 307 there for older CLIs; keep www so a redirected
|
|
48
|
-
// or leftover tab can complete the callback.
|
|
49
|
-
const CALLBACK_ORIGINS = new Set([
|
|
50
|
-
'https://www.subconscious.dev',
|
|
51
|
-
'https://platform.subconscious.dev',
|
|
52
|
-
'https://dev.subconscious.dev',
|
|
53
|
-
'https://platform-dev.subconscious.dev',
|
|
54
|
-
]);
|
|
55
|
-
|
|
56
|
-
export function isAllowedCallbackOrigin(origin, platformUrl = getPlatformUrl()) {
|
|
57
|
-
if (!origin) return false;
|
|
58
|
-
if (origin === platformUrl || CALLBACK_ORIGINS.has(origin)) return true;
|
|
59
|
-
try {
|
|
60
|
-
const { protocol, hostname } = new URL(origin);
|
|
61
|
-
return protocol === 'http:' && (hostname === 'localhost' || hostname === '127.0.0.1');
|
|
62
|
-
} catch {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ── Config helpers ──────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
46
|
async function loadConfig() {
|
|
70
47
|
try {
|
|
71
|
-
const content = await fs.readFile(
|
|
48
|
+
const content = await fs.readFile(configFile(), 'utf-8');
|
|
72
49
|
return JSON.parse(content);
|
|
73
50
|
} catch (error) {
|
|
74
|
-
if (error.code !== 'ENOENT' || !
|
|
51
|
+
if (error.code !== 'ENOENT' || !legacyConfigFile()) return {};
|
|
75
52
|
}
|
|
76
53
|
|
|
77
|
-
// One-way compatibility migration. Keep the old file untouched so this is
|
|
78
|
-
// recoverable, but all future writes go to ~/.subconscious.
|
|
79
54
|
try {
|
|
80
|
-
const content = await fs.readFile(
|
|
55
|
+
const content = await fs.readFile(legacyConfigFile(), 'utf-8');
|
|
81
56
|
const config = JSON.parse(content);
|
|
82
57
|
await saveConfig(config);
|
|
83
58
|
return config;
|
|
@@ -87,16 +62,11 @@ async function loadConfig() {
|
|
|
87
62
|
}
|
|
88
63
|
|
|
89
64
|
async function saveConfig(config) {
|
|
90
|
-
await fs.mkdir(
|
|
91
|
-
await fs.writeFile(
|
|
92
|
-
|
|
93
|
-
await fs.chmod(CONFIG_FILE, 0o600);
|
|
65
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
66
|
+
await fs.writeFile(configFile(), JSON.stringify(config, null, 2), 'utf-8');
|
|
67
|
+
await fs.chmod(configFile(), 0o600);
|
|
94
68
|
}
|
|
95
69
|
|
|
96
|
-
/**
|
|
97
|
-
* Resolve the active API key. The env var takes precedence over the saved
|
|
98
|
-
* config so CI and per-shell overrides win. Returns null when unauthenticated.
|
|
99
|
-
*/
|
|
100
70
|
export async function getApiKey(profile) {
|
|
101
71
|
const envKey = process.env.SUBCONSCIOUS_API_KEY?.trim();
|
|
102
72
|
if (envKey) return { key: envKey, source: 'SUBCONSCIOUS_API_KEY env var' };
|
|
@@ -104,8 +74,6 @@ export async function getApiKey(profile) {
|
|
|
104
74
|
const profileKey = profile?.values?.API_KEY?.trim();
|
|
105
75
|
if (profileKey) return { key: profileKey, source: profile.path };
|
|
106
76
|
|
|
107
|
-
// Named profiles are isolated: an empty/missing key must not silently fall
|
|
108
|
-
// back to the default credential and send traffic to the wrong account.
|
|
109
77
|
if (profile?.name && profile.name !== DEFAULT_PROFILE) return null;
|
|
110
78
|
|
|
111
79
|
const config = await loadConfig();
|
|
@@ -115,24 +83,6 @@ export async function getApiKey(profile) {
|
|
|
115
83
|
return null;
|
|
116
84
|
}
|
|
117
85
|
|
|
118
|
-
export async function probeLoginPage(platformUrl = getPlatformUrl(), fetchImpl = fetch) {
|
|
119
|
-
const url = `${platformUrl.replace(/\/$/, '')}/cli/auth`;
|
|
120
|
-
try {
|
|
121
|
-
const res = await fetchImpl(url, {
|
|
122
|
-
method: 'GET',
|
|
123
|
-
redirect: 'manual',
|
|
124
|
-
signal: AbortSignal.timeout(8000),
|
|
125
|
-
});
|
|
126
|
-
return res.status;
|
|
127
|
-
} catch {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function isLoginMissing(status) {
|
|
133
|
-
return status === 404;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
86
|
// ── Browser opener ──────────────────────────────────────────────────────
|
|
137
87
|
|
|
138
88
|
function openBrowser(url) {
|
|
@@ -159,159 +109,74 @@ function openBrowser(url) {
|
|
|
159
109
|
});
|
|
160
110
|
}
|
|
161
111
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
function startCallbackServer(expectedState) {
|
|
165
|
-
return new Promise((resolveSetup) => {
|
|
166
|
-
let resolveToken, rejectToken;
|
|
167
|
-
const tokenPromise = new Promise((resolve, reject) => {
|
|
168
|
-
resolveToken = resolve;
|
|
169
|
-
rejectToken = reject;
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const server = http.createServer((req, res) => {
|
|
173
|
-
// CORS: only allow known web-app origins (or localhost dev).
|
|
174
|
-
// This prevents arbitrary websites from hitting this callback.
|
|
175
|
-
const origin = req.headers.origin || '';
|
|
176
|
-
const allowed = isAllowedCallbackOrigin(origin);
|
|
177
|
-
res.setHeader(
|
|
178
|
-
'Access-Control-Allow-Origin',
|
|
179
|
-
allowed ? origin : getPlatformUrl(),
|
|
180
|
-
);
|
|
181
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
182
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
183
|
-
res.setHeader('Connection', 'close');
|
|
112
|
+
const DEVICE_POLL_INTERVAL_MS = 2000;
|
|
113
|
+
const DEVICE_POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
184
114
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const url = new URL(req.url, 'http://localhost');
|
|
192
|
-
|
|
193
|
-
if (url.pathname !== '/callback') {
|
|
194
|
-
res.writeHead(404);
|
|
195
|
-
res.end();
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const token = url.searchParams.get('token');
|
|
200
|
-
const state = url.searchParams.get('state');
|
|
201
|
-
const error = url.searchParams.get('error');
|
|
202
|
-
|
|
203
|
-
// The web app sends `Accept: application/json` via fetch(); direct
|
|
204
|
-
// browser visits get the HTML fallback page.
|
|
205
|
-
const wantsJson = (req.headers.accept || '').includes('application/json');
|
|
206
|
-
const respond = (ok, msg) => {
|
|
207
|
-
if (wantsJson) {
|
|
208
|
-
res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
209
|
-
res.end(JSON.stringify({ ok, error: msg || undefined }));
|
|
210
|
-
} else {
|
|
211
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
212
|
-
res.end(resultPage(ok, msg));
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
if (error) {
|
|
217
|
-
respond(false, error);
|
|
218
|
-
server.close();
|
|
219
|
-
rejectToken(new Error(error));
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// CSRF check: state token must match what the CLI generated
|
|
224
|
-
if (state !== expectedState) {
|
|
225
|
-
respond(false, 'State mismatch — possible CSRF attack. Please try again.');
|
|
226
|
-
server.close();
|
|
227
|
-
rejectToken(new Error('State mismatch'));
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (!token) {
|
|
232
|
-
respond(false, 'No API key received.');
|
|
233
|
-
server.close();
|
|
234
|
-
rejectToken(new Error('No API key received'));
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
respond(true);
|
|
239
|
-
server.close();
|
|
240
|
-
resolveToken({ token });
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// Port 0 = OS assigns a random available port. Bound to 127.0.0.1 only.
|
|
244
|
-
server.listen(0, '127.0.0.1', () => {
|
|
245
|
-
const port = server.address().port;
|
|
246
|
-
|
|
247
|
-
const timeout = setTimeout(() => {
|
|
248
|
-
server.close();
|
|
249
|
-
rejectToken(new Error('Authentication timed out (5 min). Please try again.'));
|
|
250
|
-
}, 5 * 60 * 1000);
|
|
115
|
+
function terminalLink(url) {
|
|
116
|
+
return `\u001b]8;;${url}\u0007${url}\u001b]8;;\u0007`;
|
|
117
|
+
}
|
|
251
118
|
|
|
252
|
-
|
|
119
|
+
export async function registerDeviceLogin(platformUrl, fetchImpl = fetch) {
|
|
120
|
+
const res = await fetchImpl(`${platformUrl}/api/cli/device/register`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
signal: AbortSignal.timeout(8000),
|
|
123
|
+
});
|
|
124
|
+
const data = await res.json().catch(() => ({}));
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
throw new Error(data.message || 'Could not start CLI login.');
|
|
127
|
+
}
|
|
128
|
+
if (!data.device_code || !data.user_code) {
|
|
129
|
+
throw new Error('CLI login response was missing a device code.');
|
|
130
|
+
}
|
|
131
|
+
return data;
|
|
132
|
+
}
|
|
253
133
|
|
|
254
|
-
|
|
255
|
-
|
|
134
|
+
export async function pollDeviceLogin(platformUrl, deviceCode, fetchImpl = fetch) {
|
|
135
|
+
const res = await fetchImpl(`${platformUrl}/api/cli/device/token`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
139
|
+
signal: AbortSignal.timeout(8000),
|
|
256
140
|
});
|
|
141
|
+
const data = await res.json().catch(() => ({}));
|
|
142
|
+
if (res.ok && data.key) return { status: 'approved', key: data.key };
|
|
143
|
+
if (data.error === 'authorization_pending') return { status: 'pending' };
|
|
144
|
+
if (data.error === 'expired') return { status: 'expired' };
|
|
145
|
+
throw new Error(data.message || data.error || 'CLI login failed.');
|
|
257
146
|
}
|
|
258
147
|
|
|
259
|
-
function
|
|
260
|
-
const
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return
|
|
272
|
-
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
273
|
-
<title>Subconscious CLI</title>
|
|
274
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
275
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
276
|
-
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
|
|
277
|
-
<style>
|
|
278
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
279
|
-
body{font-family:'Manrope',system-ui,sans-serif;min-height:100vh;background:#F6F3EF;color:#111}
|
|
280
|
-
header{display:flex;align-items:center;gap:10px;padding:20px 24px}
|
|
281
|
-
header svg{width:24px;height:24px}
|
|
282
|
-
header span{font-size:15px;font-weight:600;letter-spacing:-.01em}
|
|
283
|
-
main{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 160px)}
|
|
284
|
-
.wrap{width:100%;max-width:400px;padding:0 24px}
|
|
285
|
-
.label{text-align:center;font-size:11px;font-weight:500;text-transform:uppercase;
|
|
286
|
-
letter-spacing:.1em;color:#9ca3af;margin-bottom:16px}
|
|
287
|
-
.card{background:#fff;border:1px solid rgba(0,0,0,.08);border-radius:12px;
|
|
288
|
-
padding:32px;text-align:center}
|
|
289
|
-
.icon{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;
|
|
290
|
-
justify-content:center;margin:0 auto 16px}
|
|
291
|
-
.icon.success{background:#f0fdf4}
|
|
292
|
-
.icon.error{background:#fef2f2}
|
|
293
|
-
h1{font-size:15px;font-weight:600;margin-bottom:4px;letter-spacing:-.01em}
|
|
294
|
-
.sub{color:#6b7280;font-size:13px;line-height:1.6;max-width:280px;margin:0 auto}
|
|
295
|
-
.footer{text-align:center;margin-top:16px;font-size:11px;color:#9ca3af}
|
|
296
|
-
</style></head><body>
|
|
297
|
-
<header>${logoSvg}<span>Subconscious</span></header>
|
|
298
|
-
<main><div class="wrap">
|
|
299
|
-
<div class="label">CLI Authentication</div>
|
|
300
|
-
<div class="card">
|
|
301
|
-
${statusIcon}
|
|
302
|
-
<h1>${title}</h1>
|
|
303
|
-
<p class="sub">${subtitle}</p>
|
|
304
|
-
</div>
|
|
305
|
-
<div class="footer">subconscious.dev</div>
|
|
306
|
-
</div></main>
|
|
307
|
-
</body></html>`;
|
|
148
|
+
function printDeviceInstructions(platformUrl, userCode) {
|
|
149
|
+
const deviceUrl = `${platformUrl}/cli/device`;
|
|
150
|
+
const verificationUrl = `${deviceUrl}?code=${encodeURIComponent(userCode)}`;
|
|
151
|
+
console.log(` ${c.dim}Opening browser to sign in...${c.reset}`);
|
|
152
|
+
console.log();
|
|
153
|
+
console.log(` ${c.dim}If a window doesn't open, go to:${c.reset}`);
|
|
154
|
+
console.log(` ${c.underline}${c.cyan}${terminalLink(verificationUrl)}${c.reset}`);
|
|
155
|
+
console.log();
|
|
156
|
+
console.log(
|
|
157
|
+
` ${c.dim}Or open ${c.reset}${c.underline}${c.cyan}${terminalLink(deviceUrl)}${c.reset}${c.dim} and enter:${c.reset} ${c.bold}${userCode}${c.reset}`,
|
|
158
|
+
);
|
|
159
|
+
console.log();
|
|
160
|
+
return verificationUrl;
|
|
308
161
|
}
|
|
309
162
|
|
|
310
|
-
|
|
163
|
+
function printLoginFallback() {
|
|
164
|
+
console.error(` ${c.dim}You can also copy an API key from the dashboard and run${c.reset} ${c.cyan}subc update-key <your-api-key>${c.reset}${c.dim}.${c.reset}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function saveLoginKey(profileName, token) {
|
|
168
|
+
if (profileName === DEFAULT_PROFILE) {
|
|
169
|
+
const config = await loadConfig();
|
|
170
|
+
config.subconscious_api_key = token;
|
|
171
|
+
await saveConfig(config);
|
|
172
|
+
}
|
|
173
|
+
return ensureProfile(profileName, token);
|
|
174
|
+
}
|
|
311
175
|
|
|
312
176
|
export async function loginCommand(_argv = [], options = {}) {
|
|
313
177
|
const profileName = options.profileName || DEFAULT_PROFILE;
|
|
314
178
|
const existing = await getApiKey(options.profile);
|
|
179
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
315
180
|
|
|
316
181
|
if (existing) {
|
|
317
182
|
const profile = await ensureProfile(profileName, existing.key);
|
|
@@ -321,40 +186,29 @@ export async function loginCommand(_argv = [], options = {}) {
|
|
|
321
186
|
console.log(`\n${c.yellow}Already logged in.${c.reset}`);
|
|
322
187
|
console.log(` Key: ${c.dim}${masked}${c.reset}`);
|
|
323
188
|
console.log(` Profile: ${c.dim}${profile.path}${c.reset}`);
|
|
324
|
-
console.log(
|
|
325
|
-
`\n Run ${c.cyan}${logout}${c.reset} first to switch accounts.\n`,
|
|
326
|
-
);
|
|
189
|
+
console.log(`\n Run ${c.cyan}${logout}${c.reset} first to switch accounts.\n`);
|
|
327
190
|
return;
|
|
328
191
|
}
|
|
329
192
|
|
|
330
193
|
console.log();
|
|
331
|
-
console.log(
|
|
332
|
-
` ${c.magenta}${c.bold}Subconscious${c.reset} ${c.dim}— CLI Login${c.reset}`,
|
|
333
|
-
);
|
|
194
|
+
console.log(` ${c.magenta}${c.bold}Subconscious${c.reset} ${c.dim}— CLI Login${c.reset}`);
|
|
334
195
|
console.log();
|
|
335
196
|
|
|
336
197
|
const platformUrl = getPlatformUrl(options.profile);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
198
|
+
let registered;
|
|
199
|
+
try {
|
|
200
|
+
registered = await registerDeviceLogin(platformUrl, fetchImpl);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error(` ${c.red}✗ ${error.message}${c.reset}`);
|
|
203
|
+
printLoginFallback();
|
|
204
|
+
console.log();
|
|
340
205
|
process.exitCode = 1;
|
|
341
206
|
return;
|
|
342
207
|
}
|
|
343
208
|
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const authUrl = `${platformUrl}/cli/auth?port=${port}&state=${state}`;
|
|
209
|
+
const verificationUrl = printDeviceInstructions(platformUrl, registered.user_code);
|
|
210
|
+
(options.openBrowser || openBrowser)(verificationUrl);
|
|
348
211
|
|
|
349
|
-
console.log(` ${c.dim}Opening browser to sign in...${c.reset}`);
|
|
350
|
-
console.log();
|
|
351
|
-
console.log(` ${c.dim}If it doesn't open, visit:${c.reset}`);
|
|
352
|
-
console.log(` ${c.underline}${c.cyan}${authUrl}${c.reset}`);
|
|
353
|
-
console.log();
|
|
354
|
-
|
|
355
|
-
openBrowser(authUrl);
|
|
356
|
-
|
|
357
|
-
// Spinner while waiting
|
|
358
212
|
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
359
213
|
let i = 0;
|
|
360
214
|
const spinner = setInterval(() => {
|
|
@@ -363,39 +217,53 @@ export async function loginCommand(_argv = [], options = {}) {
|
|
|
363
217
|
);
|
|
364
218
|
}, 80);
|
|
365
219
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
220
|
+
const started = Date.now();
|
|
221
|
+
let cancelled = false;
|
|
222
|
+
const onSigint = () => {
|
|
223
|
+
cancelled = true;
|
|
224
|
+
};
|
|
225
|
+
process.on('SIGINT', onSigint);
|
|
226
|
+
|
|
372
227
|
try {
|
|
373
|
-
|
|
228
|
+
while (!cancelled) {
|
|
229
|
+
if (Date.now() - started > DEVICE_POLL_TIMEOUT_MS) {
|
|
230
|
+
throw new Error('Authentication timed out (5 min). Please try again.');
|
|
231
|
+
}
|
|
232
|
+
const polled = await pollDeviceLogin(platformUrl, registered.device_code, fetchImpl);
|
|
233
|
+
if (polled.status === 'approved') {
|
|
234
|
+
clearInterval(spinner);
|
|
235
|
+
process.stdout.write('\r' + ' '.repeat(50) + '\r');
|
|
236
|
+
const profile = await saveLoginKey(profileName, polled.key);
|
|
237
|
+
const masked = polled.key.slice(0, 8) + '...' + polled.key.slice(-4);
|
|
238
|
+
console.log(` ${c.green}${c.bold}✓ Logged in successfully!${c.reset}`);
|
|
239
|
+
console.log(` ${c.dim}Key: ${masked}${c.reset}`);
|
|
240
|
+
if (profileName === DEFAULT_PROFILE) {
|
|
241
|
+
console.log(` ${c.dim}Saved to ~/.subconscious/config.json${c.reset}`);
|
|
242
|
+
}
|
|
243
|
+
console.log(` ${c.dim}Runbook profile: ${profile.path}${c.reset}`);
|
|
244
|
+
console.log(` ${c.dim}Launch a terminal agent with ${c.reset}${c.cyan}subc claude${c.reset}${c.dim}, or install editor hooks with ${c.reset}${c.cyan}subc cursor install${c.reset}${c.dim}.${c.reset}`);
|
|
245
|
+
console.log(` ${c.dim}Pi needs ${c.reset}${c.cyan}subc pi install${c.reset}${c.dim} first. List profiles with ${c.reset}${c.cyan}subc config${c.reset}${c.dim}.${c.reset}`);
|
|
246
|
+
console.log();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (polled.status === 'expired') {
|
|
250
|
+
throw new Error('Login code expired. Please try again.');
|
|
251
|
+
}
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, DEVICE_POLL_INTERVAL_MS));
|
|
253
|
+
}
|
|
374
254
|
clearInterval(spinner);
|
|
375
255
|
process.stdout.write('\r' + ' '.repeat(50) + '\r');
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const config = await loadConfig();
|
|
379
|
-
config.subconscious_api_key = result.token;
|
|
380
|
-
await saveConfig(config);
|
|
381
|
-
}
|
|
382
|
-
const profile = await ensureProfile(profileName, result.token);
|
|
383
|
-
|
|
384
|
-
const masked = result.token.slice(0, 8) + '...' + result.token.slice(-4);
|
|
385
|
-
console.log(` ${c.green}${c.bold}✓ Logged in successfully!${c.reset}`);
|
|
386
|
-
console.log(` ${c.dim}Key: ${masked}${c.reset}`);
|
|
387
|
-
if (profileName === DEFAULT_PROFILE) {
|
|
388
|
-
console.log(` ${c.dim}Saved to ~/.subconscious/config.json${c.reset}`);
|
|
389
|
-
}
|
|
390
|
-
console.log(` ${c.dim}Runbook profile: ${profile.path}${c.reset}`);
|
|
391
|
-
console.log(` ${c.dim}Launch a terminal agent with ${c.reset}${c.cyan}subc claude${c.reset}${c.dim}, or install editor hooks with ${c.reset}${c.cyan}subc cursor install${c.reset}${c.dim}.${c.reset}`);
|
|
392
|
-
console.log(` ${c.dim}Pi needs ${c.reset}${c.cyan}subc pi install${c.reset}${c.dim} first. List profiles with ${c.reset}${c.cyan}subc config${c.reset}${c.dim}.${c.reset}`);
|
|
393
|
-
console.log();
|
|
256
|
+
console.log(`\n ${c.dim}Login cancelled.${c.reset}\n`);
|
|
257
|
+
process.exitCode = 1;
|
|
394
258
|
} catch (error) {
|
|
395
259
|
clearInterval(spinner);
|
|
396
260
|
process.stdout.write('\r' + ' '.repeat(50) + '\r');
|
|
397
|
-
console.error(` ${c.red}✗ ${error.message}${c.reset}
|
|
398
|
-
|
|
261
|
+
console.error(` ${c.red}✗ ${error.message}${c.reset}`);
|
|
262
|
+
printLoginFallback();
|
|
263
|
+
console.log();
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
} finally {
|
|
266
|
+
process.off('SIGINT', onSigint);
|
|
399
267
|
}
|
|
400
268
|
}
|
|
401
269
|
|
package/bin/cli.js
CHANGED
|
@@ -119,6 +119,8 @@ Usage:
|
|
|
119
119
|
subc login help
|
|
120
120
|
|
|
121
121
|
Authenticate and save an API key to the selected profile.
|
|
122
|
+
Opens a browser to a one-time login link. If the browser does not open, use the URL printed in the terminal.
|
|
123
|
+
To sign in without a browser, copy an API key from the dashboard and run subc update-key <your-api-key>.
|
|
122
124
|
`,
|
|
123
125
|
logout: `
|
|
124
126
|
Usage:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED