@retasc/cli 1.10.0 → 1.12.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/dist/auth.js +120 -21
- package/dist/commands/bind.js +36 -29
- package/dist/commands/identity.js +11 -7
- package/dist/commands/join.js +45 -21
- package/dist/config.js +6 -0
- package/dist/index.js +14 -3
- package/dist/lib/prompt.js +38 -0
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ConvexHttpClient } from "convex/browser";
|
|
2
2
|
import { makeFunctionReference } from "convex/server";
|
|
3
3
|
import { loadConfig, patchConfig } from "./config.js";
|
|
4
|
+
import { ask, isInteractive } from "./lib/prompt.js";
|
|
4
5
|
// The GitHub OAuth App's PUBLIC client id (safe to ship — device flow needs no
|
|
5
6
|
// secret). Baked in so `retasc login` works out-of-the-box; override via env.
|
|
6
7
|
const GITHUB_CLIENT_ID = process.env.RETASC_GITHUB_CLIENT_ID ?? "Ov23linqRy875IU8OYTW";
|
|
@@ -8,9 +9,16 @@ const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
|
8
9
|
const TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
9
10
|
const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
10
11
|
// Convex Auth's own public sign-in action. We call it with the "github-device"
|
|
11
|
-
// credentials provider; it verifies the
|
|
12
|
-
// session ({ tokens: { token, refreshToken } }).
|
|
12
|
+
// or "google-device" credentials provider; it verifies the upstream token
|
|
13
|
+
// server-side and returns a session ({ tokens: { token, refreshToken } }).
|
|
13
14
|
const signIn = makeFunctionReference("auth:signIn");
|
|
15
|
+
// RTSC-508 — the Google device flow's two legs, both server-side. Google, unlike
|
|
16
|
+
// GitHub, requires a client SECRET on the token exchange, and a published npm
|
|
17
|
+
// package cannot hold one. So there is deliberately no Google client id in this
|
|
18
|
+
// file either: the CLI never talks to Google directly, so it never needs one, and
|
|
19
|
+
// rotating that client becomes a Convex env change with no CLI release.
|
|
20
|
+
const googleDeviceStart = makeFunctionReference("googleDevice:start");
|
|
21
|
+
const googleDevicePoll = makeFunctionReference("googleDevice:poll");
|
|
14
22
|
async function postForm(url, body) {
|
|
15
23
|
const res = await fetch(url, {
|
|
16
24
|
method: "POST",
|
|
@@ -20,11 +28,14 @@ async function postForm(url, body) {
|
|
|
20
28
|
return res.json();
|
|
21
29
|
}
|
|
22
30
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
/** Print the two lines a human has to act on, identically for both doors. */
|
|
32
|
+
function announceCode(where, url, code) {
|
|
33
|
+
console.log(`\n Open: ${url}`);
|
|
34
|
+
console.log(` Enter code: ${code}\n`);
|
|
35
|
+
console.log(` Waiting for ${where} authorization…`);
|
|
36
|
+
}
|
|
37
|
+
/** Run GitHub's device flow (talking to GitHub directly) and return its access token. */
|
|
38
|
+
async function githubDeviceToken() {
|
|
28
39
|
if (!GITHUB_CLIENT_ID) {
|
|
29
40
|
throw new Error("GitHub client id not configured. Set RETASC_GITHUB_CLIENT_ID (the OAuth App's public Client ID).");
|
|
30
41
|
}
|
|
@@ -35,13 +46,10 @@ export async function deviceLogin() {
|
|
|
35
46
|
if (!start.device_code) {
|
|
36
47
|
throw new Error(`GitHub device-flow start failed: ${JSON.stringify(start)}`);
|
|
37
48
|
}
|
|
38
|
-
|
|
39
|
-
console.log(` Enter code: ${start.user_code}\n`);
|
|
40
|
-
console.log(" Waiting for authorization…");
|
|
49
|
+
announceCode("GitHub", start.verification_uri, start.user_code);
|
|
41
50
|
// Poll GitHub for the access token.
|
|
42
51
|
let intervalMs = (start.interval || 5) * 1000;
|
|
43
52
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
44
|
-
let githubToken = "";
|
|
45
53
|
while (Date.now() < deadline) {
|
|
46
54
|
await sleep(intervalMs);
|
|
47
55
|
const r = await postForm(TOKEN_URL, {
|
|
@@ -49,10 +57,8 @@ export async function deviceLogin() {
|
|
|
49
57
|
device_code: start.device_code,
|
|
50
58
|
grant_type: GRANT,
|
|
51
59
|
});
|
|
52
|
-
if (r.access_token)
|
|
53
|
-
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
60
|
+
if (r.access_token)
|
|
61
|
+
return r.access_token;
|
|
56
62
|
if (r.error === "authorization_pending")
|
|
57
63
|
continue;
|
|
58
64
|
if (r.error === "slow_down") {
|
|
@@ -61,20 +67,113 @@ export async function deviceLogin() {
|
|
|
61
67
|
}
|
|
62
68
|
throw new Error(`GitHub authorization failed: ${r.error_description ?? r.error}`);
|
|
63
69
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
throw new Error("Timed out waiting for GitHub authorization.");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Run Google's device flow and return its access token (RTSC-508).
|
|
74
|
+
*
|
|
75
|
+
* Structurally the same as the GitHub one, with the two Google calls made by the
|
|
76
|
+
* backend rather than from here — see the note on the function references above.
|
|
77
|
+
* The loop, the interval back-off and the deadline stay client-side: the wait is
|
|
78
|
+
* as long as a human takes to find their browser, which is not something to hold
|
|
79
|
+
* a Convex action open for.
|
|
80
|
+
*/
|
|
81
|
+
async function googleDeviceToken(convex) {
|
|
82
|
+
const start = (await convex.action(googleDeviceStart, {}));
|
|
83
|
+
announceCode("Google", start.verificationUrl, start.userCode);
|
|
84
|
+
let intervalMs = (start.intervalSeconds || 5) * 1000;
|
|
85
|
+
const deadline = Date.now() + start.expiresInSeconds * 1000;
|
|
86
|
+
while (Date.now() < deadline) {
|
|
87
|
+
await sleep(intervalMs);
|
|
88
|
+
// Anything terminal — declined, expired, misconfigured — is thrown by the
|
|
89
|
+
// backend as a readable userError and propagates to the caller's `fail`.
|
|
90
|
+
const r = (await convex.action(googleDevicePoll, { deviceCode: start.deviceCode }));
|
|
91
|
+
if (r.status === "ok")
|
|
92
|
+
return r.googleToken;
|
|
93
|
+
if (r.status === "slowDown")
|
|
94
|
+
intervalMs += 5000;
|
|
95
|
+
}
|
|
96
|
+
throw new Error("Timed out waiting for Google authorization.");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Which door, when the caller didn't say (RTSC-508).
|
|
100
|
+
*
|
|
101
|
+
* Asked rather than defaulted because the person who needs the second door is
|
|
102
|
+
* precisely the one who cannot be expected to know a flag exists: they signed up
|
|
103
|
+
* through the web Dash with Google, and a GitHub-only prompt is a dead end they
|
|
104
|
+
* have no way to read as one. One keystroke, and only when someone is there to
|
|
105
|
+
* press it.
|
|
106
|
+
*
|
|
107
|
+
* Non-interactive runs keep today's behaviour exactly — GitHub, no prompt — so
|
|
108
|
+
* nothing scripted against `retasc login` changes.
|
|
109
|
+
*/
|
|
110
|
+
export async function chooseProvider(
|
|
111
|
+
// Injected so the question can be pinned by tests. Picking the wrong door is
|
|
112
|
+
// not a typo you correct on the next run: it signs you in as a SECOND identity
|
|
113
|
+
// with its own membership, and nothing in the CLI can merge them back. Same
|
|
114
|
+
// reasoning as `identityLoop`'s deps — the loud path is the one that must be
|
|
115
|
+
// provable, and a live run only ever exercises the quiet one.
|
|
116
|
+
deps = {}) {
|
|
117
|
+
const askFn = deps.askFn ?? ask;
|
|
118
|
+
const interactive = deps.interactive ?? isInteractive;
|
|
119
|
+
if (!interactive())
|
|
120
|
+
return "github";
|
|
121
|
+
// A default exists ONLY when it is a recorded fact about this human — the door
|
|
122
|
+
// they last signed in through successfully. That is the whole rule. Defaulting
|
|
123
|
+
// to GitHub because most people use it would be assuming an answer to a
|
|
124
|
+
// question whose wrong answer is a second identity, for exactly the person
|
|
125
|
+
// this feature exists to serve. A remembered door assumes nothing: it already
|
|
126
|
+
// worked for them once.
|
|
127
|
+
const remembered = deps.remembered;
|
|
128
|
+
const DOORS = [
|
|
129
|
+
{ key: "github", slot: "1", label: "GitHub" },
|
|
130
|
+
{ key: "google", slot: "2", label: "Google" },
|
|
131
|
+
];
|
|
132
|
+
console.log("\nHow do you sign in to Retasc?");
|
|
133
|
+
for (const d of DOORS) {
|
|
134
|
+
console.log(` ${d.slot}) ${d.label}${d.key === remembered ? " (last used)" : ""}`);
|
|
135
|
+
}
|
|
136
|
+
const rememberedSlot = DOORS.find((d) => d.key === remembered)?.slot;
|
|
137
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
138
|
+
const a = await askFn(rememberedSlot ? `Choose a number [${rememberedSlot}]: ` : "Choose a number: ");
|
|
139
|
+
// Empty is an answer only when there is something true to answer WITH.
|
|
140
|
+
if (a === "" && remembered)
|
|
141
|
+
return remembered;
|
|
142
|
+
const hit = DOORS.find((d) => d.slot === a);
|
|
143
|
+
if (hit)
|
|
144
|
+
return hit.key;
|
|
145
|
+
console.log("Please enter 1 or 2.");
|
|
146
|
+
}
|
|
147
|
+
throw new Error("no valid choice — aborting");
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Run a device-flow login and persist the resulting Convex Auth session to
|
|
151
|
+
* ~/.retasc/config.json. Prints the user code + verification URL.
|
|
152
|
+
*
|
|
153
|
+
* With no `provider`, asks which door when a human is present and falls back to
|
|
154
|
+
* GitHub when one isn't.
|
|
155
|
+
*/
|
|
156
|
+
export async function deviceLogin(provider) {
|
|
67
157
|
const cfg = loadConfig();
|
|
158
|
+
const door = provider ?? (await chooseProvider({ remembered: cfg.loginProvider }));
|
|
68
159
|
const convex = new ConvexHttpClient(cfg.deploymentUrl);
|
|
160
|
+
// Exchange the upstream token for a Retasc (Convex Auth) session. The token is
|
|
161
|
+
// used here and never stored: the session is the only credential that persists.
|
|
162
|
+
const params = door === "google"
|
|
163
|
+
? { googleToken: await googleDeviceToken(convex) }
|
|
164
|
+
: { githubToken: await githubDeviceToken() };
|
|
69
165
|
const res = await convex.action(signIn, {
|
|
70
|
-
provider: "github-device",
|
|
71
|
-
params
|
|
166
|
+
provider: door === "google" ? "google-device" : "github-device",
|
|
167
|
+
params,
|
|
72
168
|
});
|
|
73
169
|
const tokens = res?.tokens;
|
|
74
170
|
if (!tokens?.token) {
|
|
75
171
|
throw new Error("Sign-in did not return a session token.");
|
|
76
172
|
}
|
|
77
|
-
|
|
173
|
+
// Record the door only now, on the far side of a sign-in that actually worked.
|
|
174
|
+
// Written here rather than at the point of choosing so a failed or abandoned
|
|
175
|
+
// attempt can never become next time's default.
|
|
176
|
+
patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken, loginProvider: door });
|
|
78
177
|
}
|
|
79
178
|
/**
|
|
80
179
|
* Does this thrown error clearly mean "this refresh token can never mint a new
|
package/dist/commands/bind.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createInterface } from "node:readline/promises";
|
|
2
|
-
import { stdin, stdout } from "node:process";
|
|
3
1
|
import { api, cliError } from "../api.js";
|
|
4
2
|
import { deviceLogin } from "../auth.js";
|
|
5
3
|
import { loadConfig } from "../config.js";
|
|
@@ -7,34 +5,14 @@ import { installMarker } from "./mcp.js";
|
|
|
7
5
|
import { readLocalBinding, resolveBinding } from "../lib/binding.js";
|
|
8
6
|
import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
|
|
9
7
|
import { resolveLauncher, launcherNote, selfCommand } from "../lib/launcher.js";
|
|
8
|
+
import { ask, confirm, isInteractive } from "../lib/prompt.js";
|
|
10
9
|
import { clean } from "../lib/text.js";
|
|
11
10
|
import { VERSION } from "../version.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
// readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
|
|
19
|
-
// closed pipe) — it silently drops the callback rather than resolving ""
|
|
20
|
-
// or throwing, so a bare `await` here wedges the CLI with no output and no
|
|
21
|
-
// exit. Race the close event so callers fail fast instead (RTSC-269).
|
|
22
|
-
const closed = new Promise((_, reject) => rl.once("close", () => reject(new Error("input closed"))));
|
|
23
|
-
closed.catch(() => { }); // the normal path closes rl too; that loser rejection is expected
|
|
24
|
-
return (await Promise.race([rl.question(question), closed])).trim();
|
|
25
|
-
}
|
|
26
|
-
finally {
|
|
27
|
-
rl.close();
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export async function confirm(question, assumeYes) {
|
|
31
|
-
if (assumeYes)
|
|
32
|
-
return true;
|
|
33
|
-
if (!isInteractive())
|
|
34
|
-
return false;
|
|
35
|
-
const a = (await ask(`${question} [y/N] `)).toLowerCase();
|
|
36
|
-
return a === "y" || a === "yes";
|
|
37
|
-
}
|
|
11
|
+
// RTSC-508: `ask`/`confirm`/`isInteractive` now live in lib/prompt.ts so `auth.ts`
|
|
12
|
+
// can use them without closing an import cycle (bind → auth → bind). Re-exported
|
|
13
|
+
// here because they have been part of this module's surface since RTSC-269, and
|
|
14
|
+
// moving the import in every call site would be churn for no gain.
|
|
15
|
+
export { ask, confirm, isInteractive };
|
|
38
16
|
/**
|
|
39
17
|
* Render a numbered menu and read one answer, optionally with a trailing extra option.
|
|
40
18
|
*
|
|
@@ -299,7 +277,33 @@ export async function completeWorkspaceSetup(args) {
|
|
|
299
277
|
catch {
|
|
300
278
|
/* binding written; whoami confirmation is best-effort */
|
|
301
279
|
}
|
|
280
|
+
// RTSC-519 — say that setup FINISHED, and what to do with it.
|
|
281
|
+
//
|
|
282
|
+
// Here rather than in either command, because it is true of both and this is the tail
|
|
283
|
+
// they share. `join` said it and `bind` did not, so the person who set up their own org
|
|
284
|
+
// was left reading a status line and guessing whether anything else was required — while
|
|
285
|
+
// the invited teammate, who arrived through the other command, was told. A line in one
|
|
286
|
+
// caller is a line the other has to remember; a line here is one neither can lose.
|
|
287
|
+
//
|
|
288
|
+
// AFTER the confirmation, and after everything that can throw, so it is only ever
|
|
289
|
+
// printed by a run that actually finished. Nothing below it can fail.
|
|
290
|
+
if (isInteractive())
|
|
291
|
+
console.log(`\n${NEXT_STEP}`);
|
|
302
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* What to do with a workspace that is now set up.
|
|
295
|
+
*
|
|
296
|
+
* "or restart it if it's already open" is the load-bearing half. `.mcp.json` is read when
|
|
297
|
+
* an MCP client starts, so an agent already running on this folder will not see Retasc
|
|
298
|
+
* until it restarts — the same trap RTSC-496 records for the Dash's version of this
|
|
299
|
+
* handover, where the screen otherwise waits forever on an agent that can never call in.
|
|
300
|
+
* It bites harder here: `bind` is routinely run from inside a session already open on the
|
|
301
|
+
* folder being bound.
|
|
302
|
+
*
|
|
303
|
+
* Deliberately NOT "run `retasc next`". The agent pulls work over MCP; the CLI's claim
|
|
304
|
+
* commands are a human convenience, and naming one here would teach the wrong first move.
|
|
305
|
+
*/
|
|
306
|
+
export const NEXT_STEP = "Start your agent in this folder, or restart it if it's already open, and it'll pull from the queue.";
|
|
303
307
|
/**
|
|
304
308
|
* Sign in first when there is no session at all (RTSC-481, extended to `join` by RTSC-492).
|
|
305
309
|
*
|
|
@@ -317,7 +321,10 @@ export async function ensureSignedIn() {
|
|
|
317
321
|
if (!isInteractive()) {
|
|
318
322
|
throw new Error("Not signed in. Run `retasc login` first (no TTY here for the device flow).");
|
|
319
323
|
}
|
|
320
|
-
|
|
324
|
+
// RTSC-508: don't name a provider here. `deviceLogin` asks which door, and
|
|
325
|
+
// announcing "GitHub" before the question would be wrong for the invited
|
|
326
|
+
// teammate who signed up through Google — the exact person `join` is for.
|
|
327
|
+
console.error("Not signed in yet — let's do that first.");
|
|
321
328
|
await deviceLogin();
|
|
322
329
|
}
|
|
323
330
|
export async function bindAction(opts) {
|
|
@@ -50,15 +50,19 @@ function report(outcome, orgLabel) {
|
|
|
50
50
|
console.log("Run this again after a migration — each import brings its own people across.");
|
|
51
51
|
return;
|
|
52
52
|
case "answered":
|
|
53
|
-
//
|
|
54
|
-
// from "none"
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
// They have answered here before, and nothing is left from a tool they haven't
|
|
54
|
+
// answered about. It differs from "none" only in being able to say what already
|
|
55
|
+
// happened; the advice is identical, because it is now TRUE for both.
|
|
56
|
+
//
|
|
57
|
+
// RTSC-507 rewrote this. It used to read "that answer covers the whole org,
|
|
58
|
+
// including later migrations" and send them to an owner — because one decline really
|
|
59
|
+
// did shut them out of every future import. That sentence existed only to describe a
|
|
60
|
+
// bug, and it goes in the same release the bug does.
|
|
61
|
+
console.log(`You've answered about every tool imported into ${orgLabel} so far.`);
|
|
62
|
+
console.log("Run this again after the next migration — each one is asked about separately.");
|
|
59
63
|
return;
|
|
60
64
|
case "declined":
|
|
61
|
-
console.log("Noted
|
|
65
|
+
console.log("Noted. That covers those tools only, so a later import will ask again.");
|
|
62
66
|
return;
|
|
63
67
|
case "left":
|
|
64
68
|
console.log("Nothing linked. Run `retasc identity` again whenever you want to look.");
|
package/dist/commands/join.js
CHANGED
|
@@ -16,6 +16,24 @@ const UNKNOWN_SOURCE = "another tool";
|
|
|
16
16
|
* number of migrated tools.
|
|
17
17
|
*/
|
|
18
18
|
const MAX_IDENTITY_ROUNDS = 10;
|
|
19
|
+
/**
|
|
20
|
+
* The tools on screen, named the way they'd be read aloud (RTSC-507).
|
|
21
|
+
*
|
|
22
|
+
* The twin of `sourcePhrase` in `dash/src/lib/ghostPrompt.ts`, duplicated rather than
|
|
23
|
+
* shared because the two live in separate packages with no common build — the same reason
|
|
24
|
+
* `UNKNOWN_SOURCE` above is a second copy of the Dash's constant. Keep the two in step:
|
|
25
|
+
* they answer the same question in front of the same irreversible decision.
|
|
26
|
+
*/
|
|
27
|
+
export function sourcePhrase(ghosts) {
|
|
28
|
+
const names = [...new Set(ghosts.map((g) => clean(g.sourceLabel ?? UNKNOWN_SOURCE)))].sort((a, b) =>
|
|
29
|
+
// Keep the vague fallback last, so a known tool always leads.
|
|
30
|
+
a === UNKNOWN_SOURCE ? 1 : b === UNKNOWN_SOURCE ? -1 : a.localeCompare(b));
|
|
31
|
+
if (names.length <= 1)
|
|
32
|
+
return names[0] ?? UNKNOWN_SOURCE;
|
|
33
|
+
if (names.length === 2)
|
|
34
|
+
return `${names[0]} and ${names[1]}`;
|
|
35
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
36
|
+
}
|
|
19
37
|
/** How one row reads: the person, and the tool they were carried in from. */
|
|
20
38
|
function ghostRow(g) {
|
|
21
39
|
// BOTH fields are third-party text: the name was typed by someone in ClickUp/Jira/Asana
|
|
@@ -62,13 +80,16 @@ deps = {}) {
|
|
|
62
80
|
let ghosts;
|
|
63
81
|
try {
|
|
64
82
|
const res = await d.claimableGhosts({ orgId });
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!res.ghosts.length)
|
|
71
|
-
|
|
83
|
+
// An empty list is the only ending now (RTSC-507). `asked` no longer gates anything
|
|
84
|
+
// — it used to mean "declined, permanently", and branching on it FIRST is exactly
|
|
85
|
+
// what hid a later migration's placeholders for good. It now only picks the wording:
|
|
86
|
+
// "you've answered everything so far" reads differently from "nothing was ever
|
|
87
|
+
// imported here", and both are empty. `join` treats them alike; `identity` does not.
|
|
88
|
+
if (!res.ghosts.length) {
|
|
89
|
+
if (claimedAny)
|
|
90
|
+
return "claimed";
|
|
91
|
+
return res.asked ? "answered" : "none";
|
|
92
|
+
}
|
|
72
93
|
ghosts = res.ghosts;
|
|
73
94
|
}
|
|
74
95
|
catch (e) {
|
|
@@ -80,14 +101,18 @@ deps = {}) {
|
|
|
80
101
|
? `\nWhen ${orgLabel ?? "this org"} migrated, some people were carried across.\n` +
|
|
81
102
|
"One of them may be you:"
|
|
82
103
|
: "\nAnything else here you recognise?");
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
104
|
+
// RTSC-507 — the row says what the answer COVERS, because that is what changed.
|
|
105
|
+
// Declining used to write one org-wide flag that shut the person out of every later
|
|
106
|
+
// migration, so the row had to carry the warning "don't ask again". It now records
|
|
107
|
+
// only the tools on screen, so a bare "none of these are me" is finally the literal
|
|
108
|
+
// truth, and naming the tools is what makes the answer safe to give.
|
|
87
109
|
// Compared by REFERENCE below, not by a magic id value: a sentinel that is merely a
|
|
88
|
-
// row with an empty id would let any future ghost with a falsy id read as a decline
|
|
89
|
-
|
|
90
|
-
|
|
110
|
+
// row with an empty id would let any future ghost with a falsy id read as a decline.
|
|
111
|
+
const NONE = {
|
|
112
|
+
id: "",
|
|
113
|
+
name: `None of these are me (covers ${sourcePhrase(ghosts)} only)`,
|
|
114
|
+
sourceLabel: null,
|
|
115
|
+
};
|
|
91
116
|
// No label: the heading above is the prompt, and `choose` would print a second one.
|
|
92
117
|
const chosen = await pickExisting("", [...ghosts, NONE], (g) => (g === NONE ? g.name : ghostRow(g)), d.askFn);
|
|
93
118
|
if (chosen === NONE) {
|
|
@@ -96,10 +121,10 @@ deps = {}) {
|
|
|
96
121
|
}
|
|
97
122
|
catch (e) {
|
|
98
123
|
console.error(` ! ${formatError(e).message}`);
|
|
99
|
-
// NOT "declined". A caller answers that with "
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
124
|
+
// NOT "declined". A caller answers that with "noted", and the write is the only
|
|
125
|
+
// thing that makes it true — nothing landed, so these tools WILL be offered again.
|
|
126
|
+
// The error is already on stderr; `unavailable` adds no second sentence on top of
|
|
127
|
+
// it rather than a reassuring false one.
|
|
103
128
|
return "unavailable";
|
|
104
129
|
}
|
|
105
130
|
return "declined";
|
|
@@ -212,7 +237,6 @@ export async function joinAction(link, opts) {
|
|
|
212
237
|
console.error(` Resume with: ${resume}`);
|
|
213
238
|
throw e;
|
|
214
239
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
240
|
+
// RTSC-519 — the closing line moved into `completeWorkspaceSetup`, the tail both commands
|
|
241
|
+
// already share, so `bind` gets it too. Saying it here as well would say it twice.
|
|
218
242
|
}
|
package/dist/config.js
CHANGED
|
@@ -55,6 +55,12 @@ export function loadConfig() {
|
|
|
55
55
|
user: stored.user,
|
|
56
56
|
defaultOrgId: stored.defaultOrgId,
|
|
57
57
|
defaultProjectPrefix: stored.defaultProjectPrefix,
|
|
58
|
+
// Validated on read, not trusted: this value decides which door a bare Enter
|
|
59
|
+
// takes, and the file is hand-editable. Anything unrecognised reads as "never
|
|
60
|
+
// recorded", which falls back to asking outright.
|
|
61
|
+
loginProvider: stored.loginProvider === "github" || stored.loginProvider === "google"
|
|
62
|
+
? stored.loginProvider
|
|
63
|
+
: undefined,
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
/** Move a corrupt config aside to a unique sibling so a human can recover any
|
package/dist/index.js
CHANGED
|
@@ -38,12 +38,23 @@ function fail(e) {
|
|
|
38
38
|
process.exit(1);
|
|
39
39
|
}
|
|
40
40
|
// --- auth ------------------------------------------------------------------
|
|
41
|
+
// RTSC-508: two doors, not one. Google sign-in shipped for the web Dash first, so
|
|
42
|
+
// an account can exist that GitHub has never heard of — and for that human every
|
|
43
|
+
// login-gated command here was unreachable. With no flag the command ASKS, which
|
|
44
|
+
// is the only form that helps someone who doesn't know a flag exists.
|
|
41
45
|
program
|
|
42
46
|
.command("login")
|
|
43
|
-
.description("Sign in with GitHub (device flow).")
|
|
44
|
-
.
|
|
47
|
+
.description("Sign in with GitHub or Google (device flow).")
|
|
48
|
+
.option("--github", "Sign in with GitHub, without being asked")
|
|
49
|
+
.option("--google", "Sign in with Google, without being asked")
|
|
50
|
+
.action(async (opts) => {
|
|
45
51
|
try {
|
|
46
|
-
|
|
52
|
+
// Naming both is a contradiction, not a preference — and quietly picking
|
|
53
|
+
// one could sign them in through the door that mints a SECOND identity,
|
|
54
|
+
// which nothing in the CLI can undo.
|
|
55
|
+
if (opts.github && opts.google)
|
|
56
|
+
return fail("Pass --github or --google, not both.");
|
|
57
|
+
await deviceLogin(opts.google ? "google" : opts.github ? "github" : undefined);
|
|
47
58
|
const me = (await api.me());
|
|
48
59
|
const u = me?.user ?? {};
|
|
49
60
|
console.log(`\n✓ Signed in${u.name ? ` as ${u.name}` : ""}${u.email ? ` <${u.email}>` : ""}.`);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
// The terminal-input primitives, extracted from commands/bind.ts (RTSC-508).
|
|
4
|
+
//
|
|
5
|
+
// They moved because `auth.ts` needs to ask a question too — which door to sign
|
|
6
|
+
// in through — and importing it from `commands/bind.ts` would close a cycle:
|
|
7
|
+
// bind already imports `deviceLogin` from auth. Duplicating `ask` instead would
|
|
8
|
+
// have meant duplicating the EOF race fix below, which is exactly the kind of
|
|
9
|
+
// thing that gets fixed once and then silently rots in the copy.
|
|
10
|
+
//
|
|
11
|
+
// `commands/bind.ts` re-exports all three, so every existing call site is
|
|
12
|
+
// unchanged.
|
|
13
|
+
export function isInteractive() {
|
|
14
|
+
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
15
|
+
}
|
|
16
|
+
export async function ask(question) {
|
|
17
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
18
|
+
try {
|
|
19
|
+
// readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
|
|
20
|
+
// closed pipe) — it silently drops the callback rather than resolving ""
|
|
21
|
+
// or throwing, so a bare `await` here wedges the CLI with no output and no
|
|
22
|
+
// exit. Race the close event so callers fail fast instead (RTSC-269).
|
|
23
|
+
const closed = new Promise((_, reject) => rl.once("close", () => reject(new Error("input closed"))));
|
|
24
|
+
closed.catch(() => { }); // the normal path closes rl too; that loser rejection is expected
|
|
25
|
+
return (await Promise.race([rl.question(question), closed])).trim();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
rl.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function confirm(question, assumeYes) {
|
|
32
|
+
if (assumeYes)
|
|
33
|
+
return true;
|
|
34
|
+
if (!isInteractive())
|
|
35
|
+
return false;
|
|
36
|
+
const a = (await ask(`${question} [y/N] `)).toLowerCase();
|
|
37
|
+
return a === "y" || a === "yes";
|
|
38
|
+
}
|
package/package.json
CHANGED