syndai 0.4.1 → 0.6.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/src/secrets.js ADDED
@@ -0,0 +1,317 @@
1
+ // `syndai env`, `syndai secrets` and `syndai run`: a workspace's environments and secrets from
2
+ // a terminal, Doppler-style (docs: syndai.ai/docs/cli, "Secrets and environments").
3
+ //
4
+ // The environment: --env, else SYNDAI_ENV, else the directory's link (`syndai env use`,
5
+ // `syndai link --env`), else the workspace default (where runs go). The project: the usual
6
+ // resolver (--app, SYNDAI_APP, the link, the git remote, the only project), never a picker;
7
+ // its values layer over the shared ones, as in a run. `set`, `rm` and `import` write shared
8
+ // values unless --project.
9
+ //
10
+ // A value never travels through argv, JSON output, an error or a log line: `set` reads it from
11
+ // a hidden prompt or stdin, `get` prints it only to a terminal (or with --plain), `download`
12
+ // writes a 0600 file (or a pipe), and `run` hands values to the child's environment only.
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import { constants } from "node:os";
17
+ import path from "node:path";
18
+ import { productionStepUp } from "./auth.js";
19
+ import { NAME, parseDotenv, serializeDotenv } from "./dotenv.js";
20
+ import { ApiError, CliError, EXIT } from "./errors.js";
21
+ import { environmentBySlug, listEnvironments, projectLabel, resolveProject } from "./projects.js";
22
+ import { findLink, saveLink, writeFileAtomic } from "./store.js";
23
+ import { ask, askSecret, readAll } from "./ui.js";
24
+
25
+ /** The slug the flags, SYNDAI_ENV or the link name; null means the workspace default. */
26
+ function chosenSlug(ctx) {
27
+ return ctx.flags.env || ctx.io.env.SYNDAI_ENV || findLink(ctx.paths, ctx.io.cwd)?.environment || null;
28
+ }
29
+
30
+ /** The chosen environment, or the default one (both validated against the workspace). */
31
+ async function chosenEnvironment(ctx, environments) {
32
+ const envs = environments ?? (await listEnvironments(ctx));
33
+ const slug = chosenSlug(ctx);
34
+ if (slug) return environmentBySlug(ctx, slug, envs);
35
+ const fallback = envs.find((env) => env.default) ?? envs[0];
36
+ if (!fallback) throw new CliError(EXIT.FAILED, "This workspace has no environments.", { next: "https://app.syndai.ai" });
37
+ return fallback;
38
+ }
39
+
40
+ /** The project whose values layer over the shared ones, or null (shared only). */
41
+ function layeredProject(ctx) {
42
+ return resolveProject(ctx, { app: ctx.flags.app, required: false, picker: false });
43
+ }
44
+
45
+ /** The project a write targets: shared (null) unless --project. */
46
+ async function writeTarget(ctx) {
47
+ if (!ctx.flags.project) return null;
48
+ return resolveProject(ctx, { app: ctx.flags.app, picker: false });
49
+ }
50
+
51
+ const scopeOf = (project) => (project ? `project ${projectLabel(project)}` : "shared");
52
+
53
+ // --- syndai env --------------------------------------------------------------------------
54
+
55
+ export async function env(ctx, [action = "ls", slug, ...extra]) {
56
+ if (action === "ls" && !slug) return envList(ctx);
57
+ if (action === "use" && slug && !extra.length) return envUse(ctx, slug);
58
+ throw new CliError(EXIT.USAGE, "Usage: syndai env ls | syndai env use <env>");
59
+ }
60
+
61
+ async function envList(ctx) {
62
+ const envs = await listEnvironments(ctx);
63
+ const current = (await chosenEnvironment(ctx, envs)).slug;
64
+ const rows = envs.map((e) => ({ ...e, current: e.slug === current }));
65
+ const width = Math.max(...rows.map((e) => e.slug.length), 4);
66
+ ctx.ui.result(
67
+ { environments: rows, current },
68
+ rows
69
+ .map((e) => `${e.current ? "*" : " "} ${e.slug.padEnd(width)} ${e.name} (${e.role}${e.default ? ", default" : ""})`)
70
+ .join("\n"),
71
+ );
72
+ return EXIT.OK;
73
+ }
74
+
75
+ async function envUse(ctx, slug) {
76
+ const environment = await environmentBySlug(ctx, slug);
77
+ const { paths, io } = ctx;
78
+ const here = findLink(paths, io.cwd);
79
+ // The nearest link keeps its project; with none, this directory gets an environment-only one.
80
+ const dir = here?.dir ?? path.resolve(io.cwd);
81
+ const { dir: _dir, ...kept } = here ?? { customer_id: await ctx.customerId() };
82
+ saveLink(paths, dir, { ...kept, environment: environment.slug });
83
+ ctx.ui.result({ dir, environment: environment.slug }, `${dir} now uses ${environment.slug}.`);
84
+ return EXIT.OK;
85
+ }
86
+
87
+ // --- syndai secrets ----------------------------------------------------------------------
88
+
89
+ export async function secrets(ctx, [action = "ls", ...args]) {
90
+ const handler = SECRET_ACTIONS[action];
91
+ if (!handler) {
92
+ throw new CliError(EXIT.USAGE, "Usage: syndai secrets [ls | set KEY | rm KEY | import FILE|- | get KEY | download]");
93
+ }
94
+ return handler(ctx, args);
95
+ }
96
+
97
+ /** One positional secret name; never echoes what was typed (it may be a pasted value). */
98
+ function secretName(args, verb) {
99
+ if (args.length !== 1 || args[0].includes("=")) {
100
+ throw new CliError(EXIT.USAGE, `syndai secrets ${verb} takes one name; a value never goes on the command line.`, {
101
+ next: verb === "set" ? "syndai secrets set KEY (then type the value, or pipe it on stdin)" : null,
102
+ });
103
+ }
104
+ if (!NAME.test(args[0])) {
105
+ throw new CliError(EXIT.USAGE, "A secret name is uppercase letters, digits and _ (like API_TOKEN).");
106
+ }
107
+ return args[0];
108
+ }
109
+
110
+ const SECRET_ACTIONS = {
111
+ async ls(ctx, args) {
112
+ if (args.length) throw new CliError(EXIT.USAGE, "Usage: syndai secrets [ls]");
113
+ const environment = await chosenEnvironment(ctx);
114
+ const project = await layeredProject(ctx);
115
+ const { secrets: rows } = await ctx.client.call("listSecrets");
116
+ const shown = rows
117
+ .filter((s) => s.environment === environment.slug && (!s.repository_id || s.repository_id === project?.id))
118
+ .map((s) => ({
119
+ key_name: s.key_name,
120
+ environment: s.environment,
121
+ scope: s.repository_id ? "project" : "shared",
122
+ repository_id: s.repository_id,
123
+ updated_at: s.updated_at,
124
+ }));
125
+ const width = Math.max(...shown.map((s) => s.key_name.length), 4);
126
+ const human = shown.length
127
+ ? [
128
+ `${"NAME".padEnd(width)} SCOPE UPDATED`,
129
+ ...shown.map((s) => `${s.key_name.padEnd(width)} ${s.scope.padEnd(7)} ${s.updated_at.slice(0, 10)}`),
130
+ ].join("\n")
131
+ : `No secrets in ${environment.slug} yet. Add one: syndai secrets set KEY`;
132
+ ctx.ui.result({ environment: environment.slug, project_id: project?.id ?? null, secrets: shown }, human);
133
+ return EXIT.OK;
134
+ },
135
+
136
+ async set(ctx, args) {
137
+ const { io, ui } = ctx;
138
+ const name = secretName(args, "set");
139
+ let value;
140
+ if (io.stdin.isTTY) {
141
+ if (!ui.interactive) {
142
+ throw new CliError(EXIT.USAGE, "Nobody can type the value here; pipe it on stdin.", {
143
+ next: `printf %s "$VALUE" | syndai secrets set ${name}`,
144
+ });
145
+ }
146
+ value = await askSecret(io, `Value for ${name} (hidden): `);
147
+ } else {
148
+ value = (await readAll(io.stdin)).replace(/\r?\n$/, "");
149
+ }
150
+ if (!value) throw new CliError(EXIT.USAGE, `No value for ${name}: type it at the prompt or pipe it on stdin.`);
151
+ const environment = await chosenEnvironment(ctx);
152
+ const project = await writeTarget(ctx);
153
+ const { secret } = await ctx.client.call("putSecret", {
154
+ params: { key_name: name },
155
+ body: { value, environment: environment.slug, ...(project ? { repository_id: project.id } : {}) },
156
+ });
157
+ ui.result(
158
+ { key_name: name, environment: environment.slug, scope: project ? "project" : "shared", repository_id: project?.id ?? null, updated_at: secret.updated_at },
159
+ `Set ${name} in ${environment.slug} (${scopeOf(project)}).`,
160
+ );
161
+ return EXIT.OK;
162
+ },
163
+
164
+ async rm(ctx, args) {
165
+ const { flags, io, ui } = ctx;
166
+ const name = secretName(args, "rm");
167
+ const environment = await chosenEnvironment(ctx);
168
+ const project = await writeTarget(ctx);
169
+ const where = `${environment.slug} (${scopeOf(project)})`;
170
+ if (!flags.yes) {
171
+ if (!ui.interactive) {
172
+ throw new CliError(EXIT.USAGE, "Removing a secret needs --yes when nobody can confirm it.", {
173
+ next: `syndai secrets rm ${name} --yes`,
174
+ });
175
+ }
176
+ const answer = await ask(io, `Remove ${name} from ${where}? [y/N] `);
177
+ if (!/^y/i.test(answer)) {
178
+ ui.result({ key_name: name, removed: false }, "Kept.");
179
+ return EXIT.OK;
180
+ }
181
+ }
182
+ await ctx.client.call("deleteSecret", {
183
+ params: { key_name: name },
184
+ query: { environment: environment.slug, repository_id: project?.id },
185
+ });
186
+ ui.result({ key_name: name, environment: environment.slug, removed: true }, `Removed ${name} from ${where}.`);
187
+ return EXIT.OK;
188
+ },
189
+
190
+ async import(ctx, args) {
191
+ const { io, ui } = ctx;
192
+ if (args.length !== 1) throw new CliError(EXIT.USAGE, "Usage: syndai secrets import FILE|-");
193
+ const [source] = args;
194
+ let text;
195
+ try {
196
+ text = source === "-" ? await readAll(io.stdin) : fs.readFileSync(path.resolve(io.cwd, source), "utf8");
197
+ } catch (err) {
198
+ throw new CliError(EXIT.USAGE, `Cannot read ${source} (${err.code ?? "unreadable"}).`);
199
+ }
200
+ const items = parseDotenv(text);
201
+ if (!items.length) throw new CliError(EXIT.USAGE, `${source} has no KEY=VALUE lines.`);
202
+ const environment = await chosenEnvironment(ctx);
203
+ const project = await writeTarget(ctx);
204
+ await ctx.client.call("importSecrets", {
205
+ body: { items, environment: environment.slug, ...(project ? { repository_id: project.id } : {}) },
206
+ });
207
+ const names = items.map((item) => item.key_name);
208
+ ui.result(
209
+ { environment: environment.slug, scope: project ? "project" : "shared", imported: names.length, names },
210
+ `Imported ${names.length} value${names.length === 1 ? "" : "s"} into ${environment.slug} (${scopeOf(project)}): ${names.join(", ")}.`,
211
+ );
212
+ return EXIT.OK;
213
+ },
214
+
215
+ async get(ctx, args) {
216
+ const { io, flags } = ctx;
217
+ const name = secretName(args, "get");
218
+ if (flags.json) throw new CliError(EXIT.USAGE, "JSON never carries a secret value; use --plain.");
219
+ if (!io.stdout.isTTY && !flags.plain) {
220
+ throw new CliError(EXIT.USAGE, "Not printing a secret value into a pipe or a file without --plain.", {
221
+ next: `syndai secrets get ${name} --plain`,
222
+ });
223
+ }
224
+ const { environment, values } = await resolveValues(ctx, [name]);
225
+ if (!(name in values)) throw new CliError(EXIT.FAILED, `${name} is not set in ${environment}.`, { next: "syndai secrets" });
226
+ // --plain: the exact bytes, for $(...) and pipes; a terminal gets a newline.
227
+ io.stdout.write(flags.plain ? values[name] : `${values[name]}\n`);
228
+ return EXIT.OK;
229
+ },
230
+
231
+ async download(ctx, args) {
232
+ const { io, flags, ui } = ctx;
233
+ if (args.length) throw new CliError(EXIT.USAGE, "Usage: syndai secrets download [-o FILE]");
234
+ if (!flags.output && (io.stdout.isTTY || flags.json)) {
235
+ throw new CliError(EXIT.USAGE, "Pass -o FILE: a terminal (or --json) never gets the values.", {
236
+ next: "syndai secrets download -o .env",
237
+ });
238
+ }
239
+ const { environment, values } = await resolveValues(ctx);
240
+ const text = serializeDotenv(values);
241
+ const names = Object.keys(values).sort();
242
+ if (!flags.output) {
243
+ io.stdout.write(text);
244
+ return EXIT.OK;
245
+ }
246
+ const file = path.resolve(io.cwd, flags.output);
247
+ writeFileAtomic(file, text, 0o600);
248
+ if (notIgnored(file)) {
249
+ ui.progress(ui.yellow(`Warning: ${flags.output} is not ignored by git; add it to .gitignore so it is never committed.`));
250
+ }
251
+ ui.result(
252
+ { file, environment, count: names.length, names },
253
+ `Wrote ${names.length} value${names.length === 1 ? "" : "s"} from ${environment} to ${file} (mode 600).`,
254
+ );
255
+ return EXIT.OK;
256
+ },
257
+ };
258
+
259
+ /** True only inside a git checkout that does not ignore `file` (outside one: false). */
260
+ function notIgnored(file) {
261
+ try {
262
+ execFileSync("git", ["check-ignore", "-q", path.basename(file)], {
263
+ cwd: path.dirname(file),
264
+ stdio: "ignore",
265
+ timeout: 3000,
266
+ });
267
+ return false; // exit 0: ignored
268
+ } catch (err) {
269
+ return err.status === 1; // 1: not ignored; 128 (no repo) or no git: nothing to warn about
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The chosen environment's merged values (`names` narrows them). A production environment
275
+ * the server refuses (no approved step-up) gets one browser step-up on a terminal, whose grant
276
+ * rides only this command's retry.
277
+ */
278
+ async function resolveValues(ctx, names) {
279
+ const slug = chosenSlug(ctx);
280
+ const project = await layeredProject(ctx);
281
+ const body = {
282
+ ...(slug ? { environment: slug } : {}),
283
+ ...(project ? { repository_id: project.id } : {}),
284
+ ...(names ? { names } : {}),
285
+ };
286
+ try {
287
+ return await ctx.client.call("resolveSecrets", { body });
288
+ } catch (err) {
289
+ if (!(err instanceof ApiError && err.code === "PRODUCTION_STEP_UP_REQUIRED")) throw err;
290
+ const grant = await productionStepUp(ctx);
291
+ if (!grant) throw err;
292
+ return ctx.client.call("resolveSecrets", { body: { ...body, production_grant: grant } });
293
+ }
294
+ }
295
+
296
+ // --- syndai run --------------------------------------------------------------------------
297
+
298
+ /** `syndai run [--env E] [--] <command>...`: the child alone gets the values; its exit code is ours. */
299
+ export async function run(ctx, command) {
300
+ const { io, ui } = ctx;
301
+ if (!command.length) throw new CliError(EXIT.USAGE, "Usage: syndai run [--env E] [--] <command> [args...]");
302
+ const { environment, values } = await resolveValues(ctx);
303
+ const count = Object.keys(values).length;
304
+ ui.progress(ui.dim(`Injected ${count} value${count === 1 ? "" : "s"} from ${environment}`));
305
+ let exit;
306
+ try {
307
+ exit = await io.exec(command[0], command.slice(1), { ...io.env, ...values });
308
+ } catch (err) {
309
+ if (err.code === "ENOENT") {
310
+ ui.progress(`${ui.red("error:")} command not found: ${command[0]}`);
311
+ return 127;
312
+ }
313
+ throw err;
314
+ }
315
+ if (exit.signal) return 128 + (constants.signals[exit.signal] ?? 0);
316
+ return exit.code ?? EXIT.FAILED;
317
+ }
package/src/ui.js CHANGED
@@ -59,6 +59,56 @@ export async function ask(io, question) {
59
59
  }
60
60
  }
61
61
 
62
+ /**
63
+ * Ask for a secret without echoing it, in raw mode: Enter ends it, Ctrl-C interrupts, Backspace
64
+ * edits, an escape sequence (an arrow key) is dropped. A terminal that cannot hide typing is
65
+ * refused rather than echoing, and so is a pasted multi-line value (it would be cut at its
66
+ * first line): both go through stdin instead.
67
+ */
68
+ export async function askSecret(io, question) {
69
+ const { stdin } = io;
70
+ if (typeof stdin.setRawMode !== "function") {
71
+ throw new CliError(EXIT.USAGE, "This terminal can't hide what you type; pipe the value on stdin instead.");
72
+ }
73
+ if (io.signal.aborted) throw interrupted();
74
+ io.stderr.write(question);
75
+ stdin.setRawMode(true);
76
+ stdin.setEncoding("utf8");
77
+ stdin.resume();
78
+ return new Promise((resolve, reject) => {
79
+ let value = "";
80
+ const finish = (err) => {
81
+ stdin.removeListener("data", onData);
82
+ io.signal.removeEventListener("abort", onAbort);
83
+ stdin.setRawMode(false);
84
+ stdin.pause();
85
+ io.stderr.write("\n");
86
+ if (err) reject(err);
87
+ else resolve(value);
88
+ };
89
+ const onAbort = () => finish(interrupted());
90
+ const onData = (chunk) => {
91
+ // An escape sequence (ESC [ ... final byte) is a key like an arrow, never part of a value.
92
+ const keys = chunk.replace(/\u001b\[[0-9;?]*[@-~]|\u001b./g, "");
93
+ for (let i = 0; i < keys.length; i++) {
94
+ const ch = keys[i];
95
+ if (ch === "\r" || ch === "\n") {
96
+ if (keys.slice(i + 1).replace(/^[\r\n]+/, "")) {
97
+ return finish(new CliError(EXIT.USAGE, "That value has more than one line; pipe it on stdin instead."));
98
+ }
99
+ return finish();
100
+ }
101
+ if (ch === "\u0003") return finish(interrupted());
102
+ if (ch === "\u0004") return finish(value ? null : new CliError(EXIT.NEEDS_YOU, "No answer: input closed."));
103
+ if (ch === "\u007f" || ch === "\b") value = Array.from(value).slice(0, -1).join("");
104
+ else value += ch;
105
+ }
106
+ };
107
+ stdin.on("data", onData);
108
+ io.signal.addEventListener("abort", onAbort, { once: true });
109
+ });
110
+ }
111
+
62
112
  /** Release stdin so the process can exit (called once a command is done). */
63
113
  export function closeInput(io) {
64
114
  readers.get(io.stdin)?.rl.close();
@@ -83,6 +133,11 @@ export function openBrowser(url) {
83
133
  }
84
134
  }
85
135
 
136
+ /** `review`'s rule for a hand-off page: open it on a terminal; the caller always prints it. */
137
+ export function openPage({ ui, io }, url) {
138
+ if (ui.interactive && !ui.json) io.openUrl(url);
139
+ }
140
+
86
141
  /** Show long text through $PAGER on a terminal; print it plainly otherwise. */
87
142
  export function page(proc, text) {
88
143
  if (!proc.stdout.isTTY) {
package/src/watch.js CHANGED
@@ -5,14 +5,18 @@
5
5
  // cursor. Ctrl-C only detaches: it never sends a cancel. `--logs` also tails the run's SSE
6
6
  // events (resuming with Last-Event-ID) next to the loop.
7
7
 
8
- import { ApiError, CliError, EXIT, explain } from "./errors.js";
8
+ import { ApiError, CliError, EXIT, UnavailableError, explain } from "./errors.js";
9
9
  import { backoffMs } from "./http.js";
10
- import { ask, ulid } from "./ui.js";
10
+ import { ask, openPage, ulid } from "./ui.js";
11
11
 
12
12
  const POLL_WAIT_S = 45;
13
13
  const MIN_POLL_GAP_MS = 1000;
14
14
  const DEADLINE_GRACE_MS = 2000;
15
15
  const TIMED_OUT = Symbol("timed out");
16
+ // A deploy answers 502/503 for a minute or two, longer than one request's retries: the
17
+ // watch keeps polling through it and gives up only after this long without an answer.
18
+ const OUTAGE_MAX_MS = 10 * 60_000;
19
+ const OUTAGE_MAX_WAIT_MS = 30_000;
16
20
 
17
21
  // Exit code per ChangeState state (docs: syndai.ai/docs/cli, "Exit codes").
18
22
  const STATE_EXIT = {
@@ -21,6 +25,7 @@ const STATE_EXIT = {
21
25
  ready: EXIT.OK,
22
26
  live: EXIT.OK,
23
27
  live_unverified: EXIT.UNVERIFIED,
28
+ unverified: EXIT.UNVERIFIED,
24
29
  failed: EXIT.FAILED,
25
30
  reverted: EXIT.FAILED,
26
31
  cancelled: EXIT.FAILED,
@@ -35,6 +40,7 @@ export function nextFor(state) {
35
40
  const id = state.run_id;
36
41
  if (state.state === "needs_input") return `syndai answer ${id}`;
37
42
  if (state.state === "held") return `syndai review ${id}`;
43
+ if (state.state === "unverified") return state.pr_url ?? state.run_url ?? null;
38
44
  if (state.state === "ready") return `syndai go-live ${id}`;
39
45
  if (state.state === "live_unverified") return `syndai watch ${id}`;
40
46
  return state.done ? null : `syndai watch ${id}`;
@@ -55,6 +61,8 @@ export function describe(state) {
55
61
  return `Held: ${state.held?.message ?? state.held?.reason ?? "needs your review"}`;
56
62
  case "ready":
57
63
  return `Ready for review${headline}`;
64
+ case "unverified":
65
+ return `Delivered, but checks could not be verified${headline}`;
58
66
  case "live":
59
67
  return `Live: ${state.live_url ?? state.project?.live_url ?? ""}`.trim();
60
68
  case "live_unverified":
@@ -68,12 +76,19 @@ export function describe(state) {
68
76
  export function finish(ctx, state, { exitCode = exitFor(state), next = nextFor(state) } = {}) {
69
77
  const { ui } = ctx;
70
78
  const lines = [describe(state)];
79
+ for (const link of questionLinks(state)) lines.push(`Connect it here: ${link}`);
80
+ if (state.pr_url) lines.push(ui.dim(state.pr_url));
71
81
  if (state.run_url) lines.push(ui.dim(state.run_url));
72
82
  ui.result({ ...state, ...(next ? { next } : {}) }, lines.join("\n"));
73
83
  if (next && !ui.json) ui.progress(`Next: ${next}`);
74
84
  return exitCode;
75
85
  }
76
86
 
87
+ /** The pages an open question needs (a connection to make), each an absolute portal URL. */
88
+ function questionLinks(state) {
89
+ return state.state === "needs_input" ? (state.needs_input ?? []).map((q) => q.link).filter(Boolean) : [];
90
+ }
91
+
77
92
  export async function getChange(ctx, runId) {
78
93
  return ctx.client.call("changeState", { params: { run_id: runId }, query: { wait: 0 } });
79
94
  }
@@ -95,6 +110,8 @@ export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = nul
95
110
  let last = null;
96
111
  let lastLine = null;
97
112
  const answered = new Set(); // a state read before our answer landed must not ask again
113
+ let outageSince = null;
114
+ let outageTries = 0;
98
115
  try {
99
116
  for (;;) {
100
117
  const remaining = deadline === null ? Number.POSITIVE_INFINITY : deadline - io.now();
@@ -111,14 +128,24 @@ export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = nul
111
128
  });
112
129
  } catch (err) {
113
130
  if (err === TIMED_OUT) return timedOut(ctx, runId, last);
114
- throw err;
131
+ if (!(err instanceof UnavailableError)) throw err;
132
+ outageSince ??= io.now();
133
+ if (io.now() - outageSince >= OUTAGE_MAX_MS) throw err;
134
+ if (outageTries++ === 0) ui.progress(ui.dim(`· Syndai is unreachable (${err.message}); still watching`));
135
+ await io.sleep(Math.min(OUTAGE_MAX_WAIT_MS, backoffMs(outageTries + 4)), io.signal);
136
+ continue;
115
137
  }
138
+ outageSince = null;
139
+ outageTries = 0;
116
140
  const line = describe(state);
117
141
  const changed = line !== lastLine || state.cursor !== cursor;
118
142
  const final =
119
143
  (state.done && state.state !== untilNot) || (state.state === "needs_input" && !ui.interactive);
120
144
  if (final) return finish(ctx, state);
121
- if (line !== lastLine) ui.progress(`${ui.dim("·")} ${line}`);
145
+ if (line !== lastLine) {
146
+ ui.progress(`${ui.dim("·")} ${line}`);
147
+ if (state.pr_url) ui.progress(ui.dim(state.pr_url));
148
+ }
122
149
  lastLine = line;
123
150
  cursor = state.cursor ?? cursor;
124
151
  last = state;
@@ -162,6 +189,10 @@ function timedOut(ctx, runId, last) {
162
189
  export async function answerInteractively(ctx, runId, questions) {
163
190
  for (const question of questions) {
164
191
  ctx.ui.progress(ctx.ui.bold(question.summary ?? "A question about your change"));
192
+ if (question.link) {
193
+ ctx.ui.progress(`Connect it here: ${question.link}`);
194
+ openPage(ctx, question.link);
195
+ }
165
196
  const entries = [];
166
197
  for (const input of question.inputs ?? []) {
167
198
  const entry = await askInput(ctx, input);