@vincentt-xr/cli 0.6.0 → 0.8.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.
Files changed (38) hide show
  1. package/dist/cli/args.d.ts +8 -1
  2. package/dist/cli/args.js +10 -1
  3. package/dist/cli/index.js +190 -5
  4. package/dist/dx/command.d.ts +91 -1
  5. package/dist/dx/command.js +213 -2
  6. package/dist/dx/enablement.d.ts +51 -0
  7. package/dist/dx/enablement.js +97 -0
  8. package/dist/dx/nudge.d.ts +7 -0
  9. package/dist/dx/nudge.js +54 -0
  10. package/dist/dx/qa.f7.fixtures.d.ts +73 -0
  11. package/dist/dx/qa.f7.fixtures.js +58 -0
  12. package/dist/dx/send.d.ts +70 -0
  13. package/dist/dx/send.js +108 -0
  14. package/dist/dx/skeleton.js +88 -5
  15. package/dist/lib/version.d.ts +20 -1
  16. package/dist/lib/version.js +41 -5
  17. package/dist/library/assetsCommand.d.ts +32 -0
  18. package/dist/library/assetsCommand.js +264 -0
  19. package/dist/library/client.d.ts +42 -0
  20. package/dist/library/client.js +137 -0
  21. package/dist/library/download.d.ts +46 -0
  22. package/dist/library/download.js +98 -0
  23. package/dist/library/parse.d.ts +43 -0
  24. package/dist/library/parse.js +109 -0
  25. package/dist/library/qa.f5-b32.spec.d.ts +1 -0
  26. package/dist/library/qa.f5-b32.spec.js +202 -0
  27. package/dist/library/qa.f5-cli.spec.d.ts +1 -0
  28. package/dist/library/qa.f5-cli.spec.js +433 -0
  29. package/dist/library/qa.f5-contract.spec.d.ts +1 -0
  30. package/dist/library/qa.f5-contract.spec.js +206 -0
  31. package/dist/library/render.d.ts +39 -0
  32. package/dist/library/render.js +305 -0
  33. package/dist/library/wire.d.ts +96 -0
  34. package/dist/library/wire.js +167 -0
  35. package/dist/publish/prompt.js +17 -2
  36. package/dist/shared/config.d.ts +27 -0
  37. package/dist/shared/config.js +64 -9
  38. package/package.json +1 -1
@@ -13,7 +13,14 @@ export declare function isHelpInvocation(args: string[]): boolean;
13
13
  * skip a publish the creator asked for. Anywhere else it stays an unknown flag.
14
14
  */
15
15
  export declare function isVersionInvocation(args: string[]): boolean;
16
- /** Flags that stand alone; every other `--flag` consumes the next argument. */
16
+ /**
17
+ * Flags that stand alone; every other `--flag` consumes the next argument.
18
+ *
19
+ * A boolean flag missing from this set silently eats the argument after it. For
20
+ * `vincentt assets add --download lib_7f3a…` that would swallow the asset id and
21
+ * leave the command with nothing to resolve, so every new boolean belongs here at
22
+ * the moment it is introduced.
23
+ */
17
24
  export declare const BOOLEAN_FLAGS: Set<string>;
18
25
  export declare function refusedFlag(args: string[]): string | undefined;
19
26
  /**
package/dist/cli/args.js CHANGED
@@ -17,7 +17,14 @@ export function isHelpInvocation(args) {
17
17
  export function isVersionInvocation(args) {
18
18
  return args[0] === "--version" || args[0] === "-v";
19
19
  }
20
- /** Flags that stand alone; every other `--flag` consumes the next argument. */
20
+ /**
21
+ * Flags that stand alone; every other `--flag` consumes the next argument.
22
+ *
23
+ * A boolean flag missing from this set silently eats the argument after it. For
24
+ * `vincentt assets add --download lib_7f3a…` that would swallow the asset id and
25
+ * leave the command with nothing to resolve, so every new boolean belongs here at
26
+ * the moment it is introduced.
27
+ */
21
28
  export const BOOLEAN_FLAGS = new Set([
22
29
  "--force",
23
30
  "--preview",
@@ -29,9 +36,11 @@ export const BOOLEAN_FLAGS = new Set([
29
36
  "--restart",
30
37
  "--no-qr",
31
38
  "--verbose",
39
+ "--send",
32
40
  "--no-install",
33
41
  "--allow-custom-api",
34
42
  "--yes",
43
+ "--download",
35
44
  "--help",
36
45
  "-h",
37
46
  ]);
package/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ import { runPkceLogin } from "../login/pkceLogin.js";
18
18
  import { httpAuthBackend } from "../login/authBackend.js";
19
19
  import { loginCommand } from "../login/loginCommand.js";
20
20
  import { ensureLoggedIn } from "../login/ensure.js";
21
- import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, MACHINE_CONFIG_PATH, } from "../shared/config.js";
21
+ import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
22
22
  import { getMe, ProjectUnreachableError } from "../lib/backend.js";
23
23
  import { initCommand } from "../lib/initCommand.js";
24
24
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
@@ -36,7 +36,12 @@ import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
36
36
  import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
37
37
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
38
38
  import { record as recordInvocation } from "../dx/journal.js";
39
- import { runDx } from "../dx/command.js";
39
+ import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, } from "../dx/command.js";
40
+ import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
41
+ import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
42
+ import { postReport } from "../dx/send.js";
43
+ import { nudgeFor } from "../dx/nudge.js";
44
+ import { verbOf } from "../dx/skeleton.js";
40
45
  import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
41
46
  // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
42
47
  // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
@@ -576,6 +581,151 @@ async function version() {
576
581
  for (const line of renderVersion(report))
577
582
  console.log(line);
578
583
  }
584
+ // ---- dx ---------------------------------------------------------------------
585
+ async function dx() {
586
+ const options = parseDxOptions(argv);
587
+ const now = Date.now();
588
+ // Both descriptors, matching publish and init. stdin alone let
589
+ // `vincentt dx --send --yes > out.txt` through — a redirected stdout is
590
+ // precisely the case where nobody reads what was disclosed.
591
+ const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
592
+ if (options.enable || options.disable) {
593
+ const deps = {
594
+ print: (l) => console.log(l),
595
+ now: () => now,
596
+ interactive,
597
+ confirm: (q) => stdinConfirm()(` ${q} [y/N] `, false),
598
+ // `enable` does NOT require being signed in. --send refuses without a PAT
599
+ // regardless, so a config holding only enablement keys is inert; and
600
+ // ordering the two verbs ("you must log in before you may state a
601
+ // preference") would be a rule with no safety behind it.
602
+ save: async (next) => {
603
+ await writeDxEnablement(next);
604
+ },
605
+ };
606
+ process.exitCode = options.enable
607
+ ? await dxEnableCommand(deps, DX_UNATTENDED_SUNSET)
608
+ : await dxDisableCommand(deps);
609
+ return;
610
+ }
611
+ // Read strictly and independently of auth: a truncated config must disable the
612
+ // relaxation, not throw through it.
613
+ const enablement = await loadEnablement(now);
614
+ if (!options.send) {
615
+ process.exitCode = runDx(argv, (l) => console.log(l), () => enablement);
616
+ return;
617
+ }
618
+ // `--send` is the one path out of this machine, so it resolves auth the way
619
+ // every other authed verb does and refuses rather than guessing.
620
+ process.exitCode = await dxSendCommand({
621
+ entries: readJournal,
622
+ print: (l) => console.log(l),
623
+ now: () => now,
624
+ enabled: () => journalEnabled(),
625
+ path: journalPath,
626
+ cliVersion,
627
+ auth: async () => {
628
+ // resolveConfig THROWS when there is no PAT, and its message is written
629
+ // for a verb that cannot proceed at all. Sending a DX report is
630
+ // optional, so a signed-out creator gets one plain line from
631
+ // dxSendCommand instead of a config-file stack trace.
632
+ try {
633
+ const cfg = await resolveConfig(projectDir(), {
634
+ allowCustomApi: has("--allow-custom-api"),
635
+ });
636
+ return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
637
+ }
638
+ catch {
639
+ return undefined;
640
+ }
641
+ },
642
+ post: (apiUrl, pat, report) => postReport(apiUrl, pat, report),
643
+ confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
644
+ interactive,
645
+ enablement: () => enablement,
646
+ }, options);
647
+ }
648
+ // ---- assets (f5) ------------------------------------------------------------
649
+ /**
650
+ * `vincentt assets <search|show|add>` — the CLI's FIRST nested subcommand group,
651
+ * so this is one `case` with an inner switch rather than a new flat verb.
652
+ *
653
+ * The whole group is gated on being signed in, and every screen it prints goes to
654
+ * STDERR: under `--json` stdout must carry only the JSON document, so an agent
655
+ * piping stdout into a parser never gets a mixed stream.
656
+ */
657
+ async function assets() {
658
+ const sub = positional(1);
659
+ if (sub === undefined) {
660
+ console.error("Usage: vincentt assets <search|show|add> [options]");
661
+ process.exitCode = 1;
662
+ return;
663
+ }
664
+ if (sub !== "search" && sub !== "show" && sub !== "add") {
665
+ console.error(`Unknown assets command: ${sub}`);
666
+ console.error("Usage: vincentt assets <search|show|add> [options]");
667
+ process.exitCode = 1;
668
+ return;
669
+ }
670
+ const { httpLibraryClient } = await import("../library/client.js");
671
+ const { assetsAdd, assetsSearch, assetsShow } = await import("../library/assetsCommand.js");
672
+ const cwd = projectDir();
673
+ let cfg;
674
+ try {
675
+ cfg = await resolveConfig(cwd, {
676
+ allowCustomApi: has("--allow-custom-api"),
677
+ warn: (l) => console.error(l),
678
+ });
679
+ }
680
+ catch {
681
+ // Not signed in — the exit-4 screen, because we did not answer. The library is
682
+ // a platform call, and the creator's PROJECT is unaffected either way.
683
+ const { unreachableLines } = await import("../library/render.js");
684
+ for (const l of unreachableLines("auth", "vincentt assets search \"<what you want>\"")) {
685
+ console.error(l);
686
+ }
687
+ process.exitCode = 4;
688
+ return;
689
+ }
690
+ const deps = {
691
+ out: (l) => console.log(l),
692
+ err: (l) => console.error(l),
693
+ client: httpLibraryClient(cfg),
694
+ cwd,
695
+ };
696
+ const json = has("--json");
697
+ if (sub === "search") {
698
+ // The query is the bare argument after `search`; the remaining positionals are
699
+ // ignored rather than concatenated, so a quoting mistake fails loudly.
700
+ const query = positional(2);
701
+ if (query === undefined) {
702
+ console.error('Usage: vincentt assets search "<query>" [--kind prop|frame] [--limit N]');
703
+ process.exitCode = 1;
704
+ return;
705
+ }
706
+ process.exitCode = await assetsSearch(deps, {
707
+ query,
708
+ kind: flag("--kind"),
709
+ render: flag("--render"),
710
+ palette: flag("--palette"),
711
+ limit: flag("--limit"),
712
+ json,
713
+ });
714
+ return;
715
+ }
716
+ const ids = positionalArgs(argv).slice(2);
717
+ if (sub === "show") {
718
+ process.exitCode = await assetsShow(deps, { ids, json });
719
+ return;
720
+ }
721
+ process.exitCode = await assetsAdd(deps, {
722
+ ids,
723
+ json,
724
+ download: has("--download"),
725
+ force: has("--force"),
726
+ dir: flag("--dir"),
727
+ });
728
+ }
579
729
  // ---- servers ----------------------------------------------------------------
580
730
  async function relay() {
581
731
  const { startRelay } = await import("../relay/server.js");
@@ -623,6 +773,15 @@ function usage() {
623
773
  ` --tester t1 filters to one tester (one tab, across\n` +
624
774
  ` its refreshes). --viewer <label> filters to a single\n` +
625
775
  ` connection; one tab opens several.\n\n` +
776
+ `Asset library\n` +
777
+ ` assets search "<query>" Find curated assets [--kind prop|frame]\n` +
778
+ ` [--render 3d-clay] [--palette sage] [--limit 20] [--json]\n` +
779
+ ` assets show <id> [<id>…] Everything known about an asset [--json]\n` +
780
+ ` assets add <id> [<id>…] Print the URLs to reference from your source\n` +
781
+ ` Writes nothing. [--download [--dir <path>] [--force]]\n` +
782
+ ` [--json]\n` +
783
+ ` Ids look like lib_7f3a2c67d4e5b6a7; search returns\n` +
784
+ ` them. Reference only ids the tool returned.\n\n` +
626
785
  `Reverse channel\n` +
627
786
  ` feedback --wait Block for the next phone annotation, print it, exit\n` +
628
787
  ` feedback --watch Stream annotations as they arrive\n\n` +
@@ -631,7 +790,14 @@ function usage() {
631
790
  `Meta\n` +
632
791
  ` version CLI version, and the API this machine resolves to\n` +
633
792
  ` dx Where this dev loop fought you [--days <n>] [--json]\n` +
634
- ` Reads a local journal of your own runs. Never sent.\n\n` +
793
+ ` Reads a local journal of your own runs. Local by default.\n` +
794
+ ` --send reports the findings to the Vincentt team; it\n` +
795
+ ` shows the exact payload and asks first. [--note <text>]\n` +
796
+ ` dx enable Allow reports to be sent from this machine without asking.\n` +
797
+ ` Asks you to confirm in your terminal. Stays on until you\n` +
798
+ ` disable it, and is shown on \`vincentt dx\` and\n` +
799
+ ` \`vincentt version\` while on.\n` +
800
+ ` dx disable Turn that off again.\n\n` +
635
801
  `Common options: --dir <path> (project dir, default cwd)\n` +
636
802
  ` --allow-custom-api honor a binding's apiUrl override\n`);
637
803
  }
@@ -679,9 +845,10 @@ async function main() {
679
845
  return feedback();
680
846
  case "relay":
681
847
  return relay();
848
+ case "assets":
849
+ return assets();
682
850
  case "dx":
683
- process.exitCode = runDx(argv, (l) => console.log(l));
684
- return;
851
+ return dx();
685
852
  default:
686
853
  // Flow L30: rolling back and taking an address offline are console
687
854
  // actions, and a bare "unknown command" here would be the product failing
@@ -708,6 +875,24 @@ function journal(exitCode, error) {
708
875
  exitCode,
709
876
  error,
710
877
  });
878
+ // The hint goes out AFTER the run it is about, so it reads as a footnote to
879
+ // what just happened rather than a warning about what is about to. Never on
880
+ // `dx` itself — telling someone reading the friction report that they could
881
+ // read the friction report is noise.
882
+ if (verbOf(argv) === "dx")
883
+ return;
884
+ try {
885
+ // STDERR, not stdout. This is human narration appended after EVERY verb, so on
886
+ // any `--json` invocation it would land inside the document an agent is piping
887
+ // into a parser and corrupt it. Whether that happened was down to the nudge's
888
+ // own timing heuristic, which makes it the worst kind of intermittent: green in
889
+ // a test run, broken on a creator's machine a week later.
890
+ for (const l of nudgeFor(readJournal(), Date.now()))
891
+ console.error(l);
892
+ }
893
+ catch {
894
+ // A hint that can break a publish is not worth having.
895
+ }
711
896
  }
712
897
  main()
713
898
  .then(() => {
@@ -1,17 +1,107 @@
1
+ import type { Enablement } from "./enablement.js";
1
2
  import type { JournalEntry } from "./journal.js";
3
+ import type { WireReport } from "./send.js";
2
4
  export interface DxDeps {
3
5
  entries: () => JournalEntry[];
4
6
  print: (line: string) => void;
5
7
  now: () => number;
6
8
  enabled: () => boolean;
7
9
  path: () => string;
10
+ /**
11
+ * Durable unattended-send state. Optional so the many callers that only read
12
+ * the journal need not resolve it; absent reads as not enabled.
13
+ */
14
+ enablement?: () => Enablement;
8
15
  }
9
16
  export interface DxOptions {
10
17
  /** Only consider runs from the last N days. */
11
18
  days: number;
12
19
  json: boolean;
20
+ /** Send the findings to the Vincentt team. Never implied by anything else. */
21
+ send: boolean;
22
+ /** Skip the confirmation prompt. Only meaningful with --send. */
23
+ yes: boolean;
24
+ /** The creator's own words about the friction, carried with the report. */
25
+ note?: string;
26
+ /** `vincentt dx enable` — a positional subcommand, not a flag. */
27
+ enable: boolean;
28
+ /** `vincentt dx disable` — a positional subcommand, not a flag. */
29
+ disable: boolean;
13
30
  }
14
31
  export declare function parseDxOptions(args: string[]): DxOptions;
15
32
  export declare function dxCommand(deps: DxDeps, options: DxOptions): number;
16
33
  /** Wire the command to the real journal. */
17
- export declare function runDx(args: string[], print: (line: string) => void): number;
34
+ export declare function runDx(args: string[], print: (line: string) => void, enablement?: () => Enablement): number;
35
+ export interface DxEnableDeps {
36
+ print: (line: string) => void;
37
+ now: () => number;
38
+ /** stdin.isTTY && stdout.isTTY. Evidence of an interactive session, not proof of a human. */
39
+ interactive: () => boolean;
40
+ /** Ask once. Returns false to abort. */
41
+ confirm: (question: string) => Promise<boolean>;
42
+ /**
43
+ * Persist the grant, or clear it when given undefined. NEVER called on a
44
+ * refusal path — `enable` that refuses must not touch the file at all.
45
+ */
46
+ save: (next: {
47
+ at: string;
48
+ } | undefined) => Promise<void>;
49
+ }
50
+ /**
51
+ * `vincentt dx enable` — grant this machine durable permission to send DX reports
52
+ * without asking first.
53
+ *
54
+ * The TTY requirement is a SPEED BUMP, NOT A BOUNDARY, and is written down as
55
+ * such so a later reader does not build on a guarantee that is not there. It
56
+ * stops the reflex — an agent that meets a prompt, infers there must be a way
57
+ * past it, and tries the obvious thing. It does not stop a caller that allocates
58
+ * a PTY or writes config.json directly.
59
+ *
60
+ * The refusal path writes NOTHING. Not "writes the same bytes back" — nothing at
61
+ * all: this file holds the PAT, and a refusal that round-trips the config through
62
+ * a write can lose the credential if it dies mid-write.
63
+ */
64
+ export declare function dxEnableCommand(deps: DxEnableDeps, sunset: number): Promise<number>;
65
+ /**
66
+ * `vincentt dx disable` — the obvious opposite command.
67
+ *
68
+ * Clears BOTH keys. Leaving the timestamp behind would make a later re-enable
69
+ * report its age from the first grant, which is the kind of thing that ships
70
+ * wrong and is never noticed. Needs no terminal and no confirmation: turning a
71
+ * capability OFF is always safe to do, and requiring a TTY here would strand a
72
+ * creator who wanted to revoke from a script.
73
+ */
74
+ export declare function dxDisableCommand(deps: DxEnableDeps): Promise<number>;
75
+ export interface DxSendDeps extends DxDeps {
76
+ /** Resolved API host + PAT. Absent when the creator is not signed in. */
77
+ auth: () => Promise<{
78
+ apiUrl: string;
79
+ pat: string;
80
+ } | undefined>;
81
+ post: (apiUrl: string, pat: string, report: WireReport) => Promise<{
82
+ ref: string;
83
+ findings: number;
84
+ expiresAt: string;
85
+ }>;
86
+ /** Ask before sending. Returns false to abort. */
87
+ confirm: (question: string) => Promise<boolean>;
88
+ cliVersion: () => string;
89
+ /**
90
+ * stdin.isTTY && stdout.isTTY. Evidence of an INTERACTIVE SESSION, not proof of
91
+ * a human: an agent harness that allocates a PTY passes this with nobody
92
+ * present. The enabled path below is the honest one; this arm gates
93
+ * convenience, not authorization.
94
+ */
95
+ interactive: () => boolean;
96
+ /** Durable unattended-send state, read strictly. Absent reads as not enabled. */
97
+ enablement?: () => Enablement;
98
+ }
99
+ /**
100
+ * `vincentt dx --send`.
101
+ *
102
+ * The order is the consent model and is not an implementation detail: find the
103
+ * friction, BUILD the payload, SHOW it in full, ask, and only then send. A
104
+ * creator who cannot see what leaves their machine before it leaves has not
105
+ * agreed to anything.
106
+ */
107
+ export declare function dxSendCommand(deps: DxSendDeps, options: DxOptions): Promise<number>;
@@ -1,12 +1,26 @@
1
1
  import { findClusters, rankClusters } from "./clusters.js";
2
+ import { enablementLine } from "./enablement.js";
2
3
  import { journalEnabled, journalPath, readJournal } from "./journal.js";
4
+ import { buildReport, renderPayload } from "./send.js";
3
5
  const DEFAULT_DAYS = 7;
4
6
  export function parseDxOptions(args) {
5
7
  const i = args.indexOf("--days");
6
8
  const raw = i !== -1 ? Number(args[i + 1]) : NaN;
9
+ const n = args.indexOf("--note");
10
+ const note = n !== -1 ? args[n + 1] : undefined;
11
+ // Positional, and read only from the slot right after the verb: `--note enable`
12
+ // is a note, not a subcommand. Deliberately NOT flags — a flag form
13
+ // (`--enable`) would be one more thing an agent can append to a command it is
14
+ // already running, which is the shape this whole gate exists to refuse.
15
+ const sub = args[0] === "dx" ? args[1] : args[0];
7
16
  return {
8
17
  days: Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DAYS,
9
18
  json: args.includes("--json"),
19
+ send: args.includes("--send"),
20
+ yes: args.includes("--yes"),
21
+ note: note !== undefined && !note.startsWith("-") ? note : undefined,
22
+ enable: sub === "enable",
23
+ disable: sub === "disable",
10
24
  };
11
25
  }
12
26
  function withinDays(entries, days, now) {
@@ -54,8 +68,20 @@ export function dxCommand(deps, options) {
54
68
  }
55
69
  const entries = withinDays(all, options.days, deps.now());
56
70
  const clusters = rankClusters(findClusters(entries));
71
+ const enablement = deps.enablement?.() ?? { enabled: false };
57
72
  if (options.json) {
58
- deps.print(JSON.stringify({ window: `${options.days}d`, runs: entries.length, clusters }, null, 2));
73
+ // The JSON object carries enablement as FIELDS because --json is the path an
74
+ // AGENT reads, and an agent that can send unattended should be able to see
75
+ // that it can.
76
+ deps.print(JSON.stringify({
77
+ window: `${options.days}d`,
78
+ runs: entries.length,
79
+ clusters,
80
+ dxSendEnabled: enablement.enabled,
81
+ ...(enablement.enabled && enablement.at !== undefined
82
+ ? { dxSendEnabledAt: enablement.at }
83
+ : {}),
84
+ }, null, 2));
59
85
  return 0;
60
86
  }
61
87
  deps.print(`${entries.length} runs in the last ${options.days} days.`);
@@ -81,15 +107,200 @@ export function dxCommand(deps, options) {
81
107
  // and a claim only made on some runs is one a reader cannot rely on.
82
108
  deps.print("");
83
109
  deps.print(`Journal: ${deps.path()} (local only, never sent)`);
110
+ // Beside it, and ONLY when enabled. Enablement is durable and has no expiry, so
111
+ // this line is the mitigation rather than a status decoration; printing an
112
+ // `off` counterpart on every run is how it would get skimmed past.
113
+ const line = enablementLine(enablement, deps.now());
114
+ if (line !== undefined)
115
+ deps.print(line);
84
116
  return 0;
85
117
  }
86
118
  /** Wire the command to the real journal. */
87
- export function runDx(args, print) {
119
+ export function runDx(args, print, enablement = () => ({ enabled: false })) {
88
120
  return dxCommand({
89
121
  entries: readJournal,
90
122
  print,
91
123
  now: () => Date.now(),
92
124
  enabled: () => journalEnabled(),
93
125
  path: journalPath,
126
+ enablement,
94
127
  }, parseDxOptions(args));
95
128
  }
129
+ /** A date a human can read, for the sunset refusal. */
130
+ function sunsetDate(sunset) {
131
+ return new Date(sunset).toISOString().slice(0, 10);
132
+ }
133
+ /**
134
+ * `vincentt dx enable` — grant this machine durable permission to send DX reports
135
+ * without asking first.
136
+ *
137
+ * The TTY requirement is a SPEED BUMP, NOT A BOUNDARY, and is written down as
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.
142
+ *
143
+ * The refusal path writes NOTHING. Not "writes the same bytes back" — nothing at
144
+ * all: this file holds the PAT, and a refusal that round-trips the config through
145
+ * a write can lose the credential if it dies mid-write.
146
+ */
147
+ export async function dxEnableCommand(deps, sunset) {
148
+ if (deps.now() >= sunset) {
149
+ deps.print("");
150
+ deps.print(` Unattended DX submission ended on ${sunsetDate(sunset)} and can no longer be enabled.`);
151
+ deps.print(" Reports can still be sent from your own terminal.");
152
+ deps.print("");
153
+ return 1;
154
+ }
155
+ if (!deps.interactive()) {
156
+ deps.print("");
157
+ deps.print(" Enabling unattended sending needs a terminal to ask in, and this");
158
+ deps.print(" session has none. Nothing was changed.");
159
+ deps.print("");
160
+ deps.print(" Run `vincentt dx enable` yourself, in your own terminal.");
161
+ deps.print("");
162
+ return 1;
163
+ }
164
+ // What is being granted, INCLUDING the note risk. The note is the part a
165
+ // creator does not picture: enablement is what lets an agent compose prose they
166
+ // have not read and put it on the wire.
167
+ deps.print("");
168
+ deps.print(" Unattended DX submission");
169
+ deps.print("");
170
+ deps.print(" Your agent will be able to send friction reports from this machine without");
171
+ deps.print(" asking you first. A report includes a note, which is sent exactly as written.");
172
+ deps.print(" An agent may compose a note you have not read.");
173
+ deps.print("");
174
+ deps.print(" This stays on until you run `vincentt dx disable`.");
175
+ deps.print("");
176
+ if (!(await deps.confirm("Enable on this machine?"))) {
177
+ deps.print("");
178
+ deps.print(" Not enabled.");
179
+ deps.print("");
180
+ return 0;
181
+ }
182
+ // Written on every enable, so the age reports from the LATEST grant rather than
183
+ // the first — a re-enable is a fresh decision and dates from when it was made.
184
+ await deps.save({ at: new Date(deps.now()).toISOString() });
185
+ deps.print("");
186
+ deps.print(" Enabled. Reports may now be sent without a prompt.");
187
+ deps.print("");
188
+ return 0;
189
+ }
190
+ /**
191
+ * `vincentt dx disable` — the obvious opposite command.
192
+ *
193
+ * Clears BOTH keys. Leaving the timestamp behind would make a later re-enable
194
+ * report its age from the first grant, which is the kind of thing that ships
195
+ * wrong and is never noticed. Needs no terminal and no confirmation: turning a
196
+ * capability OFF is always safe to do, and requiring a TTY here would strand a
197
+ * creator who wanted to revoke from a script.
198
+ */
199
+ export async function dxDisableCommand(deps) {
200
+ await deps.save(undefined);
201
+ deps.print("");
202
+ deps.print(" Unattended sending is off. Reports need your confirmation again.");
203
+ deps.print("");
204
+ return 0;
205
+ }
206
+ /**
207
+ * `vincentt dx --send`.
208
+ *
209
+ * The order is the consent model and is not an implementation detail: find the
210
+ * friction, BUILD the payload, SHOW it in full, ask, and only then send. A
211
+ * creator who cannot see what leaves their machine before it leaves has not
212
+ * agreed to anything.
213
+ */
214
+ export async function dxSendCommand(deps, options) {
215
+ if (!deps.enabled()) {
216
+ deps.print("The DX journal is off (VINCENTT_DX_JOURNAL=0). There is nothing to send.");
217
+ return 0;
218
+ }
219
+ const entries = withinDays(deps.entries(), options.days, deps.now());
220
+ const clusters = rankClusters(findClusters(entries));
221
+ if (clusters.length === 0) {
222
+ // Nothing to report is a result, not a failure. Sending an empty report
223
+ // would cost the creator a round trip to tell us nothing.
224
+ deps.print(`No friction detected in the last ${options.days} days — nothing to send.`);
225
+ return 0;
226
+ }
227
+ // SENDING requires either an interactive session or durable enablement — the
228
+ // gate is on --send, not on --yes.
229
+ //
230
+ // The nudge prints into an agent's transcript, and an agent that reads it has
231
+ // every incentive to act: it just failed four times. Its reflex for an
232
+ // interactive prompt is to add --yes. That path ends with the AGENT deciding
233
+ // to transmit, supplying the note, and the human seeing the payload scroll
234
+ // past in a transcript they may not be reading — which is not the opt-in this
235
+ // feature is built around.
236
+ //
237
+ // The gate used to sit on --yes, on the premise that the bare path was safe
238
+ // because "the prompt reads EOF under a pipe and fails closed". That premise
239
+ // was false: an empty pipe abandoned the promise rather than refusing, and a
240
+ // pipe carrying a line answered it — `echo y | vincentt dx --send` transmitted
241
+ // with no terminal and no human. --yes was never the boundary; --send is, so
242
+ // the check sits on the act being authorized rather than on a flag that only
243
+ // modifies how it is confirmed.
244
+ //
245
+ // The second arm is the SUPPORTED unattended path: a person, at a terminal,
246
+ // ran `vincentt dx enable` on this machine. That is a prior deliberate act
247
+ // rather than a per-send answer, which is what makes it consent at all.
248
+ const enablement = deps.enablement?.() ?? { enabled: false };
249
+ const interactive = deps.interactive();
250
+ if (!interactive && !enablement.enabled) {
251
+ // This refusal NAMES the enabling command. A refusal is an answer to a
252
+ // command the caller typed, which makes it SOLICITED output — the
253
+ // prohibition is on UNBIDDEN output instructing a caller (the nudge names
254
+ // neither `enable` nor `--send`, and still must not). A refusal naming
255
+ // nothing satisfies the letter of that rule and leaves a human at exit 1
256
+ // with no path forward, for no security gain: guessability was never the
257
+ // threat.
258
+ deps.print("Sending a report needs your confirmation, and this session has no terminal");
259
+ deps.print("to ask in. Run `vincentt dx enable` yourself to allow reports from this");
260
+ deps.print("machine, or run `vincentt dx --send` in your own terminal.");
261
+ return 1;
262
+ }
263
+ const auth = await deps.auth();
264
+ if (auth === undefined) {
265
+ deps.print("Not signed in. Run `vincentt login` to send a report.");
266
+ return 1;
267
+ }
268
+ const report = buildReport(clusters, deps.cliVersion(), options.note);
269
+ // 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.
271
+ const unattended = !interactive && enablement.enabled;
272
+ if (unattended) {
273
+ const line = enablementLine(enablement, deps.now());
274
+ if (line !== undefined) {
275
+ deps.print("");
276
+ deps.print(`${line}. Not asking.`);
277
+ }
278
+ }
279
+ // renderPayload runs on EVERY path, including the unattended one. This is
280
+ // DISCLOSURE, NOT CONSENT: on the attended path it is what the creator answers
281
+ // to; unattended it is a forensic artifact that answers "what did we send"
282
+ // after the fact. Worth its one print, and not to be cited as carrying the
283
+ // weight the question used to. Only the ASK is ever skipped, never the SHOW.
284
+ for (const l of renderPayload(report))
285
+ deps.print(l);
286
+ deps.print("");
287
+ // The enabled machine does not ask. --yes still means only "skip the question"
288
+ // and is inert without one of the two arms above.
289
+ if (!unattended && !options.yes && !(await deps.confirm("Send this report?"))) {
290
+ deps.print("Not sent.");
291
+ return 0;
292
+ }
293
+ try {
294
+ const res = await deps.post(auth.apiUrl, auth.pat, report);
295
+ deps.print("");
296
+ deps.print(`✓ Reported — ${res.findings} ${res.findings === 1 ? "finding" : "findings"}, ref ${res.ref}`);
297
+ deps.print(` Kept until ${res.expiresAt}, then deleted.`);
298
+ deps.print(" Your journal stays on this machine.");
299
+ return 0;
300
+ }
301
+ catch (err) {
302
+ deps.print("");
303
+ deps.print(`✗ ${err instanceof Error ? err.message : String(err)}`);
304
+ return 1;
305
+ }
306
+ }