cleargate 0.3.0 → 0.5.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/cli.cjs CHANGED
@@ -627,7 +627,7 @@ var import_commander = require("commander");
627
627
  // package.json
628
628
  var package_default = {
629
629
  name: "cleargate",
630
- version: "0.3.0",
630
+ version: "0.5.0",
631
631
  private: false,
632
632
  type: "module",
633
633
  description: "Planning framework for Claude Code agents \u2014 sprint/epic/story protocol, four-agent loop (architect/developer/qa/reporter), Karpathy-style awareness wiki.",
@@ -936,13 +936,295 @@ init_cjs_shims();
936
936
  var os3 = __toESM(require("os"), 1);
937
937
  init_config();
938
938
  init_factory();
939
+
940
+ // src/auth/identity-flow.ts
941
+ init_cjs_shims();
942
+ var readline = __toESM(require("readline"), 1);
943
+ var IdentityFlowError = class extends Error {
944
+ constructor(code, message) {
945
+ super(message ?? code);
946
+ this.code = code;
947
+ this.name = "IdentityFlowError";
948
+ }
949
+ code;
950
+ };
951
+ var DeviceFlowError = class extends Error {
952
+ constructor(code, message) {
953
+ super(message ?? code);
954
+ this.code = code;
955
+ this.name = "DeviceFlowError";
956
+ }
957
+ code;
958
+ };
959
+ async function pickProvider(opts) {
960
+ const available = opts.available ?? ["github", "email"];
961
+ if (opts.flag !== void 0) {
962
+ const flagLower = opts.flag.toLowerCase();
963
+ if (!available.includes(flagLower)) {
964
+ throw new IdentityFlowError(
965
+ "provider_unknown",
966
+ `cleargate: unknown provider '${opts.flag}'. Available: ${available.join(", ")}`
967
+ );
968
+ }
969
+ return flagLower;
970
+ }
971
+ if (!opts.isTTY) {
972
+ throw new IdentityFlowError(
973
+ "provider_required",
974
+ "cleargate: --auth required in non-interactive mode"
975
+ );
976
+ }
977
+ if (available.length === 1) {
978
+ return available[0];
979
+ }
980
+ return promptPicker(available, opts);
981
+ }
982
+ var PROVIDER_LABELS = {
983
+ github: "GitHub OAuth",
984
+ email: "Email magic-link"
985
+ };
986
+ async function promptPicker(options, { stdin, stdout } = {}) {
987
+ const write = stdout ?? ((s) => process.stdout.write(s));
988
+ write("How would you like to verify your email?\n");
989
+ options.forEach((p, i) => {
990
+ write(` ${i + 1}. ${PROVIDER_LABELS[p]}
991
+ `);
992
+ });
993
+ write(`Choice [1-${options.length}]: `);
994
+ const inputStream = stdin ?? process.stdin;
995
+ return new Promise((resolve14, reject) => {
996
+ let settled = false;
997
+ const rl = readline.createInterface({
998
+ input: inputStream,
999
+ output: void 0,
1000
+ terminal: false
1001
+ });
1002
+ rl.once("line", (line) => {
1003
+ settled = true;
1004
+ rl.close();
1005
+ const idx = parseInt(line.trim(), 10) - 1;
1006
+ if (isNaN(idx) || idx < 0 || idx >= options.length) {
1007
+ reject(
1008
+ new IdentityFlowError(
1009
+ "invalid_choice",
1010
+ `cleargate: invalid choice '${line.trim()}'. Enter a number between 1 and ${options.length}.`
1011
+ )
1012
+ );
1013
+ return;
1014
+ }
1015
+ resolve14(options[idx]);
1016
+ });
1017
+ rl.once("error", (err) => {
1018
+ if (!settled) {
1019
+ settled = true;
1020
+ reject(err);
1021
+ }
1022
+ });
1023
+ rl.once("close", () => {
1024
+ if (!settled) {
1025
+ settled = true;
1026
+ reject(new IdentityFlowError("provider_required", "cleargate: no provider selected"));
1027
+ }
1028
+ });
1029
+ });
1030
+ }
1031
+ function defaultSleep(ms) {
1032
+ return new Promise((resolve14) => setTimeout(resolve14, ms));
1033
+ }
1034
+ async function startDeviceFlow(opts) {
1035
+ const sleepFn = opts.sleepFn ?? defaultSleep;
1036
+ let currentIntervalMs = opts.intervalOverrideMs !== void 0 ? opts.intervalOverrideMs : Math.max(opts.interval, 5) * 1e3;
1037
+ const expiresAtMs = Date.now() + opts.expiresIn * 1e3;
1038
+ const deadline = expiresAtMs + (opts.deadlineGraceMs ?? 1e4);
1039
+ while (Date.now() < deadline) {
1040
+ await sleepFn(currentIntervalMs);
1041
+ let pollRes;
1042
+ try {
1043
+ pollRes = await opts.fetchPoll(opts.deviceCode);
1044
+ } catch {
1045
+ throw new DeviceFlowError("unreachable");
1046
+ }
1047
+ if (pollRes.status === 403) {
1048
+ let body2 = {};
1049
+ try {
1050
+ body2 = await pollRes.json();
1051
+ } catch {
1052
+ }
1053
+ if (body2["error"] === "access_denied") {
1054
+ throw new DeviceFlowError("access_denied");
1055
+ }
1056
+ throw new DeviceFlowError("not_admin");
1057
+ }
1058
+ if (pollRes.status === 410) {
1059
+ throw new DeviceFlowError("expired_token");
1060
+ }
1061
+ if (!pollRes.status || pollRes.status < 200 || pollRes.status >= 300) {
1062
+ if (pollRes.status >= 500 || pollRes.status < 100) {
1063
+ throw new DeviceFlowError("server_error");
1064
+ }
1065
+ }
1066
+ let body;
1067
+ try {
1068
+ body = await pollRes.json();
1069
+ } catch {
1070
+ throw new DeviceFlowError("server_error");
1071
+ }
1072
+ const errorField = body["error"];
1073
+ if (typeof errorField === "string") {
1074
+ if (errorField === "authorization_pending") {
1075
+ continue;
1076
+ }
1077
+ if (errorField === "slow_down") {
1078
+ const retryAfter = body["interval"];
1079
+ if (typeof retryAfter === "number") {
1080
+ const bumped = retryAfter * 1e3;
1081
+ if (bumped > currentIntervalMs) {
1082
+ currentIntervalMs = bumped;
1083
+ }
1084
+ } else {
1085
+ currentIntervalMs += 5e3;
1086
+ }
1087
+ continue;
1088
+ }
1089
+ if (errorField === "access_denied") {
1090
+ throw new DeviceFlowError("access_denied");
1091
+ }
1092
+ if (errorField === "expired_token") {
1093
+ throw new DeviceFlowError("expired_token");
1094
+ }
1095
+ throw new DeviceFlowError("server_error");
1096
+ }
1097
+ if (body["pending"] === true) {
1098
+ const shouldApplyBump = opts.sleepFn !== void 0 || opts.intervalOverrideMs === void 0;
1099
+ if (shouldApplyBump && typeof body["retry_after"] === "number") {
1100
+ const bumped = body["retry_after"] * 1e3;
1101
+ if (bumped > currentIntervalMs) {
1102
+ currentIntervalMs = bumped;
1103
+ }
1104
+ }
1105
+ continue;
1106
+ }
1107
+ if (typeof body["access_token"] === "string") {
1108
+ return { accessToken: body["access_token"] };
1109
+ }
1110
+ if (typeof body["admin_token"] === "string") {
1111
+ return { accessToken: body["admin_token"] };
1112
+ }
1113
+ throw new DeviceFlowError("server_error");
1114
+ }
1115
+ throw new DeviceFlowError("timeout");
1116
+ }
1117
+ function mapProviderError(httpStatus, errorCode, retryAfterSeconds) {
1118
+ if (httpStatus === 400) {
1119
+ switch (errorCode) {
1120
+ case "provider_not_allowed":
1121
+ return {
1122
+ message: "cleargate: this invite requires a different provider \u2014 re-run with `--auth <pinned>`",
1123
+ exitCode: 9,
1124
+ retryable: true
1125
+ };
1126
+ case "provider_unknown":
1127
+ return {
1128
+ message: "cleargate: server does not have that provider registered \u2014 contact the project admin",
1129
+ exitCode: 9,
1130
+ retryable: false
1131
+ };
1132
+ case "identity_proof_required":
1133
+ return {
1134
+ message: "cleargate: this CLI is out of date \u2014 please upgrade and retry (`npm i -g cleargate@latest`)",
1135
+ exitCode: 11,
1136
+ retryable: false
1137
+ };
1138
+ default:
1139
+ return {
1140
+ message: "cleargate: invalid request to server (please file a bug)",
1141
+ exitCode: 7,
1142
+ retryable: false
1143
+ };
1144
+ }
1145
+ }
1146
+ if (httpStatus === 403 && errorCode === "email_mismatch") {
1147
+ return {
1148
+ message: "cleargate: verified email does not match the invitee \u2014 ask your admin to re-issue the invite",
1149
+ exitCode: 10,
1150
+ retryable: false
1151
+ };
1152
+ }
1153
+ if (httpStatus === 404) {
1154
+ return {
1155
+ message: "cleargate: invite not found",
1156
+ exitCode: 4,
1157
+ retryable: false
1158
+ };
1159
+ }
1160
+ if (httpStatus === 410) {
1161
+ switch (errorCode) {
1162
+ case "invite_expired":
1163
+ return {
1164
+ message: "cleargate: invite expired. Request a new invite",
1165
+ exitCode: 3,
1166
+ retryable: false
1167
+ };
1168
+ case "invite_already_consumed":
1169
+ return {
1170
+ message: "cleargate: invite already consumed. Request a new invite",
1171
+ exitCode: 3,
1172
+ retryable: false
1173
+ };
1174
+ case "challenge_expired":
1175
+ return {
1176
+ message: "cleargate: code expired. Re-run `cleargate join <url>` to start over",
1177
+ exitCode: 3,
1178
+ retryable: false
1179
+ };
1180
+ default:
1181
+ return {
1182
+ message: "cleargate: invite no longer valid. Request a new invite",
1183
+ exitCode: 3,
1184
+ retryable: false
1185
+ };
1186
+ }
1187
+ }
1188
+ if (httpStatus === 429) {
1189
+ const retryHint = retryAfterSeconds !== void 0 ? `${retryAfterSeconds}` : "900";
1190
+ return {
1191
+ message: `cleargate: too many requests. Retry after ${retryHint}s`,
1192
+ exitCode: 8,
1193
+ retryable: true
1194
+ };
1195
+ }
1196
+ if (httpStatus === 502 && errorCode === "provider_error") {
1197
+ return {
1198
+ message: "cleargate: code didn't match. Try again, or restart with `cleargate join <url>`",
1199
+ exitCode: 12,
1200
+ retryable: true
1201
+ };
1202
+ }
1203
+ if (httpStatus >= 500) {
1204
+ return {
1205
+ message: `cleargate: server error ${httpStatus}`,
1206
+ exitCode: 6,
1207
+ retryable: false
1208
+ };
1209
+ }
1210
+ return {
1211
+ message: `cleargate: unexpected error ${httpStatus} ${errorCode}`,
1212
+ exitCode: 7,
1213
+ retryable: false
1214
+ };
1215
+ }
1216
+
1217
+ // src/commands/join.ts
1218
+ var readline2 = __toESM(require("readline"), 1);
939
1219
  var UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1220
+ var GITHUB_DEVICE_FLOW_URL = "https://github.com/login/oauth/access_token";
940
1221
  async function joinHandler(opts) {
941
1222
  const fetchFn = opts.fetch ?? globalThis.fetch;
942
1223
  const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
943
1224
  const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
944
1225
  const exit = opts.exit ?? ((c) => process.exit(c));
945
1226
  const hostname3 = opts.hostname ?? (() => os3.hostname());
1227
+ const isTTY = opts.isTTY ?? process.stdin.isTTY === true;
946
1228
  let token;
947
1229
  let baseUrl;
948
1230
  try {
@@ -973,60 +1255,309 @@ async function joinHandler(opts) {
973
1255
  exit(5);
974
1256
  return;
975
1257
  }
976
- let response;
977
- try {
978
- response = await fetchFn(`${baseUrl}/join/${token}`, { method: "POST" });
979
- } catch (err) {
980
- stderr(
981
- `cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
982
- `
983
- );
984
- exit(2);
1258
+ if (opts.nonInteractive && !opts.auth) {
1259
+ stderr("cleargate: --auth required in non-interactive mode\n");
1260
+ exit(1);
985
1261
  return;
986
1262
  }
987
- if (response.status === 410) {
988
- const body = await response.json().catch(() => ({}));
989
- if (body.error === "invite_expired") {
990
- stderr("cleargate: invite expired. Request a new invite.\n");
991
- } else {
992
- stderr("cleargate: invite already consumed. Request a new invite.\n");
993
- }
994
- exit(3);
1263
+ if (opts.nonInteractive && opts.auth === "email" && !opts.code) {
1264
+ stderr("cleargate: --code required for email provider in non-interactive mode\n");
1265
+ exit(1);
995
1266
  return;
996
1267
  }
997
- if (response.status === 404) {
998
- stderr("cleargate: invite not found.\n");
999
- exit(4);
1268
+ if (opts.nonInteractive && opts.auth === "github") {
1269
+ stderr("cleargate: GitHub auth requires browser interaction; use `--auth email` for non-interactive flows\n");
1270
+ exit(1);
1000
1271
  return;
1001
1272
  }
1002
- if (response.status === 429) {
1003
- const retry = response.headers.get("retry-after") ?? "900";
1004
- stderr(`cleargate: too many requests. Retry after ${retry}s.
1273
+ let provider;
1274
+ try {
1275
+ provider = await pickProvider({
1276
+ flag: opts.auth,
1277
+ isTTY: !opts.nonInteractive && isTTY,
1278
+ available: ["github", "email"],
1279
+ stdin: opts.stdin,
1280
+ stdout
1281
+ });
1282
+ } catch (err) {
1283
+ if (err instanceof IdentityFlowError) {
1284
+ stderr(`${err.message}
1005
1285
  `);
1006
- exit(8);
1007
- return;
1286
+ exit(1);
1287
+ return;
1288
+ }
1289
+ throw err;
1008
1290
  }
1009
- if (response.status >= 500) {
1010
- stderr(`cleargate: server error ${response.status}.
1011
- `);
1012
- exit(6);
1291
+ let challengeRes;
1292
+ try {
1293
+ challengeRes = await fetchFn(`${baseUrl}/join/${token}/challenge`, {
1294
+ method: "POST",
1295
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
1296
+ body: JSON.stringify({ provider })
1297
+ });
1298
+ } catch (err) {
1299
+ stderr(
1300
+ `cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
1301
+ `
1302
+ );
1303
+ exit(2);
1013
1304
  return;
1014
1305
  }
1015
- if (!response.ok) {
1016
- stderr(`cleargate: unexpected status ${response.status}.
1306
+ if (!challengeRes.ok) {
1307
+ const body = await challengeRes.json().catch(() => ({}));
1308
+ const { message, exitCode } = mapProviderError(
1309
+ challengeRes.status,
1310
+ body.error ?? "",
1311
+ parseRetryAfter(challengeRes)
1312
+ );
1313
+ stderr(`${message}
1017
1314
  `);
1018
- exit(7);
1315
+ exit(exitCode);
1019
1316
  return;
1020
1317
  }
1021
- let rawBody;
1318
+ let challengeBody;
1022
1319
  try {
1023
- rawBody = await response.json();
1320
+ challengeBody = await challengeRes.json();
1024
1321
  } catch {
1025
1322
  stderr("cleargate: server returned non-JSON response.\n");
1026
1323
  exit(7);
1027
1324
  return;
1028
1325
  }
1029
- const b = rawBody;
1326
+ const challengeId = challengeBody.challenge_id;
1327
+ const clientHints = challengeBody.client_hints;
1328
+ let completeRawBody;
1329
+ if (provider === "github") {
1330
+ const deviceCode = clientHints["device_code"];
1331
+ const userCode = clientHints["user_code"];
1332
+ const verificationUri = clientHints["verification_uri"];
1333
+ const expiresIn = typeof clientHints["expires_in"] === "number" ? clientHints["expires_in"] : 900;
1334
+ const interval = typeof clientHints["interval"] === "number" ? clientHints["interval"] : 5;
1335
+ stdout(`Open the following URL in your browser and enter the code:
1336
+ `);
1337
+ stdout(` URL: ${verificationUri}
1338
+ `);
1339
+ stdout(` Code: ${userCode}
1340
+ `);
1341
+ stdout(` (Code expires in ${Math.floor(expiresIn / 60)} minutes)
1342
+ `);
1343
+ stdout("Waiting for authorization...\n");
1344
+ let accessToken;
1345
+ try {
1346
+ const result = await startDeviceFlow({
1347
+ deviceCode,
1348
+ interval,
1349
+ expiresIn,
1350
+ fetchPoll: async (dc) => {
1351
+ const res = await fetchFn(GITHUB_DEVICE_FLOW_URL, {
1352
+ method: "POST",
1353
+ headers: {
1354
+ Accept: "application/json",
1355
+ "Content-Type": "application/json"
1356
+ },
1357
+ body: JSON.stringify({
1358
+ device_code: dc,
1359
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
1360
+ })
1361
+ });
1362
+ return {
1363
+ status: res.status,
1364
+ json: () => res.json()
1365
+ };
1366
+ },
1367
+ ...opts.sleepFn !== void 0 ? { sleepFn: opts.sleepFn } : {},
1368
+ ...opts.intervalOverrideMs !== void 0 ? { intervalOverrideMs: opts.intervalOverrideMs } : {}
1369
+ });
1370
+ accessToken = result.accessToken;
1371
+ } catch (err) {
1372
+ if (err instanceof DeviceFlowError) {
1373
+ switch (err.code) {
1374
+ case "access_denied":
1375
+ stderr("cleargate: access denied \u2014 you declined authorization in the browser.\n");
1376
+ exit(5);
1377
+ return;
1378
+ case "expired_token":
1379
+ stderr("cleargate: device code expired \u2014 please re-run `cleargate join <url>`.\n");
1380
+ exit(5);
1381
+ return;
1382
+ case "unreachable":
1383
+ stderr("cleargate: cannot reach GitHub. Check your connection and retry.\n");
1384
+ exit(2);
1385
+ return;
1386
+ default:
1387
+ stderr(`cleargate: GitHub device flow error: ${err.code}
1388
+ `);
1389
+ exit(6);
1390
+ return;
1391
+ }
1392
+ }
1393
+ stderr("cleargate: unexpected error during GitHub device flow\n");
1394
+ exit(6);
1395
+ return;
1396
+ }
1397
+ let completeRes;
1398
+ try {
1399
+ completeRes = await fetchFn(`${baseUrl}/join/${token}/complete`, {
1400
+ method: "POST",
1401
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
1402
+ body: JSON.stringify({ challenge_id: challengeId, proof: { access_token: accessToken } })
1403
+ });
1404
+ } catch (err) {
1405
+ stderr(`cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
1406
+ `);
1407
+ exit(2);
1408
+ return;
1409
+ }
1410
+ if (!completeRes.ok) {
1411
+ const body = await completeRes.json().catch(() => ({}));
1412
+ const { message, exitCode } = mapProviderError(
1413
+ completeRes.status,
1414
+ body.error ?? "",
1415
+ parseRetryAfter(completeRes)
1416
+ );
1417
+ stderr(`${message}
1418
+ `);
1419
+ exit(exitCode);
1420
+ return;
1421
+ }
1422
+ try {
1423
+ completeRawBody = await completeRes.json();
1424
+ } catch {
1425
+ stderr("cleargate: server returned non-JSON response.\n");
1426
+ exit(7);
1427
+ return;
1428
+ }
1429
+ } else {
1430
+ const sentTo = typeof clientHints["sent_to"] === "string" ? clientHints["sent_to"] : "(unknown)";
1431
+ const maxRetries = 3;
1432
+ stdout(`We sent a 6-digit code to ${sentTo}.
1433
+ `);
1434
+ if (opts.code !== void 0) {
1435
+ let completeRes;
1436
+ try {
1437
+ completeRes = await fetchFn(`${baseUrl}/join/${token}/complete`, {
1438
+ method: "POST",
1439
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
1440
+ body: JSON.stringify({ challenge_id: challengeId, proof: { code: opts.code } })
1441
+ });
1442
+ } catch (err) {
1443
+ stderr(`cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
1444
+ `);
1445
+ exit(2);
1446
+ return;
1447
+ }
1448
+ if (!completeRes.ok) {
1449
+ const body = await completeRes.json().catch(() => ({}));
1450
+ const { message, exitCode } = mapProviderError(
1451
+ completeRes.status,
1452
+ body.error ?? "",
1453
+ parseRetryAfter(completeRes)
1454
+ );
1455
+ stderr(`${message}
1456
+ `);
1457
+ exit(exitCode);
1458
+ return;
1459
+ }
1460
+ try {
1461
+ completeRawBody = await completeRes.json();
1462
+ } catch {
1463
+ stderr("cleargate: server returned non-JSON response.\n");
1464
+ exit(7);
1465
+ return;
1466
+ }
1467
+ } else {
1468
+ let readNextLine2 = function() {
1469
+ if (lineQueue.length > 0) return Promise.resolve(lineQueue.shift());
1470
+ if (rlClosed) return Promise.resolve("");
1471
+ return new Promise((resolve14) => {
1472
+ lineWaiters.push(resolve14);
1473
+ });
1474
+ };
1475
+ var readNextLine = readNextLine2;
1476
+ const inputStream = opts.stdin ?? process.stdin;
1477
+ const rl = readline2.createInterface({
1478
+ input: inputStream,
1479
+ output: void 0,
1480
+ terminal: false
1481
+ });
1482
+ const lineQueue = [];
1483
+ const lineWaiters = [];
1484
+ let rlClosed = false;
1485
+ rl.on("line", (line) => {
1486
+ const waiter = lineWaiters.shift();
1487
+ if (waiter) {
1488
+ waiter(line);
1489
+ } else {
1490
+ lineQueue.push(line);
1491
+ }
1492
+ });
1493
+ rl.once("close", () => {
1494
+ rlClosed = true;
1495
+ for (const waiter of lineWaiters.splice(0)) {
1496
+ waiter("");
1497
+ }
1498
+ });
1499
+ let succeeded = false;
1500
+ try {
1501
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
1502
+ stdout("Enter code: ");
1503
+ const otpCode = (await readNextLine2()).trim();
1504
+ let completeRes;
1505
+ try {
1506
+ completeRes = await fetchFn(`${baseUrl}/join/${token}/complete`, {
1507
+ method: "POST",
1508
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
1509
+ body: JSON.stringify({ challenge_id: challengeId, proof: { code: otpCode } })
1510
+ });
1511
+ } catch (err) {
1512
+ stderr(`cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
1513
+ `);
1514
+ exit(2);
1515
+ return;
1516
+ }
1517
+ if (completeRes.ok) {
1518
+ try {
1519
+ completeRawBody = await completeRes.json();
1520
+ } catch {
1521
+ stderr("cleargate: server returned non-JSON response.\n");
1522
+ exit(7);
1523
+ return;
1524
+ }
1525
+ succeeded = true;
1526
+ break;
1527
+ }
1528
+ const body = await completeRes.json().catch(() => ({}));
1529
+ const errorCode = body.error ?? "";
1530
+ if (completeRes.status === 410 || errorCode === "challenge_expired") {
1531
+ const { message, exitCode } = mapProviderError(completeRes.status, errorCode, parseRetryAfter(completeRes));
1532
+ stderr(`${message}
1533
+ `);
1534
+ exit(exitCode);
1535
+ return;
1536
+ }
1537
+ if (completeRes.status === 403 || completeRes.status >= 400 && completeRes.status < 500 && errorCode !== "provider_error") {
1538
+ const { message, exitCode } = mapProviderError(completeRes.status, errorCode, parseRetryAfter(completeRes));
1539
+ stderr(`${message}
1540
+ `);
1541
+ exit(exitCode);
1542
+ return;
1543
+ }
1544
+ if (attempt < maxRetries) {
1545
+ stderr(`cleargate: code didn't match. ${maxRetries - attempt} attempt${maxRetries - attempt === 1 ? "" : "s"} remaining.
1546
+ `);
1547
+ }
1548
+ }
1549
+ } finally {
1550
+ rl.close();
1551
+ }
1552
+ if (!succeeded) {
1553
+ stderr(`cleargate: code didn't match after ${maxRetries} tries. Run \`cleargate join <url>\` again to get a new code.
1554
+ `);
1555
+ exit(12);
1556
+ return;
1557
+ }
1558
+ }
1559
+ }
1560
+ const b = completeRawBody;
1030
1561
  if (typeof b.refresh_token !== "string" || typeof b.project_name !== "string") {
1031
1562
  stderr("cleargate: server returned unexpected response shape.\n");
1032
1563
  exit(7);
@@ -1049,6 +1580,12 @@ async function joinHandler(opts) {
1049
1580
  exit(99);
1050
1581
  }
1051
1582
  }
1583
+ function parseRetryAfter(res) {
1584
+ const hdr = res.headers?.get?.("retry-after");
1585
+ if (!hdr) return void 0;
1586
+ const n = parseInt(hdr, 10);
1587
+ return isNaN(n) ? void 0 : n;
1588
+ }
1052
1589
 
1053
1590
  // src/commands/stamp.ts
1054
1591
  init_cjs_shims();
@@ -2258,13 +2795,13 @@ async function readDriftState(projectRoot) {
2258
2795
 
2259
2796
  // src/lib/prompts.ts
2260
2797
  init_cjs_shims();
2261
- var readline = __toESM(require("readline"), 1);
2798
+ var readline3 = __toESM(require("readline"), 1);
2262
2799
  async function promptYesNo(question, defaultYes, opts) {
2263
2800
  const stdoutFn = opts?.stdout ?? ((s) => process.stdout.write(s));
2264
2801
  stdoutFn(question + "\n");
2265
2802
  const inputStream = opts?.stdin ?? process.stdin;
2266
2803
  return new Promise((resolve14) => {
2267
- const rl = readline.createInterface({
2804
+ const rl = readline3.createInterface({
2268
2805
  input: inputStream,
2269
2806
  output: void 0,
2270
2807
  // we handle output ourselves
@@ -2295,7 +2832,7 @@ async function promptEmail(question, defaultValue, opts) {
2295
2832
  stdoutFn(question + "\n");
2296
2833
  const inputStream = opts?.stdin ?? process.stdin;
2297
2834
  return new Promise((resolve14) => {
2298
- const rl = readline.createInterface({
2835
+ const rl = readline3.createInterface({
2299
2836
  input: inputStream,
2300
2837
  output: void 0,
2301
2838
  // we handle output ourselves
@@ -3654,7 +4191,7 @@ ${row}
3654
4191
  init_cjs_shims();
3655
4192
  var fs21 = __toESM(require("fs"), 1);
3656
4193
  var path23 = __toESM(require("path"), 1);
3657
- var readline2 = __toESM(require("readline"), 1);
4194
+ var readline4 = __toESM(require("readline"), 1);
3658
4195
  var TERMINAL = /* @__PURE__ */ new Set(["Completed", "Done", "Abandoned", "Closed", "Resolved"]);
3659
4196
  async function wikiAuditStatusHandler(opts = {}) {
3660
4197
  const cwd = opts.cwd ?? process.cwd();
@@ -3793,7 +4330,7 @@ async function wikiAuditStatusHandler(opts = {}) {
3793
4330
  answer = await opts.promptReader();
3794
4331
  } else {
3795
4332
  answer = await new Promise((resolve14) => {
3796
- const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
4333
+ const rl = readline4.createInterface({ input: process.stdin, output: process.stdout });
3797
4334
  rl.question(`apply ${fixable.length} changes? [y/N] `, (ans) => {
3798
4335
  rl.close();
3799
4336
  resolve14(ans);
@@ -8507,9 +9044,6 @@ var fs35 = __toESM(require("fs"), 1);
8507
9044
  var path46 = __toESM(require("path"), 1);
8508
9045
  var os7 = __toESM(require("os"), 1);
8509
9046
  var DEFAULT_MCP_URL = "http://localhost:3000";
8510
- function sleep(ms) {
8511
- return new Promise((resolve14) => setTimeout(resolve14, ms));
8512
- }
8513
9047
  function resolveMcpUrl(mcpUrlFlag, env) {
8514
9048
  return (mcpUrlFlag ?? (env ?? process.env)["CLEARGATE_MCP_URL"] ?? DEFAULT_MCP_URL).replace(/\/$/, "");
8515
9049
  }
@@ -8530,7 +9064,6 @@ async function adminLoginHandler(opts = {}) {
8530
9064
  const stdout = opts.stdout ?? ((msg) => process.stdout.write(msg + "\n"));
8531
9065
  const stderr = opts.stderr ?? ((msg) => process.stderr.write(msg + "\n"));
8532
9066
  const exitFn = opts.exit ?? ((code) => process.exit(code));
8533
- const sleepFn = opts.sleepFn ?? sleep;
8534
9067
  const mcpBase = resolveMcpUrl(opts.mcpUrl, opts.env);
8535
9068
  let startData;
8536
9069
  try {
@@ -8557,79 +9090,95 @@ async function adminLoginHandler(opts = {}) {
8557
9090
  stdout(` Code: ${startData.user_code}`);
8558
9091
  stdout(` (Code expires in ${Math.floor(startData.expires_in / 60)} minutes)`);
8559
9092
  stdout("Waiting for authorization...");
8560
- let currentInterval = opts.intervalOverrideMs ?? Math.max(startData.interval, 5) * 1e3;
8561
- const expiresAtMs = Date.now() + startData.expires_in * 1e3;
8562
- const deadline = expiresAtMs + 1e4;
8563
- let successData = null;
8564
- while (Date.now() < deadline) {
8565
- await sleepFn(currentInterval);
8566
- let pollRes;
8567
- try {
8568
- pollRes = await fetchFn(`${mcpBase}/admin-api/v1/auth/device/poll`, {
8569
- method: "POST",
8570
- headers: { "Content-Type": "application/json", Accept: "application/json" },
8571
- body: JSON.stringify({ device_code: startData.device_code })
8572
- });
8573
- } catch (err) {
8574
- stderr(`cleargate: error: network error while polling (${err instanceof Error ? err.message : String(err)})`);
8575
- return exitFn(3);
8576
- }
8577
- if (pollRes.status === 403) {
8578
- const body = await pollRes.json().catch(() => ({}));
8579
- if (body.error === "access_denied") {
8580
- stderr("cleargate: error: access denied \u2014 you declined authorization in the browser.");
8581
- return exitFn(5);
8582
- }
8583
- stderr("cleargate: error: your GitHub account is not authorized as an admin user.");
8584
- return exitFn(4);
8585
- }
8586
- if (pollRes.status === 410) {
8587
- stderr("cleargate: error: device code expired \u2014 please run `cleargate admin login` again.");
8588
- return exitFn(5);
8589
- }
8590
- if (!pollRes.ok) {
8591
- const body = await pollRes.json().catch(() => ({}));
8592
- stderr(`cleargate: error: unexpected server response ${pollRes.status}: ${body.error ?? "unknown"}`);
8593
- return exitFn(6);
8594
- }
8595
- const pollBody = await pollRes.json();
8596
- if (pollBody.pending) {
8597
- const shouldApplyBump = opts.sleepFn !== void 0 || opts.intervalOverrideMs === void 0;
8598
- if (shouldApplyBump && pollBody.retry_after !== void 0) {
8599
- const bumped = pollBody.retry_after * 1e3;
8600
- if (bumped > currentInterval) {
8601
- currentInterval = bumped;
9093
+ let capturedSuccessBody = null;
9094
+ const fetchPollCapture = async (deviceCode) => {
9095
+ const res = await fetchFn(`${mcpBase}/admin-api/v1/auth/device/poll`, {
9096
+ method: "POST",
9097
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
9098
+ body: JSON.stringify({ device_code: deviceCode })
9099
+ });
9100
+ const originalJson = res.json.bind(res);
9101
+ return {
9102
+ status: res.status,
9103
+ json: async () => {
9104
+ const body = await originalJson();
9105
+ if (body["pending"] === false) {
9106
+ capturedSuccessBody = body;
8602
9107
  }
9108
+ return body;
9109
+ }
9110
+ };
9111
+ };
9112
+ try {
9113
+ await startDeviceFlow({
9114
+ deviceCode: startData.device_code,
9115
+ interval: startData.interval,
9116
+ expiresIn: startData.expires_in,
9117
+ fetchPoll: fetchPollCapture,
9118
+ // Only pass sleepFn if the caller explicitly injected one (test seam).
9119
+ // When sleepFn is omitted, startDeviceFlow uses its own defaultSleep.
9120
+ // This preserves the original bump-suppression logic:
9121
+ // shouldApplyBump = (sleepFn provided) || (intervalOverrideMs not set).
9122
+ ...opts.sleepFn !== void 0 ? { sleepFn: opts.sleepFn } : {},
9123
+ ...opts.intervalOverrideMs !== void 0 ? { intervalOverrideMs: opts.intervalOverrideMs } : {},
9124
+ deadlineGraceMs: 1e4
9125
+ });
9126
+ } catch (err) {
9127
+ if (err instanceof DeviceFlowError) {
9128
+ switch (err.code) {
9129
+ case "access_denied":
9130
+ stderr("cleargate: error: access denied \u2014 you declined authorization in the browser.");
9131
+ return exitFn(5);
9132
+ case "not_admin":
9133
+ stderr("cleargate: error: your GitHub account is not authorized as an admin user.");
9134
+ return exitFn(4);
9135
+ case "expired_token":
9136
+ stderr("cleargate: error: device code expired \u2014 please run `cleargate admin login` again.");
9137
+ return exitFn(5);
9138
+ case "timeout":
9139
+ stderr("cleargate: error: timed out waiting for authorization. Please try again.");
9140
+ return exitFn(5);
9141
+ case "unreachable":
9142
+ stderr(`cleargate: error: network error while polling`);
9143
+ return exitFn(3);
9144
+ default:
9145
+ stderr(`cleargate: error: unexpected server response`);
9146
+ return exitFn(6);
8603
9147
  }
8604
- continue;
8605
9148
  }
8606
- successData = pollBody;
8607
- break;
9149
+ stderr(`cleargate: error: unexpected error during device flow`);
9150
+ return exitFn(6);
8608
9151
  }
8609
- if (!successData) {
9152
+ if (!capturedSuccessBody) {
8610
9153
  stderr("cleargate: error: timed out waiting for authorization. Please try again.");
8611
9154
  return exitFn(5);
8612
9155
  }
9156
+ const successBody = capturedSuccessBody;
8613
9157
  const authFilePath = resolveAuthFilePath(opts);
8614
9158
  try {
8615
- writeAdminAuth(authFilePath, successData.admin_token);
9159
+ writeAdminAuth(authFilePath, successBody.admin_token);
8616
9160
  } catch (err) {
8617
9161
  stderr(`cleargate: error: failed to write ${authFilePath}: ${err instanceof Error ? err.message : String(err)}`);
8618
9162
  return exitFn(99);
8619
9163
  }
8620
- stdout(`Logged in successfully. Token expires ${successData.expires_at}.`);
9164
+ stdout(`Logged in successfully. Token expires ${successBody.expires_at}.`);
8621
9165
  stdout(`Credentials saved to ${authFilePath} (chmod 600).`);
8622
9166
  }
8623
9167
 
8624
9168
  // src/cli.ts
8625
9169
  var program = new import_commander.Command();
8626
9170
  program.name("cleargate").description("ClearGate CLI \u2014 connects AI agent teams to the ClearGate MCP server").version(package_default.version, "-V, --version").option("--profile <name>", "configuration profile to use", "default").option("--mcp-url <url>", "MCP server URL (overrides config file and env)").showHelpAfterError("(use `cleargate --help`)");
8627
- program.command("join <invite-url>").description("join a ClearGate workspace using an invite URL").action(async (inviteUrl, _opts, command) => {
9171
+ program.command("join <invite-url>").description("join a ClearGate workspace using an invite URL").option("--auth <provider>", "identity provider: github | email").option("--non-interactive", "fail instead of prompting (CI mode)").option("--code <code>", "OTP code for non-interactive email auth").action(async (inviteUrl, _opts, command) => {
8628
9172
  const globals = command.parent.opts();
9173
+ const cmdOpts = command.opts();
8629
9174
  await joinHandler({
8630
9175
  inviteUrl,
8631
9176
  profile: globals.profile,
8632
- mcpUrlFlag: globals.mcpUrl
9177
+ mcpUrlFlag: globals.mcpUrl,
9178
+ // FLASHCARD #cli #commander #optional-key: only set keys when defined
9179
+ ...cmdOpts.auth !== void 0 ? { auth: cmdOpts.auth } : {},
9180
+ ...cmdOpts.nonInteractive === true ? { nonInteractive: true } : {},
9181
+ ...cmdOpts.code !== void 0 ? { code: cmdOpts.code } : {}
8633
9182
  });
8634
9183
  });
8635
9184
  program.command("init").description("initialise a repo with ClearGate scaffold (CLAUDE.md block, hook config, agents, templates)").option("--force", "overwrite existing files that differ from the bundled payload").option("--yes", "non-interactive: accept all defaults without prompting").action(async (opts) => {