orizu 0.5.51 → 0.5.52

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 (50) hide show
  1. package/dist/app-commands.d.ts.map +1 -1
  2. package/dist/app-commands.js +4 -12
  3. package/dist/auth-commands.d.ts.map +1 -1
  4. package/dist/auth-commands.js +3 -106
  5. package/dist/cli-write-commands.d.ts +143 -0
  6. package/dist/cli-write-commands.d.ts.map +1 -0
  7. package/dist/cli-write-commands.js +123 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +44 -50
  10. package/dist/last-error-record.d.ts +7 -0
  11. package/dist/last-error-record.d.ts.map +1 -1
  12. package/dist/last-error-record.js +14 -2
  13. package/dist/login-flow.d.ts +37 -0
  14. package/dist/login-flow.d.ts.map +1 -0
  15. package/dist/login-flow.js +295 -0
  16. package/dist/logout-diagnostic.d.ts.map +1 -1
  17. package/dist/logout-diagnostic.js +2 -1
  18. package/dist/manifests-cli.d.ts.map +1 -1
  19. package/dist/manifests-cli.js +36 -22
  20. package/dist/one-request-write.d.ts +59 -0
  21. package/dist/one-request-write.d.ts.map +1 -0
  22. package/dist/one-request-write.js +157 -0
  23. package/dist/provider-write-workflow.d.ts +12 -0
  24. package/dist/provider-write-workflow.d.ts.map +1 -0
  25. package/dist/provider-write-workflow.js +110 -0
  26. package/dist/providers-cli.d.ts.map +1 -1
  27. package/dist/providers-cli.js +27 -23
  28. package/dist/scores-accept-cli.d.ts +1 -1
  29. package/dist/scores-accept-cli.d.ts.map +1 -1
  30. package/dist/scores-accept-cli.js +4 -28
  31. package/dist/task-commands.d.ts.map +1 -1
  32. package/dist/task-commands.js +26 -62
  33. package/package.json +1 -1
  34. package/src/app-commands.ts +3 -18
  35. package/src/auth-commands.ts +6 -125
  36. package/src/cli-write-commands.ts +143 -0
  37. package/src/index.ts +23 -57
  38. package/src/last-error-record.ts +16 -2
  39. package/src/login-flow.ts +368 -0
  40. package/src/logout-diagnostic.ts +2 -1
  41. package/src/manifests-cli.ts +36 -23
  42. package/src/one-request-write.ts +192 -0
  43. package/src/provider-write-workflow.ts +117 -0
  44. package/src/providers-cli.ts +24 -17
  45. package/src/scores-accept-cli.ts +4 -46
  46. package/src/task-commands.ts +22 -115
  47. package/dist/headless-login.d.ts +0 -19
  48. package/dist/headless-login.d.ts.map +0 -1
  49. package/dist/headless-login.js +0 -108
  50. package/src/headless-login.ts +0 -192
@@ -116,6 +116,19 @@ function serializeRecord(record) {
116
116
  function isSerializedRecordTooLarge(record) {
117
117
  return Buffer.byteLength(serializeRecord(record), 'utf8') > LAST_ERROR_FILE_MAX_BYTES;
118
118
  }
119
+ /** Shorter values are left alone: replacing them would garble ordinary text. */
120
+ const MIN_ERROR_OUTPUT_SECRET_LENGTH = 8;
121
+ /**
122
+ * The values no error output may show: this process's remembered secrets, the
123
+ * current bearer and runner-forwarded keys. last-error.json and the terminal's
124
+ * error line both use this list. Longest first, so a secret inside another
125
+ * can't leave part of the longer one behind.
126
+ */
127
+ export function errorOutputSecrets() {
128
+ return [...new Set([...resolveActiveProcessSecrets(), ...runnerForwardedSecretValues()])]
129
+ .filter(secret => typeof secret === 'string' && secret.length >= MIN_ERROR_OUTPUT_SECRET_LENGTH)
130
+ .sort((left, right) => right.length - left.length);
131
+ }
119
132
  function resolveActiveProcessSecrets() {
120
133
  const rememberedSecrets = getSecretsSeenThisProcess();
121
134
  try {
@@ -230,8 +243,7 @@ export async function writeLastErrorRecord(input, options = {}) {
230
243
  ...input,
231
244
  homeDir: input.homeDir === undefined ? resolveHomeDir() : input.homeDir,
232
245
  secrets: [
233
- ...resolveActiveProcessSecrets(),
234
- ...runnerForwardedSecretValues(),
246
+ ...errorOutputSecrets(),
235
247
  ...(input.secrets ?? []),
236
248
  ],
237
249
  });
@@ -0,0 +1,37 @@
1
+ export interface LoginFlowDependencies {
2
+ parseJsonResponse: <T>(response: Response, context: string) => Promise<T>;
3
+ printProgress: (message: string) => void;
4
+ sanitizeTerminalText: (text: string) => string;
5
+ validateBrowserUrl: (url: string, expectedOrigin?: string) => URL;
6
+ sleep?: (milliseconds: number) => Promise<void>;
7
+ }
8
+ export interface CompletedLogin {
9
+ apiKey: string;
10
+ user: {
11
+ id: string;
12
+ email: string | null;
13
+ };
14
+ }
15
+ export declare function shouldUseHeadlessLogin({ isForced, platform, environment, }: {
16
+ isForced: boolean;
17
+ platform?: NodeJS.Platform;
18
+ environment?: Record<string, string | undefined>;
19
+ }): boolean;
20
+ export declare const POLL_INTERVAL_MS = 2000;
21
+ /** For tests: the headless wait alone, returning the authorization code. */
22
+ export declare function waitForHeadlessAuthorization(input: {
23
+ baseUrl: string;
24
+ codeChallenge: string;
25
+ }, dependencies: LoginFlowDependencies, signal?: AbortSignal): Promise<string>;
26
+ /**
27
+ * Waits for the person to approve the login (in the browser, or by polling
28
+ * when headless), then exchanges the code. `callbackPort` undefined means
29
+ * headless. `signal` stops it, closing the loopback server.
30
+ */
31
+ export declare function completeLogin(input: {
32
+ baseUrl: string;
33
+ codeVerifier: string;
34
+ codeChallenge: string;
35
+ callbackPort?: number;
36
+ }, dependencies: LoginFlowDependencies, signal?: AbortSignal): Promise<CompletedLogin>;
37
+ //# sourceMappingURL=login-flow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login-flow.d.ts","sourceRoot":"","sources":["../src/login-flow.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;IACzE,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IACxC,oBAAoB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IAC9C,kBAAkB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,MAAM,KAAK,GAAG,CAAA;IACjE,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAChD;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAC3C;AAED,wBAAgB,sBAAsB,CAAC,EACrC,QAAQ,EACR,QAA2B,EAC3B,WAAyB,GAC1B,EAAE;IACD,QAAQ,EAAE,OAAO,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CACjD,GAAG,OAAO,CAMV;AA2ID,eAAO,MAAM,gBAAgB,OAAQ,CAAA;AA2JrC,4EAA4E;AAC5E,wBAAgB,4BAA4B,CAC1C,KAAK,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,EACjD,YAAY,EAAE,qBAAqB,EACnC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC,CAEjB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9F,YAAY,EAAE,qBAAqB,EACnC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAOzB"}
@@ -0,0 +1,295 @@
1
+ // `orizu login` from the start request to the server's answer (ORI-2422):
2
+ // the browser flow's loopback server and launch, the headless poll loop, and
3
+ // the code exchange. Loaded by `await import(...)` inside login() only after
4
+ // the already-logged-in check, so other commands and the hosted token path
5
+ // (ORIZU_TOKEN / ORIZU_TOKEN_FILE) never load Effect. No stop handlers: a
6
+ // Ctrl-C ends the process at once, which frees the port and saves nothing.
7
+ import { spawn } from 'child_process';
8
+ import { createServer } from 'http';
9
+ import { Cause, Effect, Exit, Result, Schema } from 'effect';
10
+ import { renderCliAuthBrowserPage } from './cli-auth-browser-page.js';
11
+ import { rememberProcessSecret } from './credentials.js';
12
+ export function shouldUseHeadlessLogin({ isForced, platform = process.platform, environment = process.env, }) {
13
+ if (isForced || environment.SSH_CONNECTION || environment.SSH_TTY) {
14
+ return true;
15
+ }
16
+ return platform === 'linux' && !environment.DISPLAY && !environment.WAYLAND_DISPLAY;
17
+ }
18
+ const toError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
19
+ /** A request that stops when the run is interrupted, and after `timeoutMs` when given. */
20
+ function request(url, init, timeoutMs) {
21
+ return Effect.tryPromise({
22
+ try: signal => fetch(url, {
23
+ ...init,
24
+ signal: timeoutMs === undefined ? signal : AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]),
25
+ }),
26
+ catch: toError,
27
+ });
28
+ }
29
+ const readText = (response) => Effect.tryPromise({ try: () => response.text(), catch: toError });
30
+ const parseJson = (dependencies, response, context) => Effect.tryPromise({ try: () => dependencies.parseJsonResponse(response, context), catch: toError });
31
+ /**
32
+ * The loopback server the browser returns to. Only `GET /callback` counts:
33
+ * other paths get 404 and the wait goes on, and `/callback` without a code
34
+ * gets the error page and the wait goes on. The release closes the server and
35
+ * drops every connection on every ending. It runs after the success page has
36
+ * been sent, so a browser that keeps its connection open no longer holds the
37
+ * CLI after "Logged in".
38
+ */
39
+ function loopback(port) {
40
+ return Effect.acquireRelease(Effect.callback(resume => {
41
+ let deliver = () => { };
42
+ const code = new Promise(resolve => { deliver = resolve; });
43
+ const server = createServer((incoming, response) => {
44
+ let url;
45
+ try {
46
+ url = new URL(incoming.url || '/', `http://127.0.0.1:${port}`);
47
+ }
48
+ catch {
49
+ // A malformed request target (from any local process) is refused; the wait goes on.
50
+ response.statusCode = 400;
51
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
52
+ response.end('Bad request');
53
+ return;
54
+ }
55
+ if (incoming.method !== 'GET' || url.pathname !== '/callback') {
56
+ response.statusCode = 404;
57
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
58
+ response.end('Not found');
59
+ return;
60
+ }
61
+ const received = url.searchParams.get('code');
62
+ response.setHeader('content-type', 'text/html; charset=utf-8');
63
+ if (!received) {
64
+ response.statusCode = 400;
65
+ response.end(renderCliAuthBrowserPage('error'));
66
+ return;
67
+ }
68
+ response.statusCode = 200;
69
+ response.end(renderCliAuthBrowserPage('success'));
70
+ deliver(received);
71
+ });
72
+ server.once('error', (error) => {
73
+ resume(Effect.fail(error.code === 'EADDRINUSE'
74
+ ? new Error(`Port ${port} is already in use. Set ORIZU_AUTH_PORT to a different port (1024–65535) and retry.`)
75
+ : error));
76
+ });
77
+ server.listen(port, '127.0.0.1', () => resume(Effect.succeed({ server, code })));
78
+ }), ({ server }) => Effect.sync(() => {
79
+ server.close();
80
+ server.closeAllConnections();
81
+ }));
82
+ }
83
+ const waitForCode = (code) => Effect.callback(resume => {
84
+ void code.then(value => resume(Effect.succeed(value)));
85
+ });
86
+ /**
87
+ * Starts the person's browser on `url`. The command is detached and never
88
+ * killed: on Linux `xdg-open` can become the browser itself. If it can't be
89
+ * started or exits with a failure, the URL is printed to open by hand and the
90
+ * wait goes on.
91
+ */
92
+ function launchBrowser(url, dependencies) {
93
+ return Effect.sync(() => {
94
+ const command = process.platform === 'darwin'
95
+ ? { file: 'open', args: [url] }
96
+ : process.platform === 'win32'
97
+ ? { file: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }
98
+ : { file: 'xdg-open', args: [url] };
99
+ let toldByHand = false;
100
+ const openByHand = () => {
101
+ if (toldByHand)
102
+ return;
103
+ toldByHand = true;
104
+ dependencies.printProgress(`Could not open a browser. Open this URL to log in: ${dependencies.sanitizeTerminalText(url)}`);
105
+ };
106
+ const child = spawn(command.file, command.args, {
107
+ detached: true,
108
+ stdio: 'ignore',
109
+ windowsHide: true,
110
+ });
111
+ child.once('error', openByHand);
112
+ child.once('exit', status => { if (status !== 0)
113
+ openByHand(); });
114
+ child.unref();
115
+ });
116
+ }
117
+ function browserAuthorization({ baseUrl, codeChallenge, port }, dependencies) {
118
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
119
+ return Effect.scoped(Effect.gen(function* () {
120
+ const { code } = yield* loopback(port);
121
+ const response = yield* request(`${baseUrl}/api/cli/auth/start`, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ codeChallenge, redirectUri }),
125
+ });
126
+ if (!response.ok)
127
+ return yield* Effect.fail(new Error(`Failed to start login: ${yield* readText(response)}`));
128
+ const { authorizeUrl } = yield* parseJson(dependencies, response, 'CLI auth start');
129
+ const safeAuthorizeUrl = yield* Effect.try({ try: () => dependencies.validateBrowserUrl(authorizeUrl, baseUrl).href, catch: toError });
130
+ dependencies.printProgress(`Opening browser for login: ${dependencies.sanitizeTerminalText(safeAuthorizeUrl)}`);
131
+ yield* launchBrowser(safeAuthorizeUrl, dependencies);
132
+ return yield* waitForCode(code);
133
+ }));
134
+ }
135
+ // --- Headless flow ----------------------------------------------------------
136
+ export const POLL_INTERVAL_MS = 2_000;
137
+ const POLL_REQUEST_TIMEOUT_MS = 15_000;
138
+ const MAX_RETRY_DELAY_MS = 60_000;
139
+ const MAX_CONSECUTIVE_TRANSPORT_ERRORS = 3;
140
+ const HeadlessStart = Schema.Struct({
141
+ requestId: Schema.NonEmptyString,
142
+ pollToken: Schema.NonEmptyString,
143
+ expiresInSeconds: Schema.Finite,
144
+ });
145
+ const HeadlessPoll = Schema.Struct({
146
+ status: Schema.Literals(['pending', 'approved', 'expired']),
147
+ code: Schema.optionalKey(Schema.String),
148
+ });
149
+ function retryDelayMs(response) {
150
+ if (response.status !== 429)
151
+ return POLL_INTERVAL_MS;
152
+ const retryAfterHeader = response.headers.get('retry-after');
153
+ const retryAfterSeconds = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
154
+ return Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
155
+ ? Math.min(retryAfterSeconds * 1000, MAX_RETRY_DELAY_MS)
156
+ : POLL_INTERVAL_MS;
157
+ }
158
+ function headlessAuthorization({ baseUrl, codeChallenge }, dependencies) {
159
+ const sleep = (milliseconds) => dependencies.sleep
160
+ ? Effect.promise(() => dependencies.sleep(milliseconds))
161
+ : Effect.sleep(milliseconds);
162
+ return Effect.gen(function* () {
163
+ const startResponse = yield* request(`${baseUrl}/api/cli/auth/start`, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify({ codeChallenge, flow: 'headless' }),
167
+ }, POLL_REQUEST_TIMEOUT_MS);
168
+ if (!startResponse.ok)
169
+ return yield* Effect.fail(new Error(`Failed to start login: ${yield* readText(startResponse)}`));
170
+ const started = yield* parseJson(dependencies, startResponse, 'CLI headless auth start');
171
+ const authorizeUrl = yield* Effect.try({ try: () => dependencies.validateBrowserUrl(started.authorizeUrl, baseUrl).href, catch: toError });
172
+ const decodedStart = Schema.decodeUnknownResult(HeadlessStart)(started);
173
+ if (Result.isFailure(decodedStart)) {
174
+ return yield* Effect.fail(new Error('Server returned an invalid headless login response.'));
175
+ }
176
+ const { requestId, pollToken, expiresInSeconds } = decodedStart.success;
177
+ rememberProcessSecret(pollToken);
178
+ dependencies.printProgress(`Open this URL to log in: ${dependencies.sanitizeTerminalText(authorizeUrl)}`);
179
+ const expiresAt = Date.now() + (expiresInSeconds * 1000);
180
+ const sleepWithinDeadline = (delayMs) => {
181
+ const remainingMs = expiresAt - Date.now();
182
+ return remainingMs <= 0 ? Effect.void : sleep(Math.min(delayMs, remainingMs));
183
+ };
184
+ let consecutiveTransportErrors = 0;
185
+ const retryAfterTransportError = (error) => Effect.gen(function* () {
186
+ consecutiveTransportErrors += 1;
187
+ if (consecutiveTransportErrors >= MAX_CONSECUTIVE_TRANSPORT_ERRORS) {
188
+ return yield* Effect.fail(new Error(`Headless login could not reach the server after ${consecutiveTransportErrors} attempts: ${error.message}`));
189
+ }
190
+ yield* sleepWithinDeadline(POLL_INTERVAL_MS);
191
+ });
192
+ while (Date.now() < expiresAt) {
193
+ const remainingMs = expiresAt - Date.now();
194
+ const polled = yield* Effect.result(request(`${baseUrl}/api/cli/auth/poll`, {
195
+ method: 'POST',
196
+ headers: { 'Content-Type': 'application/json' },
197
+ body: JSON.stringify({ requestId, pollToken }),
198
+ }, Math.max(1, Math.min(POLL_REQUEST_TIMEOUT_MS, remainingMs))));
199
+ if (Result.isFailure(polled)) {
200
+ yield* retryAfterTransportError(polled.failure);
201
+ continue;
202
+ }
203
+ const pollResponse = polled.success;
204
+ if (!pollResponse.ok) {
205
+ if (pollResponse.status === 429) {
206
+ consecutiveTransportErrors = 0;
207
+ yield* sleepWithinDeadline(retryDelayMs(pollResponse));
208
+ continue;
209
+ }
210
+ if (pollResponse.status >= 500) {
211
+ const detail = yield* readText(pollResponse).pipe(Effect.orElseSucceed(() => 'response body unavailable'));
212
+ yield* retryAfterTransportError(new Error(`Server returned ${pollResponse.status}: ${detail}`));
213
+ continue;
214
+ }
215
+ return yield* Effect.fail(new Error(`Failed to poll login: ${yield* readText(pollResponse)}`));
216
+ }
217
+ const parsed = yield* Effect.result(parseJson(dependencies, pollResponse, 'CLI headless auth poll'));
218
+ if (Result.isFailure(parsed)) {
219
+ yield* retryAfterTransportError(parsed.failure);
220
+ continue;
221
+ }
222
+ consecutiveTransportErrors = 0;
223
+ const decoded = Schema.decodeUnknownResult(HeadlessPoll)(parsed.success);
224
+ if (Result.isFailure(decoded)) {
225
+ return yield* Effect.fail(new Error('Server returned an invalid headless login status.'));
226
+ }
227
+ const { status, code } = decoded.success;
228
+ if (status === 'approved' && code)
229
+ return rememberProcessSecret(code);
230
+ if (status === 'expired')
231
+ break;
232
+ if (status !== 'pending') {
233
+ return yield* Effect.fail(new Error('Server returned an invalid headless login status.'));
234
+ }
235
+ yield* sleepWithinDeadline(POLL_INTERVAL_MS);
236
+ }
237
+ return yield* Effect.fail(new Error('Headless login expired. Run `orizu login --headless` to try again.'));
238
+ });
239
+ }
240
+ // --- Exchange ---------------------------------------------------------------
241
+ const ExchangeReply = Schema.Struct({
242
+ apiKey: Schema.optionalKey(Schema.Unknown),
243
+ user: Schema.Struct({
244
+ id: Schema.String,
245
+ email: Schema.optionalKey(Schema.NullOr(Schema.String)),
246
+ }),
247
+ });
248
+ function exchange({ baseUrl, code, codeVerifier, redirectUri }, dependencies) {
249
+ return Effect.gen(function* () {
250
+ rememberProcessSecret(code);
251
+ const response = yield* request(`${baseUrl}/api/cli/auth/exchange`, {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({ code, codeVerifier, redirectUri }),
255
+ });
256
+ if (!response.ok)
257
+ return yield* Effect.fail(new Error(`Failed to exchange auth code: ${yield* readText(response)}`));
258
+ const data = yield* parseJson(dependencies, response, 'CLI auth exchange');
259
+ const decoded = Schema.decodeUnknownResult(ExchangeReply)(data);
260
+ const apiKey = Result.isSuccess(decoded) ? decoded.success.apiKey : data?.apiKey;
261
+ if (typeof apiKey !== 'string' || apiKey === '') {
262
+ return yield* Effect.fail(new Error('Server did not return an API key. Upgrade the Orizu server and run `orizu login` again.'));
263
+ }
264
+ if (Result.isFailure(decoded))
265
+ return yield* Effect.fail(new Error('Server returned an invalid login response.'));
266
+ const { user } = decoded.success;
267
+ return { apiKey, user: { id: user.id, email: user.email ?? null } };
268
+ });
269
+ }
270
+ // --- Entry ------------------------------------------------------------------
271
+ /** For tests: the headless wait alone, returning the authorization code. */
272
+ export function waitForHeadlessAuthorization(input, dependencies, signal) {
273
+ return run(headlessAuthorization(input, dependencies), signal);
274
+ }
275
+ /**
276
+ * Waits for the person to approve the login (in the browser, or by polling
277
+ * when headless), then exchanges the code. `callbackPort` undefined means
278
+ * headless. `signal` stops it, closing the loopback server.
279
+ */
280
+ export function completeLogin(input, dependencies, signal) {
281
+ const { baseUrl, codeVerifier, codeChallenge, callbackPort } = input;
282
+ const authorization = callbackPort === undefined
283
+ ? headlessAuthorization({ baseUrl, codeChallenge }, dependencies)
284
+ : browserAuthorization({ baseUrl, codeChallenge, port: callbackPort }, dependencies);
285
+ const redirectUri = callbackPort === undefined ? undefined : `http://127.0.0.1:${callbackPort}/callback`;
286
+ return run(Effect.flatMap(authorization, code => exchange({ baseUrl, code, codeVerifier, redirectUri }, dependencies)), signal);
287
+ }
288
+ async function run(program, signal) {
289
+ const exit = await Effect.runPromiseExit(program, signal ? { signal } : undefined);
290
+ if (Exit.isSuccess(exit))
291
+ return exit.value;
292
+ if (signal?.aborted && Cause.hasInterruptsOnly(exit.cause))
293
+ throw signal.reason;
294
+ throw Cause.squash(exit.cause);
295
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"logout-diagnostic.d.ts","sourceRoot":"","sources":["../src/logout-diagnostic.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,uBAAuB,MAAM,CAAA;AAQ1C,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,OAAO,CAAC,MAAM,CAAC,CAYjB;AAED,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,CAMR"}
1
+ {"version":3,"file":"logout-diagnostic.d.ts","sourceRoot":"","sources":["../src/logout-diagnostic.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,uBAAuB,MAAM,CAAA;AAS1C,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,OAAO,CAAC,MAAM,CAAC,CAYjB;AAED,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,CAMR"}
@@ -2,7 +2,8 @@ import { sanitizeHumanInlineText, sanitizeTerminalText } from './json-response.j
2
2
  import { redactSecrets } from './secret-redaction.js';
3
3
  export const LOGOUT_DIAGNOSTIC_LIMIT = 180;
4
4
  function boundedLogoutDiagnostic(value, secrets) {
5
- const bounded = sanitizeTerminalText(redactSecrets(value, { secrets }))
5
+ // Scrubbed again after control characters are stripped, so one inside a secret can't hide it.
6
+ const bounded = redactSecrets(sanitizeTerminalText(redactSecrets(value, { secrets })), { secrets })
6
7
  .slice(0, LOGOUT_DIAGNOSTIC_LIMIT);
7
8
  return sanitizeHumanInlineText(sanitizeTerminalText, bounded).slice(0, LOGOUT_DIAGNOSTIC_LIMIT);
8
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"manifests-cli.d.ts","sourceRoot":"","sources":["../src/manifests-cli.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AAEhF,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7B,OAAO,CAAC,EAAE,UAAU,CAAA;IACpB,kBAAkB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAC5D,gEAAgE;IAChE,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AA0PD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkG9F"}
1
+ {"version":3,"file":"manifests-cli.d.ts","sourceRoot":"","sources":["../src/manifests-cli.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AAEhF,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7B,OAAO,CAAC,EAAE,UAAU,CAAA;IACpB,kBAAkB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAC5D,gEAAgE;IAChE,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AA0PD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CA6E9F"}
@@ -264,6 +264,8 @@ export async function manifestsCommand(args, io) {
264
264
  throw new Error(`Usage: orizu manifests ${subcommand} <id> [--json]`);
265
265
  }
266
266
  const id = await resolveManifestId(io, args, idArg);
267
+ if (subcommand === 'reject')
268
+ return await rejectManifest(io, id);
267
269
  const postAction = () => fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`, {
268
270
  method: 'POST',
269
271
  headers: { 'Content-Type': 'application/json' },
@@ -289,30 +291,42 @@ export async function manifestsCommand(args, io) {
289
291
  }
290
292
  const data = await requireOk(response, `Manifest ${subcommand}`);
291
293
  const manifest = (data.manifest ?? {});
292
- const lines = [manifestLine(manifest)];
293
- // Reject reaps the session branch best-effort; a failed reap must never be
294
- // silent (ALI-1043) — surface it with the exact cleanup command.
295
- if (subcommand === 'reject' && manifest.actionType === 'repo_merge') {
296
- const outcome = asRecord(manifest.outcome);
297
- const branch = asRecord(manifest.proposedState).branch;
298
- // Terse server-provided reason for a failed reap (ALI-1043) — shown so
299
- // the operator knows WHY manual cleanup is needed.
300
- const cause = typeof outcome.branchDeleteError === 'string' && outcome.branchDeleteError.length > 0
301
- ? ` (${outcome.branchDeleteError})`
302
- : '';
303
- if (outcome.branchDeleted === true) {
304
- lines.push('session branch deleted');
305
- }
306
- else if (typeof branch === 'string' && branch.length > 0) {
307
- lines.push(`warning: the session branch could not be deleted${cause} — remove it with: git push origin --delete ${branch}`);
308
- }
309
- else {
310
- lines.push(`warning: the session branch could not be deleted${cause} (branch name unavailable)`);
311
- }
312
- }
313
- emit(io, { manifest }, lines.join('\n'));
294
+ emit(io, { manifest }, manifestLine(manifest));
314
295
  return 0;
315
296
  }
316
297
  io.print('Usage: orizu manifests <list|show|approve|reject|apply|revert> ... [--json]');
317
298
  return 1;
318
299
  }
300
+ // ORI-2726: one write with a deadline and Ctrl-C handling; a lost answer says
301
+ // the manifest may already be rejected instead of printing "(unknown)".
302
+ async function rejectManifest(io, id) {
303
+ const { manifestReject } = await import('./cli-write-commands.js');
304
+ const { sendOneRequestWrite } = await import('./one-request-write.js');
305
+ const sent = await sendOneRequestWrite({ ...manifestReject(id), json: io.json, print: io.print, ...(io.fetcher ? { fetcher: io.fetcher } : {}) });
306
+ if (sent.kind === 'interrupted')
307
+ return sent.exitCode;
308
+ const manifest = sent.reply.manifest;
309
+ const lines = [manifestLine(manifest)];
310
+ // Reject reaps the session branch best-effort; a failed reap must never be
311
+ // silent (ALI-1043) — surface it with the exact cleanup command.
312
+ if (manifest.actionType === 'repo_merge') {
313
+ const outcome = asRecord(manifest.outcome);
314
+ const branch = asRecord(manifest.proposedState).branch;
315
+ // Terse server-provided reason for a failed reap (ALI-1043) — shown so
316
+ // the operator knows WHY manual cleanup is needed.
317
+ const cause = typeof outcome.branchDeleteError === 'string' && outcome.branchDeleteError.length > 0
318
+ ? ` (${outcome.branchDeleteError})`
319
+ : '';
320
+ if (outcome.branchDeleted === true) {
321
+ lines.push('session branch deleted');
322
+ }
323
+ else if (typeof branch === 'string' && branch.length > 0) {
324
+ lines.push(`warning: the session branch could not be deleted${cause} — remove it with: git push origin --delete ${branch}`);
325
+ }
326
+ else {
327
+ lines.push(`warning: the session branch could not be deleted${cause} (branch name unavailable)`);
328
+ }
329
+ }
330
+ emit(io, { manifest }, lines.join('\n'));
331
+ return 0;
332
+ }
@@ -0,0 +1,59 @@
1
+ import { Schema } from 'effect';
2
+ export interface MayAlreadyBeSaved {
3
+ readonly mayAlreadyBeSaved: true;
4
+ readonly advice: string;
5
+ }
6
+ /** `The <thing> may already be <done>: <advice>.` */
7
+ export declare function mayAlreadyBeSavedLine(thing: string, done: string, advice: string): string;
8
+ /** The fields a `--json` failure object gains. */
9
+ export declare function mayAlreadyBeSavedFields(advice: string): MayAlreadyBeSaved;
10
+ /** What to tell a person when a write may have saved but no clear answer came back. */
11
+ export interface MayAlreadyBeSavedNote {
12
+ /** For example "task". */
13
+ thing: string;
14
+ /** For example "created". */
15
+ done: string;
16
+ /** What to do next, for example "check `orizu tasks list` before creating it again". */
17
+ advice: string;
18
+ }
19
+ export interface OneRequestWrite<A> {
20
+ /** For example "update task": every failure starts `Failed to update task:`. */
21
+ label: string;
22
+ /** For example "Task update": Ctrl-C prints `Task update interrupted.`. */
23
+ name: string;
24
+ path: string;
25
+ method: 'POST' | 'PATCH' | 'DELETE';
26
+ body?: unknown;
27
+ reply: Schema.Decoder<A>;
28
+ mayAlreadyBeSaved: MayAlreadyBeSavedNote;
29
+ /**
30
+ * The message and `--json` object for a reply that is the app's own
31
+ * `{ error }`, when the command words it itself (task create).
32
+ */
33
+ describeError?: (response: Response) => Promise<{
34
+ message: string;
35
+ json: {
36
+ error: string;
37
+ } & Record<string, unknown>;
38
+ }>;
39
+ json: boolean;
40
+ print: (line: string) => void;
41
+ /** Stands in for `authedFetch` in tests; the request counts as sent once it is called. */
42
+ fetcher?: (path: string, init: RequestInit) => Promise<Response>;
43
+ }
44
+ export type OneRequestWriteOutcome<A> = {
45
+ kind: 'saved';
46
+ reply: A;
47
+ } | {
48
+ kind: 'interrupted';
49
+ exitCode: 130 | 143;
50
+ };
51
+ /**
52
+ * Sends one write. Ctrl-C or SIGTERM stops the wait and returns `interrupted`
53
+ * with the shell's usual exit code; the server may still finish on its own, so
54
+ * once the request was sent the person is told what may be saved. Any other
55
+ * failure throws, adding that line when the outcome is unknown. With `json`,
56
+ * every failure also prints one object on stdout.
57
+ */
58
+ export declare function sendOneRequestWrite<A>(write: OneRequestWrite<A>): Promise<OneRequestWriteOutcome<A>>;
59
+ //# sourceMappingURL=one-request-write.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"one-request-write.d.ts","sourceRoot":"","sources":["../src/one-request-write.ts"],"names":[],"mappings":"AAAA,OAAO,EAA+B,MAAM,EAAE,MAAM,QAAQ,CAAA;AAgB5D,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,iBAAiB,EAAE,IAAI,CAAA;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED,qDAAqD;AACrD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEzF;AAED,kDAAkD;AAClD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,CAEzE;AAED,uFAAuF;AACvF,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,wFAAwF;IACxF,MAAM,EAAE,MAAM,CAAA;CACf;AAID,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAA;IACb,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAA;IACnC,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,KAAK,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAA;IACxB,iBAAiB,EAAE,qBAAqB,CAAA;IACxC;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAA;IACvH,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7B,0FAA0F;IAC1F,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CACjE;AAED,MAAM,MAAM,sBAAsB,CAAC,CAAC,IAChC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC3B;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,EAAE,GAAG,GAAG,GAAG,CAAA;CAAE,CAAA;AAyFhD;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAAE,KAAK,EAAE,eAAe,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,CA6B1G"}