@anyslate/cli 0.1.0 → 0.3.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 +313 -19
- package/package.json +6 -6
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +61 -18
- package/src/commands/doctor.mjs +616 -0
- package/src/commands/hook.mjs +81 -23
- package/src/commands/login.mjs +362 -25
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +136 -26
- package/src/config.mjs +162 -13
- package/src/credentials.mjs +162 -0
- package/src/hooks.mjs +170 -8
- package/src/index.mjs +61 -6
- package/src/io.mjs +30 -0
- package/src/mcp-client.mjs +291 -45
- package/src/oauth.mjs +633 -0
- package/src/runlog.mjs +196 -0
- package/src/stdin.mjs +85 -15
- package/src/verify.mjs +262 -0
- package/src/version.mjs +21 -0
- package/templates/git/post-commit +71 -0
package/src/commands/hook.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `anyslate hook <subcommand>`
|
|
1
|
+
// `anyslate hook <subcommand>` - submit a lifecycle event to the Activity feed.
|
|
2
2
|
//
|
|
3
3
|
// Subcommands:
|
|
4
4
|
// anyslate hook session-start
|
|
@@ -8,41 +8,77 @@
|
|
|
8
8
|
// Hooks fail open: any error short-circuits to a stderr warning + exit 0 so a
|
|
9
9
|
// misconfigured CLI never breaks the parent Claude Code session. Pass
|
|
10
10
|
// --strict to opt into exit 1 on failure (useful for setup verification).
|
|
11
|
+
//
|
|
12
|
+
// Fail-open is correct policy. Fail-SILENT was the bug: Claude Code files
|
|
13
|
+
// `exit 0 + stderr` as `hook_success` and nothing reads the stderr. So every
|
|
14
|
+
// run is now persisted (W7) and, after N consecutive failures, SessionStart
|
|
15
|
+
// escalates through a channel that actually renders.
|
|
16
|
+
//
|
|
17
|
+
// Tool-level errors (HTTP 200 + result.isError) now count as failures and are
|
|
18
|
+
// therefore visible to --strict — previously a 403 handle denial printed
|
|
19
|
+
// nothing and exited 0.
|
|
11
20
|
|
|
12
|
-
import { loadConfig, requireToken } from '../config.mjs';
|
|
13
|
-
import {
|
|
21
|
+
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
22
|
+
import { formatCallFailure } from '../mcp-client.mjs';
|
|
23
|
+
import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
|
|
14
24
|
import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
|
|
15
|
-
import { readStdin } from '../stdin.mjs';
|
|
25
|
+
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
26
|
+
import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
|
|
27
|
+
import { VERSION } from '../version.mjs';
|
|
28
|
+
import { makeIo } from '../io.mjs';
|
|
16
29
|
|
|
17
30
|
const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
|
|
18
31
|
|
|
19
32
|
/**
|
|
20
33
|
* @param {string[]} argv arguments after `hook`
|
|
34
|
+
* @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
|
|
21
35
|
* @returns {Promise<number>}
|
|
22
36
|
*/
|
|
23
|
-
export async function runHook(argv) {
|
|
37
|
+
export async function runHook(argv, deps = {}) {
|
|
38
|
+
const env = deps.env ?? process.env;
|
|
39
|
+
const { out, err } = makeIo(deps);
|
|
24
40
|
const sub = argv[0];
|
|
25
41
|
if (!sub || !ALLOWED.has(sub)) {
|
|
26
|
-
|
|
42
|
+
err.write('usage: anyslate hook <session-start|post-tool-use|stop> [--strict] [--session <id>] [--note <text>] [--host <hint>]\n');
|
|
27
43
|
return 2;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
const flags = parseFlags(argv.slice(1));
|
|
31
47
|
const strict = flags.strict;
|
|
32
|
-
const cfg = loadConfig();
|
|
48
|
+
const cfg = loadConfig(env);
|
|
33
49
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
if (cfg.disabled) {
|
|
51
|
+
err.write(`${DISABLED_NOTICE}\n`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const prefix = `anyslate hook ${sub}`;
|
|
56
|
+
const fail = (message, extra = {}) => {
|
|
57
|
+
err.write(message);
|
|
58
|
+
const state = recordRun(
|
|
59
|
+
{ command: `hook ${sub}`, ok: false, apiUrl: cfg.apiUrl, version: VERSION, error: message.trim(), exitCode: strict ? 1 : 0, ...extra },
|
|
60
|
+
env,
|
|
61
|
+
);
|
|
62
|
+
escalateIfNeeded(out, sub, state, message.trim());
|
|
37
63
|
return strict ? 1 : 0;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const notice = apiUrlNormalizationNotice(cfg);
|
|
67
|
+
if (notice) err.write(notice);
|
|
68
|
+
|
|
69
|
+
// Credential PRESENCE only. An expired OAuth access token is not "missing" —
|
|
70
|
+
// callToolWithAuth refreshes it below — so gating on `cfg.mcpToken` here (as
|
|
71
|
+
// this did before OAuth) would turn every hour-old session into a silent
|
|
72
|
+
// no-capture with a misleading "no token configured" in the run log.
|
|
73
|
+
if (cfg.authMode === 'none') {
|
|
74
|
+
return fail(`${prefix}: ${requireToken({ mcpToken: null }).error}\n`);
|
|
38
75
|
}
|
|
39
76
|
|
|
40
77
|
let stdinRaw = '';
|
|
41
78
|
try {
|
|
42
|
-
stdinRaw = await readStdin();
|
|
79
|
+
stdinRaw = await readStdin(deps.stdin ?? process.stdin, { timeoutMs: stdinTimeoutFromEnv(env) });
|
|
43
80
|
} catch (e) {
|
|
44
|
-
|
|
45
|
-
return strict ? 1 : 0;
|
|
81
|
+
return fail(`${prefix}: stdin read failed (${e?.message ?? e})\n`);
|
|
46
82
|
}
|
|
47
83
|
|
|
48
84
|
const event = parseHookEvent(stdinRaw);
|
|
@@ -63,27 +99,49 @@ export async function runHook(argv) {
|
|
|
63
99
|
if (submission.sessionIdHint) args.session_id_hint = submission.sessionIdHint;
|
|
64
100
|
|
|
65
101
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
102
|
+
// Never opens a browser: this path runs unattended inside Claude Code.
|
|
103
|
+
// A refresh failure surfaces as an ordinary failure and still exits 0.
|
|
104
|
+
const res = await callToolWithAuth({
|
|
105
|
+
cfg,
|
|
106
|
+
env,
|
|
69
107
|
toolName: 'activity_submit',
|
|
70
108
|
args,
|
|
109
|
+
fetchImpl: deps.fetchImpl,
|
|
71
110
|
});
|
|
111
|
+
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
72
112
|
if (!res.ok) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
113
|
+
return fail(formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res), {
|
|
114
|
+
status: res.status,
|
|
115
|
+
isError: !!res.isError,
|
|
116
|
+
networkError: !!res.networkError,
|
|
117
|
+
});
|
|
76
118
|
}
|
|
77
|
-
|
|
78
|
-
|
|
119
|
+
recordRun(
|
|
120
|
+
{ command: `hook ${sub}`, ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 },
|
|
121
|
+
env,
|
|
122
|
+
);
|
|
123
|
+
if (env.ANYSLATE_VERBOSE) {
|
|
124
|
+
out.write(`${JSON.stringify(res.data)}\n`);
|
|
79
125
|
}
|
|
80
126
|
return 0;
|
|
81
127
|
} catch (e) {
|
|
82
|
-
|
|
83
|
-
return strict ? 1 : 0;
|
|
128
|
+
return fail(`${prefix}: request failed (${e?.message ?? e})\n`);
|
|
84
129
|
}
|
|
85
130
|
}
|
|
86
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Escalate through a channel Claude Code renders — plain stderr on an exit-0
|
|
134
|
+
* hook is filed as `hook_success` and read by nobody.
|
|
135
|
+
*
|
|
136
|
+
* Only from SessionStart: PostToolUse exit 2 is a real blocking channel and
|
|
137
|
+
* must stay reserved for hard failures.
|
|
138
|
+
*/
|
|
139
|
+
function escalateIfNeeded(out, sub, state, error) {
|
|
140
|
+
if (sub !== 'session-start') return;
|
|
141
|
+
if (!shouldEscalate(state)) return;
|
|
142
|
+
out.write(escalationPayload({ ...state, error }));
|
|
143
|
+
}
|
|
144
|
+
|
|
87
145
|
/** @param {string[]} argv */
|
|
88
146
|
function parseFlags(argv) {
|
|
89
147
|
const out = { strict: false };
|
package/src/commands/login.mjs
CHANGED
|
@@ -1,67 +1,404 @@
|
|
|
1
|
-
// `anyslate login`
|
|
1
|
+
// `anyslate login` - two credential paths, one config file.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// anyslate login OAuth browser flow (default, humans)
|
|
4
|
+
// anyslate login --api-url <root> ... against dev / a local wrangler
|
|
5
|
+
// anyslate login --token as_mcp_… static token (CI, air-gapped, scripts)
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// The static-token path is UNCHANGED and stays first-class. It is the only one
|
|
8
|
+
// that works where no browser exists, and a long-lived token is the right shape
|
|
9
|
+
// for a CI secret. `--token` is therefore the switch between the two paths: its
|
|
10
|
+
// presence selects the static flow, its absence selects OAuth.
|
|
9
11
|
//
|
|
10
|
-
//
|
|
12
|
+
// Ordering in the static path is load-bearing and is preserved verbatim:
|
|
13
|
+
// 1. token format,
|
|
14
|
+
// 2. URL shape (MUST precede any token verdict — a wrong URL 404s before auth
|
|
15
|
+
// middleware and masks the real answer),
|
|
16
|
+
// 3. one live GET {root}/mcp/auth/verify,
|
|
17
|
+
// 4. scope warning,
|
|
18
|
+
// 5. write ONLY on success.
|
|
19
|
+
// `--force` writes anyway; `--no-verify` skips the probe (air-gapped setup).
|
|
20
|
+
//
|
|
21
|
+
// The OAuth path ends at the same place — one `/mcp/auth/verify` probe, the same
|
|
22
|
+
// printed verdict line — so "logged in" means exactly one thing regardless of
|
|
23
|
+
// how the credential was obtained.
|
|
11
24
|
|
|
12
|
-
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
25
|
import { join } from 'node:path';
|
|
26
|
+
import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
|
|
27
|
+
import { checkUrlShape, isValidTokenFormat, probeVerify, scopeWarning, tokenPreview } from '../verify.mjs';
|
|
28
|
+
import { readConfigFile, writeConfigFile } from '../credentials.mjs';
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_CALLBACK_TIMEOUT_S,
|
|
31
|
+
REGISTERED_REDIRECT_URI,
|
|
32
|
+
buildAuthorizeUrl,
|
|
33
|
+
discover,
|
|
34
|
+
exchangeCode,
|
|
35
|
+
generatePkce,
|
|
36
|
+
generateState,
|
|
37
|
+
openBrowser,
|
|
38
|
+
registerClient,
|
|
39
|
+
startCallbackServer,
|
|
40
|
+
} from '../oauth.mjs';
|
|
41
|
+
import { makeIo } from '../io.mjs';
|
|
42
|
+
|
|
43
|
+
export const USAGE =
|
|
44
|
+
'usage: anyslate login [--api-url <URL>] [--no-browser] [--timeout <seconds>] [--handle <ID>]\n' +
|
|
45
|
+
' anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>] [--force] [--no-verify]\n' +
|
|
46
|
+
'\n' +
|
|
47
|
+
' With no --token, `login` opens your browser and signs you in with OAuth.\n' +
|
|
48
|
+
' --token keeps the static-token path for CI and air-gapped setups.';
|
|
15
49
|
|
|
16
50
|
/**
|
|
17
51
|
* @param {string[]} argv arguments after `login`
|
|
52
|
+
* @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv,
|
|
53
|
+
* openBrowserImpl?: typeof openBrowser,
|
|
54
|
+
* onAuthorizeUrl?: (url: string, ctx: object) => unknown}} [deps]
|
|
18
55
|
* @returns {Promise<number>}
|
|
19
56
|
*/
|
|
20
|
-
export async function runLogin(argv) {
|
|
57
|
+
export async function runLogin(argv, deps = {}) {
|
|
21
58
|
const flags = parseFlags(argv);
|
|
22
|
-
if (
|
|
23
|
-
|
|
59
|
+
if (flags.help) {
|
|
60
|
+
makeIo(deps).err.write(`${USAGE}\n`);
|
|
24
61
|
return 2;
|
|
25
62
|
}
|
|
63
|
+
return flags.token ? runTokenLogin(flags, deps) : runOauthLogin(flags, deps);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Shared
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the service root exactly as the static path always has: an explicit
|
|
72
|
+
* --api-url wins, else the stored value is inherited AND re-normalized so a
|
|
73
|
+
* previously-broken `/mcp`-suffixed apiUrl cannot survive a re-login.
|
|
74
|
+
*/
|
|
75
|
+
function resolveRoot(flags, existing, io) {
|
|
76
|
+
const merged = flags.apiUrl ?? existing.apiUrl ?? existing.api_url ?? DEFAULT_API_URL;
|
|
77
|
+
const shape = checkUrlShape(merged);
|
|
78
|
+
const root = shape.ok ? shape.root : normalizeApiRoot(merged);
|
|
79
|
+
if (shape.ok && shape.normalized) {
|
|
80
|
+
io.out.write(
|
|
81
|
+
`anyslate: apiUrl "${shape.original}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
|
|
82
|
+
`anyslate: using "${root}". Run \`anyslate login --api-url ${root}\` to persist.\n`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { shape, root };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function noteIfCaptureDisabled(env, io) {
|
|
89
|
+
if (!isCaptureDisabled(env)) return;
|
|
90
|
+
io.err.write(
|
|
91
|
+
'anyslate: note — ANYSLATE_DISABLE is set, so capture is off in this shell. `login` still writes your config.\n',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
26
94
|
|
|
27
|
-
|
|
28
|
-
const path = join(dir, 'cli.json');
|
|
29
|
-
let existing = {};
|
|
95
|
+
function hostOf(root) {
|
|
30
96
|
try {
|
|
31
|
-
|
|
97
|
+
return new URL(root).host;
|
|
32
98
|
} catch {
|
|
33
|
-
|
|
99
|
+
return root;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Static token path (behaviour unchanged)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
async function runTokenLogin(flags, deps) {
|
|
108
|
+
const env = deps.env ?? process.env;
|
|
109
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
110
|
+
const io = makeIo(deps);
|
|
111
|
+
const { out, err } = io;
|
|
112
|
+
|
|
113
|
+
noteIfCaptureDisabled(env, io);
|
|
114
|
+
|
|
115
|
+
const path = join(anyslateDir(env), 'cli.json');
|
|
116
|
+
const existing = readConfigFile(env);
|
|
117
|
+
|
|
118
|
+
// --- 1. Token format ----------------------------------------------------
|
|
119
|
+
if (!isValidTokenFormat(flags.token)) {
|
|
120
|
+
const msg =
|
|
121
|
+
`anyslate: that doesn't look like an AnySlate token (expected as_mcp_… or as_oauth_…, got "${tokenPreview(flags.token)}").\n` +
|
|
122
|
+
'anyslate: mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.\n';
|
|
123
|
+
if (!flags.force) {
|
|
124
|
+
err.write(msg);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
err.write(msg);
|
|
128
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- 2. URL shape (MUST precede any token verdict) ----------------------
|
|
132
|
+
const { shape, root } = resolveRoot(flags, existing, io);
|
|
133
|
+
if (!shape.ok) {
|
|
134
|
+
err.write(`${shape.message}\n`);
|
|
135
|
+
if (!flags.force) return 1;
|
|
136
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- 3. Live probe ------------------------------------------------------
|
|
140
|
+
let verified = null;
|
|
141
|
+
if (flags.noVerify) {
|
|
142
|
+
err.write('anyslate: --no-verify given — skipping the live connection check.\n');
|
|
143
|
+
} else {
|
|
144
|
+
verified = await probeVerify({ root, token: flags.token, fetchImpl });
|
|
145
|
+
if (!verified.ok) {
|
|
146
|
+
err.write(`${verified.message}\n`);
|
|
147
|
+
if (!flags.force) {
|
|
148
|
+
err.write(
|
|
149
|
+
'anyslate: nothing was written. Re-run with a working --token/--api-url, or `--force` to write anyway, or `--no-verify` to skip the check.\n',
|
|
150
|
+
);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
154
|
+
} else {
|
|
155
|
+
out.write(`${verified.message}\n`);
|
|
156
|
+
// --- 4. Scope check (warn, never block) -----------------------------
|
|
157
|
+
const warning = scopeWarning(verified.scopes);
|
|
158
|
+
if (warning) err.write(`${warning}\n`);
|
|
159
|
+
}
|
|
34
160
|
}
|
|
35
161
|
|
|
162
|
+
// --- 5. Write only on success (or --force / --no-verify) ----------------
|
|
36
163
|
const next = {
|
|
37
164
|
...existing,
|
|
38
165
|
mcp_token: flags.token,
|
|
39
166
|
handle: flags.handle ?? existing.handle ?? null,
|
|
40
|
-
apiUrl:
|
|
167
|
+
apiUrl: root,
|
|
41
168
|
};
|
|
169
|
+
delete next.api_url; // collapse the legacy alias so only one key can drift
|
|
42
170
|
|
|
43
171
|
try {
|
|
44
|
-
|
|
45
|
-
writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
172
|
+
writeConfigFile(next, env);
|
|
46
173
|
} catch (e) {
|
|
47
|
-
|
|
174
|
+
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
48
175
|
return 1;
|
|
49
176
|
}
|
|
50
177
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
178
|
+
out.write(`anyslate: wrote ${path}\n`);
|
|
179
|
+
out.write(` apiUrl: ${next.apiUrl}\n`);
|
|
180
|
+
out.write(` handle: ${next.handle ?? '(none — bearer token only)'}\n`);
|
|
181
|
+
if (verified?.ok && verified.defaultHandleId) {
|
|
182
|
+
out.write(` token is bound to handle: ${verified.defaultHandleId} (server-side scope)\n`);
|
|
183
|
+
}
|
|
184
|
+
out.write('anyslate: run `anyslate doctor` to verify the full setup.\n');
|
|
54
185
|
return 0;
|
|
55
186
|
}
|
|
56
187
|
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// OAuth browser path
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async function runOauthLogin(flags, deps) {
|
|
193
|
+
const env = deps.env ?? process.env;
|
|
194
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
195
|
+
const io = makeIo(deps);
|
|
196
|
+
const { out, err } = io;
|
|
197
|
+
|
|
198
|
+
if (flags.timeoutInvalid) {
|
|
199
|
+
err.write('anyslate: --timeout takes a positive number of seconds.\n');
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
noteIfCaptureDisabled(env, io);
|
|
204
|
+
|
|
205
|
+
const existing = readConfigFile(env);
|
|
206
|
+
const { shape, root } = resolveRoot(flags, existing, io);
|
|
207
|
+
if (!shape.ok) {
|
|
208
|
+
err.write(`${shape.message}\n`);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
const host = hostOf(root);
|
|
212
|
+
|
|
213
|
+
// --- 1. Discovery. No guessed endpoint paths, ever. ---------------------
|
|
214
|
+
const discovery = await discover({ root, fetchImpl });
|
|
215
|
+
if (!discovery.ok) {
|
|
216
|
+
err.write(`${discovery.message}\n`);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- 2. Client id: cached per root, because DCR is 10/hour --------------
|
|
221
|
+
const clients = existing.oauth_clients && typeof existing.oauth_clients === 'object' ? existing.oauth_clients : {};
|
|
222
|
+
let clientId = typeof clients[root] === 'string' && clients[root] ? clients[root] : null;
|
|
223
|
+
let registered = false;
|
|
224
|
+
if (clientId) {
|
|
225
|
+
out.write(`anyslate: reusing this CLI's registered OAuth client for ${host}.\n`);
|
|
226
|
+
} else {
|
|
227
|
+
const reg = await registerClient({
|
|
228
|
+
registrationEndpoint: discovery.registrationEndpoint,
|
|
229
|
+
redirectUri: REGISTERED_REDIRECT_URI,
|
|
230
|
+
fetchImpl,
|
|
231
|
+
});
|
|
232
|
+
if (!reg.ok) {
|
|
233
|
+
err.write(`${reg.message}\n`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
clientId = reg.clientId;
|
|
237
|
+
registered = true;
|
|
238
|
+
// Persist immediately, BEFORE the browser round trip. A user who closes the
|
|
239
|
+
// consent tab must not burn a second registration on their next attempt.
|
|
240
|
+
try {
|
|
241
|
+
writeConfigFile({ ...readConfigFile(env), oauth_clients: { ...clients, [root]: clientId } }, env);
|
|
242
|
+
} catch {
|
|
243
|
+
err.write('anyslate: warning — could not cache the client registration; the next login will register again.\n');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- 3. PKCE + state ----------------------------------------------------
|
|
248
|
+
const pkce = generatePkce();
|
|
249
|
+
const state = generateState();
|
|
250
|
+
|
|
251
|
+
// --- 4. Loopback listener on an ephemeral port --------------------------
|
|
252
|
+
const timeoutMs = (flags.timeout ?? DEFAULT_CALLBACK_TIMEOUT_S) * 1000;
|
|
253
|
+
let listener;
|
|
254
|
+
try {
|
|
255
|
+
listener = await startCallbackServer({ state, timeoutMs });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
err.write(`anyslate: could not bind a loopback port for the OAuth callback (${e?.message ?? e}).\n`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
263
|
+
authorizationEndpoint: discovery.authorizationEndpoint,
|
|
264
|
+
clientId,
|
|
265
|
+
redirectUri: listener.redirectUri,
|
|
266
|
+
codeChallenge: pkce.challenge,
|
|
267
|
+
state,
|
|
268
|
+
resource: discovery.resource,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// --- 5. Browser -------------------------------------------------------
|
|
272
|
+
out.write(`anyslate: signing in to ${host}${registered ? ' (registered this CLI)' : ''}.\n`);
|
|
273
|
+
if (flags.noBrowser) {
|
|
274
|
+
out.write('anyslate: --no-browser given. Open this URL to authorize:\n');
|
|
275
|
+
} else {
|
|
276
|
+
const opened = (deps.openBrowserImpl ?? openBrowser)(authorizeUrl);
|
|
277
|
+
out.write(
|
|
278
|
+
opened.ok
|
|
279
|
+
? 'anyslate: opened your browser. If nothing appeared, open this URL:\n'
|
|
280
|
+
: `anyslate: could not launch a browser (${opened.error ?? 'unknown error'}). Open this URL:\n`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
// Printed on BOTH paths. A browser that reported success but silently failed
|
|
284
|
+
// to appear would otherwise leave the user at a hung prompt with no way in.
|
|
285
|
+
out.write(`\n ${authorizeUrl}\n\n`);
|
|
286
|
+
out.write(`anyslate: waiting up to ${Math.round(timeoutMs / 1000)}s for the callback on ${listener.redirectUri} …\n`);
|
|
287
|
+
|
|
288
|
+
await deps.onAuthorizeUrl?.(authorizeUrl, { redirectUri: listener.redirectUri, state, root });
|
|
289
|
+
|
|
290
|
+
// --- 6. Callback (state validated inside the listener) ----------------
|
|
291
|
+
const callback = await listener.waitForResult();
|
|
292
|
+
if (!callback.ok) {
|
|
293
|
+
err.write(`${callback.message}\n`);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- 7. Token exchange ------------------------------------------------
|
|
298
|
+
const exchanged = await exchangeCode({
|
|
299
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
300
|
+
code: callback.code,
|
|
301
|
+
redirectUri: listener.redirectUri,
|
|
302
|
+
codeVerifier: pkce.verifier,
|
|
303
|
+
clientId,
|
|
304
|
+
resource: discovery.resource,
|
|
305
|
+
fetchImpl,
|
|
306
|
+
});
|
|
307
|
+
if (!exchanged.ok) {
|
|
308
|
+
err.write(`${exchanged.message}\n`);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const oauth = {
|
|
313
|
+
client_id: clientId,
|
|
314
|
+
access_token: exchanged.tokens.access_token,
|
|
315
|
+
refresh_token: exchanged.tokens.refresh_token,
|
|
316
|
+
expires_at: exchanged.tokens.expires_at,
|
|
317
|
+
// Cached so an unattended refresh costs one request rather than three.
|
|
318
|
+
// Bound to `root` so switching environments re-discovers instead of
|
|
319
|
+
// reusing dev's token endpoint against prod.
|
|
320
|
+
token_endpoint: discovery.tokenEndpoint,
|
|
321
|
+
resource: discovery.resource,
|
|
322
|
+
root,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// --- 8. Verify, exactly as the static path does -----------------------
|
|
326
|
+
let verified = null;
|
|
327
|
+
if (flags.noVerify) {
|
|
328
|
+
err.write('anyslate: --no-verify given — skipping the live connection check.\n');
|
|
329
|
+
} else {
|
|
330
|
+
verified = await probeVerify({ root, token: oauth.access_token, fetchImpl });
|
|
331
|
+
if (verified.ok) {
|
|
332
|
+
out.write(`${verified.message}\n`);
|
|
333
|
+
const warning = scopeWarning(verified.scopes);
|
|
334
|
+
if (warning) err.write(`${warning}\n`);
|
|
335
|
+
} else {
|
|
336
|
+
err.write(`${verified.message}\n`);
|
|
337
|
+
if (!flags.force) {
|
|
338
|
+
err.write('anyslate: nothing was written. Re-run `anyslate login`, or pass --force to write anyway.\n');
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- 9. Persist -------------------------------------------------------
|
|
346
|
+
const path = join(anyslateDir(env), 'cli.json');
|
|
347
|
+
let handle = null;
|
|
348
|
+
try {
|
|
349
|
+
const current = readConfigFile(env);
|
|
350
|
+
handle = flags.handle ?? current.handle ?? null;
|
|
351
|
+
const next = {
|
|
352
|
+
...current,
|
|
353
|
+
apiUrl: root,
|
|
354
|
+
handle,
|
|
355
|
+
oauth_clients: { ...(current.oauth_clients ?? {}), [root]: clientId },
|
|
356
|
+
oauth,
|
|
357
|
+
};
|
|
358
|
+
delete next.api_url;
|
|
359
|
+
writeConfigFile(next, env);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
362
|
+
return 1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
out.write(`anyslate: wrote ${path}\n`);
|
|
366
|
+
out.write(` apiUrl: ${root}\n`);
|
|
367
|
+
out.write(' auth: oauth (browser)\n');
|
|
368
|
+
out.write(` handle: ${handle ?? '(none — bearer token only)'}\n`);
|
|
369
|
+
out.write(
|
|
370
|
+
` access token expires ${oauth.expires_at}${
|
|
371
|
+
oauth.refresh_token ? '; it refreshes automatically' : ' (no refresh token issued)'
|
|
372
|
+
}\n`,
|
|
373
|
+
);
|
|
374
|
+
if (verified?.ok && verified.defaultHandleId) {
|
|
375
|
+
out.write(` token is bound to handle: ${verified.defaultHandleId} (server-side scope)\n`);
|
|
376
|
+
}
|
|
377
|
+
out.write('anyslate: run `anyslate doctor` to verify the full setup.\n');
|
|
378
|
+
return 0;
|
|
379
|
+
} finally {
|
|
380
|
+
await listener.close();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
57
384
|
/** @param {string[]} argv */
|
|
58
385
|
function parseFlags(argv) {
|
|
59
|
-
const out = {};
|
|
386
|
+
const out = { force: false, noVerify: false, noBrowser: false, help: false };
|
|
60
387
|
for (let i = 0; i < argv.length; i += 1) {
|
|
61
388
|
const a = argv[i];
|
|
62
389
|
if (a === '--token' && argv[i + 1]) out.token = argv[++i];
|
|
63
390
|
else if (a === '--handle' && argv[i + 1]) out.handle = argv[++i];
|
|
64
391
|
else if ((a === '--api-url' || a === '--api_url') && argv[i + 1]) out.apiUrl = argv[++i];
|
|
392
|
+
else if (a === '--force') out.force = true;
|
|
393
|
+
else if (a === '--no-verify' || a === '--skip-verify') out.noVerify = true;
|
|
394
|
+
else if (a === '--no-browser') out.noBrowser = true;
|
|
395
|
+
else if (a === '--timeout' && argv[i + 1]) {
|
|
396
|
+
const seconds = Number(argv[++i]);
|
|
397
|
+
if (Number.isFinite(seconds) && seconds > 0) out.timeout = seconds;
|
|
398
|
+
else out.timeoutInvalid = true;
|
|
399
|
+
} else if (a === '--help' || a === '-h') out.help = true;
|
|
65
400
|
}
|
|
66
401
|
return out;
|
|
67
402
|
}
|
|
403
|
+
|
|
404
|
+
export const __testing = { parseFlags };
|