pylot-cli 0.1.3 → 0.1.4

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.
Files changed (2) hide show
  1. package/dist/index.mjs +421 -119
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -3032,6 +3032,123 @@ var require_commander = __commonJS({
3032
3032
  }
3033
3033
  });
3034
3034
 
3035
+ // src/lib/http.mts
3036
+ var http_exports = {};
3037
+ __export(http_exports, {
3038
+ ApiError: () => ApiError,
3039
+ NetworkError: () => NetworkError,
3040
+ request: () => request
3041
+ });
3042
+ function errorMessage(status, body, cfg) {
3043
+ const err = body && typeof body === "object" ? body : {};
3044
+ if (status === 401) {
3045
+ return `unauthorized \u2014 the gateway rejected your token (source: ${cfg.tokenSource}).
3046
+ hint: check 'pylot auth status'; inside a Pylot container the right token is already in the environment, otherwise export PYLOT_API_TOKEN or run 'pylot config set token <token>'`;
3047
+ }
3048
+ if (status === 403 && err.error === "insufficient_scope" && typeof err.required === "string") {
3049
+ return `forbidden \u2014 your token (source: ${cfg.tokenSource}) lacks the '${err.required}' scope.
3050
+ hint: mission broker tokens can only heartbeat and mint git tokens; session tokens cannot use admin routes. Use a token with the '${err.required}' scope (an admin can mint one: pylot auth tokens create --scopes ${err.required})`;
3051
+ }
3052
+ const parts = [`${status}${err.error ? ` ${err.error}` : ""}`];
3053
+ if (err.detail) parts.push(String(err.detail));
3054
+ if (err.hint) parts.push(`hint: ${err.hint}`);
3055
+ const extras = Object.entries(err).filter(([k]) => !["error", "detail", "hint"].includes(k));
3056
+ if (extras.length > 0) {
3057
+ parts.push(extras.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" "));
3058
+ }
3059
+ if (!err.error && !err.detail && extras.length === 0) {
3060
+ const text = typeof body === "string" ? body : JSON.stringify(body);
3061
+ if (text && text !== "null") parts.push(text.slice(0, 300));
3062
+ }
3063
+ return `API error ${parts.join(" \u2014 ")}`;
3064
+ }
3065
+ async function request(cfg, path5, opts = {}) {
3066
+ const method = opts.method ?? "GET";
3067
+ const retry = opts.retry ?? method === "GET";
3068
+ const timeoutMs = opts.timeoutMs ?? 3e4;
3069
+ const url = new URL(cfg.baseUrl + path5);
3070
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
3071
+ if (v !== void 0) url.searchParams.set(k, String(v));
3072
+ }
3073
+ const headers = {};
3074
+ if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
3075
+ let bodyText;
3076
+ if (opts.rawBody !== void 0) {
3077
+ bodyText = opts.rawBody;
3078
+ headers["content-type"] = "text/plain; charset=utf-8";
3079
+ } else if (opts.body !== void 0) {
3080
+ bodyText = JSON.stringify(opts.body);
3081
+ headers["content-type"] = "application/json";
3082
+ }
3083
+ const maxAttempts = retry ? RETRY_DELAYS_MS.length + 1 : 1;
3084
+ let lastError;
3085
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3086
+ if (attempt > 0) await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt - 1]));
3087
+ let res;
3088
+ try {
3089
+ res = await fetch(url, {
3090
+ method,
3091
+ headers,
3092
+ body: bodyText,
3093
+ signal: AbortSignal.timeout(timeoutMs)
3094
+ });
3095
+ } catch (err) {
3096
+ lastError = err;
3097
+ continue;
3098
+ }
3099
+ if (retry && RETRYABLE_STATUSES.has(res.status) && attempt < maxAttempts - 1) {
3100
+ lastError = new ApiError(`API error ${res.status}`, res.status, null);
3101
+ continue;
3102
+ }
3103
+ const text = await res.text();
3104
+ let json = null;
3105
+ try {
3106
+ json = JSON.parse(text);
3107
+ } catch {
3108
+ }
3109
+ const ok = res.status >= 200 && res.status < 300 || (opts.okStatuses ?? []).includes(res.status);
3110
+ if (!ok) {
3111
+ const apiErr = new ApiError(errorMessage(res.status, json ?? text, cfg), res.status, json ?? text);
3112
+ const errBody = json;
3113
+ if (res.status === 403 && errBody?.error === "insufficient_scope") apiErr.requiredScope = errBody.required;
3114
+ throw apiErr;
3115
+ }
3116
+ return { status: res.status, json, text, headers: res.headers };
3117
+ }
3118
+ const cause = lastError instanceof Error ? lastError.message : String(lastError);
3119
+ const maybeDelivered = method !== "GET";
3120
+ throw new NetworkError(
3121
+ `request failed: ${method} ${url.pathname} \u2014 ${cause}` + (maybeDelivered ? "\nhint: the request may still have been received \u2014 verify before retrying (e.g. pylot ps)" : ""),
3122
+ maybeDelivered
3123
+ );
3124
+ }
3125
+ var ApiError, NetworkError, RETRYABLE_STATUSES, RETRY_DELAYS_MS;
3126
+ var init_http = __esm({
3127
+ "src/lib/http.mts"() {
3128
+ "use strict";
3129
+ ApiError = class extends Error {
3130
+ status;
3131
+ body;
3132
+ requiredScope;
3133
+ constructor(message, status, body) {
3134
+ super(message);
3135
+ this.status = status;
3136
+ this.body = body;
3137
+ }
3138
+ };
3139
+ NetworkError = class extends Error {
3140
+ /** True when the request may have reached the server (sent, then failed). */
3141
+ maybeDelivered;
3142
+ constructor(message, maybeDelivered) {
3143
+ super(message);
3144
+ this.maybeDelivered = maybeDelivered;
3145
+ }
3146
+ };
3147
+ RETRYABLE_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
3148
+ RETRY_DELAYS_MS = [500, 1500];
3149
+ }
3150
+ });
3151
+
3035
3152
  // node_modules/dot-prop/index.js
3036
3153
  function getPathSegments(path5) {
3037
3154
  const parts = [];
@@ -7322,7 +7439,7 @@ var require_schemes = __commonJS({
7322
7439
  urnComponent.nss = (uuidComponent.uuid || "").toLowerCase();
7323
7440
  return urnComponent;
7324
7441
  }
7325
- var http = (
7442
+ var http2 = (
7326
7443
  /** @type {SchemeHandler} */
7327
7444
  {
7328
7445
  scheme: "http",
@@ -7335,7 +7452,7 @@ var require_schemes = __commonJS({
7335
7452
  /** @type {SchemeHandler} */
7336
7453
  {
7337
7454
  scheme: "https",
7338
- domainHost: http.domainHost,
7455
+ domainHost: http2.domainHost,
7339
7456
  parse: httpParse,
7340
7457
  serialize: httpSerialize
7341
7458
  }
@@ -7379,7 +7496,7 @@ var require_schemes = __commonJS({
7379
7496
  var SCHEMES = (
7380
7497
  /** @type {Record<SchemeName, SchemeHandler>} */
7381
7498
  {
7382
- http,
7499
+ http: http2,
7383
7500
  https,
7384
7501
  ws,
7385
7502
  wss,
@@ -14120,112 +14237,70 @@ var {
14120
14237
  Help
14121
14238
  } = import_index.default;
14122
14239
 
14123
- // src/lib/http.mts
14124
- var ApiError = class extends Error {
14125
- status;
14126
- body;
14127
- requiredScope;
14128
- constructor(message, status, body) {
14129
- super(message);
14130
- this.status = status;
14131
- this.body = body;
14132
- }
14133
- };
14134
- var NetworkError = class extends Error {
14135
- /** True when the request may have reached the server (sent, then failed). */
14136
- maybeDelivered;
14137
- constructor(message, maybeDelivered) {
14138
- super(message);
14139
- this.maybeDelivered = maybeDelivered;
14140
- }
14141
- };
14142
- var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
14143
- var RETRY_DELAYS_MS = [500, 1500];
14144
- function errorMessage(status, body, cfg) {
14145
- const err = body && typeof body === "object" ? body : {};
14146
- if (status === 401) {
14147
- return `unauthorized \u2014 the gateway rejected your token (source: ${cfg.tokenSource}).
14148
- hint: check 'pylot auth status'; inside a Pylot container the right token is already in the environment, otherwise export PYLOT_API_TOKEN or run 'pylot config set token <token>'`;
14149
- }
14150
- if (status === 403 && err.error === "insufficient_scope" && typeof err.required === "string") {
14151
- return `forbidden \u2014 your token (source: ${cfg.tokenSource}) lacks the '${err.required}' scope.
14152
- hint: mission broker tokens can only heartbeat and mint git tokens; session tokens cannot use admin routes. Use a token with the '${err.required}' scope (an admin can mint one: pylot auth tokens create --scopes ${err.required})`;
14153
- }
14154
- const parts = [`${status}${err.error ? ` ${err.error}` : ""}`];
14155
- if (err.detail) parts.push(String(err.detail));
14156
- if (err.hint) parts.push(`hint: ${err.hint}`);
14157
- const extras = Object.entries(err).filter(([k]) => !["error", "detail", "hint"].includes(k));
14158
- if (extras.length > 0) {
14159
- parts.push(extras.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" "));
14160
- }
14161
- if (!err.error && !err.detail && extras.length === 0) {
14162
- const text = typeof body === "string" ? body : JSON.stringify(body);
14163
- if (text && text !== "null") parts.push(text.slice(0, 300));
14164
- }
14165
- return `API error ${parts.join(" \u2014 ")}`;
14240
+ // src/lib/output.mts
14241
+ init_http();
14242
+
14243
+ // src/lib/config.mts
14244
+ import { readFileSync, writeFileSync as writeFileSync2, chmodSync, mkdirSync, renameSync } from "node:fs";
14245
+ import { homedir as homedir2 } from "node:os";
14246
+ import { join, dirname } from "node:path";
14247
+ function credentialsPath() {
14248
+ return join(homedir2(), ".pylot", "credentials");
14166
14249
  }
14167
- async function request(cfg, path5, opts = {}) {
14168
- const method = opts.method ?? "GET";
14169
- const retry = opts.retry ?? method === "GET";
14170
- const timeoutMs = opts.timeoutMs ?? 3e4;
14171
- const url = new URL(cfg.baseUrl + path5);
14172
- for (const [k, v] of Object.entries(opts.query ?? {})) {
14173
- if (v !== void 0) url.searchParams.set(k, String(v));
14174
- }
14175
- const headers = {};
14176
- if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
14177
- let bodyText;
14178
- if (opts.rawBody !== void 0) {
14179
- bodyText = opts.rawBody;
14180
- headers["content-type"] = "text/plain; charset=utf-8";
14181
- } else if (opts.body !== void 0) {
14182
- bodyText = JSON.stringify(opts.body);
14183
- headers["content-type"] = "application/json";
14184
- }
14185
- const maxAttempts = retry ? RETRY_DELAYS_MS.length + 1 : 1;
14186
- let lastError;
14187
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
14188
- if (attempt > 0) await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt - 1]));
14189
- let res;
14190
- try {
14191
- res = await fetch(url, {
14192
- method,
14193
- headers,
14194
- body: bodyText,
14195
- signal: AbortSignal.timeout(timeoutMs)
14196
- });
14197
- } catch (err) {
14198
- lastError = err;
14199
- continue;
14200
- }
14201
- if (retry && RETRYABLE_STATUSES.has(res.status) && attempt < maxAttempts - 1) {
14202
- lastError = new ApiError(`API error ${res.status}`, res.status, null);
14203
- continue;
14204
- }
14205
- const text = await res.text();
14206
- let json = null;
14207
- try {
14208
- json = JSON.parse(text);
14209
- } catch {
14210
- }
14211
- const ok = res.status >= 200 && res.status < 300 || (opts.okStatuses ?? []).includes(res.status);
14212
- if (!ok) {
14213
- const apiErr = new ApiError(errorMessage(res.status, json ?? text, cfg), res.status, json ?? text);
14214
- const errBody = json;
14215
- if (res.status === 403 && errBody?.error === "insufficient_scope") apiErr.requiredScope = errBody.required;
14216
- throw apiErr;
14250
+ function isNearExpiry(expiresAt) {
14251
+ if (!expiresAt) return false;
14252
+ const t = new Date(expiresAt).getTime();
14253
+ if (Number.isNaN(t)) return false;
14254
+ return t - Date.now() <= 5 * 60 * 1e3;
14255
+ }
14256
+ async function refreshOrgCredential(baseUrl, org, entry) {
14257
+ const { request: request2, ApiError: ApiError2 } = await Promise.resolve().then(() => (init_http(), http_exports));
14258
+ const cfg = { baseUrl, token: null, tokenSource: "none", urlSource: "credentials-refresh" };
14259
+ try {
14260
+ const resp = await request2(cfg, "/auth/oauth/refresh", {
14261
+ method: "POST",
14262
+ body: { refresh_token: entry.refresh_token },
14263
+ retry: false
14264
+ });
14265
+ const d = resp.json;
14266
+ if (!d?.token || !d.refresh_token) return null;
14267
+ return {
14268
+ token_id: d.id,
14269
+ token: d.token,
14270
+ scopes: d.scopes,
14271
+ expires_at: d.expires_at,
14272
+ refresh_token: d.refresh_token,
14273
+ refresh_expires_at: d.refresh_expires_at
14274
+ };
14275
+ } catch (err) {
14276
+ if (err instanceof ApiError2 && err.status === 409) {
14277
+ try {
14278
+ const latest = readCredentials().orgs[org];
14279
+ if (latest && latest.token !== entry.token) return latest;
14280
+ } catch {
14281
+ }
14282
+ return null;
14217
14283
  }
14218
- return { status: res.status, json, text, headers: res.headers };
14284
+ throw err;
14219
14285
  }
14220
- const cause = lastError instanceof Error ? lastError.message : String(lastError);
14221
- const maybeDelivered = method !== "GET";
14222
- throw new NetworkError(
14223
- `request failed: ${method} ${url.pathname} \u2014 ${cause}` + (maybeDelivered ? "\nhint: the request may still have been received \u2014 verify before retrying (e.g. pylot ps)" : ""),
14224
- maybeDelivered
14225
- );
14226
14286
  }
14227
-
14228
- // src/lib/config.mts
14287
+ function readCredentials() {
14288
+ try {
14289
+ const raw = readFileSync(credentialsPath(), "utf8");
14290
+ const parsed = JSON.parse(raw);
14291
+ if (parsed && typeof parsed.orgs === "object") return parsed;
14292
+ } catch {
14293
+ }
14294
+ return { orgs: {} };
14295
+ }
14296
+ function writeCredentials(data) {
14297
+ const path5 = credentialsPath();
14298
+ mkdirSync(dirname(path5), { recursive: true });
14299
+ const tmp = `${path5}.${process.pid}.tmp`;
14300
+ writeFileSync2(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 384 });
14301
+ chmodSync(tmp, 384);
14302
+ renameSync(tmp, path5);
14303
+ }
14229
14304
  var UsageError = class extends Error {
14230
14305
  };
14231
14306
  function normalizeBaseUrl(raw) {
@@ -14294,6 +14369,31 @@ async function resolveConfig(opts) {
14294
14369
  pickUrl(env2.PYLOT_API, "PYLOT_API");
14295
14370
  pickToken(await confGet("token"), "config file");
14296
14371
  pickUrl(await confGet("url"), "config file");
14372
+ if (token === null) {
14373
+ const org = opts.org ?? await confGet("org");
14374
+ if (org) {
14375
+ try {
14376
+ const creds = readCredentials();
14377
+ let entry = creds.orgs[org];
14378
+ if (entry?.token && entry.refresh_token && baseUrl && isNearExpiry(entry.expires_at)) {
14379
+ const rotated = await refreshOrgCredential(baseUrl, org, entry).catch(() => null);
14380
+ if (rotated) {
14381
+ creds.orgs[org] = rotated;
14382
+ try {
14383
+ writeCredentials(creds);
14384
+ } catch {
14385
+ }
14386
+ entry = rotated;
14387
+ } else if (new Date(entry.expires_at).getTime() <= Date.now()) {
14388
+ throw new UsageError(`stored token for ${org} expired and refresh failed \u2014 run: pylot auth login`);
14389
+ }
14390
+ }
14391
+ if (entry?.token) pickToken(entry.token, "credentials file");
14392
+ } catch (err) {
14393
+ if (err instanceof UsageError) throw err;
14394
+ }
14395
+ }
14396
+ }
14297
14397
  if (!baseUrl) {
14298
14398
  throw new UsageError(
14299
14399
  "no gateway URL configured \u2014 set PYLOT_DISPATCH_URL / PYLOT_API_URL / PYLOT_GATEWAY_URL, pass --url, or run: pylot config set url <url>"
@@ -14375,6 +14475,7 @@ async function ctx(cmd) {
14375
14475
  }
14376
14476
 
14377
14477
  // src/commands/core.mts
14478
+ init_http();
14378
14479
  function registerCore(program3) {
14379
14480
  program3.command("health").description("gateway liveness, version, and counters (GET /health)").action(async (_opts, cmd) => {
14380
14481
  const { opts, cfg } = await ctx(cmd);
@@ -14386,6 +14487,12 @@ function registerCore(program3) {
14386
14487
  });
14387
14488
  }
14388
14489
 
14490
+ // src/commands/dispatch.mts
14491
+ init_http();
14492
+
14493
+ // src/commands/missions.mts
14494
+ init_http();
14495
+
14389
14496
  // src/lib/poll.mts
14390
14497
  var PollTimeoutError = class extends Error {
14391
14498
  last;
@@ -14635,8 +14742,8 @@ function registerMissions(program3) {
14635
14742
  async function bodyFromArgs(fields, file) {
14636
14743
  let body = {};
14637
14744
  if (file) {
14638
- const { readFileSync } = await import("node:fs");
14639
- const text = file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8");
14745
+ const { readFileSync: readFileSync2 } = await import("node:fs");
14746
+ const text = file === "-" ? readFileSync2(0, "utf8") : readFileSync2(file, "utf8");
14640
14747
  body = JSON.parse(text);
14641
14748
  }
14642
14749
  for (const field of fields) {
@@ -14712,6 +14819,64 @@ function registerDispatch(program3) {
14712
14819
  }
14713
14820
 
14714
14821
  // src/commands/auth.mts
14822
+ import * as http from "node:http";
14823
+ import * as net from "node:net";
14824
+ import { exec } from "node:child_process";
14825
+ init_http();
14826
+ function openBrowser(url) {
14827
+ const cmd = process.platform === "darwin" ? `open ${JSON.stringify(url)}` : process.platform === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`;
14828
+ exec(cmd, (err) => {
14829
+ if (err) process.stderr.write(`[auth] could not open browser automatically.
14830
+ Open manually: ${url}
14831
+ `);
14832
+ });
14833
+ }
14834
+ async function pickEphemeralPort() {
14835
+ return new Promise((resolve, reject) => {
14836
+ const srv = net.createServer();
14837
+ srv.listen(0, "127.0.0.1", () => {
14838
+ const addr = srv.address();
14839
+ const port = typeof addr === "object" && addr ? addr.port : 0;
14840
+ srv.close((err) => {
14841
+ if (err) reject(err);
14842
+ else resolve(port);
14843
+ });
14844
+ });
14845
+ srv.on("error", reject);
14846
+ });
14847
+ }
14848
+ async function waitForCallback(port, timeoutMs = 3e5) {
14849
+ return new Promise((resolve, reject) => {
14850
+ const timer = setTimeout(() => {
14851
+ server.close();
14852
+ reject(new Error("timed out waiting for browser callback (5 min)"));
14853
+ }, timeoutMs);
14854
+ const server = http.createServer((req, res) => {
14855
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
14856
+ const code = url.searchParams.get("code");
14857
+ const error = url.searchParams.get("error");
14858
+ const html = `<!doctype html><html><body style="font-family:sans-serif;padding:2em"><h2>You can close this tab</h2><p>Return to your terminal.</p></body></html>`;
14859
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
14860
+ res.end(html);
14861
+ clearTimeout(timer);
14862
+ server.close();
14863
+ if (error) {
14864
+ reject(new Error(`OAuth error: ${error}`));
14865
+ return;
14866
+ }
14867
+ if (!code) {
14868
+ reject(new Error("no code in callback"));
14869
+ return;
14870
+ }
14871
+ resolve(code);
14872
+ });
14873
+ server.on("error", (err) => {
14874
+ clearTimeout(timer);
14875
+ reject(err);
14876
+ });
14877
+ server.listen(port, "127.0.0.1");
14878
+ });
14879
+ }
14715
14880
  function registerAuth(program3) {
14716
14881
  const auth = program3.command("auth").description("identity, tokens, and credentials");
14717
14882
  auth.command("status").description("who am I \u2014 token identity, scopes, and CLI config resolution (GET /auth/me)").action(async (_flags, cmd) => {
@@ -14787,12 +14952,129 @@ function registerAuth(program3) {
14787
14952
  const { opts, cfg } = await ctx(cmd);
14788
14953
  emit(opts, await request(cfg, "/bot-identity"));
14789
14954
  });
14790
- program3.command("login", { hidden: true }).description("OAuth login (not available yet \u2014 token-plane M5)").action(() => {
14791
- process.stderr.write(
14792
- "error: OAuth login is not available yet (token-plane M5, fellowship-dev/pylot#1889).\nGet a token instead:\n - inside a Pylot container the right token is already in the environment\n - an admin can mint one: pylot auth tokens create --org <org> --scopes dispatch,missions:read\n - then: export PYLOT_API_TOKEN=<token> (or: pylot config set token <token>)\n"
14793
- );
14794
- process.exit(1);
14795
- });
14955
+ function isHeadless() {
14956
+ if (process.env.SSH_TTY) return true;
14957
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
14958
+ return false;
14959
+ }
14960
+ async function runDeviceLogin(flags, cmd) {
14961
+ const parentOpts = cmd.parent?.parent?.opts() ?? cmd.parent?.opts() ?? {};
14962
+ const rawUrl = flags.url ?? parentOpts.url ?? process.env.PYLOT_DISPATCH_URL ?? process.env.PYLOT_API_URL ?? process.env.PYLOT_GATEWAY_URL ?? process.env.PYLOT_API ?? "";
14963
+ if (!rawUrl) throw new UsageError("no gateway URL configured \u2014 pass --url or set PYLOT_API_URL");
14964
+ const baseUrl = normalizeBaseUrl(rawUrl);
14965
+ const cfg = { baseUrl, token: null, tokenSource: "none", urlSource: "--url" };
14966
+ const startResp = await request(cfg, "/auth/oauth/device/start", { method: "POST", body: {} });
14967
+ const { user_code, verification_uri, interval: rawInterval, device_session } = startResp.json ?? {};
14968
+ if (!user_code || !verification_uri || !device_session) throw new Error("gateway returned incomplete device-start response");
14969
+ process.stderr.write(`
14970
+ Open in your browser:
14971
+ ${verification_uri}
14972
+
14973
+ Enter code: ${user_code}
14974
+
14975
+ Waiting for authorization\u2026
14976
+ `);
14977
+ let intervalSecs = typeof rawInterval === "number" ? rawInterval : 5;
14978
+ const timeoutAt = Date.now() + 15 * 60 * 1e3;
14979
+ while (Date.now() < timeoutAt) {
14980
+ await new Promise((r) => setTimeout(r, intervalSecs * 1e3));
14981
+ const pollResp = await request(
14982
+ cfg,
14983
+ "/auth/oauth/device/poll",
14984
+ { method: "POST", body: { device_session } }
14985
+ ).catch((err) => {
14986
+ throw new Error(`poll failed: ${err.message}`);
14987
+ });
14988
+ const data = pollResp.json;
14989
+ if (data?.slow_down) {
14990
+ intervalSecs += 5;
14991
+ continue;
14992
+ }
14993
+ if (data?.status === "pending") continue;
14994
+ if (Array.isArray(data?.tokens)) {
14995
+ const tokens2 = data.tokens;
14996
+ if (tokens2.length === 0) throw new Error("gateway returned no tokens");
14997
+ const creds = readCredentials();
14998
+ for (const t of tokens2) {
14999
+ creds.orgs[t.org] = {
15000
+ token_id: t.id,
15001
+ token: t.token,
15002
+ scopes: t.scopes,
15003
+ expires_at: t.expires_at,
15004
+ ...t.refresh_token ? { refresh_token: t.refresh_token, refresh_expires_at: t.refresh_expires_at } : {}
15005
+ };
15006
+ }
15007
+ writeCredentials(creds);
15008
+ const orgList = tokens2.map((t) => t.org).join(", ");
15009
+ if (flags.org && !tokens2.find((t) => t.org === flags.org)) {
15010
+ process.stderr.write(`warning: --org ${flags.org} not found in returned tokens (got: ${orgList})
15011
+ `);
15012
+ }
15013
+ process.stdout.write(`Logged in. Tokens stored for: ${orgList}
15014
+ `);
15015
+ process.stdout.write(`Credentials written to ~/.pylot/credentials (mode 0600)
15016
+ `);
15017
+ if (flags.org) {
15018
+ process.stdout.write(`Active org: ${flags.org} \u2014 use PYLOT_API_TOKEN or pylot config set org ${flags.org}
15019
+ `);
15020
+ }
15021
+ return;
15022
+ }
15023
+ throw new Error(`unexpected poll response: ${JSON.stringify(data)}`);
15024
+ }
15025
+ throw new Error("device code expired \u2014 run pylot auth login again");
15026
+ }
15027
+ async function runLogin(flags, cmd) {
15028
+ if (flags.device || isHeadless()) return runDeviceLogin(flags, cmd);
15029
+ const parentOpts = cmd.parent?.parent?.opts() ?? cmd.parent?.opts() ?? {};
15030
+ const rawUrl = flags.url ?? parentOpts.url ?? process.env.PYLOT_DISPATCH_URL ?? process.env.PYLOT_API_URL ?? process.env.PYLOT_GATEWAY_URL ?? process.env.PYLOT_API ?? "";
15031
+ if (!rawUrl) throw new UsageError("no gateway URL configured \u2014 pass --url or set PYLOT_API_URL");
15032
+ const baseUrl = normalizeBaseUrl(rawUrl);
15033
+ const port = await pickEphemeralPort();
15034
+ const loginUrl = `${baseUrl}/auth/oauth/cli/login?port=${port}`;
15035
+ process.stderr.write(`Opening browser for login\u2026
15036
+ `);
15037
+ openBrowser(loginUrl);
15038
+ process.stderr.write(`Waiting for browser callback on http://localhost:${port}/callback
15039
+ `);
15040
+ process.stderr.write(`If your browser did not open, visit:
15041
+ ${loginUrl}
15042
+ `);
15043
+ const exchangeCode = await waitForCallback(port);
15044
+ const cfg = { baseUrl, token: null, tokenSource: "none", urlSource: "--url" };
15045
+ const resp = await request(cfg, "/auth/oauth/complete", {
15046
+ method: "POST",
15047
+ body: { code: exchangeCode }
15048
+ });
15049
+ const tokens2 = resp.json?.tokens ?? [];
15050
+ if (tokens2.length === 0) throw new Error("gateway returned no tokens");
15051
+ const creds = readCredentials();
15052
+ for (const t of tokens2) {
15053
+ creds.orgs[t.org] = {
15054
+ token_id: t.id,
15055
+ token: t.token,
15056
+ scopes: t.scopes,
15057
+ expires_at: t.expires_at,
15058
+ ...t.refresh_token ? { refresh_token: t.refresh_token, refresh_expires_at: t.refresh_expires_at } : {}
15059
+ };
15060
+ }
15061
+ writeCredentials(creds);
15062
+ const orgList = tokens2.map((t) => t.org).join(", ");
15063
+ if (flags.org && !tokens2.find((t) => t.org === flags.org)) {
15064
+ process.stderr.write(`warning: --org ${flags.org} not found in returned tokens (got: ${orgList})
15065
+ `);
15066
+ }
15067
+ process.stdout.write(`Logged in. Tokens stored for: ${orgList}
15068
+ `);
15069
+ process.stdout.write(`Credentials written to ~/.pylot/credentials (mode 0600)
15070
+ `);
15071
+ if (flags.org) {
15072
+ process.stdout.write(`Active org: ${flags.org} \u2014 use PYLOT_API_TOKEN or pylot config set org ${flags.org}
15073
+ `);
15074
+ }
15075
+ }
15076
+ auth.command("login").description("authenticate and store per-org tokens in ~/.pylot/credentials (browser or --device for headless)").option("--org <org>", "filter stored credentials to a single org").option("--url <url>", "gateway base URL (overrides env / config)").option("--device", "use device-code flow (auto-selected in headless/SSH environments)").action(runLogin);
15077
+ program3.command("login", { hidden: true }).description("authenticate \u2014 alias for `pylot auth login` (M5a #2145, M5b #2146)").option("--org <org>", "filter stored credentials to a single org").option("--url <url>", "gateway base URL").option("--device", "use device-code flow (auto-selected in headless/SSH environments)").action(runLogin);
14796
15078
  }
14797
15079
 
14798
15080
  // src/commands/config-cmd.mts
@@ -14843,6 +15125,7 @@ function registerConfig(program3) {
14843
15125
  }
14844
15126
 
14845
15127
  // src/commands/observability.mts
15128
+ init_http();
14846
15129
  function registerObservability(program3) {
14847
15130
  program3.command("events").description("event ledger entries (GET /events)").option("--type <type>", "filter by event type").option("--since <iso>", "events since this timestamp").option("--org <org>", "filter by org").option("--limit <n>", "max rows (server caps at 200)", (v) => parseInt(v, 10)).action(async (flags, cmd) => {
14848
15131
  const { opts, cfg } = await ctx(cmd);
@@ -14943,6 +15226,7 @@ function registerObservability(program3) {
14943
15226
  }
14944
15227
 
14945
15228
  // src/commands/workers.mts
15229
+ init_http();
14946
15230
  function scopedBase(flags) {
14947
15231
  if (Boolean(flags.mission) === Boolean(flags.conversation)) {
14948
15232
  throw new UsageError("pass exactly one of --mission <id> / --conversation <id>");
@@ -14995,6 +15279,7 @@ function registerWorkers(program3) {
14995
15279
  }
14996
15280
 
14997
15281
  // src/commands/devboxes.mts
15282
+ init_http();
14998
15283
  function registerDevboxes(program3) {
14999
15284
  const devboxes = program3.command("devboxes").description("devbox environments (Fargate dev containers)");
15000
15285
  devboxes.command("projects").description("projects with devbox configs (GET /devboxes/projects)").action(async (_flags, cmd) => {
@@ -15005,9 +15290,16 @@ function registerDevboxes(program3) {
15005
15290
  const { opts, cfg } = await ctx(cmd);
15006
15291
  emit(opts, await request(cfg, `/devboxes/projects/${encodeURIComponent(project)}`));
15007
15292
  });
15008
- devboxes.command("spawn").description("spawn a devbox environment (POST /devboxes/projects/:project)").argument("<org/repo>").argument("[fields...]", "key=value pairs").option("--file <path>", "JSON body from file ('-' for stdin)").action(async (project, fields, flags, cmd) => {
15293
+ devboxes.command("spawn").description("spawn a devbox environment (POST /devboxes/projects/:project)").argument("<org/repo>").argument("[fields...]", "key=value pairs").option("--file <path>", "JSON body from file ('-' for stdin)").option("--idle-ttl <secs>", "idle-reap TTL in seconds (0\u2013604800); 0 = never idle-reap").action(async (project, fields, flags, cmd) => {
15009
15294
  const { opts, cfg } = await ctx(cmd);
15010
15295
  const body = fields.length || flags.file ? await bodyFromArgs(fields, flags.file) : {};
15296
+ if (flags.idleTtl != null) {
15297
+ const secs = Number(flags.idleTtl);
15298
+ if (!Number.isInteger(secs) || secs < 0 || secs > 604800) {
15299
+ throw new UsageError("--idle-ttl must be an integer between 0 and 604800 (0 = never idle-reap)");
15300
+ }
15301
+ body.idle_ttl_secs = secs;
15302
+ }
15011
15303
  emit(opts, await request(cfg, `/devboxes/projects/${encodeURIComponent(project)}`, { method: "POST", body }));
15012
15304
  });
15013
15305
  devboxes.command("list").description("running devbox environments for a project (GET /devboxes/projects/:project/list)").argument("<org/repo>").action(async (project, _flags, cmd) => {
@@ -15034,6 +15326,7 @@ function registerDevboxes(program3) {
15034
15326
  }
15035
15327
 
15036
15328
  // src/commands/conversations.mts
15329
+ init_http();
15037
15330
  function convPath(id, sub = "") {
15038
15331
  return `/conversations/${encodeURIComponent(id)}${sub}`;
15039
15332
  }
@@ -15178,9 +15471,10 @@ function registerConversations(program3) {
15178
15471
  }
15179
15472
 
15180
15473
  // src/commands/teams.mts
15474
+ init_http();
15181
15475
  async function readText(file) {
15182
- const { readFileSync } = await import("node:fs");
15183
- return file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8");
15476
+ const { readFileSync: readFileSync2 } = await import("node:fs");
15477
+ return file === "-" ? readFileSync2(0, "utf8") : readFileSync2(file, "utf8");
15184
15478
  }
15185
15479
  function registerTeams(program3) {
15186
15480
  const teams = program3.command("teams").description("team config, operators, playbooks, goals, ledger");
@@ -15309,6 +15603,7 @@ function registerTeams(program3) {
15309
15603
  }
15310
15604
 
15311
15605
  // src/commands/admin.mts
15606
+ init_http();
15312
15607
  function registerAdmin(program3) {
15313
15608
  const admin = program3.command("admin").description("system config, audit, org caps, DB migrations");
15314
15609
  admin.command("config").description("read the config table (GET /admin/config) or one runtime key (GET /admin/config/:key)").argument("[key]", "system_state key, e.g. snapshot.mission_workers_enabled").action(async (key, _flags, cmd) => {
@@ -15350,6 +15645,7 @@ function registerAdmin(program3) {
15350
15645
  }
15351
15646
 
15352
15647
  // src/commands/deploy.mts
15648
+ init_http();
15353
15649
  var TERMINAL_BUILD_STATUSES = /* @__PURE__ */ new Set(["SUCCEEDED", "FAILED", "FAULT", "STOPPED", "TIMED_OUT"]);
15354
15650
  function printNewPhases(opts, body, seen) {
15355
15651
  for (const p of body?.phases ?? []) {
@@ -15518,6 +15814,7 @@ function registerDeploy(program3) {
15518
15814
  }
15519
15815
 
15520
15816
  // src/commands/secrets.mts
15817
+ init_http();
15521
15818
  function registerSecrets(program3) {
15522
15819
  const secrets = program3.command("secrets").description("ASM secret bundles (path-based, admin-scoped)");
15523
15820
  secrets.command("tree").description("full secrets tree (GET /admin/secrets)").action(async (_flags, cmd) => {
@@ -15553,8 +15850,8 @@ function registerSecrets(program3) {
15553
15850
  }
15554
15851
  let value;
15555
15852
  if (flags.stdin) {
15556
- const { readFileSync } = await import("node:fs");
15557
- value = readFileSync(0, "utf8").replace(/\r?\n$/, "");
15853
+ const { readFileSync: readFileSync2 } = await import("node:fs");
15854
+ value = readFileSync2(0, "utf8").replace(/\r?\n$/, "");
15558
15855
  } else {
15559
15856
  value = String(flags.value);
15560
15857
  }
@@ -15585,6 +15882,7 @@ function registerSecrets(program3) {
15585
15882
  }
15586
15883
 
15587
15884
  // src/commands/skills.mts
15885
+ init_http();
15588
15886
  function registerSkills(program3) {
15589
15887
  const skills = program3.command("skills").description("skills catalog, sync pipeline, procedures");
15590
15888
  skills.command("list").description("skills catalog (GET /admin/skills)").option("--org <org>", "scope to one org (default: all orgs)").option("--tag <tag>", "filter by tag").option("--origin <org/repo>", "filter by origin repo").option("--search <text>", "search name/description").action(async (flags, cmd) => {
@@ -15656,6 +15954,7 @@ function registerSkills(program3) {
15656
15954
  }
15657
15955
 
15658
15956
  // src/commands/automations.mts
15957
+ init_http();
15659
15958
  function registerAutomations(program3) {
15660
15959
  const automations = program3.command("automations").description("event-driven automation rules");
15661
15960
  automations.command("list").description("automation rules with run stats (GET /automations); --db raw DB view; --snapshots snapshot history").option("--db", "raw DB view (GET /automations/_db)").option("--snapshots", "snapshot history (GET /automations/_snapshots)").option("--limit <n>", "max snapshot rows (with --snapshots, default 20)", (v) => parseInt(v, 10)).action(async (flags, cmd) => {
@@ -15702,6 +16001,7 @@ function registerAutomations(program3) {
15702
16001
  }
15703
16002
 
15704
16003
  // src/commands/providers.mts
16004
+ init_http();
15705
16005
  function registerProviders(program3) {
15706
16006
  const providers = program3.command("providers").description("LLM provider instances and org purpose-chains");
15707
16007
  providers.command("list").description("provider instances for an org (GET /admin/providers?org=)").requiredOption("--org <org>", "org to list (required by the gateway)").action(async (flags, cmd) => {
@@ -15758,6 +16058,7 @@ function registerProviders(program3) {
15758
16058
  }
15759
16059
 
15760
16060
  // src/commands/orgs.mts
16061
+ init_http();
15761
16062
  function registerOrgs(program3) {
15762
16063
  const orgs = program3.command("orgs").description("GitHub orgs and repo onboarding");
15763
16064
  orgs.command("list").description("orgs visible to this token (GET /orgs)").action(async (_flags, cmd) => {
@@ -15774,6 +16075,7 @@ function registerOrgs(program3) {
15774
16075
  }
15775
16076
 
15776
16077
  // src/commands/assets.mts
16078
+ init_http();
15777
16079
  function registerAssets(program3) {
15778
16080
  const assets = program3.command("assets").description("S3 asset presign, view, publish");
15779
16081
  assets.command("presign").description("mint a presigned upload URL (POST /assets/presign) \u2014 needs one of --conversation/--job/--repo").requiredOption("--content-type <ct>", "asset content type, e.g. image/png").requiredOption("--size <bytes>", "upload size in bytes", (v) => parseInt(v, 10)).option("--conversation <id>", "attach to a conversation").option("--job <id>", "attach to a mission job").option("--repo <org/repo>", "attach to a repo").action(async (flags, cmd) => {
@@ -15811,7 +16113,7 @@ function registerAssets(program3) {
15811
16113
  }
15812
16114
 
15813
16115
  // src/index.mts
15814
- var VERSION = true ? "0.1.3" : "0.0.0-dev";
16116
+ var VERSION = true ? "0.1.4" : "0.0.0-dev";
15815
16117
  installEpipeGuard();
15816
16118
  var program2 = new Command();
15817
16119
  program2.name("pylot").description(
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pylot-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
- "description": "pylot first-class CLI for the Pylot gateway API. Replaces curl workflows for operators and humans.",
5
+ "description": "pylot \u2014 first-class CLI for the Pylot gateway API. Replaces curl workflows for operators and humans.",
6
6
  "bin": {
7
7
  "pylot": "dist/index.mjs"
8
8
  },