recess-cli 1.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 +95 -0
- package/dist/api.js +120 -0
- package/dist/args.js +71 -0
- package/dist/auth.js +189 -0
- package/dist/cli.js +1537 -0
- package/dist/config.js +79 -0
- package/dist/errors.js +18 -0
- package/dist/index.js +41 -0
- package/dist/safety.js +7 -0
- package/dist/setup.js +43 -0
- package/package.json +48 -0
- package/skill/recess-cli/SKILL.md +290 -0
- package/skill/recess-cli/agents/openai.yaml +4 -0
- package/skill/recess-cli/reference/billing.md +146 -0
- package/skill/recess-cli/reference/cancellation-credits.md +53 -0
- package/skill/recess-cli/reference/class-ops-reschedule.md +41 -0
- package/skill/recess-cli/reference/class-ops.md +89 -0
- package/skill/recess-cli/reference/map-scores.md +28 -0
- package/skill/recess-cli/reference/onboarding.md +79 -0
- package/skill/recess-cli/reference/payout.md +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Recess CLI
|
|
2
|
+
|
|
3
|
+
`recess-cli` is the typed, agent-friendly command layer for Recess staff operations. It uses the web-server OpenAPI document, authenticates admins through Recess SSO, emits stable JSON, and refuses live writes until the exact command is rerun with `--confirm` after human approval.
|
|
4
|
+
|
|
5
|
+
## Install (staff — no checkout needed)
|
|
6
|
+
|
|
7
|
+
Published to npm as [`recess-cli`](https://www.npmjs.com/package/recess-cli). On any machine with Node 20+:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g recess-cli
|
|
11
|
+
recess setup
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`setup` installs the bundled skill for both Codex and Claude and then opens Recess SSO in your browser (skip the browser step with `--skill-only`; it is also skipped when a live session already exists). Restart your agent afterwards so it discovers the skill. `npx -y recess-cli setup` works too, but leaves no `recess` on your PATH — which is the command the installed skill tells the agent to run — so `setup` warns when it detects it is running from an npx cache.
|
|
15
|
+
|
|
16
|
+
Publishing is a manual GitHub Action (`.github/workflows/admin-cli-publish.yml`): bump `version` in `apps/admin-cli/package.json` in a normal PR, then run the workflow. It publishes with `pnpm`, not `npm`, because the manifest uses the workspace `catalog:` protocol for `openapi-fetch` and only pnpm rewrites it to a real range in the tarball.
|
|
17
|
+
|
|
18
|
+
## Install (from a checkout — CLI development)
|
|
19
|
+
|
|
20
|
+
From the monolith root:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm install
|
|
24
|
+
pnpm --dir apps/admin-cli run client:generate
|
|
25
|
+
pnpm --dir apps/admin-cli run install-persistent
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops).
|
|
29
|
+
|
|
30
|
+
## One-time SSO setup
|
|
31
|
+
|
|
32
|
+
Create an approved `OAuthClient` row in each Recess environment. This remains a manual security operation. Configure:
|
|
33
|
+
|
|
34
|
+
- `name`: `Recess Admin CLI`
|
|
35
|
+
- `approved`: `true`
|
|
36
|
+
- `adminCliEnabled`: `true`
|
|
37
|
+
- `jwtSecret`: a new secret
|
|
38
|
+
- `redirectUris`: exactly `http://127.0.0.1:8765/callback`
|
|
39
|
+
- `defaultlaunchUrl`: leave `null`; the CLI constructs and opens the Recess OAuth URL itself
|
|
40
|
+
|
|
41
|
+
The production row ID (`c7e34138-18f9-45b1-a2fb-26a4e3a6d739`) is the CLI's built-in default, so production login needs no client-ID setup. `--client-id`, `RECESS_CLI_OAUTH_CLIENT_ID`, and a stored client ID remain overrides for local/staging clients. The web-server decodes the assertion audience, loads that exact `OAuthClient`, and requires both `approved` and `adminCliEnabled`; there is no separate server environment allowlist. Production redirect validation is exact, so a different callback port must also be explicitly registered.
|
|
42
|
+
|
|
43
|
+
The browser SSO assertion is exchanged once and discarded. The CLI stores a separate 12-hour signed Recess admin session at `~/.recess-cli/config.json` with mode `0600`.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
recess --json auth login
|
|
47
|
+
recess --json doctor
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## JSON contract
|
|
51
|
+
|
|
52
|
+
With `--json`, stdout contains only one JSON object.
|
|
53
|
+
|
|
54
|
+
Success:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{"ok":true,"data":{"results":[]}}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Error or write preview:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{"ok":false,"error":{"code":"confirmation_required","message":"...","details":{"preview":{},"requiredFlag":"--confirm"}}}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
|
|
67
|
+
|
|
68
|
+
## Common flow
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
recess --json users search "Morgan Rivera"
|
|
72
|
+
recess --json users tier get <kid-id>
|
|
73
|
+
recess --json users tier preview <kid-id> --tier lite --slots 1
|
|
74
|
+
recess --json students upload-map-scores --student <kid-id> --file /path/to/map-report.pdf
|
|
75
|
+
recess --json enrollments list --user <kid-id>
|
|
76
|
+
recess --json subscriptions list --family <family-id> --kid <kid-id>
|
|
77
|
+
recess --json invoices list --subscription <subscription-id>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Preview a write by omitting `--confirm`:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
recess --json billing pause --subscription <subscription-id> --until 2026-09-01
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
After a human approves that exact preview, rerun the unchanged command with `--confirm`.
|
|
87
|
+
|
|
88
|
+
School tier writes require the `updatedAt` token from `users tier get`. Their read-only preflight
|
|
89
|
+
shows `capabilitiesLockedNow`/`capabilitiesLockedAfter`, the resolved class allowance, current
|
|
90
|
+
`slotsUsed`, and whether the change would strand registrations. Stranding is refused unless the
|
|
91
|
+
human explicitly approves the exceptional `--allow-strand` override.
|
|
92
|
+
|
|
93
|
+
MAP uploads accept one PDF up to 15 MB. The preview includes the resolved path, byte count, and SHA-256 without contacting the API; the confirmed command sends the report to the existing tutor-dashboard extraction route.
|
|
94
|
+
|
|
95
|
+
See `recess --help` for the complete P0 command surface. The raw escape hatch is intentionally read-only: `recess --json request get /path`.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import createClient from "openapi-fetch";
|
|
2
|
+
import { apiError, CliError } from "./errors.js";
|
|
3
|
+
export class RecessAdminApi {
|
|
4
|
+
config;
|
|
5
|
+
client;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.client = createClient({
|
|
9
|
+
baseUrl: config.apiOrigin,
|
|
10
|
+
headers: config.sessionCookie
|
|
11
|
+
? { cookie: config.sessionCookie }
|
|
12
|
+
: undefined,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
requireAuth() {
|
|
16
|
+
if (!this.config.sessionCookie) {
|
|
17
|
+
throw new CliError("auth_required", "No admin session found. Run `recess auth login`.");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async rawGet(path) {
|
|
21
|
+
this.requireAuth();
|
|
22
|
+
if (!path.startsWith("/") || path.startsWith("//")) {
|
|
23
|
+
throw new CliError("invalid_arguments", "Raw request paths must start with one '/'.");
|
|
24
|
+
}
|
|
25
|
+
const response = await fetch(new URL(path, this.config.apiOrigin), {
|
|
26
|
+
headers: { cookie: this.config.sessionCookie },
|
|
27
|
+
});
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
let body = text;
|
|
30
|
+
try {
|
|
31
|
+
body = text ? JSON.parse(text) : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Preserve a non-JSON API response as text.
|
|
35
|
+
}
|
|
36
|
+
if (!response.ok)
|
|
37
|
+
throw apiError(response.status, body);
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
async villageRequest(path, options = {}) {
|
|
41
|
+
this.requireAuth();
|
|
42
|
+
const response = await fetch(new URL(path, this.config.apiOrigin), {
|
|
43
|
+
method: options.method ?? "GET",
|
|
44
|
+
headers: {
|
|
45
|
+
cookie: this.config.sessionCookie,
|
|
46
|
+
...(options.body === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { "content-type": "application/json" }),
|
|
49
|
+
},
|
|
50
|
+
...(options.body === undefined
|
|
51
|
+
? {}
|
|
52
|
+
: { body: JSON.stringify(options.body) }),
|
|
53
|
+
});
|
|
54
|
+
const text = await response.text();
|
|
55
|
+
let body = text;
|
|
56
|
+
try {
|
|
57
|
+
body = text ? JSON.parse(text) : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Preserve a non-JSON bridge response as text.
|
|
61
|
+
}
|
|
62
|
+
if (!response.ok)
|
|
63
|
+
throw apiError(response.status, body);
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
async uploadVillageModel(worldId, file, fileName, metadata) {
|
|
67
|
+
this.requireAuth();
|
|
68
|
+
const form = new FormData();
|
|
69
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
70
|
+
if (value !== undefined)
|
|
71
|
+
form.set(key, String(value));
|
|
72
|
+
}
|
|
73
|
+
const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
|
|
74
|
+
form.set("file", new Blob([arrayBuffer], { type: "model/gltf-binary" }), fileName);
|
|
75
|
+
const response = await fetch(new URL(`/admin/village/models/upload?worldId=${encodeURIComponent(worldId)}`, this.config.apiOrigin), {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { cookie: this.config.sessionCookie },
|
|
78
|
+
body: form,
|
|
79
|
+
});
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
let body = text;
|
|
82
|
+
try {
|
|
83
|
+
body = text ? JSON.parse(text) : null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Preserve a non-JSON bridge response as text.
|
|
87
|
+
}
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
throw apiError(response.status, body);
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
async uploadMapTestScores(studentId, pdf, fileName) {
|
|
93
|
+
this.requireAuth();
|
|
94
|
+
const formData = new FormData();
|
|
95
|
+
formData.append("file", new Blob([pdf], { type: "application/pdf" }), fileName);
|
|
96
|
+
const response = await fetch(new URL(`/tutor/students/${encodeURIComponent(studentId)}/map-test-scores/upload`, this.config.apiOrigin), {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { cookie: this.config.sessionCookie },
|
|
99
|
+
body: formData,
|
|
100
|
+
});
|
|
101
|
+
const text = await response.text();
|
|
102
|
+
let body = text;
|
|
103
|
+
try {
|
|
104
|
+
body = text ? JSON.parse(text) : null;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Preserve a non-JSON API response as text.
|
|
108
|
+
}
|
|
109
|
+
if (!response.ok)
|
|
110
|
+
throw apiError(response.status, body);
|
|
111
|
+
return body;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function unwrap(result) {
|
|
115
|
+
if (!result.response.ok || result.data === undefined) {
|
|
116
|
+
throw apiError(result.response.status, result.error);
|
|
117
|
+
}
|
|
118
|
+
return result.data;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=api.js.map
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
const BOOLEAN_FLAGS = new Set([
|
|
3
|
+
"allow-strand",
|
|
4
|
+
"cancel-subscriptions",
|
|
5
|
+
"archived",
|
|
6
|
+
"confirm",
|
|
7
|
+
"full",
|
|
8
|
+
"help",
|
|
9
|
+
"immediate",
|
|
10
|
+
"mirrored",
|
|
11
|
+
"no-collision",
|
|
12
|
+
"json",
|
|
13
|
+
"restore",
|
|
14
|
+
"revoke",
|
|
15
|
+
"send-email",
|
|
16
|
+
"visual-only",
|
|
17
|
+
]);
|
|
18
|
+
export function parseArgs(args) {
|
|
19
|
+
const positionals = [];
|
|
20
|
+
const flags = new Map();
|
|
21
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
22
|
+
const value = args[index];
|
|
23
|
+
if (!value.startsWith("--")) {
|
|
24
|
+
positionals.push(value);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const equalsAt = value.indexOf("=");
|
|
28
|
+
if (equalsAt > 2) {
|
|
29
|
+
flags.set(value.slice(2, equalsAt), value.slice(equalsAt + 1));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const name = value.slice(2);
|
|
33
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
34
|
+
flags.set(name, true);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const next = args[index + 1];
|
|
38
|
+
if (next && !next.startsWith("--")) {
|
|
39
|
+
flags.set(name, next);
|
|
40
|
+
index += 1;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
flags.set(name, true);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { positionals, flags };
|
|
47
|
+
}
|
|
48
|
+
export function flagString(parsed, name, options = {}) {
|
|
49
|
+
const value = parsed.flags.get(name);
|
|
50
|
+
if (value === true) {
|
|
51
|
+
throw new CliError("invalid_arguments", `--${name} requires a value.`);
|
|
52
|
+
}
|
|
53
|
+
if (value === undefined && options.required) {
|
|
54
|
+
throw new CliError("invalid_arguments", `Missing required --${name}.`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
export function flagNumber(parsed, name) {
|
|
59
|
+
const value = flagString(parsed, name);
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
const number = Number(value);
|
|
63
|
+
if (!Number.isFinite(number)) {
|
|
64
|
+
throw new CliError("invalid_arguments", `--${name} must be a number.`);
|
|
65
|
+
}
|
|
66
|
+
return number;
|
|
67
|
+
}
|
|
68
|
+
export function hasFlag(parsed, name) {
|
|
69
|
+
return parsed.flags.has(name);
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=args.js.map
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { clearPendingDeviceAuth, updateStoredConfig } from "./config.js";
|
|
5
|
+
import { apiError, CliError } from "./errors.js";
|
|
6
|
+
function openBrowser(url) {
|
|
7
|
+
const command = process.platform === "darwin"
|
|
8
|
+
? "open"
|
|
9
|
+
: process.platform === "win32"
|
|
10
|
+
? "cmd"
|
|
11
|
+
: "xdg-open";
|
|
12
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
13
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
14
|
+
child.unref();
|
|
15
|
+
}
|
|
16
|
+
export function waitForSsoCallback(port, expectedState) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const timeout = setTimeout(() => {
|
|
19
|
+
server.close();
|
|
20
|
+
reject(new CliError("auth_timeout", "Timed out waiting for Recess SSO."));
|
|
21
|
+
}, 5 * 60 * 1000);
|
|
22
|
+
const server = createServer((request, response) => {
|
|
23
|
+
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
|
|
24
|
+
if (url.pathname !== "/callback") {
|
|
25
|
+
response.writeHead(404).end("Not found");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const state = url.searchParams.get("state");
|
|
29
|
+
if (state !== expectedState) {
|
|
30
|
+
response.writeHead(400, { "content-type": "text/plain" });
|
|
31
|
+
response.end("Invalid Recess admin CLI sign-in callback.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const oauthError = url.searchParams.get("error");
|
|
35
|
+
if (oauthError) {
|
|
36
|
+
response.writeHead(400, { "content-type": "text/plain" });
|
|
37
|
+
response.end("Recess admin CLI sign-in was denied.");
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
server.close();
|
|
40
|
+
reject(new CliError("auth_failed", `SSO login was denied: ${oauthError}.`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const token = url.searchParams.get("token");
|
|
44
|
+
if (!token) {
|
|
45
|
+
response.writeHead(400, { "content-type": "text/plain" });
|
|
46
|
+
response.end("Invalid Recess admin CLI sign-in callback.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
50
|
+
response.end("<!doctype html><title>Recess admin CLI</title><p>Signed in. You can close this tab and return to the terminal.</p>");
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
server.close();
|
|
53
|
+
resolve(token);
|
|
54
|
+
});
|
|
55
|
+
server.once("error", (error) => {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
reject(new CliError("callback_unavailable", `Could not listen on 127.0.0.1:${port}: ${error.message}`));
|
|
58
|
+
});
|
|
59
|
+
server.listen(port, "127.0.0.1");
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export async function login(config, options) {
|
|
63
|
+
const state = randomBytes(24).toString("base64url");
|
|
64
|
+
const redirectUri = `http://127.0.0.1:${options.callbackPort}/callback`;
|
|
65
|
+
const authorizeUrl = new URL("/oauth", config.webOrigin);
|
|
66
|
+
authorizeUrl.searchParams.set("client_id", options.oauthClientId);
|
|
67
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
68
|
+
authorizeUrl.searchParams.set("state", state);
|
|
69
|
+
authorizeUrl.searchParams.set("type", "sso");
|
|
70
|
+
const callback = waitForSsoCallback(options.callbackPort, state);
|
|
71
|
+
openBrowser(authorizeUrl.toString());
|
|
72
|
+
const assertion = await callback;
|
|
73
|
+
const response = await fetch(new URL("/auth/admin-cli/exchange/", config.apiOrigin), {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { authorization: `Bearer ${assertion}` },
|
|
76
|
+
});
|
|
77
|
+
const body = (await response.json());
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
throw apiError(response.status, body);
|
|
80
|
+
const pair = response.headers.get("set-cookie")?.split(";", 1)[0];
|
|
81
|
+
if (!pair?.includes("=")) {
|
|
82
|
+
throw new CliError("auth_failed", "The Recess API did not return an admin session cookie.");
|
|
83
|
+
}
|
|
84
|
+
const exchange = body;
|
|
85
|
+
await updateStoredConfig({
|
|
86
|
+
apiOrigin: config.apiOrigin,
|
|
87
|
+
webOrigin: config.webOrigin,
|
|
88
|
+
oauthClientId: options.oauthClientId,
|
|
89
|
+
sessionCookie: pair,
|
|
90
|
+
sessionExpiresAt: exchange.expiresAt,
|
|
91
|
+
user: exchange.user,
|
|
92
|
+
});
|
|
93
|
+
return exchange;
|
|
94
|
+
}
|
|
95
|
+
// Start the headless (device-authorization) sign-in. Non-blocking: it stores the secret
|
|
96
|
+
// device code locally and returns the approval URL for the agent to surface to a human, who
|
|
97
|
+
// approves it in a browser. The agent then calls `pollDeviceAuth` to collect the session.
|
|
98
|
+
export async function requestDeviceAuth(config, options) {
|
|
99
|
+
const response = await fetch(new URL("/auth/admin-cli/device/authorize/", config.apiOrigin), {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "content-type": "application/json" },
|
|
102
|
+
body: JSON.stringify(options.label ? { label: options.label } : {}),
|
|
103
|
+
});
|
|
104
|
+
const body = (await response.json());
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
throw apiError(response.status, body);
|
|
107
|
+
const authorize = body;
|
|
108
|
+
await updateStoredConfig({
|
|
109
|
+
apiOrigin: config.apiOrigin,
|
|
110
|
+
webOrigin: config.webOrigin,
|
|
111
|
+
pendingDeviceCode: authorize.deviceCode,
|
|
112
|
+
pendingUserCode: authorize.userCode,
|
|
113
|
+
pendingApprovalUrl: authorize.approvalUrl,
|
|
114
|
+
pendingExpiresAt: authorize.expiresAt,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
approvalUrl: authorize.approvalUrl,
|
|
118
|
+
userCode: authorize.userCode,
|
|
119
|
+
expiresAt: authorize.expiresAt,
|
|
120
|
+
interval: authorize.interval,
|
|
121
|
+
instructions: `Open ${authorize.approvalUrl} in a browser, approve as a Recess admin, then run \`recess auth poll\`.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
125
|
+
// Poll the device-authorization request until an admin approves it (then store the session),
|
|
126
|
+
// or it is denied / expires / the timeout elapses. On a plain timeout the pending request is
|
|
127
|
+
// preserved so the agent can call `auth poll` again after the human approves.
|
|
128
|
+
export async function pollDeviceAuth(config, options) {
|
|
129
|
+
const deviceCode = config.pendingDeviceCode;
|
|
130
|
+
if (!deviceCode) {
|
|
131
|
+
throw new CliError("auth_required", "No pending sign-in. Run `recess auth request` first.");
|
|
132
|
+
}
|
|
133
|
+
const intervalMs = options.intervalMs ?? 5000;
|
|
134
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
135
|
+
const pendingExpiry = config.pendingExpiresAt
|
|
136
|
+
? Date.parse(config.pendingExpiresAt)
|
|
137
|
+
: undefined;
|
|
138
|
+
for (;;) {
|
|
139
|
+
const response = await fetch(new URL("/auth/admin-cli/device/token/", config.apiOrigin), {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ deviceCode }),
|
|
143
|
+
});
|
|
144
|
+
const body = (await response.json());
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
await clearPendingDeviceAuth();
|
|
147
|
+
throw apiError(response.status, body);
|
|
148
|
+
}
|
|
149
|
+
const token = body;
|
|
150
|
+
if (token.status === "approved") {
|
|
151
|
+
const pair = response.headers.get("set-cookie")?.split(";", 1)[0];
|
|
152
|
+
if (!pair?.includes("=")) {
|
|
153
|
+
throw new CliError("auth_failed", "The Recess API approved the request but did not return a session cookie.");
|
|
154
|
+
}
|
|
155
|
+
const exchange = {
|
|
156
|
+
success: true,
|
|
157
|
+
expiresAt: token.expiresAt ?? "",
|
|
158
|
+
user: token.user,
|
|
159
|
+
};
|
|
160
|
+
await updateStoredConfig({
|
|
161
|
+
apiOrigin: config.apiOrigin,
|
|
162
|
+
webOrigin: config.webOrigin,
|
|
163
|
+
sessionCookie: pair,
|
|
164
|
+
sessionExpiresAt: exchange.expiresAt,
|
|
165
|
+
user: exchange.user,
|
|
166
|
+
});
|
|
167
|
+
await clearPendingDeviceAuth();
|
|
168
|
+
return exchange;
|
|
169
|
+
}
|
|
170
|
+
if (token.status === "denied") {
|
|
171
|
+
await clearPendingDeviceAuth();
|
|
172
|
+
throw new CliError("auth_failed", "The sign-in request was denied.");
|
|
173
|
+
}
|
|
174
|
+
if (token.status === "expired" || token.status === "consumed") {
|
|
175
|
+
await clearPendingDeviceAuth();
|
|
176
|
+
throw new CliError("auth_failed", token.status === "expired"
|
|
177
|
+
? "The sign-in request expired. Run `recess auth request` again."
|
|
178
|
+
: "The sign-in request was already used. Run `recess auth request` again.");
|
|
179
|
+
}
|
|
180
|
+
// status === "pending": wait and retry, unless we would run past the deadline
|
|
181
|
+
// or the request's own expiry.
|
|
182
|
+
const nextTick = Date.now() + intervalMs;
|
|
183
|
+
if (nextTick >= deadline || (pendingExpiry && nextTick >= pendingExpiry)) {
|
|
184
|
+
throw new CliError("auth_timeout", "Timed out waiting for approval. Approve the link, then run `recess auth poll`.");
|
|
185
|
+
}
|
|
186
|
+
await sleep(intervalMs);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=auth.js.map
|