@vincentt-xr/cli 0.12.3 → 0.14.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/README.md +20 -1
- package/dist/cli/index.js +43 -19
- package/dist/cli/usage-text.js +4 -1
- package/dist/dx/command.d.ts +79 -15
- package/dist/dx/command.js +173 -22
- package/dist/dx/consent.d.ts +77 -0
- package/dist/dx/consent.js +197 -0
- package/dist/dx/enablement.d.ts +35 -1
- package/dist/dx/enablement.js +56 -8
- package/dist/lib/listProjects.d.ts +12 -1
- package/dist/lib/listProjects.js +99 -8
- package/dist/lib/locations.d.ts +59 -0
- package/dist/lib/locations.js +117 -0
- package/dist/login/loopback.d.ts +72 -0
- package/dist/login/loopback.js +119 -0
- package/dist/login/pkceLogin.d.ts +1 -0
- package/dist/login/pkceLogin.js +41 -45
- package/dist/shared/config.d.ts +8 -0
- package/dist/shared/config.js +18 -1
- package/dist/shared/locationStore.d.ts +82 -0
- package/dist/shared/locationStore.js +216 -0
- package/dist/shared/recordLocation.d.ts +33 -0
- package/dist/shared/recordLocation.js +76 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,8 @@ Project
|
|
|
67
67
|
[--force] [--no-install]
|
|
68
68
|
link <project-id> Link this directory to an existing project
|
|
69
69
|
unlink Detach this directory from its project
|
|
70
|
-
projects List the workspace's projects and
|
|
70
|
+
projects List the workspace's projects, their ids, and where each
|
|
71
|
+
one lives on this machine
|
|
71
72
|
[--json] [--org <s>]
|
|
72
73
|
|
|
73
74
|
Dev loop
|
|
@@ -119,5 +120,23 @@ In a directory that is not linked to anything, `npx vincentt projects` lists the
|
|
|
119
120
|
workspace's projects with their ids, which is what `vincentt link <project-id>`
|
|
120
121
|
needs. `--json` prints the same rows for a program to read.
|
|
121
122
|
|
|
123
|
+
It also reports **where each project lives on this machine**. Any command run in
|
|
124
|
+
a linked folder records that folder against the project, so a project you have
|
|
125
|
+
worked on here is listed with its directory on an indented line under its row:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
66f1c2a9e4b7 museum-ar-tour Museum AR Tour museum-ar-tour.vincentt.app
|
|
129
|
+
↳ /Users/alice/code/museum-ar-tour
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
A project this machine has no folder for reads `no folder known on this machine`
|
|
133
|
+
rather than leaving a gap, and a project checked out in several places lists
|
|
134
|
+
every one without picking. The folder is checked when it is read — it is only
|
|
135
|
+
reported if it still holds that project's binding — so a path that has moved or
|
|
136
|
+
been unbound is never handed back. Under `--json` every project carries a
|
|
137
|
+
`locations` array: empty when unknown, one member when located, several when the
|
|
138
|
+
project is in more than one place. Nothing about this leaves the machine, and
|
|
139
|
+
deleting `~/.vincentt/locations.ndjson` costs nothing but the shortcut.
|
|
140
|
+
|
|
122
141
|
`vincentt` is a project devDependency in the scaffold, so invoke it as
|
|
123
142
|
`npx vincentt <verb>` and it resolves to the local copy — no global install.
|
package/dist/cli/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { httpAuthBackend } from "../login/authBackend.js";
|
|
|
19
19
|
import { loginCommand } from "../login/loginCommand.js";
|
|
20
20
|
import { ensureLoggedIn } from "../login/ensure.js";
|
|
21
21
|
import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
|
|
22
|
+
import { fileLocationStore } from "../shared/locationStore.js";
|
|
22
23
|
import { getMe, listProjects, ProjectUnreachableError } from "../lib/backend.js";
|
|
23
24
|
import { listProjectsCommand } from "../lib/listProjects.js";
|
|
24
25
|
import { initCommand } from "../lib/initCommand.js";
|
|
@@ -41,6 +42,7 @@ import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
|
|
|
41
42
|
import * as feedbackCopy from "../lib/feedbackCopy.js";
|
|
42
43
|
import { record as recordInvocation } from "../dx/journal.js";
|
|
43
44
|
import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, } from "../dx/command.js";
|
|
45
|
+
import { httpConsentBackend, runConsentInBrowser } from "../dx/consent.js";
|
|
44
46
|
import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
|
|
45
47
|
import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
|
|
46
48
|
import { postReport } from "../dx/send.js";
|
|
@@ -395,6 +397,7 @@ async function projects() {
|
|
|
395
397
|
return resolveOrgForCommand(cfg, { flag: flag("--org") });
|
|
396
398
|
},
|
|
397
399
|
list: listProjects,
|
|
400
|
+
store: fileLocationStore,
|
|
398
401
|
}, { json: has("--json") });
|
|
399
402
|
}
|
|
400
403
|
/**
|
|
@@ -946,6 +949,25 @@ async function version() {
|
|
|
946
949
|
console.log(line);
|
|
947
950
|
}
|
|
948
951
|
// ---- dx ---------------------------------------------------------------------
|
|
952
|
+
/**
|
|
953
|
+
* The API host + PAT for a dx call, or undefined when signed out.
|
|
954
|
+
*
|
|
955
|
+
* resolveConfig THROWS when there is no PAT, and its message is written for a verb that
|
|
956
|
+
* cannot proceed at all. Everything dx does is optional, so a signed-out creator gets
|
|
957
|
+
* one plain line from the command layer instead of a config-file stack trace. Shared by
|
|
958
|
+
* `enable`, `disable`, and `--send` so all three read the credential identically.
|
|
959
|
+
*/
|
|
960
|
+
async function dxAuth() {
|
|
961
|
+
try {
|
|
962
|
+
const cfg = await resolveConfig(projectDir(), {
|
|
963
|
+
allowCustomApi: has("--allow-custom-api"),
|
|
964
|
+
});
|
|
965
|
+
return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
return undefined;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
949
971
|
async function dx() {
|
|
950
972
|
const options = parseDxOptions(argv);
|
|
951
973
|
const now = Date.now();
|
|
@@ -954,18 +976,30 @@ async function dx() {
|
|
|
954
976
|
// precisely the case where nobody reads what was disclosed.
|
|
955
977
|
const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
956
978
|
if (options.enable || options.disable) {
|
|
979
|
+
const consent = httpConsentBackend();
|
|
957
980
|
const deps = {
|
|
958
981
|
print: (l) => console.log(l),
|
|
959
982
|
now: () => now,
|
|
960
983
|
interactive,
|
|
961
984
|
confirm: (q) => stdinConfirm()(` ${q} [y/N] `, false),
|
|
962
|
-
// `enable`
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
985
|
+
// `enable` NOW REQUIRES being signed in, reversing f7's
|
|
986
|
+
// D-Enable-does-not-require-being-signed-in. The browser path records consent on
|
|
987
|
+
// the ACCOUNT, so there has to be an account to record it against and a credential
|
|
988
|
+
// to reach it with — the precondition is load-bearing now rather than ceremonial.
|
|
989
|
+
auth: dxAuth,
|
|
966
990
|
save: async (next) => {
|
|
967
991
|
await writeDxEnablement(next);
|
|
968
992
|
},
|
|
993
|
+
// The no-TTY consent round trip. Defaults are the real seams; the whole thing is
|
|
994
|
+
// injected as ONE function so dxEnableCommand needs no socket to test.
|
|
995
|
+
consentInBrowser: async () => {
|
|
996
|
+
const { consoleUrl } = await resolveAuthUrls();
|
|
997
|
+
return runConsentInBrowser({
|
|
998
|
+
consoleUrl,
|
|
999
|
+
onProgress: (l) => console.log(l),
|
|
1000
|
+
});
|
|
1001
|
+
},
|
|
1002
|
+
revokeAccountConsent: (auth) => consent.revoke(auth.apiUrl, auth.pat),
|
|
969
1003
|
};
|
|
970
1004
|
process.exitCode = options.enable
|
|
971
1005
|
? await dxEnableCommand(deps, DX_UNATTENDED_SUNSET)
|
|
@@ -988,25 +1022,15 @@ async function dx() {
|
|
|
988
1022
|
enabled: () => journalEnabled(),
|
|
989
1023
|
path: journalPath,
|
|
990
1024
|
cliVersion,
|
|
991
|
-
auth:
|
|
992
|
-
// resolveConfig THROWS when there is no PAT, and its message is written
|
|
993
|
-
// for a verb that cannot proceed at all. Sending a DX report is
|
|
994
|
-
// optional, so a signed-out creator gets one plain line from
|
|
995
|
-
// dxSendCommand instead of a config-file stack trace.
|
|
996
|
-
try {
|
|
997
|
-
const cfg = await resolveConfig(projectDir(), {
|
|
998
|
-
allowCustomApi: has("--allow-custom-api"),
|
|
999
|
-
});
|
|
1000
|
-
return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
|
|
1001
|
-
}
|
|
1002
|
-
catch {
|
|
1003
|
-
return undefined;
|
|
1004
|
-
}
|
|
1005
|
-
},
|
|
1025
|
+
auth: dxAuth,
|
|
1006
1026
|
post: (apiUrl, pat, report) => postReport(apiUrl, pat, report),
|
|
1007
1027
|
confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
|
|
1008
1028
|
interactive,
|
|
1009
1029
|
enablement: () => enablement,
|
|
1030
|
+
// The third arm: the account-wide grant, read from /me. Consulted only when the
|
|
1031
|
+
// first two have not already allowed the send, and allowed to throw — a /me that
|
|
1032
|
+
// is unreachable falls back to the other two arms rather than blocking.
|
|
1033
|
+
accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
|
|
1010
1034
|
}, options);
|
|
1011
1035
|
}
|
|
1012
1036
|
// ---- assets (f5) ------------------------------------------------------------
|
package/dist/cli/usage-text.js
CHANGED
|
@@ -30,7 +30,10 @@ export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
|
|
|
30
30
|
` [--org <s>] [--preview] [--force] [--no-install]\n` +
|
|
31
31
|
` link <project-id> Link this folder to an existing project\n` +
|
|
32
32
|
` unlink Disconnect this folder from its project\n` +
|
|
33
|
-
` projects List the workspace's projects and
|
|
33
|
+
` projects List the workspace's projects, their ids, and where each\n` +
|
|
34
|
+
` one lives on this machine [--json]\n` +
|
|
35
|
+
` A project this machine has no folder for says\n` +
|
|
36
|
+
` "no folder known on this machine" rather than leaving a gap.\n` +
|
|
34
37
|
` [--org <s>] to list a workspace other than the active one\n\n` +
|
|
35
38
|
`Dev loop\n` +
|
|
36
39
|
` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
|
package/dist/dx/command.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConsentDecision } from "./consent.js";
|
|
1
2
|
import type { Enablement } from "./enablement.js";
|
|
2
3
|
import type { JournalEntry } from "./journal.js";
|
|
3
4
|
import type { WireReport } from "./send.js";
|
|
@@ -46,30 +47,83 @@ export interface DxEnableDeps {
|
|
|
46
47
|
save: (next: {
|
|
47
48
|
at: string;
|
|
48
49
|
} | undefined) => Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Resolved API host + PAT, or undefined when signed out.
|
|
52
|
+
*
|
|
53
|
+
* `enable` now REQUIRES this, which REVERSES f7's
|
|
54
|
+
* D-Enable-does-not-require-being-signed-in. That decision reasoned that ordering the
|
|
55
|
+
* two verbs would be "a rule with no safety behind it" — true while the grant lived
|
|
56
|
+
* only in a local file. It no longer does: the browser path records consent against
|
|
57
|
+
* the ACCOUNT, so there has to be an account to record it against, and the CLI has to
|
|
58
|
+
* hold a credential to reach it. The precondition is now load-bearing rather than
|
|
59
|
+
* ceremonial, so the refusal names `vincentt login`.
|
|
60
|
+
*/
|
|
61
|
+
auth?: () => Promise<{
|
|
62
|
+
apiUrl: string;
|
|
63
|
+
pat: string;
|
|
64
|
+
} | undefined>;
|
|
65
|
+
/**
|
|
66
|
+
* Run the console consent round trip: open the browser, wait for the loopback
|
|
67
|
+
* callback, and return the human's answer. Injected whole so the command layer needs
|
|
68
|
+
* no socket, no browser, and no clock to test.
|
|
69
|
+
*/
|
|
70
|
+
consentInBrowser?: () => Promise<ConsentDecision>;
|
|
71
|
+
/**
|
|
72
|
+
* DELETE /me/dx-consent. Used only by `disable`, and allowed to throw — the caller
|
|
73
|
+
* treats a network failure as "local revoke still stands", never as a reason to leave
|
|
74
|
+
* the capability on.
|
|
75
|
+
*/
|
|
76
|
+
revokeAccountConsent?: (auth: {
|
|
77
|
+
apiUrl: string;
|
|
78
|
+
pat: string;
|
|
79
|
+
}) => Promise<void>;
|
|
49
80
|
}
|
|
50
81
|
/**
|
|
51
|
-
* `vincentt dx enable` — grant
|
|
52
|
-
*
|
|
82
|
+
* `vincentt dx enable` — grant durable permission to send DX reports without asking
|
|
83
|
+
* first.
|
|
84
|
+
*
|
|
85
|
+
* TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
|
|
86
|
+
*
|
|
87
|
+
* - At a TTY: the original terminal prompt, unchanged. A person is already here and
|
|
88
|
+
* can read the disclosure where they stand; sending them to a browser would be
|
|
89
|
+
* ceremony.
|
|
90
|
+
* - With no TTY: the BROWSER path. This is the case the feature exists for — a
|
|
91
|
+
* creator driving the CLI through a coding agent has no terminal, so the old code
|
|
92
|
+
* refused and the flow dead-ended. It now opens the console, where a human can
|
|
93
|
+
* actually give consent.
|
|
53
94
|
*
|
|
54
|
-
* The TTY
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
95
|
+
* The no-TTY path is no longer a refusal, but that is NOT a relaxation of the gate. The
|
|
96
|
+
* old TTY check was explicitly a SPEED BUMP, NOT A BOUNDARY (it never stopped a caller
|
|
97
|
+
* that allocated a PTY or wrote config.json directly); what replaces it is stronger,
|
|
98
|
+
* because the browser path puts the decision in front of a human on a page the agent
|
|
99
|
+
* cannot see, click, or forge — the agent can start the flow but cannot finish it.
|
|
59
100
|
*
|
|
60
|
-
* The refusal
|
|
61
|
-
* all: this file holds the PAT, and a refusal that round-trips the config through
|
|
62
|
-
*
|
|
101
|
+
* The refusal paths still write NOTHING. Not "write the same bytes back" — nothing at
|
|
102
|
+
* all: this file holds the PAT, and a refusal that round-trips the config through a
|
|
103
|
+
* write can lose the credential if it dies mid-write.
|
|
63
104
|
*/
|
|
64
105
|
export declare function dxEnableCommand(deps: DxEnableDeps, sunset: number): Promise<number>;
|
|
65
106
|
/**
|
|
66
107
|
* `vincentt dx disable` — the obvious opposite command.
|
|
67
108
|
*
|
|
68
|
-
* Clears BOTH keys
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
109
|
+
* Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
|
|
110
|
+
* with. Both halves are needed: a creator who runs `disable` means "stop sending", and
|
|
111
|
+
* clearing only the local flag would leave the account grant standing, so the very next
|
|
112
|
+
* `--send` would be authorized by arm (c) and the command would appear not to have
|
|
113
|
+
* worked.
|
|
114
|
+
*
|
|
115
|
+
* ORDER IS DELIBERATE: local first, network second. The local clear is the one that
|
|
116
|
+
* cannot fail and the one that stops THIS machine, which is the machine the creator is
|
|
117
|
+
* standing at.
|
|
118
|
+
*
|
|
119
|
+
* A NETWORK FAILURE MUST NEVER BLOCK A REVOKE. Turning a capability off is always safe,
|
|
120
|
+
* so the failure path still clears locally, still exits 0, and says plainly that the
|
|
121
|
+
* account grant may remain and where to clear it. The opposite design — fail the command
|
|
122
|
+
* so the creator "knows it did not work" — leaves them with the capability still on and
|
|
123
|
+
* a non-zero exit that suggests nothing happened at all.
|
|
124
|
+
*
|
|
125
|
+
* Needs no terminal and no confirmation: requiring a TTY here would strand a creator who
|
|
126
|
+
* wanted to revoke from a script, and this is the direction that is always safe.
|
|
73
127
|
*/
|
|
74
128
|
export declare function dxDisableCommand(deps: DxEnableDeps): Promise<number>;
|
|
75
129
|
export interface DxSendDeps extends DxDeps {
|
|
@@ -95,6 +149,16 @@ export interface DxSendDeps extends DxDeps {
|
|
|
95
149
|
interactive: () => boolean;
|
|
96
150
|
/** Durable unattended-send state, read strictly. Absent reads as not enabled. */
|
|
97
151
|
enablement?: () => Enablement;
|
|
152
|
+
/**
|
|
153
|
+
* The ACCOUNT's consent, read from GET /me. The third arm of the gate.
|
|
154
|
+
*
|
|
155
|
+
* Allowed to throw, and its throw is not fatal — see the gate below. Absent (the dep
|
|
156
|
+
* not wired at all) reads as not consented, never as "unknown, so allow".
|
|
157
|
+
*/
|
|
158
|
+
accountConsent?: (auth: {
|
|
159
|
+
apiUrl: string;
|
|
160
|
+
pat: string;
|
|
161
|
+
}) => Promise<Enablement>;
|
|
98
162
|
}
|
|
99
163
|
/**
|
|
100
164
|
* `vincentt dx --send`.
|
package/dist/dx/command.js
CHANGED
|
@@ -131,18 +131,28 @@ function sunsetDate(sunset) {
|
|
|
131
131
|
return new Date(sunset).toISOString().slice(0, 10);
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
|
-
* `vincentt dx enable` — grant
|
|
135
|
-
*
|
|
134
|
+
* `vincentt dx enable` — grant durable permission to send DX reports without asking
|
|
135
|
+
* first.
|
|
136
136
|
*
|
|
137
|
-
*
|
|
138
|
-
* such so a later reader does not build on a guarantee that is not there. It
|
|
139
|
-
* stops the reflex — an agent that meets a prompt, infers there must be a way
|
|
140
|
-
* past it, and tries the obvious thing. It does not stop a caller that allocates
|
|
141
|
-
* a PTY or writes config.json directly.
|
|
137
|
+
* TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
|
|
142
138
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
139
|
+
* - At a TTY: the original terminal prompt, unchanged. A person is already here and
|
|
140
|
+
* can read the disclosure where they stand; sending them to a browser would be
|
|
141
|
+
* ceremony.
|
|
142
|
+
* - With no TTY: the BROWSER path. This is the case the feature exists for — a
|
|
143
|
+
* creator driving the CLI through a coding agent has no terminal, so the old code
|
|
144
|
+
* refused and the flow dead-ended. It now opens the console, where a human can
|
|
145
|
+
* actually give consent.
|
|
146
|
+
*
|
|
147
|
+
* The no-TTY path is no longer a refusal, but that is NOT a relaxation of the gate. The
|
|
148
|
+
* old TTY check was explicitly a SPEED BUMP, NOT A BOUNDARY (it never stopped a caller
|
|
149
|
+
* that allocated a PTY or wrote config.json directly); what replaces it is stronger,
|
|
150
|
+
* because the browser path puts the decision in front of a human on a page the agent
|
|
151
|
+
* cannot see, click, or forge — the agent can start the flow but cannot finish it.
|
|
152
|
+
*
|
|
153
|
+
* The refusal paths still write NOTHING. Not "write the same bytes back" — nothing at
|
|
154
|
+
* all: this file holds the PAT, and a refusal that round-trips the config through a
|
|
155
|
+
* write can lose the credential if it dies mid-write.
|
|
146
156
|
*/
|
|
147
157
|
export async function dxEnableCommand(deps, sunset) {
|
|
148
158
|
if (deps.now() >= sunset) {
|
|
@@ -152,15 +162,26 @@ export async function dxEnableCommand(deps, sunset) {
|
|
|
152
162
|
deps.print("");
|
|
153
163
|
return 1;
|
|
154
164
|
}
|
|
155
|
-
|
|
165
|
+
// Being signed in is now a PRECONDITION, checked before either path and before any
|
|
166
|
+
// disclosure is printed. This REVERSES f7's D-Enable-does-not-require-being-signed-in
|
|
167
|
+
// — see the note on DxEnableDeps.auth for why that reversal is justified rather than
|
|
168
|
+
// merely convenient: the browser path records consent on the ACCOUNT, so there must be
|
|
169
|
+
// one, and reaching it needs a credential. Checked FIRST because a creator who is
|
|
170
|
+
// signed out cannot complete either path, and reading a consent disclosure before
|
|
171
|
+
// being told so is wasted attention.
|
|
172
|
+
const auth = await deps.auth?.();
|
|
173
|
+
if (auth === undefined) {
|
|
156
174
|
deps.print("");
|
|
157
|
-
deps.print("
|
|
158
|
-
deps.print("
|
|
175
|
+
deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
|
|
176
|
+
deps.print(" this machine is not signed in. Nothing was changed.");
|
|
159
177
|
deps.print("");
|
|
160
|
-
deps.print(" Run `vincentt
|
|
178
|
+
deps.print(" Run `vincentt login` first.");
|
|
161
179
|
deps.print("");
|
|
162
180
|
return 1;
|
|
163
181
|
}
|
|
182
|
+
if (!deps.interactive()) {
|
|
183
|
+
return enableViaBrowser(deps);
|
|
184
|
+
}
|
|
164
185
|
// What is being granted, INCLUDING the note risk. The note is the part a
|
|
165
186
|
// creator does not picture: enablement is what lets an agent compose prose they
|
|
166
187
|
// have not read and put it on the wire.
|
|
@@ -187,17 +208,111 @@ export async function dxEnableCommand(deps, sunset) {
|
|
|
187
208
|
deps.print("");
|
|
188
209
|
return 0;
|
|
189
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* The no-TTY arm: hand the decision to a human in a browser.
|
|
213
|
+
*
|
|
214
|
+
* The disclosure itself is deliberately NOT printed here. It lives on the console page,
|
|
215
|
+
* where the person deciding will actually see it; printing it into an agent's transcript
|
|
216
|
+
* would put the warning everywhere except in front of the human it is written for. What
|
|
217
|
+
* is printed here is only what a reader of the transcript needs — that a browser is
|
|
218
|
+
* being opened, and the URL.
|
|
219
|
+
*
|
|
220
|
+
* Note the CLI's PAT is NOT passed into this round trip and never rides the console URL.
|
|
221
|
+
* The console holds its own session and POSTs /me/dx-consent itself, so the browser
|
|
222
|
+
* carries only a `state` and a decision. The PAT's role was to prove there is an account
|
|
223
|
+
* at all, which the caller has already checked.
|
|
224
|
+
*/
|
|
225
|
+
async function enableViaBrowser(deps) {
|
|
226
|
+
if (deps.consentInBrowser === undefined) {
|
|
227
|
+
// No browser seam wired: refuse rather than silently falling back to a path that
|
|
228
|
+
// asks nobody. Reachable only from a caller that built deps by hand.
|
|
229
|
+
deps.print("");
|
|
230
|
+
deps.print(" Enabling unattended sending needs either a terminal to ask in or a");
|
|
231
|
+
deps.print(" browser to open, and this session has neither. Nothing was changed.");
|
|
232
|
+
deps.print("");
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
let decision;
|
|
236
|
+
try {
|
|
237
|
+
decision = await deps.consentInBrowser();
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
// A timeout, a cancelled window, an unreachable console. Nothing is written on any
|
|
241
|
+
// of them — an enable that could not be confirmed is not an enable.
|
|
242
|
+
deps.print("");
|
|
243
|
+
deps.print(` ✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
244
|
+
deps.print(" Nothing was changed.");
|
|
245
|
+
deps.print("");
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
if (decision !== "granted") {
|
|
249
|
+
// Denied writes NOTHING and exits 0. Exit 0 because declining is a successful
|
|
250
|
+
// outcome of the command the caller ran, not a malfunction — an agent that reads a
|
|
251
|
+
// non-zero exit here would reasonably retry, which is how a refused consent prompt
|
|
252
|
+
// becomes a loop the human has to keep dismissing.
|
|
253
|
+
deps.print("");
|
|
254
|
+
deps.print(" Not enabled.");
|
|
255
|
+
deps.print("");
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
// The grant is already recorded on the account by the console (the page POSTs
|
|
259
|
+
// /me/dx-consent before it redirects). The local flag is cached alongside it so this
|
|
260
|
+
// machine can decide without a network round trip — and so a later --send is not
|
|
261
|
+
// blocked by an API that happens to be unreachable.
|
|
262
|
+
await deps.save({ at: new Date(deps.now()).toISOString() });
|
|
263
|
+
deps.print("");
|
|
264
|
+
deps.print(" Enabled. Reports may now be sent without a prompt.");
|
|
265
|
+
deps.print(" Turn it off with `vincentt dx disable`, or in the console.");
|
|
266
|
+
deps.print("");
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
190
269
|
/**
|
|
191
270
|
* `vincentt dx disable` — the obvious opposite command.
|
|
192
271
|
*
|
|
193
|
-
* Clears BOTH keys
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
272
|
+
* Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
|
|
273
|
+
* with. Both halves are needed: a creator who runs `disable` means "stop sending", and
|
|
274
|
+
* clearing only the local flag would leave the account grant standing, so the very next
|
|
275
|
+
* `--send` would be authorized by arm (c) and the command would appear not to have
|
|
276
|
+
* worked.
|
|
277
|
+
*
|
|
278
|
+
* ORDER IS DELIBERATE: local first, network second. The local clear is the one that
|
|
279
|
+
* cannot fail and the one that stops THIS machine, which is the machine the creator is
|
|
280
|
+
* standing at.
|
|
281
|
+
*
|
|
282
|
+
* A NETWORK FAILURE MUST NEVER BLOCK A REVOKE. Turning a capability off is always safe,
|
|
283
|
+
* so the failure path still clears locally, still exits 0, and says plainly that the
|
|
284
|
+
* account grant may remain and where to clear it. The opposite design — fail the command
|
|
285
|
+
* so the creator "knows it did not work" — leaves them with the capability still on and
|
|
286
|
+
* a non-zero exit that suggests nothing happened at all.
|
|
287
|
+
*
|
|
288
|
+
* Needs no terminal and no confirmation: requiring a TTY here would strand a creator who
|
|
289
|
+
* wanted to revoke from a script, and this is the direction that is always safe.
|
|
198
290
|
*/
|
|
199
291
|
export async function dxDisableCommand(deps) {
|
|
200
292
|
await deps.save(undefined);
|
|
293
|
+
// Signed out: there is no account grant this machine can reach, and nothing to say
|
|
294
|
+
// about one. `logout` already cleared the local flag on its way out.
|
|
295
|
+
const auth = await deps.auth?.();
|
|
296
|
+
if (auth !== undefined && deps.revokeAccountConsent !== undefined) {
|
|
297
|
+
try {
|
|
298
|
+
await deps.revokeAccountConsent(auth);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
deps.print("");
|
|
302
|
+
deps.print(" Unattended sending is off on this machine.");
|
|
303
|
+
deps.print("");
|
|
304
|
+
deps.print(` ! Could not reach Vincentt to clear the account-wide grant:`);
|
|
305
|
+
deps.print(` ${err instanceof Error ? err.message : String(err)}`);
|
|
306
|
+
deps.print("");
|
|
307
|
+
deps.print(" If you allowed reports in the console, that grant may still be");
|
|
308
|
+
deps.print(" active for your other machines. Clear it in the console settings.");
|
|
309
|
+
deps.print("");
|
|
310
|
+
// Exit 0: the revoke the creator asked for HAPPENED here, and the part that did
|
|
311
|
+
// not is named with the place to finish it. A non-zero exit would read as "the
|
|
312
|
+
// command failed", which would be false about the machine they are on.
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
201
316
|
deps.print("");
|
|
202
317
|
deps.print(" Unattended sending is off. Reports need your confirmation again.");
|
|
203
318
|
deps.print("");
|
|
@@ -245,8 +360,45 @@ export async function dxSendCommand(deps, options) {
|
|
|
245
360
|
// The second arm is the SUPPORTED unattended path: a person, at a terminal,
|
|
246
361
|
// ran `vincentt dx enable` on this machine. That is a prior deliberate act
|
|
247
362
|
// rather than a per-send answer, which is what makes it consent at all.
|
|
248
|
-
|
|
363
|
+
//
|
|
364
|
+
// The THIRD arm is the same kind of prior deliberate act, made in the console and
|
|
365
|
+
// recorded on the ACCOUNT rather than on this machine. It exists because the second
|
|
366
|
+
// arm cannot be reached at all by the creator this feature is for: driving the CLI
|
|
367
|
+
// through a coding agent, there is no terminal to run `dx enable` in.
|
|
368
|
+
const localEnablement = deps.enablement?.() ?? { enabled: false };
|
|
249
369
|
const interactive = deps.interactive();
|
|
370
|
+
// Auth is resolved BEFORE the gate now, because arm (c) needs a credential to read
|
|
371
|
+
// /me. The signed-out refusal still comes AFTER the gate, so a caller with neither a
|
|
372
|
+
// terminal nor consent gets the refusal about consent — the thing they need to fix —
|
|
373
|
+
// rather than being sent to `login` first and only then told that logging in was not
|
|
374
|
+
// sufficient.
|
|
375
|
+
const auth = await deps.auth();
|
|
376
|
+
// Arm (c) is consulted ONLY when the first two have not already allowed the send.
|
|
377
|
+
// Skipping it otherwise is not just an optimization: it keeps `--send` from making a
|
|
378
|
+
// network call it does not need, so a creator at their own terminal is never blocked
|
|
379
|
+
// by an API being slow or down.
|
|
380
|
+
let accountEnablement = { enabled: false };
|
|
381
|
+
if (!interactive && !localEnablement.enabled && auth !== undefined) {
|
|
382
|
+
try {
|
|
383
|
+
accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// A NETWORK FAILURE FALLS BACK TO THE OTHER TWO ARMS, and since we only get here
|
|
387
|
+
// when both have already said no, that means refusing. This is the fail-CLOSED
|
|
388
|
+
// direction: an unreachable API can never authorize a send.
|
|
389
|
+
//
|
|
390
|
+
// The swallow is deliberate and is the whole point of the arm's placement — a
|
|
391
|
+
// /me that is down must never BLOCK a send that (a) or (b) already allows, and it
|
|
392
|
+
// cannot, because those paths never reach this call at all.
|
|
393
|
+
accountEnablement = { enabled: false };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// The grant that authorizes this send, and the one whose age gets reported. The local
|
|
397
|
+
// flag wins when both exist: it is the more specific statement (this machine) and it
|
|
398
|
+
// is the one `dx disable` can always clear.
|
|
399
|
+
const enablement = localEnablement.enabled
|
|
400
|
+
? localEnablement
|
|
401
|
+
: accountEnablement;
|
|
250
402
|
if (!interactive && !enablement.enabled) {
|
|
251
403
|
// This refusal NAMES the enabling command. A refusal is an answer to a
|
|
252
404
|
// command the caller typed, which makes it SOLICITED output — the
|
|
@@ -260,14 +412,13 @@ export async function dxSendCommand(deps, options) {
|
|
|
260
412
|
deps.print("machine, or run `vincentt dx --send` in your own terminal.");
|
|
261
413
|
return 1;
|
|
262
414
|
}
|
|
263
|
-
const auth = await deps.auth();
|
|
264
415
|
if (auth === undefined) {
|
|
265
416
|
deps.print("Not signed in. Run `vincentt login` to send a report.");
|
|
266
417
|
return 1;
|
|
267
418
|
}
|
|
268
419
|
const report = buildReport(clusters, deps.cliVersion(), options.note);
|
|
269
420
|
// Above the payload, on the unattended path only: name that this is enabled,
|
|
270
|
-
// how old the grant is, and that nothing is going to ask.
|
|
421
|
+
// how old the grant is, WHERE it was granted, and that nothing is going to ask.
|
|
271
422
|
const unattended = !interactive && enablement.enabled;
|
|
272
423
|
if (unattended) {
|
|
273
424
|
const line = enablementLine(enablement, deps.now());
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type Enablement } from "./enablement.js";
|
|
2
|
+
/**
|
|
3
|
+
* The account-consent seam.
|
|
4
|
+
*
|
|
5
|
+
* `read` returns the parsed Enablement rather than the raw envelope so the strict
|
|
6
|
+
* `=== true` interpretation happens in exactly one place and cannot be re-derived
|
|
7
|
+
* loosely by a second caller.
|
|
8
|
+
*/
|
|
9
|
+
export interface ConsentBackend {
|
|
10
|
+
/** GET /me → the account's dxReportsConsent fields, read strictly. */
|
|
11
|
+
read(apiUrl: string, pat: string, now: number): Promise<Enablement>;
|
|
12
|
+
/** POST /me/dx-consent — grant. Idempotent; a re-grant refreshes the timestamp. */
|
|
13
|
+
grant(apiUrl: string, pat: string, now: number): Promise<Enablement>;
|
|
14
|
+
/** DELETE /me/dx-consent — revoke. Never fails on already-revoked. */
|
|
15
|
+
revoke(apiUrl: string, pat: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
|
|
18
|
+
export declare function httpConsentBackend(): ConsentBackend;
|
|
19
|
+
/**
|
|
20
|
+
* The console URL the browser opens for the consent decision:
|
|
21
|
+
* `<console>/cli-dx-consent?redirect_uri=<loopback>&state=…`.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors buildCliAuthUrl deliberately, including the param NAMES — the console's page
|
|
24
|
+
* reads exactly `redirect_uri` and `state`, and it validates that redirect_uri is a
|
|
25
|
+
* 127.0.0.1 loopback. No PKCE material rides here: unlike login, nothing is being
|
|
26
|
+
* redeemed. The callback carries only a decision, and `state` is what binds that
|
|
27
|
+
* decision to this CLI invocation.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildConsentUrl(consoleBase: string, redirectUri: string, state: string): string;
|
|
30
|
+
export type ConsentDecision = "granted" | "denied";
|
|
31
|
+
export type ConsentCallback = {
|
|
32
|
+
ok: true;
|
|
33
|
+
decision: ConsentDecision;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: "state_mismatch" | "missing_result";
|
|
37
|
+
message: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Interpret the loopback callback (`?state=…&result=granted|denied`).
|
|
41
|
+
*
|
|
42
|
+
* State is verified constant-time FIRST, before `result` is even read, so a stray or
|
|
43
|
+
* forged callback can never grant consent — the same discipline as parseCallback, and
|
|
44
|
+
* for a higher-stakes decision: here the forged value would not be a code that still has
|
|
45
|
+
* to survive a PKCE exchange, it would be the answer itself.
|
|
46
|
+
*
|
|
47
|
+
* Anything that is not exactly "granted" reads as DENIED rather than as an error, so an
|
|
48
|
+
* unrecognized result can only ever fail closed.
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseConsentCallback(params: URLSearchParams, expectedState: string, stateMatches: (expected: string, actual: string | null) => boolean): ConsentCallback;
|
|
51
|
+
/** Thrown when the human never answers in the browser. */
|
|
52
|
+
export declare class ConsentTimeoutError extends Error {
|
|
53
|
+
constructor();
|
|
54
|
+
}
|
|
55
|
+
export interface ConsentBrowserOptions {
|
|
56
|
+
/** Console origin that serves /cli-dx-consent. */
|
|
57
|
+
consoleUrl: string;
|
|
58
|
+
/** Abort if the browser round trip does not complete in time. Default 5 min. */
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
pickPort?: () => Promise<number>;
|
|
61
|
+
openBrowser?: (url: string) => void;
|
|
62
|
+
/** Mint the anti-CSRF state. Injected only so a test can pin it. */
|
|
63
|
+
mintState?: () => string;
|
|
64
|
+
onProgress?: (line: string) => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Open the console's consent page and wait for the human's answer.
|
|
68
|
+
*
|
|
69
|
+
* The mechanics are the SHARED loopback helper, not a second copy of it: listener up
|
|
70
|
+
* before the browser, `state` verified constant-time on every callback, a stray callback
|
|
71
|
+
* answered but not accepted, and a bounded wait. The only thing that differs from login
|
|
72
|
+
* is what comes back — a decision instead of an auth code.
|
|
73
|
+
*
|
|
74
|
+
* Nothing is written here. This function reports what the human said; persisting it is
|
|
75
|
+
* the command layer's job, so the "denied writes nothing" rule lives in one place.
|
|
76
|
+
*/
|
|
77
|
+
export declare function runConsentInBrowser(opts: ConsentBrowserOptions): Promise<ConsentDecision>;
|