sleev 0.0.0 → 0.0.10
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 +47 -1
- package/dist/apps/claude.js +90 -0
- package/dist/apps/codex.js +184 -0
- package/dist/apps/opencode-config.js +268 -0
- package/dist/apps/opencode.js +158 -0
- package/dist/apps.js +148 -0
- package/dist/auth/browser.js +80 -0
- package/dist/auth/command.js +109 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/login.js +55 -0
- package/dist/auth/loopback.js +134 -0
- package/dist/auth/schema.js +46 -0
- package/dist/auth/validation.js +27 -0
- package/dist/cli.js +69 -0
- package/dist/credentials/index.js +3 -0
- package/dist/credentials/schema.js +63 -0
- package/dist/credentials/store.js +52 -0
- package/dist/harnesses.js +66 -0
- package/dist/index.js +3 -0
- package/dist/product.js +27 -0
- package/dist/providers.js +30 -0
- package/dist/terminal.js +55 -0
- package/package.json +36 -6
- package/index.js +0 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// OpenCode setup owns credential discovery and provider selection for config patching.
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
4
|
+
import { PROVIDERS } from '../providers.js';
|
|
5
|
+
import { field, line, note, prompt, success } from '../terminal.js';
|
|
6
|
+
import { openCodeAuthPath, openCodeJsoncPath, openCodePath, patchOpenCodeConfig, resolveOpenCodePath, unpatchOpenCodeConfig, } from './opencode-config.js';
|
|
7
|
+
export async function setupOpenCode(auth, input = process.stdin, output = process.stdout, options = {}) {
|
|
8
|
+
const path = options.configPath ?? (await resolveOpenCodePath());
|
|
9
|
+
const available = await availableOpenCodeProviders(options.authPath);
|
|
10
|
+
if (available.length === 0) {
|
|
11
|
+
note(output, 'Skipping OpenCode setup because no supported OpenCode credentials were found');
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const providers = await selectOpenCodeProviders(available, input, output);
|
|
15
|
+
if (providers.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
const results = [];
|
|
18
|
+
for (const provider of providers) {
|
|
19
|
+
results.push(await patchOpenCode(auth, path, provider.id));
|
|
20
|
+
}
|
|
21
|
+
success(output, 'Configured OpenCode');
|
|
22
|
+
field(output, 'providers', providers.map((provider) => provider.label).join(', '));
|
|
23
|
+
field(output, 'config', path);
|
|
24
|
+
return results[0] ?? null;
|
|
25
|
+
}
|
|
26
|
+
export async function availableOpenCodeProviders(path = openCodeAuthPath()) {
|
|
27
|
+
const auth = await readAuth(path);
|
|
28
|
+
return openCodeProviders().filter((provider) => provider.id in auth);
|
|
29
|
+
}
|
|
30
|
+
export async function patchOpenCode(auth, path, provider = 'openai') {
|
|
31
|
+
const target = await patchOpenCodeConfig(auth, path, provider);
|
|
32
|
+
return { path: target, provider };
|
|
33
|
+
}
|
|
34
|
+
export async function unpatchOpenCode(path) {
|
|
35
|
+
await unpatchOpenCodeConfig(path);
|
|
36
|
+
}
|
|
37
|
+
export { openCodeAuthPath, openCodeJsoncPath, openCodePath, resolveOpenCodePath };
|
|
38
|
+
async function selectOpenCodeProviders(providers, input, output) {
|
|
39
|
+
if (providers.length === 1) {
|
|
40
|
+
const provider = providers[0];
|
|
41
|
+
if (!provider)
|
|
42
|
+
return [];
|
|
43
|
+
note(output, `Using OpenCode credential: ${provider.label}`);
|
|
44
|
+
return [provider];
|
|
45
|
+
}
|
|
46
|
+
if (!canSelect(input)) {
|
|
47
|
+
note(output, 'Skipping OpenCode setup because this terminal is not interactive');
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
return selectTty(providers, input, output);
|
|
51
|
+
}
|
|
52
|
+
function selectTty(providers, input, output) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let cursor = 0;
|
|
55
|
+
let selected = new Set();
|
|
56
|
+
let rendered = false;
|
|
57
|
+
const render = () => {
|
|
58
|
+
if (rendered)
|
|
59
|
+
output.write(`\x1b[${providers.length + 2}A`);
|
|
60
|
+
output.write(`${prompt(output, 'choose the OpenCode credentials to patch')}\x1b[K\n`);
|
|
61
|
+
for (const [index, provider] of providers.entries()) {
|
|
62
|
+
const marker = index === cursor ? '>' : ' ';
|
|
63
|
+
const checked = selected.has(provider.id) ? 'x' : ' ';
|
|
64
|
+
output.write(`${marker} (${checked}) ${provider.label}\x1b[K\n`);
|
|
65
|
+
}
|
|
66
|
+
output.write(' space toggles, enter confirms\x1b[K\n');
|
|
67
|
+
rendered = true;
|
|
68
|
+
};
|
|
69
|
+
const cleanup = () => {
|
|
70
|
+
input.off('keypress', onKey);
|
|
71
|
+
input.setRawMode?.(false);
|
|
72
|
+
input.pause();
|
|
73
|
+
output.write('\x1b[?25h');
|
|
74
|
+
};
|
|
75
|
+
const done = () => {
|
|
76
|
+
cleanup();
|
|
77
|
+
line(output);
|
|
78
|
+
resolve(providers.filter((provider) => selected.has(provider.id)));
|
|
79
|
+
};
|
|
80
|
+
const cancel = () => {
|
|
81
|
+
cleanup();
|
|
82
|
+
line(output);
|
|
83
|
+
reject(new Error('Canceled.'));
|
|
84
|
+
};
|
|
85
|
+
const move = (delta) => {
|
|
86
|
+
cursor = (cursor + delta + providers.length) % providers.length;
|
|
87
|
+
render();
|
|
88
|
+
};
|
|
89
|
+
const toggle = () => {
|
|
90
|
+
const provider = providers[cursor];
|
|
91
|
+
if (!provider)
|
|
92
|
+
return;
|
|
93
|
+
const next = new Set(selected);
|
|
94
|
+
if (next.has(provider.id))
|
|
95
|
+
next.delete(provider.id);
|
|
96
|
+
else
|
|
97
|
+
next.add(provider.id);
|
|
98
|
+
selected = next;
|
|
99
|
+
render();
|
|
100
|
+
};
|
|
101
|
+
const onKey = (_chunk, key = {}) => {
|
|
102
|
+
if (key.ctrl && key.name === 'c') {
|
|
103
|
+
cancel();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
107
|
+
move(-1);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (key.name === 'down' || key.name === 'j') {
|
|
111
|
+
move(1);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (key.name === 'space' || key.sequence === ' ') {
|
|
115
|
+
toggle();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (key.name === 'return' || key.name === 'enter')
|
|
119
|
+
done();
|
|
120
|
+
};
|
|
121
|
+
emitKeypressEvents(input);
|
|
122
|
+
input.setRawMode?.(true);
|
|
123
|
+
input.resume();
|
|
124
|
+
input.on('keypress', onKey);
|
|
125
|
+
output.write('\x1b[?25l');
|
|
126
|
+
render();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async function readAuth(path) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = await readFile(path, 'utf8');
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
return object(parsed);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (code(err) === 'ENOENT')
|
|
137
|
+
return {};
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function openCodeProviders() {
|
|
142
|
+
return PROVIDERS;
|
|
143
|
+
}
|
|
144
|
+
function object(value) {
|
|
145
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
146
|
+
return {};
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function canSelect(input) {
|
|
150
|
+
const tty = input;
|
|
151
|
+
return tty.isTTY === true && typeof tty.setRawMode === 'function';
|
|
152
|
+
}
|
|
153
|
+
function code(err) {
|
|
154
|
+
if (typeof err !== 'object' || err === null || !('code' in err))
|
|
155
|
+
return undefined;
|
|
156
|
+
const value = err.code;
|
|
157
|
+
return typeof value === 'string' ? value : undefined;
|
|
158
|
+
}
|
package/dist/apps.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Interactive app selection for the post-login setup flow.
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access } from 'node:fs/promises';
|
|
4
|
+
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
6
|
+
import { HARNESSES } from './harnesses.js';
|
|
7
|
+
import { line, note, prompt } from './terminal.js';
|
|
8
|
+
export const APPS = HARNESSES;
|
|
9
|
+
export async function selectApps(input = process.stdin, output = process.stdout, apps) {
|
|
10
|
+
const available = apps ?? (await detectApps());
|
|
11
|
+
if (available.length === 0) {
|
|
12
|
+
note(output, 'Skipping app setup because no supported local harnesses were detected');
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
if (canSelect(input, output))
|
|
16
|
+
return selectTty(available, input, output);
|
|
17
|
+
note(output, 'Skipping app setup because this terminal is not interactive');
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
export async function detectApps(env = process.env, platform = process.platform) {
|
|
21
|
+
const checks = await Promise.all(APPS.map(async (app) => ((await detected(app, env, platform)) ? app : null)));
|
|
22
|
+
return checks.filter((app) => app !== null);
|
|
23
|
+
}
|
|
24
|
+
export function appLabels(ids) {
|
|
25
|
+
if (ids.length === 0)
|
|
26
|
+
return 'none';
|
|
27
|
+
const labels = ids.map((id) => APPS.find((app) => app.id === id)?.label ?? id);
|
|
28
|
+
return labels.join(', ');
|
|
29
|
+
}
|
|
30
|
+
function selectTty(apps, input, output) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const initial = new Set();
|
|
33
|
+
let cursor = 0;
|
|
34
|
+
let selected = initial;
|
|
35
|
+
let rendered = false;
|
|
36
|
+
const render = () => {
|
|
37
|
+
if (rendered)
|
|
38
|
+
output.write(`\x1b[${apps.length + 2}A`);
|
|
39
|
+
output.write(`${prompt(output, 'choose the apps you want to use Sleev with')}\x1b[K\n`);
|
|
40
|
+
for (const [index, app] of apps.entries()) {
|
|
41
|
+
const marker = index === cursor ? '>' : ' ';
|
|
42
|
+
const checked = selected.has(app.id) ? 'x' : ' ';
|
|
43
|
+
output.write(`${marker} (${checked}) ${app.label}\x1b[K\n`);
|
|
44
|
+
}
|
|
45
|
+
output.write(' space toggles, enter confirms\x1b[K\n');
|
|
46
|
+
rendered = true;
|
|
47
|
+
};
|
|
48
|
+
const cleanup = () => {
|
|
49
|
+
input.off('keypress', onKey);
|
|
50
|
+
input.setRawMode?.(false);
|
|
51
|
+
input.pause();
|
|
52
|
+
output.write('\x1b[?25h');
|
|
53
|
+
};
|
|
54
|
+
const done = () => {
|
|
55
|
+
cleanup();
|
|
56
|
+
line(output);
|
|
57
|
+
resolve(apps.filter((app) => selected.has(app.id)).map((app) => app.id));
|
|
58
|
+
};
|
|
59
|
+
const cancel = () => {
|
|
60
|
+
cleanup();
|
|
61
|
+
line(output);
|
|
62
|
+
reject(new Error('Canceled.'));
|
|
63
|
+
};
|
|
64
|
+
const move = (delta) => {
|
|
65
|
+
cursor = (cursor + delta + apps.length) % apps.length;
|
|
66
|
+
render();
|
|
67
|
+
};
|
|
68
|
+
const toggle = () => {
|
|
69
|
+
const app = apps[cursor];
|
|
70
|
+
if (!app)
|
|
71
|
+
return;
|
|
72
|
+
const next = new Set(selected);
|
|
73
|
+
if (next.has(app.id))
|
|
74
|
+
next.delete(app.id);
|
|
75
|
+
else
|
|
76
|
+
next.add(app.id);
|
|
77
|
+
selected = next;
|
|
78
|
+
render();
|
|
79
|
+
};
|
|
80
|
+
const toggleAll = () => {
|
|
81
|
+
selected = selected.size === apps.length ? new Set() : new Set(apps.map((app) => app.id));
|
|
82
|
+
render();
|
|
83
|
+
};
|
|
84
|
+
const onKey = (_chunk, key = {}) => {
|
|
85
|
+
if (key.ctrl && key.name === 'c') {
|
|
86
|
+
cancel();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
90
|
+
move(-1);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (key.name === 'down' || key.name === 'j') {
|
|
94
|
+
move(1);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (key.name === 'space' || key.sequence === ' ') {
|
|
98
|
+
toggle();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (key.name === 'a' || key.sequence === 'a') {
|
|
102
|
+
toggleAll();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.name === 'return' || key.name === 'enter')
|
|
106
|
+
done();
|
|
107
|
+
};
|
|
108
|
+
emitKeypressEvents(input);
|
|
109
|
+
input.setRawMode?.(true);
|
|
110
|
+
input.resume();
|
|
111
|
+
input.on('keypress', onKey);
|
|
112
|
+
output.write('\x1b[?25l');
|
|
113
|
+
render();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function canSelect(input, output) {
|
|
117
|
+
const tty = input;
|
|
118
|
+
return tty.isTTY === true && typeof tty.setRawMode === 'function';
|
|
119
|
+
}
|
|
120
|
+
async function exists(command, env, platform) {
|
|
121
|
+
const path = env.PATH ?? '';
|
|
122
|
+
const dirs = path.split(delimiter).filter(Boolean);
|
|
123
|
+
const names = platform === 'win32' ? windowsNames(command, env) : [command];
|
|
124
|
+
const paths = dirs.flatMap((dir) => names.map((name) => join(dir, name)));
|
|
125
|
+
const checks = await Promise.all(paths.map((path) => executable(path)));
|
|
126
|
+
return checks.some(Boolean);
|
|
127
|
+
}
|
|
128
|
+
async function detected(app, env, platform) {
|
|
129
|
+
if (!(await exists(app.command, env, platform)))
|
|
130
|
+
return false;
|
|
131
|
+
if (!app.available)
|
|
132
|
+
return true;
|
|
133
|
+
return app.available();
|
|
134
|
+
}
|
|
135
|
+
function windowsNames(command, env) {
|
|
136
|
+
const ext = env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD';
|
|
137
|
+
const exts = ext.split(';').filter(Boolean);
|
|
138
|
+
return [command, ...exts.map((item) => `${command}${item.toLowerCase()}`), ...exts.map((item) => `${command}${item.toUpperCase()}`)];
|
|
139
|
+
}
|
|
140
|
+
async function executable(path) {
|
|
141
|
+
try {
|
|
142
|
+
await access(path, constants.X_OK);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Cross-platform browser opener used by the login flow.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
export async function openBrowser(url) {
|
|
5
|
+
const commands = browserCommands(url);
|
|
6
|
+
for (const command of commands) {
|
|
7
|
+
const opened = await open(command);
|
|
8
|
+
if (opened)
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
function open(command) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const child = spawn(command.command, command.args, {
|
|
16
|
+
stdio: 'ignore',
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
});
|
|
19
|
+
const timer = setTimeout(() => finish(true), 3000);
|
|
20
|
+
let done = false;
|
|
21
|
+
const finish = (ok) => {
|
|
22
|
+
if (done)
|
|
23
|
+
return;
|
|
24
|
+
done = true;
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
child.removeAllListeners('error');
|
|
27
|
+
child.removeAllListeners('close');
|
|
28
|
+
if (ok)
|
|
29
|
+
child.unref();
|
|
30
|
+
resolve(ok);
|
|
31
|
+
};
|
|
32
|
+
child.once('error', () => finish(false));
|
|
33
|
+
child.once('close', (code) => finish(code === 0));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function browserCommands(url, context = {}) {
|
|
37
|
+
const platform = context.platform ?? process.platform;
|
|
38
|
+
if (platform === 'darwin')
|
|
39
|
+
return [{ command: 'open', args: [url] }];
|
|
40
|
+
if (platform === 'win32') {
|
|
41
|
+
return [
|
|
42
|
+
{ command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] },
|
|
43
|
+
{ command: 'powershell', args: powershellArgs(url) },
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
if (platform === 'linux' && isWsl(context)) {
|
|
47
|
+
return [
|
|
48
|
+
...linuxCommands(url),
|
|
49
|
+
{ command: 'wslview', args: [url] },
|
|
50
|
+
{ command: 'powershell.exe', args: powershellArgs(url) },
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
return linuxCommands(url);
|
|
54
|
+
}
|
|
55
|
+
function linuxCommands(url) {
|
|
56
|
+
return [
|
|
57
|
+
{ command: 'sensible-browser', args: [url] },
|
|
58
|
+
{ command: 'gio', args: ['open', url] },
|
|
59
|
+
{ command: 'xdg-open', args: [url] },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function powershellArgs(url) {
|
|
63
|
+
return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `Start-Process '${ps(url)}'`];
|
|
64
|
+
}
|
|
65
|
+
function ps(value) {
|
|
66
|
+
return value.replace(/'/g, "''");
|
|
67
|
+
}
|
|
68
|
+
function isWsl(context) {
|
|
69
|
+
const env = context.env ?? process.env;
|
|
70
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP)
|
|
71
|
+
return true;
|
|
72
|
+
const release = context.release ?? kernelRelease();
|
|
73
|
+
return release.toLowerCase().includes('microsoft') || release.toLowerCase().includes('wsl');
|
|
74
|
+
}
|
|
75
|
+
function kernelRelease() {
|
|
76
|
+
const path = '/proc/sys/kernel/osrelease';
|
|
77
|
+
if (!existsSync(path))
|
|
78
|
+
return '';
|
|
79
|
+
return readFileSync(path, 'utf8');
|
|
80
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Auth command flows for the CLI dispatcher.
|
|
2
|
+
import { selectApps } from '../apps.js';
|
|
3
|
+
import { authPath, clearAuth, createAuthFile, readAuth, writeAuth } from '../credentials/index.js';
|
|
4
|
+
import { setupHarnesses, unpatchHarnesses } from '../harnesses.js';
|
|
5
|
+
import { proxyUrl } from '../product.js';
|
|
6
|
+
import { field, line, note, success } from '../terminal.js';
|
|
7
|
+
import { login as browserLogin } from './login.js';
|
|
8
|
+
import { validateAuth } from './validation.js';
|
|
9
|
+
export function commandRuntime() {
|
|
10
|
+
return {
|
|
11
|
+
output: process.stdout,
|
|
12
|
+
authPath,
|
|
13
|
+
readAuth,
|
|
14
|
+
writeAuth,
|
|
15
|
+
clearAuth,
|
|
16
|
+
createAuthFile,
|
|
17
|
+
login: browserLogin,
|
|
18
|
+
validateAuth,
|
|
19
|
+
selectApps: () => selectApps(),
|
|
20
|
+
setupHarnesses,
|
|
21
|
+
unpatchHarnesses,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function loginCommand(options = {}, runtime = commandRuntime()) {
|
|
25
|
+
const path = runtime.authPath();
|
|
26
|
+
const existing = await readLocalAuth(path, runtime);
|
|
27
|
+
const local = existing ? canonicalAuth(existing) : null;
|
|
28
|
+
const state = local && !options.force ? await runtime.validateAuth(local) : 'invalid';
|
|
29
|
+
if (state === 'valid') {
|
|
30
|
+
note(runtime.output, 'Using existing valid local auth');
|
|
31
|
+
}
|
|
32
|
+
if (existing && options.force) {
|
|
33
|
+
note(runtime.output, 'Replacing existing local auth. The previous remote Sleeve token is not revoked');
|
|
34
|
+
}
|
|
35
|
+
if (existing && state === 'invalid' && !options.force) {
|
|
36
|
+
note(runtime.output, 'Existing local auth is expired or revoked. Creating a new token');
|
|
37
|
+
}
|
|
38
|
+
if (existing && state === 'unknown' && !options.force) {
|
|
39
|
+
note(runtime.output, 'Could not verify existing local auth. Creating a new token');
|
|
40
|
+
}
|
|
41
|
+
const useLocal = state === 'valid' && local;
|
|
42
|
+
const auth = useLocal ? local : runtime.createAuthFile(await runtime.login());
|
|
43
|
+
const changed = useLocal && existing?.proxyUrl !== auth.proxyUrl;
|
|
44
|
+
const written = useLocal && !changed ? { path } : await runtime.writeAuth(auth, path);
|
|
45
|
+
if (changed)
|
|
46
|
+
note(runtime.output, 'Updated local auth to use the current Sleev proxy');
|
|
47
|
+
line(runtime.output);
|
|
48
|
+
success(runtime.output, useLocal ? 'Already logged in' : 'Logged in');
|
|
49
|
+
field(runtime.output, 'credential', written.path);
|
|
50
|
+
field(runtime.output, 'proxy', auth.proxyUrl);
|
|
51
|
+
note(runtime.output, 'Keep auth.json private. It contains your live Sleeve token');
|
|
52
|
+
line(runtime.output);
|
|
53
|
+
const apps = await runtime.selectApps();
|
|
54
|
+
const harnesses = await runtime.setupHarnesses(auth, apps);
|
|
55
|
+
success(runtime.output, 'Setup complete');
|
|
56
|
+
field(runtime.output, 'applications configured to use sleev', harnesses.label);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
async function readLocalAuth(path, runtime) {
|
|
60
|
+
try {
|
|
61
|
+
return await runtime.readAuth(path);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (!recoverableAuthError(err))
|
|
65
|
+
throw err;
|
|
66
|
+
note(runtime.output, 'Replacing invalid local auth file');
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function logoutCommand(runtime = commandRuntime()) {
|
|
71
|
+
const path = runtime.authPath();
|
|
72
|
+
const existing = await readLogoutAuth(path, runtime);
|
|
73
|
+
const cleanup = await runtime.unpatchHarnesses();
|
|
74
|
+
await runtime.clearAuth(path);
|
|
75
|
+
if (!existing) {
|
|
76
|
+
note(runtime.output, 'No local auth file found');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
success(runtime.output, 'Removed local auth');
|
|
80
|
+
field(runtime.output, 'removed', path);
|
|
81
|
+
note(runtime.output, 'Remote Sleeve token is not revoked and remains active');
|
|
82
|
+
if (cleanup.failures[0])
|
|
83
|
+
note(runtime.output, `Some harness config cleanup failed: ${cleanup.failures.join('; ')}`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
function recoverableAuthError(err) {
|
|
87
|
+
if (err instanceof SyntaxError)
|
|
88
|
+
return true;
|
|
89
|
+
if (!(err instanceof Error))
|
|
90
|
+
return false;
|
|
91
|
+
return err.message.startsWith('Sleev auth ');
|
|
92
|
+
}
|
|
93
|
+
function canonicalAuth(auth) {
|
|
94
|
+
const proxy = proxyUrl();
|
|
95
|
+
if (auth.proxyUrl === proxy)
|
|
96
|
+
return auth;
|
|
97
|
+
return { ...auth, proxyUrl: proxy };
|
|
98
|
+
}
|
|
99
|
+
async function readLogoutAuth(path, runtime) {
|
|
100
|
+
try {
|
|
101
|
+
return await runtime.readAuth(path);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (!recoverableAuthError(err))
|
|
105
|
+
throw err;
|
|
106
|
+
note(runtime.output, 'Removing invalid local auth file');
|
|
107
|
+
return { version: 1, token: '', label: null, proxyUrl: '', createdAt: '' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Browser login orchestration for `sleev auth login`.
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { webOrigin as defaultWebOrigin } from '../product.js';
|
|
5
|
+
import { brand, field, line, note, step, success } from '../terminal.js';
|
|
6
|
+
import { openBrowser } from './browser.js';
|
|
7
|
+
import { startLoopback } from './loopback.js';
|
|
8
|
+
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
9
|
+
export async function login(options = {}) {
|
|
10
|
+
const output = options.output ?? process.stdout;
|
|
11
|
+
brand(output);
|
|
12
|
+
return runHandoff({ ...options, mode: options.mode ?? 'signup', output });
|
|
13
|
+
}
|
|
14
|
+
async function runHandoff(options) {
|
|
15
|
+
const output = options.output;
|
|
16
|
+
const mode = options.mode;
|
|
17
|
+
const origin = options.webOrigin ?? defaultWebOrigin();
|
|
18
|
+
const opener = options.opener ?? openBrowser;
|
|
19
|
+
const state = randomBytes(32).toString('base64url');
|
|
20
|
+
const loopback = await startLoopback({
|
|
21
|
+
state,
|
|
22
|
+
webOrigin: origin,
|
|
23
|
+
timeoutMs: options.timeoutMs ?? LOGIN_TIMEOUT_MS,
|
|
24
|
+
});
|
|
25
|
+
const url = loginUrl(origin, mode, state, loopback.callback);
|
|
26
|
+
const action = mode === 'signup' ? 'create your account' : 'log in';
|
|
27
|
+
step(output, `Opening browser to ${action}`);
|
|
28
|
+
field(output, 'url', url);
|
|
29
|
+
try {
|
|
30
|
+
const opened = await opener(url);
|
|
31
|
+
if (!opened) {
|
|
32
|
+
note(output, 'Could not open a browser automatically. Open the URL above instead');
|
|
33
|
+
}
|
|
34
|
+
note(output, 'Waiting for the browser confirmation...');
|
|
35
|
+
const payload = await loopback.wait;
|
|
36
|
+
success(output, 'Browser login confirmed');
|
|
37
|
+
return {
|
|
38
|
+
token: payload.token,
|
|
39
|
+
label: payload.label,
|
|
40
|
+
proxyUrl: payload.proxyUrl,
|
|
41
|
+
createdAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await loopback.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function loginUrl(origin, mode, state, callback) {
|
|
49
|
+
const url = new URL('/cli/login', origin);
|
|
50
|
+
url.searchParams.set('mode', mode);
|
|
51
|
+
url.searchParams.set('state', state);
|
|
52
|
+
url.searchParams.set('callback', callback);
|
|
53
|
+
url.searchParams.set('device', hostname().slice(0, 80));
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Temporary loopback server that receives the browser login handoff.
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { parseHandoff } from './schema.js';
|
|
4
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
5
|
+
export async function startLoopback(options) {
|
|
6
|
+
let settled = false;
|
|
7
|
+
let finish = () => undefined;
|
|
8
|
+
let fail = () => undefined;
|
|
9
|
+
const wait = new Promise((resolve, reject) => {
|
|
10
|
+
finish = resolve;
|
|
11
|
+
fail = reject;
|
|
12
|
+
});
|
|
13
|
+
const server = createServer(async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
await handleRequest(req, res, options, (payload) => {
|
|
16
|
+
if (settled)
|
|
17
|
+
return;
|
|
18
|
+
settled = true;
|
|
19
|
+
finish(payload);
|
|
20
|
+
}, (err) => {
|
|
21
|
+
if (settled)
|
|
22
|
+
return;
|
|
23
|
+
settled = true;
|
|
24
|
+
fail(err);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : 'Invalid request.' }, options.webOrigin);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
await listen(server);
|
|
32
|
+
const address = server.address();
|
|
33
|
+
if (!address || typeof address === 'string') {
|
|
34
|
+
await closeServer(server);
|
|
35
|
+
throw new Error('Could not bind local login callback server.');
|
|
36
|
+
}
|
|
37
|
+
const timeout = setTimeout(() => {
|
|
38
|
+
if (settled)
|
|
39
|
+
return;
|
|
40
|
+
settled = true;
|
|
41
|
+
fail(new Error('Timed out waiting for browser login.'));
|
|
42
|
+
void closeServer(server);
|
|
43
|
+
}, options.timeoutMs);
|
|
44
|
+
wait.finally(() => clearTimeout(timeout)).catch(() => undefined);
|
|
45
|
+
return {
|
|
46
|
+
callback: `http://127.0.0.1:${address.port}/callback`,
|
|
47
|
+
wait,
|
|
48
|
+
close: () => closeServer(server),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function handleRequest(req, res, options, accept, reject) {
|
|
52
|
+
if (req.method === 'OPTIONS') {
|
|
53
|
+
handleOptions(req, res, options.webOrigin);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (req.method !== 'POST' || req.url !== '/callback') {
|
|
57
|
+
sendJson(res, 404, { error: 'Not found.' }, options.webOrigin);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!hasAllowedOrigin(req, options.webOrigin)) {
|
|
61
|
+
sendJson(res, 403, { error: 'Origin is not allowed.' }, options.webOrigin);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const body = await readBody(req);
|
|
65
|
+
const payload = parseHandoff(JSON.parse(body));
|
|
66
|
+
if (payload.state !== options.state) {
|
|
67
|
+
sendJson(res, 403, { error: 'Login state did not match.' }, options.webOrigin);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
accept(payload);
|
|
71
|
+
sendJson(res, 200, { ok: true }, options.webOrigin);
|
|
72
|
+
}
|
|
73
|
+
function handleOptions(req, res, origin) {
|
|
74
|
+
if (!hasAllowedOrigin(req, origin)) {
|
|
75
|
+
sendJson(res, 403, { error: 'Origin is not allowed.' }, origin);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
writeCors(res, origin);
|
|
79
|
+
res.writeHead(204);
|
|
80
|
+
res.end();
|
|
81
|
+
}
|
|
82
|
+
function hasAllowedOrigin(req, origin) {
|
|
83
|
+
const actual = req.headers.origin;
|
|
84
|
+
return !actual || actual === origin;
|
|
85
|
+
}
|
|
86
|
+
function readBody(req) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
req.on('data', (chunk) => {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > MAX_BODY_BYTES) {
|
|
93
|
+
reject(new Error('Login callback body is too large.'));
|
|
94
|
+
req.destroy();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
});
|
|
99
|
+
req.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
100
|
+
req.once('error', () => reject(new Error('Could not read login callback.')));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function sendJson(res, status, body, origin) {
|
|
104
|
+
if (res.writableEnded)
|
|
105
|
+
return;
|
|
106
|
+
writeCors(res, origin);
|
|
107
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
108
|
+
res.end(JSON.stringify(body));
|
|
109
|
+
}
|
|
110
|
+
function writeCors(res, origin) {
|
|
111
|
+
res.setHeader('access-control-allow-origin', origin);
|
|
112
|
+
res.setHeader('access-control-allow-methods', 'POST, OPTIONS');
|
|
113
|
+
res.setHeader('access-control-allow-headers', 'content-type');
|
|
114
|
+
}
|
|
115
|
+
function listen(server) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
server.once('error', reject);
|
|
118
|
+
server.listen(0, '127.0.0.1', () => {
|
|
119
|
+
server.removeListener('error', reject);
|
|
120
|
+
resolve();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function closeServer(server) {
|
|
125
|
+
if (!server.listening)
|
|
126
|
+
return Promise.resolve();
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
server.close((err) => {
|
|
129
|
+
if (err)
|
|
130
|
+
reject(err);
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|