@vincentt-xr/cli 0.21.0 → 0.22.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 +15 -0
- package/dist/cli/index.js +23 -6
- 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/report.d.ts +4 -24
- package/dist/dx/report.js +32 -25
- 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 +1 -1
package/MIGRATION.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
Per version, newest first. Read by the platform's own version surface and served to
|
|
4
4
|
creators' agents. Accurate or absent, never speculative (B-F5-2).
|
|
5
5
|
|
|
6
|
+
## 0.22.0
|
|
7
|
+
|
|
8
|
+
required: false
|
|
9
|
+
No action required. Behaviour that changed:
|
|
10
|
+
- `vincentt dx enable` at a terminal now asks `Allow unattended reports on your account? [y/N]` and, on `y`, records the grant on your Vincentt account first, then sets this machine. Success reads `Enabled for your account. Reports may now be sent without a prompt from any machine signed in to it.`
|
|
11
|
+
- If the account does not record the grant, `vincentt dx enable` changes nothing and exits 1 with `✗ Vincentt did not record your consent.`, `✗ Too many consent changes. Wait a minute and try again.`, or `✗ Could not reach Vincentt to record your consent.` A refused sign-in exits 4 and a too-old CLI exits 5, as on other verbs.
|
|
12
|
+
- Unattended `vincentt dx --send` and `vincentt report` now check the account first. An account grant sends; an account that is not granted refuses (exit 1) and clears this machine's setting, so turning reports off in the console or on another machine reaches this one. This machine's setting is used on its own only when the account cannot be reached. An interactive session still asks and makes no account check.
|
|
13
|
+
- The `Unattended send: ENABLED since <day> (<n> days)` line now ends with where the answer came from: `· granted from the CLI`, `· granted in the console`, `· on your account`, or `· this machine (account not checked)`. `vincentt dx` and `vincentt version` check the account for up to 3 seconds first; `vincentt version` never fails because of that check.
|
|
14
|
+
- `vincentt dx --json` adds `dxConsent`: `{"state": "on" | "off" | "unknown", "checked": "account" | "machine", "at"?, "via"?}`. `dxSendEnabled` and `dxSendEnabledAt` still describe this machine's setting.
|
|
15
|
+
- `vincentt preview` has a new ending: when the preview server answers the tunnel with a reply this CLI cannot accept, the preview ends once with `✗ Preview stopped: the preview server sent an invalid reply. Update the CLI or try again later.` (exit 1) instead of reconnecting every few seconds indefinitely. The screen names the upgrade command and `vincentt preview`.
|
|
16
|
+
- `vincentt publish` refuses the whole publish, listing the files, when a folder in the build output turns into a link to somewhere outside it while publish reads it. Let the build finish, then publish again.
|
|
17
|
+
- `vincentt publish` refuses to upload when the platform's upload link does not carry the checksum publish computed for that file; nothing is sent, and the screen names the file.
|
|
18
|
+
- A folder or file in the build output that publish cannot read now ends the publish with the "changed while publish was reading it" screen naming the path inside the build output, instead of a raw error message holding the full path.
|
|
19
|
+
- File names printed on publish refusal screens show bidirectional and direction-mark characters as visible `\uXXXX` escapes.
|
|
20
|
+
|
|
6
21
|
## 0.21.0
|
|
7
22
|
|
|
8
23
|
required: false
|
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { listProjectsCommand } from "../lib/listProjects.js";
|
|
|
29
29
|
import { initCommand } from "../lib/initCommand.js";
|
|
30
30
|
import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
|
|
31
31
|
import { whoamiCommand } from "../lib/whoami.js";
|
|
32
|
-
import { renderVersion, versionReport } from "../lib/version.js";
|
|
32
|
+
import { renderVersion, VERSION_ACCOUNT_TIMEOUT_MS, versionReport, } from "../lib/version.js";
|
|
33
33
|
import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, } from "../lib/lifecycle.js";
|
|
34
34
|
import { publishCommand } from "../publish/command.js";
|
|
35
35
|
import { stdinAsk, stdinConfirm } from "../publish/prompt.js";
|
|
@@ -53,7 +53,7 @@ import * as feedbackCopy from "../lib/feedbackCopy.js";
|
|
|
53
53
|
import { record as recordInvocation } from "../dx/journal.js";
|
|
54
54
|
import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, sinceUsageLines, } from "../dx/command.js";
|
|
55
55
|
import { httpConsentBackend, runConsentInBrowser } from "../dx/consent.js";
|
|
56
|
-
import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
|
|
56
|
+
import { DX_UNATTENDED_SUNSET, loadEnablement, resolveConsentView, } from "../dx/enablement.js";
|
|
57
57
|
import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
|
|
58
58
|
import { postReport } from "../dx/send.js";
|
|
59
59
|
import { nudgeFor } from "../dx/nudge.js";
|
|
@@ -1139,14 +1139,22 @@ function cliVersion() {
|
|
|
1139
1139
|
}
|
|
1140
1140
|
}
|
|
1141
1141
|
async function version() {
|
|
1142
|
+
const now = Date.now();
|
|
1142
1143
|
const report = await versionReport(projectDir(), {
|
|
1143
1144
|
cliVersion: cliVersion(),
|
|
1144
1145
|
allowCustomApi: has("--allow-custom-api"),
|
|
1146
|
+
consent: {
|
|
1147
|
+
auth: dxAuth,
|
|
1148
|
+
readAccount: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now, VERSION_ACCOUNT_TIMEOUT_MS),
|
|
1149
|
+
clearLocal: () => writeDxEnablement(undefined),
|
|
1150
|
+
},
|
|
1145
1151
|
});
|
|
1146
1152
|
for (const line of renderVersion(report))
|
|
1147
1153
|
console.log(line);
|
|
1148
1154
|
}
|
|
1149
1155
|
// ---- dx ---------------------------------------------------------------------
|
|
1156
|
+
/** The account bound on `vincentt dx` (f7 Scenario 3c); the send gate uses the default. */
|
|
1157
|
+
const DX_ACCOUNT_TIMEOUT_MS = 3_000;
|
|
1150
1158
|
/**
|
|
1151
1159
|
* The API host + PAT for a dx call, or undefined when signed out.
|
|
1152
1160
|
*
|
|
@@ -1197,6 +1205,7 @@ async function dx() {
|
|
|
1197
1205
|
onProgress: (l) => console.log(l),
|
|
1198
1206
|
});
|
|
1199
1207
|
},
|
|
1208
|
+
grantAccountConsent: (auth) => consent.grant(auth.apiUrl, auth.pat, now),
|
|
1200
1209
|
revokeAccountConsent: (auth) => consent.revoke(auth.apiUrl, auth.pat),
|
|
1201
1210
|
};
|
|
1202
1211
|
process.exitCode = options.enable
|
|
@@ -1214,7 +1223,15 @@ async function dx() {
|
|
|
1214
1223
|
// relaxation, not throw through it.
|
|
1215
1224
|
const enablement = await loadEnablement(now);
|
|
1216
1225
|
if (!options.send) {
|
|
1217
|
-
|
|
1226
|
+
// The report tail and `dxConsent` read the account first, bounded at 3 s.
|
|
1227
|
+
const view = await resolveConsentView({
|
|
1228
|
+
auth: await dxAuth(),
|
|
1229
|
+
readAccount: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now, DX_ACCOUNT_TIMEOUT_MS),
|
|
1230
|
+
local: () => enablement,
|
|
1231
|
+
clearLocal: () => writeDxEnablement(undefined),
|
|
1232
|
+
timeoutMs: DX_ACCOUNT_TIMEOUT_MS,
|
|
1233
|
+
});
|
|
1234
|
+
process.exitCode = runDx(argv, (l) => console.log(l), () => view.local, () => view);
|
|
1218
1235
|
return;
|
|
1219
1236
|
}
|
|
1220
1237
|
// `--send` is the one path out of this machine, so it resolves auth the way
|
|
@@ -1231,10 +1248,9 @@ async function dx() {
|
|
|
1231
1248
|
confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
|
|
1232
1249
|
interactive,
|
|
1233
1250
|
enablement: () => enablement,
|
|
1234
|
-
// The
|
|
1235
|
-
// first two have not already allowed the send, and allowed to throw — a /me that
|
|
1236
|
-
// is unreachable falls back to the other two arms rather than blocking.
|
|
1251
|
+
// The account decides when it answers; never read on an interactive session.
|
|
1237
1252
|
accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
|
|
1253
|
+
clearEnablement: () => writeDxEnablement(undefined),
|
|
1238
1254
|
// `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
|
|
1239
1255
|
// works on a send as it does everywhere else.
|
|
1240
1256
|
cwd: projectDir(),
|
|
@@ -1276,6 +1292,7 @@ async function report() {
|
|
|
1276
1292
|
interactive,
|
|
1277
1293
|
enablement: () => enablement,
|
|
1278
1294
|
accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
|
|
1295
|
+
clearEnablement: () => writeDxEnablement(undefined),
|
|
1279
1296
|
// `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
|
|
1280
1297
|
// works on a report as it does everywhere else.
|
|
1281
1298
|
cwd: projectDir(),
|
package/dist/dx/command.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ConsentDecision } from "./consent.js";
|
|
2
|
-
import type { Enablement } from "./enablement.js";
|
|
1
|
+
import type { ConsentDecision, GrantResult } from "./consent.js";
|
|
2
|
+
import type { AccountRead, ConsentView, Enablement } from "./enablement.js";
|
|
3
3
|
import type { JournalEntry, JournalWindow } from "./journal.js";
|
|
4
4
|
import type { WireReport } from "./send.js";
|
|
5
5
|
import { type BuiltWith } from "../platform/resolve.js";
|
|
@@ -11,10 +11,15 @@ export interface DxDeps {
|
|
|
11
11
|
enabled: () => boolean;
|
|
12
12
|
path: () => string;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
* the journal need not resolve it; absent reads as not enabled.
|
|
14
|
+
* This machine's copy of the grant (the local flag). Optional so the many callers that
|
|
15
|
+
* only read the journal need not resolve it; absent reads as not enabled.
|
|
16
16
|
*/
|
|
17
17
|
enablement?: () => Enablement;
|
|
18
|
+
/**
|
|
19
|
+
* The account-first answer the report tail and `dxConsent` show. Absent falls back to
|
|
20
|
+
* the local flag, reported as checked on this machine only.
|
|
21
|
+
*/
|
|
22
|
+
consentView?: () => ConsentView;
|
|
18
23
|
}
|
|
19
24
|
/** How far back a report looks. */
|
|
20
25
|
export interface DxWindow {
|
|
@@ -53,7 +58,7 @@ export declare function sinceUsageLines(raw: string): string[];
|
|
|
53
58
|
export declare function parseDxOptions(args: string[]): DxOptions;
|
|
54
59
|
export declare function dxCommand(deps: DxDeps, options: DxOptions): number;
|
|
55
60
|
/** Wire the command to the real journal. */
|
|
56
|
-
export declare function runDx(args: string[], print: (line: string) => void, enablement?: () => Enablement): number;
|
|
61
|
+
export declare function runDx(args: string[], print: (line: string) => void, enablement?: () => Enablement, consentView?: () => ConsentView): number;
|
|
57
62
|
export interface DxEnableDeps {
|
|
58
63
|
print: (line: string) => void;
|
|
59
64
|
now: () => number;
|
|
@@ -89,6 +94,14 @@ export interface DxEnableDeps {
|
|
|
89
94
|
* no socket, no browser, and no clock to test.
|
|
90
95
|
*/
|
|
91
96
|
consentInBrowser?: () => Promise<ConsentDecision>;
|
|
97
|
+
/**
|
|
98
|
+
* POST /me/dx-consent with the PAT, on the terminal path. Rejects only with the version
|
|
99
|
+
* refusal or the sign-in error; every other outcome is a GrantResult.
|
|
100
|
+
*/
|
|
101
|
+
grantAccountConsent?: (auth: {
|
|
102
|
+
apiUrl: string;
|
|
103
|
+
pat: string;
|
|
104
|
+
}) => Promise<GrantResult>;
|
|
92
105
|
/**
|
|
93
106
|
* DELETE /me/dx-consent. Used only by `disable`, and allowed to throw — the caller
|
|
94
107
|
* treats a network failure as "local revoke still stands", never as a reason to leave
|
|
@@ -103,11 +116,13 @@ export interface DxEnableDeps {
|
|
|
103
116
|
* `vincentt dx enable` — grant durable permission to send DX reports without asking
|
|
104
117
|
* first.
|
|
105
118
|
*
|
|
106
|
-
* TWO PATHS, and which one runs is decided by whether there is a
|
|
119
|
+
* TWO PATHS to ONE account grant, and which one runs is decided by whether there is a
|
|
120
|
+
* terminal to ask in:
|
|
107
121
|
*
|
|
108
|
-
* - At a TTY: the
|
|
109
|
-
*
|
|
110
|
-
*
|
|
122
|
+
* - At a TTY: the terminal prompt. A person is already here and can read the
|
|
123
|
+
* disclosure where they stand; sending them to a browser would be ceremony. On `y`
|
|
124
|
+
* the CLI POSTs /me/dx-consent with the PAT, and writes the local flag only after
|
|
125
|
+
* the account recorded the grant.
|
|
111
126
|
* - With no TTY: the BROWSER path. This is the case the feature exists for — a
|
|
112
127
|
* creator driving the CLI through a coding agent has no terminal, so the old code
|
|
113
128
|
* refused and the flow dead-ended. It now opens the console, where a human can
|
|
@@ -130,8 +145,8 @@ export declare function dxEnableCommand(deps: DxEnableDeps, sunset: number): Pro
|
|
|
130
145
|
* Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
|
|
131
146
|
* with. Both halves are needed: a creator who runs `disable` means "stop sending", and
|
|
132
147
|
* clearing only the local flag would leave the account grant standing, so the very next
|
|
133
|
-
* `--send` would be authorized by
|
|
134
|
-
* worked.
|
|
148
|
+
* `--send` would be authorized by the account and the command would appear not to
|
|
149
|
+
* have worked.
|
|
135
150
|
*
|
|
136
151
|
* ORDER IS DELIBERATE: local first, network second. The local clear is the one that
|
|
137
152
|
* cannot fail and the one that stops THIS machine, which is the machine the creator is
|
|
@@ -168,18 +183,19 @@ export interface DxSendDeps extends DxDeps {
|
|
|
168
183
|
* convenience, not authorization.
|
|
169
184
|
*/
|
|
170
185
|
interactive: () => boolean;
|
|
171
|
-
/**
|
|
186
|
+
/** This machine's flag, read strictly. Absent reads as not enabled. */
|
|
172
187
|
enablement?: () => Enablement;
|
|
173
188
|
/**
|
|
174
|
-
* The ACCOUNT's consent, read from GET /me.
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* not wired at all) reads as not consented, never as "unknown, so allow".
|
|
189
|
+
* The ACCOUNT's consent, read from GET /me. Allowed to throw; a throw reads as
|
|
190
|
+
* unreadable. Absent reads as unreadable too: it can only let a valid local flag stand,
|
|
191
|
+
* never authorize on its own.
|
|
178
192
|
*/
|
|
179
193
|
accountConsent?: (auth: {
|
|
180
194
|
apiUrl: string;
|
|
181
195
|
pat: string;
|
|
182
|
-
}) => Promise<
|
|
196
|
+
}) => Promise<AccountRead>;
|
|
197
|
+
/** Clears this machine's flag when the account answers "not granted". */
|
|
198
|
+
clearEnablement?: () => Promise<unknown>;
|
|
183
199
|
/**
|
|
184
200
|
* The version set. Injectable so a fixed set — or a REJECTING promise, the arm
|
|
185
201
|
* proving a fault omits — can be driven with no filesystem. Defaults to the one
|
|
@@ -188,6 +204,46 @@ export interface DxSendDeps extends DxDeps {
|
|
|
188
204
|
builtWith?: () => Promise<BuiltWith | undefined>;
|
|
189
205
|
cwd?: string;
|
|
190
206
|
}
|
|
207
|
+
/** What the send gate needs. `dx --send` and `report` both satisfy it. */
|
|
208
|
+
export interface SendGateDeps {
|
|
209
|
+
interactive: () => boolean;
|
|
210
|
+
enablement?: () => Enablement;
|
|
211
|
+
accountConsent?: (auth: {
|
|
212
|
+
apiUrl: string;
|
|
213
|
+
pat: string;
|
|
214
|
+
}) => Promise<AccountRead>;
|
|
215
|
+
clearEnablement?: () => Promise<unknown>;
|
|
216
|
+
}
|
|
217
|
+
export type SendGate = {
|
|
218
|
+
arm: "interactive";
|
|
219
|
+
} | {
|
|
220
|
+
arm: "unattended";
|
|
221
|
+
enablement: Enablement;
|
|
222
|
+
} | {
|
|
223
|
+
arm: "refused";
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* The one consent gate both send paths call (f7 Scenario 3), checked in order:
|
|
227
|
+
*
|
|
228
|
+
* 1. an interactive session → ask, then send. No /me read at all, so a creator at their
|
|
229
|
+
* own terminal is never blocked by an API being slow or down.
|
|
230
|
+
* 2. signed in and GET /me answers: a strict grant sends (source: account); any other
|
|
231
|
+
* 200 clears this machine's flag and refuses.
|
|
232
|
+
* 3. /me cannot be read and this machine's flag is valid → send (source: machine).
|
|
233
|
+
*
|
|
234
|
+
* An unreadable /me never authorizes by itself; it only lets an existing local flag stand.
|
|
235
|
+
*/
|
|
236
|
+
export declare function gateSend(deps: SendGateDeps, auth: {
|
|
237
|
+
apiUrl: string;
|
|
238
|
+
pat: string;
|
|
239
|
+
} | undefined): Promise<SendGate>;
|
|
240
|
+
/**
|
|
241
|
+
* The gate's refusal. It NAMES the enabling command: a refusal is an answer to a
|
|
242
|
+
* command the caller typed, which makes it SOLICITED output — the prohibition is on
|
|
243
|
+
* UNBIDDEN output instructing a caller. A refusal naming nothing leaves a human at
|
|
244
|
+
* exit 1 with no path forward, for no security gain.
|
|
245
|
+
*/
|
|
246
|
+
export declare const SEND_REFUSAL_LINES: readonly string[];
|
|
191
247
|
/**
|
|
192
248
|
* `vincentt dx --send`.
|
|
193
249
|
*
|
|
@@ -195,6 +251,6 @@ export interface DxSendDeps extends DxDeps {
|
|
|
195
251
|
* who cannot see what leaves their machine before it leaves has not agreed to
|
|
196
252
|
* anything:
|
|
197
253
|
*
|
|
198
|
-
* friction →
|
|
254
|
+
* friction → auth → GATE → RESOLVE → build → SHOW → ask → post
|
|
199
255
|
*/
|
|
200
256
|
export declare function dxSendCommand(deps: DxSendDeps, options: DxOptions): Promise<number>;
|
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;
|