@letterstory/cli 0.145.1 → 0.145.3
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/lib/cli.mjs +5 -2
- package/lib/commands/auth.mjs +2 -2
- package/lib/oauth.mjs +65 -0
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -63,14 +63,16 @@ import {
|
|
|
63
63
|
cmdSim,
|
|
64
64
|
} from "./commands.mjs";
|
|
65
65
|
|
|
66
|
-
//
|
|
67
|
-
export const VERSION = "0.145.
|
|
66
|
+
// Matches cli/package.json; publish-cli.yml rewrites both to the published version.
|
|
67
|
+
export const VERSION = "0.145.3";
|
|
68
68
|
|
|
69
69
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
70
70
|
// can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
|
|
71
71
|
// `negate` spelling) in a command's ARGS map must be listed; boolean-flags.test.ts enforces it.
|
|
72
72
|
export const BOOLEAN_FLAGS = new Set([
|
|
73
73
|
"remap",
|
|
74
|
+
// login: one-link device sign-in instead of the loopback browser flow.
|
|
75
|
+
"device",
|
|
74
76
|
"json",
|
|
75
77
|
// sim seed: make the new seed active; collections it doesn't cover start empty.
|
|
76
78
|
"activate",
|
|
@@ -366,6 +368,7 @@ Usage:
|
|
|
366
368
|
|
|
367
369
|
Auth:
|
|
368
370
|
login [--url <url>] Sign in via your browser (default url: https://app.letterstory.com)
|
|
371
|
+
login --device [--url <url>] Sign in with one link, opened in any browser (for agents and remote shells)
|
|
369
372
|
login --key <ls_…> [--url <url>] Save a static API key instead (for CI/automation)
|
|
370
373
|
logout Forget saved credentials (revokes an OAuth session, if any)
|
|
371
374
|
config Show the resolved url + credential source
|
package/lib/commands/auth.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { LetterstoryClient, resolveConfig, writeConfigFile, clearConfigFile, configPath } from "../client.mjs";
|
|
10
10
|
import { flagStr } from "./shared.mjs";
|
|
11
|
-
import { browserLogin, revokeToken } from "../oauth.mjs";
|
|
11
|
+
import { browserLogin, deviceLogin, revokeToken } from "../oauth.mjs";
|
|
12
12
|
|
|
13
13
|
export async function cmdLogin(ctx) {
|
|
14
14
|
const { flags, io } = ctx;
|
|
@@ -39,7 +39,7 @@ export async function cmdLogin(ctx) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const tokens = await browserLogin({ url, io });
|
|
42
|
+
const tokens = flags.device ? await deviceLogin({ url, io }) : await browserLogin({ url, io });
|
|
43
43
|
const oauth = {
|
|
44
44
|
access_token: tokens.access_token,
|
|
45
45
|
refresh_token: tokens.refresh_token,
|
package/lib/oauth.mjs
CHANGED
|
@@ -186,6 +186,71 @@ export async function browserLogin({ url, io, fetchImpl = globalThis.fetch }) {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
190
|
+
|
|
191
|
+
// RFC 8628 device sign-in: for a CLI whose browser is somewhere else (a cloud agent, a remote
|
|
192
|
+
// box). Prints one link for the person to open and Allow, then polls /token until they do.
|
|
193
|
+
export async function deviceLogin({
|
|
194
|
+
url,
|
|
195
|
+
io,
|
|
196
|
+
fetchImpl = globalThis.fetch,
|
|
197
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
198
|
+
}) {
|
|
199
|
+
const base = url.replace(/\/+$/, "");
|
|
200
|
+
const endpoint = `${base}/api/oauth/device_authorization`;
|
|
201
|
+
let res;
|
|
202
|
+
try {
|
|
203
|
+
res = await fetchImpl(endpoint, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
206
|
+
body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
|
|
207
|
+
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
throw new CliError(`Could not reach ${endpoint}: ${err.message}`);
|
|
210
|
+
}
|
|
211
|
+
const start = await res.json().catch(() => null);
|
|
212
|
+
if (!res.ok || !start?.device_code) {
|
|
213
|
+
const detail = start?.error_description || start?.error || `HTTP ${res.status}`;
|
|
214
|
+
throw new CliError(`Login failed: ${detail}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
io.log(`Open this link, pick the organization and click Allow:\n ${start.verification_uri_complete}\n`);
|
|
218
|
+
io.log(`Code: ${start.user_code} (the page shows the same one)`);
|
|
219
|
+
io.log("Waiting for approval…");
|
|
220
|
+
|
|
221
|
+
let intervalMs = (start.interval ?? 5) * 1000;
|
|
222
|
+
const deadline = Date.now() + (start.expires_in ?? 600) * 1000;
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
await sleep(intervalMs);
|
|
225
|
+
let poll;
|
|
226
|
+
try {
|
|
227
|
+
poll = await fetchImpl(`${base}/api/oauth/token`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
230
|
+
body: new URLSearchParams({
|
|
231
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
232
|
+
client_id: CLIENT_ID,
|
|
233
|
+
device_code: start.device_code,
|
|
234
|
+
}).toString(),
|
|
235
|
+
});
|
|
236
|
+
} catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const json = await poll.json().catch(() => null);
|
|
240
|
+
if (poll.ok && json?.access_token) return json;
|
|
241
|
+
const error = json?.error;
|
|
242
|
+
if (error === "authorization_pending") continue;
|
|
243
|
+
if (error === "slow_down") {
|
|
244
|
+
intervalMs += 5000;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (error === "access_denied") throw new CliError("Sign-in was cancelled in the browser.");
|
|
248
|
+
if (error === "expired_token") throw new CliError("The link expired. Run `login --device` again.");
|
|
249
|
+
throw new CliError(`Login failed: ${json?.error_description || error || `HTTP ${poll.status}`}`);
|
|
250
|
+
}
|
|
251
|
+
throw new CliError("The link expired before it was approved. Run `login --device` again.");
|
|
252
|
+
}
|
|
253
|
+
|
|
189
254
|
export async function refreshAccessToken({ url, refreshToken, fetchImpl = globalThis.fetch }) {
|
|
190
255
|
return tokenRequest(
|
|
191
256
|
url,
|