@vincentt-xr/cli 0.21.0 → 0.23.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/MIGRATION.md +26 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/index.js +50 -8
- package/dist/cli/usage-text.js +10 -1
- package/dist/cli/verb-surface.d.ts +2 -1
- package/dist/cli/verb-surface.js +5 -0
- package/dist/dx/command.d.ts +74 -18
- package/dist/dx/command.js +137 -86
- package/dist/dx/consent.d.ts +25 -6
- package/dist/dx/consent.js +58 -25
- package/dist/dx/enablement.d.ts +63 -11
- package/dist/dx/enablement.js +93 -21
- package/dist/dx/journal.d.ts +3 -0
- package/dist/dx/journal.js +6 -1
- package/dist/dx/report.d.ts +4 -24
- package/dist/dx/report.js +32 -25
- package/dist/dx/skeleton.js +3 -0
- package/dist/editor/app.d.ts +61 -0
- package/dist/editor/app.js +180 -0
- package/dist/editor/copy.d.ts +22 -0
- package/dist/editor/copy.js +95 -0
- package/dist/editor/handoffs.d.ts +12 -0
- package/dist/editor/handoffs.js +93 -0
- package/dist/editor/launcher.d.ts +86 -0
- package/dist/editor/launcher.js +255 -0
- package/dist/editor/localFiles.d.ts +31 -0
- package/dist/editor/localFiles.js +100 -0
- package/dist/editor/reads.d.ts +47 -0
- package/dist/editor/reads.js +269 -0
- package/dist/editor/text.d.ts +7 -0
- package/dist/editor/text.js +58 -0
- package/dist/editor/tools.d.ts +5 -0
- package/dist/editor/tools.js +20 -0
- package/dist/lib/version.d.ts +28 -2
- package/dist/lib/version.js +30 -3
- package/dist/preview/command.js +2 -0
- package/dist/preview/copy.d.ts +5 -0
- package/dist/preview/copy.js +15 -0
- package/dist/preview/runner.d.ts +6 -0
- package/dist/preview/runner.js +1 -0
- package/dist/publish/command.js +5 -2
- package/dist/publish/copy.d.ts +6 -0
- package/dist/publish/copy.js +27 -6
- package/dist/publish/manifest.d.ts +11 -2
- package/dist/publish/manifest.js +45 -9
- package/dist/publish/upload.d.ts +10 -0
- package/dist/publish/upload.js +23 -0
- package/dist/shared/config.d.ts +6 -6
- package/dist/tunnel/client.d.ts +11 -3
- package/dist/tunnel/client.js +11 -8
- package/package.json +2 -2
package/dist/dx/command.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { findClusters, rankClusters } from "./clusters.js";
|
|
2
|
-
import { enablementLine } from "./enablement.js";
|
|
2
|
+
import { dxConsentJson, enablementLine, resolveConsentView } from "./enablement.js";
|
|
3
3
|
import { journalEnabled, journalPath, readJournal } from "./journal.js";
|
|
4
4
|
import { buildReport, renderPayload } from "./send.js";
|
|
5
5
|
import { resolveBuiltWith } from "../platform/resolve.js";
|
|
6
6
|
import { EXIT } from "../lib/exitCodes.js";
|
|
7
|
+
import { isAuthRefused } from "../shared/authError.js";
|
|
7
8
|
import { UpgradeRequiredError } from "../shared/upgradeError.js";
|
|
8
9
|
const MINUTE_MS = 60 * 1000;
|
|
9
10
|
const HOUR_MS = 60 * MINUTE_MS;
|
|
@@ -121,19 +122,27 @@ export function dxCommand(deps, options) {
|
|
|
121
122
|
return 0;
|
|
122
123
|
}
|
|
123
124
|
const clusters = rankClusters(findClusters(entries));
|
|
124
|
-
const
|
|
125
|
+
const local = deps.enablement?.() ?? { enabled: false };
|
|
126
|
+
const view = deps.consentView?.() ?? {
|
|
127
|
+
shown: local,
|
|
128
|
+
local,
|
|
129
|
+
checked: "machine",
|
|
130
|
+
signedIn: false,
|
|
131
|
+
};
|
|
125
132
|
if (options.json) {
|
|
126
133
|
// The JSON object carries enablement as FIELDS because --json is the path an
|
|
127
134
|
// AGENT reads, and an agent that can send unattended should be able to see
|
|
128
|
-
// that it can.
|
|
135
|
+
// that it can. `dxSendEnabled*` stay this machine's flag; `dxConsent` is the
|
|
136
|
+
// account-first answer and says where it was checked.
|
|
129
137
|
deps.print(JSON.stringify({
|
|
130
138
|
window: options.window.label,
|
|
131
139
|
runs: entries.length,
|
|
132
140
|
clusters,
|
|
133
|
-
dxSendEnabled:
|
|
134
|
-
...(
|
|
135
|
-
? { dxSendEnabledAt:
|
|
141
|
+
dxSendEnabled: view.local.enabled,
|
|
142
|
+
...(view.local.enabled && view.local.at !== undefined
|
|
143
|
+
? { dxSendEnabledAt: view.local.at }
|
|
136
144
|
: {}),
|
|
145
|
+
dxConsent: dxConsentJson(view),
|
|
137
146
|
}, null, 2));
|
|
138
147
|
return 0;
|
|
139
148
|
}
|
|
@@ -163,13 +172,13 @@ export function dxCommand(deps, options) {
|
|
|
163
172
|
// Beside it, and ONLY when enabled. Enablement is durable and has no expiry, so
|
|
164
173
|
// this line is the mitigation rather than a status decoration; printing an
|
|
165
174
|
// `off` counterpart on every run is how it would get skimmed past.
|
|
166
|
-
const line = enablementLine(
|
|
175
|
+
const line = enablementLine(view.shown, deps.now());
|
|
167
176
|
if (line !== undefined)
|
|
168
177
|
deps.print(line);
|
|
169
178
|
return 0;
|
|
170
179
|
}
|
|
171
180
|
/** Wire the command to the real journal. */
|
|
172
|
-
export function runDx(args, print, enablement = () => ({ enabled: false })) {
|
|
181
|
+
export function runDx(args, print, enablement = () => ({ enabled: false }), consentView) {
|
|
173
182
|
return dxCommand({
|
|
174
183
|
entries: (window) => readJournal(window),
|
|
175
184
|
print,
|
|
@@ -177,6 +186,7 @@ export function runDx(args, print, enablement = () => ({ enabled: false })) {
|
|
|
177
186
|
enabled: () => journalEnabled(),
|
|
178
187
|
path: journalPath,
|
|
179
188
|
enablement,
|
|
189
|
+
consentView,
|
|
180
190
|
}, parseDxOptions(args));
|
|
181
191
|
}
|
|
182
192
|
/** A date a human can read, for the sunset refusal. */
|
|
@@ -187,11 +197,13 @@ function sunsetDate(sunset) {
|
|
|
187
197
|
* `vincentt dx enable` — grant durable permission to send DX reports without asking
|
|
188
198
|
* first.
|
|
189
199
|
*
|
|
190
|
-
* TWO PATHS, and which one runs is decided by whether there is a
|
|
200
|
+
* TWO PATHS to ONE account grant, and which one runs is decided by whether there is a
|
|
201
|
+
* terminal to ask in:
|
|
191
202
|
*
|
|
192
|
-
* - At a TTY: the
|
|
193
|
-
*
|
|
194
|
-
*
|
|
203
|
+
* - At a TTY: the terminal prompt. A person is already here and can read the
|
|
204
|
+
* disclosure where they stand; sending them to a browser would be ceremony. On `y`
|
|
205
|
+
* the CLI POSTs /me/dx-consent with the PAT, and writes the local flag only after
|
|
206
|
+
* the account recorded the grant.
|
|
195
207
|
* - With no TTY: the BROWSER path. This is the case the feature exists for — a
|
|
196
208
|
* creator driving the CLI through a coding agent has no terminal, so the old code
|
|
197
209
|
* refused and the flow dead-ended. It now opens the console, where a human can
|
|
@@ -223,44 +235,87 @@ export async function dxEnableCommand(deps, sunset) {
|
|
|
223
235
|
// signed out cannot complete either path, and reading a consent disclosure before
|
|
224
236
|
// being told so is wasted attention.
|
|
225
237
|
const auth = await deps.auth?.();
|
|
226
|
-
if (auth === undefined)
|
|
227
|
-
deps
|
|
228
|
-
deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
|
|
229
|
-
deps.print(" this machine is not signed in. Nothing was changed.");
|
|
230
|
-
deps.print("");
|
|
231
|
-
deps.print(" Run `vincentt login` first.");
|
|
232
|
-
deps.print("");
|
|
233
|
-
return EXIT.NOT_AUTHENTICATED;
|
|
234
|
-
}
|
|
238
|
+
if (auth === undefined)
|
|
239
|
+
return signedOutRefusal(deps);
|
|
235
240
|
if (!deps.interactive()) {
|
|
236
241
|
return enableViaBrowser(deps);
|
|
237
242
|
}
|
|
238
243
|
// What is being granted, INCLUDING the note risk. The note is the part a
|
|
239
244
|
// creator does not picture: enablement is what lets an agent compose prose they
|
|
240
245
|
// have not read and put it on the wire.
|
|
246
|
+
// The wording names the ACCOUNT because the answer reaches every machine on it; a
|
|
247
|
+
// prompt that said "this machine" would ask for less than it grants.
|
|
241
248
|
deps.print("");
|
|
242
249
|
deps.print(" Unattended DX submission");
|
|
243
250
|
deps.print("");
|
|
244
|
-
deps.print(" Your agent will be able to send friction reports
|
|
245
|
-
deps.print("
|
|
246
|
-
deps.print(" An agent may compose a note you have not read.");
|
|
251
|
+
deps.print(" Your agent will be able to send friction reports without asking you first,");
|
|
252
|
+
deps.print(" from any machine signed in to your Vincentt account. A report includes a note,");
|
|
253
|
+
deps.print(" which is sent exactly as written. An agent may compose a note you have not read.");
|
|
247
254
|
deps.print("");
|
|
248
|
-
deps.print(" This stays on until you run `vincentt dx disable
|
|
255
|
+
deps.print(" This stays on until you run `vincentt dx disable` or turn it off in the console.");
|
|
249
256
|
deps.print("");
|
|
250
|
-
if (!(await deps.confirm("
|
|
257
|
+
if (!(await deps.confirm("Allow unattended reports on your account?"))) {
|
|
251
258
|
deps.print("");
|
|
252
259
|
deps.print(" Not enabled.");
|
|
253
260
|
deps.print("");
|
|
254
261
|
return 0;
|
|
255
262
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
263
|
+
return grantAtTerminal(deps, auth);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The terminal grant: the account first, then this machine's copy.
|
|
267
|
+
*
|
|
268
|
+
* ORDER IS THE PROPERTY. A local flag written before the account recorded the grant is a
|
|
269
|
+
* machine-only grant, which is exactly what this path no longer makes; so every failure
|
|
270
|
+
* arm returns before `save` and leaves config.json untouched. A 426 or 401 propagates,
|
|
271
|
+
* so the caller renders the shared version or sign-in refusal.
|
|
272
|
+
*
|
|
273
|
+
* The local date is the ACCOUNT's, so a re-grant re-dates both and the age always
|
|
274
|
+
* reports from the latest grant.
|
|
275
|
+
*/
|
|
276
|
+
async function grantAtTerminal(deps, auth) {
|
|
277
|
+
let result;
|
|
278
|
+
try {
|
|
279
|
+
result =
|
|
280
|
+
deps.grantAccountConsent === undefined
|
|
281
|
+
? { kind: "unreachable" }
|
|
282
|
+
: await deps.grantAccountConsent(auth);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
if (err instanceof UpgradeRequiredError)
|
|
286
|
+
throw err;
|
|
287
|
+
if (isAuthRefused(err))
|
|
288
|
+
return signedOutRefusal(deps);
|
|
289
|
+
result = { kind: "unreachable" };
|
|
290
|
+
}
|
|
291
|
+
if (result.kind !== "granted" || result.enablement.at === undefined) {
|
|
292
|
+
deps.print("");
|
|
293
|
+
deps.print(` ✗ ${GRANT_FAILURE[result.kind === "granted" ? "not_granted" : result.kind]}`);
|
|
294
|
+
deps.print(" Nothing was changed.");
|
|
295
|
+
deps.print("");
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
await deps.save({ at: result.enablement.at });
|
|
259
299
|
deps.print("");
|
|
260
|
-
deps.print(" Enabled. Reports may now be sent without a prompt
|
|
300
|
+
deps.print(" Enabled for your account. Reports may now be sent without a prompt from any");
|
|
301
|
+
deps.print(" machine signed in to it.");
|
|
261
302
|
deps.print("");
|
|
262
303
|
return 0;
|
|
263
304
|
}
|
|
305
|
+
const GRANT_FAILURE = {
|
|
306
|
+
not_granted: "Vincentt did not record your consent.",
|
|
307
|
+
rate_limited: "Too many consent changes. Wait a minute and try again.",
|
|
308
|
+
unreachable: "Could not reach Vincentt to record your consent.",
|
|
309
|
+
};
|
|
310
|
+
function signedOutRefusal(deps) {
|
|
311
|
+
deps.print("");
|
|
312
|
+
deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
|
|
313
|
+
deps.print(" this machine is not signed in. Nothing was changed.");
|
|
314
|
+
deps.print("");
|
|
315
|
+
deps.print(" Run `vincentt login` first.");
|
|
316
|
+
deps.print("");
|
|
317
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
318
|
+
}
|
|
264
319
|
/**
|
|
265
320
|
* The no-TTY arm: hand the decision to a human in a browser.
|
|
266
321
|
*
|
|
@@ -325,8 +380,8 @@ async function enableViaBrowser(deps) {
|
|
|
325
380
|
* Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
|
|
326
381
|
* with. Both halves are needed: a creator who runs `disable` means "stop sending", and
|
|
327
382
|
* clearing only the local flag would leave the account grant standing, so the very next
|
|
328
|
-
* `--send` would be authorized by
|
|
329
|
-
* worked.
|
|
383
|
+
* `--send` would be authorized by the account and the command would appear not to
|
|
384
|
+
* have worked.
|
|
330
385
|
*
|
|
331
386
|
* ORDER IS DELIBERATE: local first, network second. The local clear is the one that
|
|
332
387
|
* cannot fail and the one that stops THIS machine, which is the machine the creator is
|
|
@@ -371,6 +426,41 @@ export async function dxDisableCommand(deps) {
|
|
|
371
426
|
deps.print("");
|
|
372
427
|
return 0;
|
|
373
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* The one consent gate both send paths call (f7 Scenario 3), checked in order:
|
|
431
|
+
*
|
|
432
|
+
* 1. an interactive session → ask, then send. No /me read at all, so a creator at their
|
|
433
|
+
* own terminal is never blocked by an API being slow or down.
|
|
434
|
+
* 2. signed in and GET /me answers: a strict grant sends (source: account); any other
|
|
435
|
+
* 200 clears this machine's flag and refuses.
|
|
436
|
+
* 3. /me cannot be read and this machine's flag is valid → send (source: machine).
|
|
437
|
+
*
|
|
438
|
+
* An unreadable /me never authorizes by itself; it only lets an existing local flag stand.
|
|
439
|
+
*/
|
|
440
|
+
export async function gateSend(deps, auth) {
|
|
441
|
+
if (deps.interactive())
|
|
442
|
+
return { arm: "interactive" };
|
|
443
|
+
const view = await resolveConsentView({
|
|
444
|
+
auth,
|
|
445
|
+
readAccount: deps.accountConsent,
|
|
446
|
+
local: () => deps.enablement?.() ?? { enabled: false },
|
|
447
|
+
clearLocal: () => deps.clearEnablement?.() ?? Promise.resolve(),
|
|
448
|
+
});
|
|
449
|
+
return view.shown.enabled
|
|
450
|
+
? { arm: "unattended", enablement: view.shown }
|
|
451
|
+
: { arm: "refused" };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The gate's refusal. It NAMES the enabling command: a refusal is an answer to a
|
|
455
|
+
* command the caller typed, which makes it SOLICITED output — the prohibition is on
|
|
456
|
+
* UNBIDDEN output instructing a caller. A refusal naming nothing leaves a human at
|
|
457
|
+
* exit 1 with no path forward, for no security gain.
|
|
458
|
+
*/
|
|
459
|
+
export const SEND_REFUSAL_LINES = [
|
|
460
|
+
"Sending a report needs consent that has not been given on this account.",
|
|
461
|
+
"`vincentt dx enable` opens the Vincentt console so you can allow it.",
|
|
462
|
+
"It takes one click, and it works from here.",
|
|
463
|
+
];
|
|
374
464
|
/**
|
|
375
465
|
* `vincentt dx --send`.
|
|
376
466
|
*
|
|
@@ -378,7 +468,7 @@ export async function dxDisableCommand(deps) {
|
|
|
378
468
|
* who cannot see what leaves their machine before it leaves has not agreed to
|
|
379
469
|
* anything:
|
|
380
470
|
*
|
|
381
|
-
* friction →
|
|
471
|
+
* friction → auth → GATE → RESOLVE → build → SHOW → ask → post
|
|
382
472
|
*/
|
|
383
473
|
export async function dxSendCommand(deps, options) {
|
|
384
474
|
if (!deps.enabled()) {
|
|
@@ -411,59 +501,20 @@ export async function dxSendCommand(deps, options) {
|
|
|
411
501
|
// the check sits on the act being authorized rather than on a flag that only
|
|
412
502
|
// modifies how it is confirmed.
|
|
413
503
|
//
|
|
414
|
-
// The
|
|
415
|
-
//
|
|
416
|
-
//
|
|
504
|
+
// The unattended path is a prior deliberate act — a person granted it on the
|
|
505
|
+
// account, at a terminal or in the console — rather than a per-send answer, which
|
|
506
|
+
// is what makes it consent at all. See gateSend for the order.
|
|
417
507
|
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// arm cannot be reached at all by the creator this feature is for: driving the CLI
|
|
421
|
-
// through a coding agent, there is no terminal to run `dx enable` in.
|
|
422
|
-
const localEnablement = deps.enablement?.() ?? { enabled: false };
|
|
423
|
-
const interactive = deps.interactive();
|
|
424
|
-
// Auth is resolved BEFORE the gate now, because arm (c) needs a credential to read
|
|
425
|
-
// /me. The signed-out refusal still comes AFTER the gate, so a caller with neither a
|
|
508
|
+
// Auth is resolved BEFORE the gate, because the account read needs a credential.
|
|
509
|
+
// The signed-out refusal still comes AFTER the gate, so a caller with neither a
|
|
426
510
|
// terminal nor consent gets the refusal about consent — the thing they need to fix —
|
|
427
511
|
// rather than being sent to `login` first and only then told that logging in was not
|
|
428
512
|
// sufficient.
|
|
429
513
|
const auth = await deps.auth();
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
let accountEnablement = { enabled: false };
|
|
435
|
-
if (!interactive && !localEnablement.enabled && auth !== undefined) {
|
|
436
|
-
try {
|
|
437
|
-
accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
|
|
438
|
-
}
|
|
439
|
-
catch {
|
|
440
|
-
// A NETWORK FAILURE FALLS BACK TO THE OTHER TWO ARMS, and since we only get here
|
|
441
|
-
// when both have already said no, that means refusing. This is the fail-CLOSED
|
|
442
|
-
// direction: an unreachable API can never authorize a send.
|
|
443
|
-
//
|
|
444
|
-
// The swallow is deliberate and is the whole point of the arm's placement — a
|
|
445
|
-
// /me that is down must never BLOCK a send that (a) or (b) already allows, and it
|
|
446
|
-
// cannot, because those paths never reach this call at all.
|
|
447
|
-
accountEnablement = { enabled: false };
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// The grant that authorizes this send, and the one whose age gets reported. The local
|
|
451
|
-
// flag wins when both exist: it is the more specific statement (this machine) and it
|
|
452
|
-
// is the one `dx disable` can always clear.
|
|
453
|
-
const enablement = localEnablement.enabled
|
|
454
|
-
? localEnablement
|
|
455
|
-
: accountEnablement;
|
|
456
|
-
if (!interactive && !enablement.enabled) {
|
|
457
|
-
// This refusal NAMES the enabling command. A refusal is an answer to a
|
|
458
|
-
// command the caller typed, which makes it SOLICITED output — the
|
|
459
|
-
// prohibition is on UNBIDDEN output instructing a caller (the nudge names
|
|
460
|
-
// neither `enable` nor `--send`, and still must not). A refusal naming
|
|
461
|
-
// nothing satisfies the letter of that rule and leaves a human at exit 1
|
|
462
|
-
// with no path forward, for no security gain: guessability was never the
|
|
463
|
-
// threat.
|
|
464
|
-
deps.print("Sending a report needs consent that has not been given on this account.");
|
|
465
|
-
deps.print("`vincentt dx enable` opens the Vincentt console so you can allow it.");
|
|
466
|
-
deps.print("It takes one click, and it works from here.");
|
|
514
|
+
const gate = await gateSend(deps, auth);
|
|
515
|
+
if (gate.arm === "refused") {
|
|
516
|
+
for (const l of SEND_REFUSAL_LINES)
|
|
517
|
+
deps.print(l);
|
|
467
518
|
return 1;
|
|
468
519
|
}
|
|
469
520
|
if (auth === undefined) {
|
|
@@ -492,9 +543,9 @@ export async function dxSendCommand(deps, options) {
|
|
|
492
543
|
const report = buildReport(clusters, deps.cliVersion(), options.note, builtWith);
|
|
493
544
|
// Above the payload, on the unattended path only: name that this is enabled,
|
|
494
545
|
// how old the grant is, WHERE it was granted, and that nothing is going to ask.
|
|
495
|
-
const unattended =
|
|
496
|
-
if (unattended) {
|
|
497
|
-
const line = enablementLine(enablement, deps.now());
|
|
546
|
+
const unattended = gate.arm === "unattended";
|
|
547
|
+
if (gate.arm === "unattended") {
|
|
548
|
+
const line = enablementLine(gate.enablement, deps.now());
|
|
498
549
|
if (line !== undefined) {
|
|
499
550
|
deps.print("");
|
|
500
551
|
deps.print(`${line}. Not asking.`);
|
|
@@ -512,8 +563,8 @@ export async function dxSendCommand(deps, options) {
|
|
|
512
563
|
for (const l of renderPayload(report))
|
|
513
564
|
deps.print(l);
|
|
514
565
|
deps.print("");
|
|
515
|
-
// The
|
|
516
|
-
// and is inert without
|
|
566
|
+
// The unattended path does not ask. --yes still means only "skip the question"
|
|
567
|
+
// and is inert without a grant.
|
|
517
568
|
if (!unattended && !options.yes && !(await deps.confirm("Send this report?"))) {
|
|
518
569
|
deps.print("Not sent.");
|
|
519
570
|
return 0;
|
package/dist/dx/consent.d.ts
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
|
-
import { type Enablement } from "./enablement.js";
|
|
1
|
+
import { type AccountRead, type Enablement } from "./enablement.js";
|
|
2
|
+
/** POST /me/dx-consent, sorted into the outcomes `dx enable` answers differently. */
|
|
3
|
+
export type GrantResult = {
|
|
4
|
+
kind: "granted";
|
|
5
|
+
enablement: Enablement;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "not_granted";
|
|
8
|
+
} | {
|
|
9
|
+
kind: "rate_limited";
|
|
10
|
+
} | {
|
|
11
|
+
kind: "unreachable";
|
|
12
|
+
};
|
|
2
13
|
/**
|
|
3
14
|
* The account-consent seam.
|
|
4
15
|
*
|
|
5
|
-
*
|
|
16
|
+
* Both reads return parsed results rather than the raw envelope so the strict
|
|
6
17
|
* `=== true` interpretation happens in exactly one place and cannot be re-derived
|
|
7
18
|
* loosely by a second caller.
|
|
8
19
|
*/
|
|
9
20
|
export interface ConsentBackend {
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
/**
|
|
22
|
+
* GET /me → the account's consent, read strictly. A 426, a 5xx, a network error or a
|
|
23
|
+
* timeout is `unreadable`: the CLI floor refuses /me while reports stay floor-exempt,
|
|
24
|
+
* so a below-floor CLI must fall back to its local flag rather than stop. A 401 still
|
|
25
|
+
* rejects with the shared sign-in error.
|
|
26
|
+
*/
|
|
27
|
+
read(apiUrl: string, pat: string, now: number, timeoutMs?: number): Promise<AccountRead>;
|
|
28
|
+
/**
|
|
29
|
+
* POST /me/dx-consent — grant. A re-grant refreshes the timestamp. Rejects only with
|
|
30
|
+
* the version refusal (426) or the sign-in error (401); every other outcome is a result.
|
|
31
|
+
*/
|
|
32
|
+
grant(apiUrl: string, pat: string, now: number): Promise<GrantResult>;
|
|
14
33
|
/** DELETE /me/dx-consent — revoke. Never fails on already-revoked. */
|
|
15
34
|
revoke(apiUrl: string, pat: string): Promise<void>;
|
|
16
35
|
}
|
package/dist/dx/consent.js
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The ACCOUNT
|
|
2
|
+
* The ACCOUNT grant for unattended sends: the authority, which the local flag caches.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* The grant is PER-ACCOUNT, not per-machine: one decision covers every machine the
|
|
10
|
-
* creator signs in on. That is a wider blast radius than the local flag, which is why
|
|
11
|
-
* the console page carries the full disclosure (including that an agent may compose a
|
|
12
|
-
* note the user has not read) and why the visibility line names the console as the
|
|
13
|
-
* source — a creator who wants it off needs to know that clearing a local flag is not
|
|
14
|
-
* enough.
|
|
4
|
+
* A person grants it at a terminal (`dx enable` POSTs /me/dx-consent with the PAT) or,
|
|
5
|
+
* with no terminal, on the console page the CLI opens. Either way the grant is
|
|
6
|
+
* PER-ACCOUNT: one decision covers every machine the creator signs in on, which is why
|
|
7
|
+
* both surfaces carry the full disclosure (including that an agent may compose a note
|
|
8
|
+
* the user has not read) and why the visibility line names where it was granted.
|
|
15
9
|
*
|
|
16
10
|
* Every call is isolated behind ConsentBackend so the command layer is unit-testable
|
|
17
11
|
* with no network.
|
|
@@ -21,10 +15,10 @@ import { waitForLoopbackCallback } from "../login/loopback.js";
|
|
|
21
15
|
import { browserPage } from "../login/pkceLogin.js";
|
|
22
16
|
import { stateMatches } from "../login/pkce.js";
|
|
23
17
|
import { getFreePort } from "../preview/net.js";
|
|
24
|
-
import { authFailureMessage } from "../shared/authError.js";
|
|
25
|
-
import { apiRequest } from "../shared/http.js";
|
|
26
|
-
import { rejectUpgradeRequired } from "../shared/upgradeError.js";
|
|
27
|
-
import {
|
|
18
|
+
import { AuthExpiredError, authFailureMessage, rejectAuthExpired, } from "../shared/authError.js";
|
|
19
|
+
import { apiFetch, apiRequest, withDeadline } from "../shared/http.js";
|
|
20
|
+
import { rejectUpgradeRequired, UpgradeRequiredError } from "../shared/upgradeError.js";
|
|
21
|
+
import { readAccountEnablement, } from "./enablement.js";
|
|
28
22
|
async function safeText(res) {
|
|
29
23
|
try {
|
|
30
24
|
return (await res.text()).slice(0, 300);
|
|
@@ -54,19 +48,58 @@ export function httpConsentBackend() {
|
|
|
54
48
|
return res;
|
|
55
49
|
};
|
|
56
50
|
return {
|
|
57
|
-
async read(apiUrl, pat, now) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
async read(apiUrl, pat, now, timeoutMs) {
|
|
52
|
+
const fetchImpl = timeoutMs === undefined
|
|
53
|
+
? apiFetch
|
|
54
|
+
: withDeadline((input, init) => globalThis.fetch(input, init), timeoutMs);
|
|
55
|
+
let res;
|
|
56
|
+
let body;
|
|
57
|
+
try {
|
|
58
|
+
res = await apiRequest({ apiUrl, pat }, "/me", {}, { authenticated: true }, fetchImpl);
|
|
59
|
+
if (res.status === 401)
|
|
60
|
+
rejectAuthExpired(res);
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
return { kind: "unreadable" };
|
|
63
|
+
body = await res.json();
|
|
61
64
|
}
|
|
62
|
-
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof AuthExpiredError)
|
|
67
|
+
throw err;
|
|
68
|
+
// A 200 whose body does not parse (a captive portal, a proxy page) is not an
|
|
69
|
+
// account answer, so it must not clear this machine's flag.
|
|
70
|
+
return { kind: "unreadable" };
|
|
71
|
+
}
|
|
72
|
+
const enablement = readAccountEnablement(unwrapMe(body), now);
|
|
73
|
+
return enablement.enabled
|
|
74
|
+
? { kind: "granted", enablement }
|
|
75
|
+
: { kind: "not_granted" };
|
|
63
76
|
},
|
|
64
77
|
async grant(apiUrl, pat, now) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await call(apiUrl, pat, "/me/dx-consent", { method: "POST" });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
if (err instanceof UpgradeRequiredError)
|
|
84
|
+
throw err;
|
|
85
|
+
return { kind: "unreachable" };
|
|
86
|
+
}
|
|
87
|
+
rejectAuthExpired(res);
|
|
88
|
+
if (res.status === 429)
|
|
89
|
+
return { kind: "rate_limited" };
|
|
90
|
+
if (!res.ok)
|
|
91
|
+
return { kind: "unreachable" };
|
|
92
|
+
let body;
|
|
93
|
+
try {
|
|
94
|
+
body = await res.json();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { kind: "not_granted" };
|
|
68
98
|
}
|
|
69
|
-
|
|
99
|
+
const enablement = readAccountEnablement(unwrapMe(body), now);
|
|
100
|
+
return enablement.enabled
|
|
101
|
+
? { kind: "granted", enablement }
|
|
102
|
+
: { kind: "not_granted" };
|
|
70
103
|
},
|
|
71
104
|
async revoke(apiUrl, pat) {
|
|
72
105
|
const res = await call(apiUrl, pat, "/me/dx-consent", { method: "DELETE" });
|
package/dist/dx/enablement.d.ts
CHANGED
|
@@ -11,21 +11,21 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export declare const DX_UNATTENDED_SUNSET: number;
|
|
13
13
|
/**
|
|
14
|
-
* WHERE the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* The source is carried rather than inferred because the two have different blast radii
|
|
18
|
-
* and different places to revoke. A creator reading `ENABLED` on a machine they never
|
|
19
|
-
* ran `dx enable` on has no way to act on it without being told the grant lives in their
|
|
20
|
-
* account — they would go looking for a local flag that is not there.
|
|
14
|
+
* WHERE the answer came from. The account is the authority whenever GET /me answers;
|
|
15
|
+
* `machine` means this machine's cached copy was used because the account could not be
|
|
16
|
+
* read. The two are reported differently because only one of them was checked just now.
|
|
21
17
|
*/
|
|
22
|
-
export type EnablementSource = "
|
|
18
|
+
export type EnablementSource = "account" | "machine";
|
|
19
|
+
/** Where the person answered the account grant, as the api recorded it. */
|
|
20
|
+
export type EnablementVia = "cli" | "console";
|
|
23
21
|
export interface Enablement {
|
|
24
22
|
enabled: boolean;
|
|
25
23
|
/** The raw ISO8601 grant time. Present only when `enabled` is true. */
|
|
26
24
|
at?: string;
|
|
27
|
-
/** Where the
|
|
25
|
+
/** Where the answer came from. Present only when `enabled` is true. */
|
|
28
26
|
source?: EnablementSource;
|
|
27
|
+
/** Account grants only, and only when the api recorded it. */
|
|
28
|
+
via?: EnablementVia;
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* Decide enablement from an already-parsed config object. Pure, and STRICT:
|
|
@@ -49,7 +49,57 @@ export declare function readEnablement(cfg: unknown, now: number): Enablement;
|
|
|
49
49
|
* stops being honored too, or "forgetting the sunset turns the feature off" would only be
|
|
50
50
|
* true of the local half.
|
|
51
51
|
*/
|
|
52
|
-
export declare function
|
|
52
|
+
export declare function readAccountEnablement(raw: unknown, now: number): Enablement;
|
|
53
|
+
/** GET /me, as the consent gate and the visibility surfaces need it. */
|
|
54
|
+
export type AccountRead = {
|
|
55
|
+
kind: "granted";
|
|
56
|
+
enablement: Enablement;
|
|
57
|
+
} | {
|
|
58
|
+
kind: "not_granted";
|
|
59
|
+
} | {
|
|
60
|
+
kind: "unreadable";
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* What the consent surfaces show: the answer that decides (`shown`), this machine's copy
|
|
64
|
+
* (`local`, after any clear), and where the answer was checked.
|
|
65
|
+
*/
|
|
66
|
+
export interface ConsentView {
|
|
67
|
+
shown: Enablement;
|
|
68
|
+
local: Enablement;
|
|
69
|
+
checked: "account" | "machine";
|
|
70
|
+
signedIn: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface ConsentViewDeps {
|
|
73
|
+
auth: {
|
|
74
|
+
apiUrl: string;
|
|
75
|
+
pat: string;
|
|
76
|
+
} | undefined;
|
|
77
|
+
/** Allowed to throw; a throw reads as unreadable. */
|
|
78
|
+
readAccount?: (auth: {
|
|
79
|
+
apiUrl: string;
|
|
80
|
+
pat: string;
|
|
81
|
+
}) => Promise<AccountRead>;
|
|
82
|
+
local: () => Promise<Enablement> | Enablement;
|
|
83
|
+
/** Clears this machine's copy. Allowed to throw; see below. */
|
|
84
|
+
clearLocal: () => Promise<unknown>;
|
|
85
|
+
/** Bounds the account read from outside, so a backend that hangs cannot hang the caller. */
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The account-first read shared by the send gate, `vincentt dx` and `vincentt version`
|
|
90
|
+
* (f7 Scenario 3, 3c). The account decides when it answers; a "not granted" answer clears
|
|
91
|
+
* this machine's copy so a revoke made elsewhere reaches it. Only when the account cannot
|
|
92
|
+
* be read does the local flag stand on its own.
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveConsentView(deps: ConsentViewDeps): Promise<ConsentView>;
|
|
95
|
+
/** `dxConsent` in `vincentt dx --json` (f7 Scenario 3c). */
|
|
96
|
+
export interface DxConsentJson {
|
|
97
|
+
state: "on" | "off" | "unknown";
|
|
98
|
+
checked: "account" | "machine";
|
|
99
|
+
at?: string;
|
|
100
|
+
via?: EnablementVia;
|
|
101
|
+
}
|
|
102
|
+
export declare function dxConsentJson(view: ConsentView): DxConsentJson;
|
|
53
103
|
/**
|
|
54
104
|
* Read enablement from the real machine config.
|
|
55
105
|
*
|
|
@@ -66,8 +116,10 @@ export declare function grantedOn(at: string): string;
|
|
|
66
116
|
/**
|
|
67
117
|
* The one visibility line, in the single pinned shape every human surface uses:
|
|
68
118
|
*
|
|
119
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days) · granted from the CLI
|
|
69
120
|
* Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
|
|
70
|
-
* Unattended send: ENABLED since 2026-04-01 (145 days) ·
|
|
121
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days) · on your account
|
|
122
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days) · this machine (account not checked)
|
|
71
123
|
*
|
|
72
124
|
* Returns undefined when not enabled. Nothing is printed in that case, on
|
|
73
125
|
* purpose: a line that prints unconditionally is a line readers learn to skip,
|