@pixelhop/dit 0.2.0 → 0.4.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 +98 -13
- package/dist/args.d.ts +9 -0
- package/dist/args.js +32 -3
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +10 -7
- package/dist/device.d.ts +25 -0
- package/dist/device.js +160 -0
- package/dist/index.js +40 -4
- package/dist/operations.js +17 -1
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.js +6 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,28 +8,39 @@ Node 22+. No runtime dependencies.
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npm install -g @pixelhop/dit
|
|
11
|
-
dit login
|
|
11
|
+
dit login
|
|
12
|
+
dit project create --name "Marketing site"
|
|
12
13
|
dit upload --project marketing-site --review pr-1234 --file shot.png
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
`dit login` on its own prints a link. Open it, approve, and the CLI has its own key —
|
|
17
|
+
nothing to copy, and no account needed beforehand. `--url` defaults to
|
|
18
|
+
`https://diditthough.app` and the token is remembered, so neither is repeated.
|
|
17
19
|
|
|
18
20
|
## Signing in
|
|
19
21
|
|
|
20
22
|
Sign in once; after that every command is just the command.
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
|
-
dit login
|
|
25
|
+
dit login
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
With no arguments this runs a browser approval. The CLI prints a link and a short code and
|
|
29
|
+
waits; a person opens the link, signs in or signs up, and clicks approve. The CLI is then
|
|
30
|
+
issued its own agent token scoped to that person's workspace. Nothing is ever copied
|
|
31
|
+
between windows, and the person's session is discarded immediately — it is a courier for
|
|
32
|
+
the approval, not the credential the agent keeps.
|
|
33
|
+
|
|
34
|
+
You can still pass a token directly, which is what CI does:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dit login --token dit_…
|
|
38
|
+
```
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
Either way the token is checked against the API before being stored — a token saved
|
|
41
|
+
without checking turns one clear failure here into a puzzling one on the next upload — and
|
|
42
|
+
written to `~/.config/dit/config.json` with `0600` permissions. `$XDG_CONFIG_HOME` is
|
|
43
|
+
respected, and `DIT_CONFIG` overrides the path outright. `dit logout` removes it.
|
|
33
44
|
|
|
34
45
|
### Where each value comes from
|
|
35
46
|
|
|
@@ -51,13 +62,47 @@ of one-line errors.
|
|
|
51
62
|
### `dit login` and `dit logout`
|
|
52
63
|
|
|
53
64
|
```bash
|
|
54
|
-
dit login
|
|
65
|
+
dit login # browser approval
|
|
66
|
+
dit login --token dit_… # a token you already have
|
|
55
67
|
dit login --token dit_… --url http://localhost:3000 # a local server
|
|
56
68
|
dit logout
|
|
57
69
|
```
|
|
58
70
|
|
|
59
71
|
`login` also accepts the token from `DIT_TOKEN`, which keeps it out of your shell history.
|
|
60
72
|
|
|
73
|
+
### Uploading with no account at all
|
|
74
|
+
|
|
75
|
+
If nothing is signed in, `dit upload` does not fail. It creates a temporary workspace that
|
|
76
|
+
nobody owns yet, uploads into that, and prints a claim link alongside the usual Markdown:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
This workspace has not been claimed. Everything uploaded here is deleted in 5 days unless
|
|
80
|
+
somebody claims it.
|
|
81
|
+
Claim it: https://diditthough.app/claim/…
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Pass that link on. Whoever follows it and signs in becomes the owner, and the workspace
|
|
85
|
+
moves to the Free plan with everything in it intact — including evidence that has already
|
|
86
|
+
stopped displaying, because the visible window is derived from the current plan rather than
|
|
87
|
+
frozen at upload time. Unclaimed workspaces are small on purpose: one project, three
|
|
88
|
+
reviews, media visible for five days and recoverable for seven more.
|
|
89
|
+
|
|
90
|
+
Deployments can switch this off, in which case `dit upload` asks you to run `dit login`
|
|
91
|
+
instead.
|
|
92
|
+
|
|
93
|
+
### `dit project create`
|
|
94
|
+
|
|
95
|
+
A project is where reviews live, one per repository or site. An agent can make its own,
|
|
96
|
+
provided its token is workspace-scoped rather than pinned to a single project — a token
|
|
97
|
+
tied to one project cannot create another, because widening your own reach is not
|
|
98
|
+
something a credential should be able to do.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
dit project create --name "Marketing site"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It prints the slug to pass to `--project` afterwards.
|
|
105
|
+
|
|
61
106
|
### `dit upload`
|
|
62
107
|
|
|
63
108
|
Uploads one or more files to a review, creating the review if it does not exist yet.
|
|
@@ -71,11 +116,15 @@ dit upload --project marketing-site --review pr-1234 \
|
|
|
71
116
|
|
|
72
117
|
`--review` takes either an existing `rev_…` id or your own reference — a PR number, a run
|
|
73
118
|
id, anything stable. A reference that has been seen before reuses that review, so a job
|
|
74
|
-
that runs twice on the same PR adds
|
|
119
|
+
that runs twice on the same PR adds screens to the existing review. To update an existing
|
|
120
|
+
screen, use `dit revision --artifact <id>` instead. `--project`
|
|
75
121
|
takes the project slug or its `prj_…` id.
|
|
76
122
|
|
|
77
123
|
Per-capture context, all optional: `--route /checkout`, `--viewport 390x844@3`,
|
|
78
|
-
`--scenario "logged out"`, `--title
|
|
124
|
+
`--scenario "logged out"`, `--title`, `--description`, and `--group`. Give each screen a
|
|
125
|
+
clear title and describe what the reviewer should check. These flags apply to every file
|
|
126
|
+
in a command; use separate commands with the same `--round` key when titles, descriptions
|
|
127
|
+
or viewports differ. Pass `--json` for the machine-readable form; the
|
|
79
128
|
default prints the review URL and the Markdown block to paste into a PR.
|
|
80
129
|
|
|
81
130
|
Images are sniffed by magic bytes (PNG, JPEG, WebP, GIF) and their dimensions read from
|
|
@@ -83,6 +132,42 @@ the file, so a mislabelled extension does not matter. For MP4 and WebM the CLI s
|
|
|
83
132
|
to `ffprobe` for duration and dimensions, and to `ffmpeg` for a poster frame — both are
|
|
84
133
|
optional. Without them the upload still succeeds, with a warning, minus the poster.
|
|
85
134
|
|
|
135
|
+
### Design alternatives
|
|
136
|
+
|
|
137
|
+
Use the same `--group` name within a review for alternatives to the same design. Upload
|
|
138
|
+
options separately so each has its own title, description, artifact ID and revision history:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
dit upload --project marketing-site --review pr-1234 --file checkout-a.png \
|
|
142
|
+
--title "Checkout — compact" --description "Shorter form with an inline order summary" \
|
|
143
|
+
--group "Checkout layout" --round checkout-options --summary "Two checkout layouts to compare"
|
|
144
|
+
dit upload --project marketing-site --review pr-1234 --file checkout-b.png \
|
|
145
|
+
--title "Checkout — split" --description "Form and order summary side by side" \
|
|
146
|
+
--group "Checkout layout" --round checkout-options
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Use `--json` to get artifact IDs and keep them for later `revision` commands. A variation group
|
|
150
|
+
connects alternatives; a round groups a batch of changes. The human chooses the preferred
|
|
151
|
+
option.
|
|
152
|
+
|
|
153
|
+
### Review rounds
|
|
154
|
+
|
|
155
|
+
Each upload or revision command creates one round; an upload includes all of its files. Use the same
|
|
156
|
+
`--round` key on upload and revision commands to group one batch of changes:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
dit revision --artifact art_123 --file overview.png --round polish-2 --summary "Tightened spacing and simplified the mobile header"
|
|
160
|
+
dit upload --project my-app --review rev_123 --file mobile.png --round polish-2
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Use a new key for the next batch. Historical round keys cannot be extended after a
|
|
164
|
+
new round starts. The first declaration supplies the round's optional summary.
|
|
165
|
+
Review rounds track evidence; screen titles, choices and feedback retain their
|
|
166
|
+
current state. Older uploads are shown as an Earlier uploads baseline.
|
|
167
|
+
Rounds let the reviewer step through the evidence timeline. Their Updated filter and
|
|
168
|
+
Mark as seen state are personal: agents must not clear them or verify the review.
|
|
169
|
+
After a feedback pass, reply, mark the relevant threads addressed, and return the review URL.
|
|
170
|
+
|
|
86
171
|
### `dit feedback`
|
|
87
172
|
|
|
88
173
|
```bash
|
package/dist/args.d.ts
CHANGED
|
@@ -16,7 +16,11 @@ export type CliArgs = ({
|
|
|
16
16
|
review: string;
|
|
17
17
|
files: string[];
|
|
18
18
|
} & RuntimeFlags & JsonFlag & {
|
|
19
|
+
round?: string;
|
|
20
|
+
summary?: string;
|
|
19
21
|
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
group?: string;
|
|
20
24
|
commit?: string;
|
|
21
25
|
branch?: string;
|
|
22
26
|
route?: string;
|
|
@@ -38,10 +42,15 @@ export type CliArgs = ({
|
|
|
38
42
|
command: "revision";
|
|
39
43
|
artifact: string;
|
|
40
44
|
file: string;
|
|
45
|
+
round?: string;
|
|
46
|
+
summary?: string;
|
|
41
47
|
} & RuntimeFlags & JsonFlag) | ({
|
|
42
48
|
command: "markdown";
|
|
43
49
|
review: string;
|
|
44
50
|
} & RuntimeFlags) | ({
|
|
51
|
+
command: "project:create";
|
|
52
|
+
name: string;
|
|
53
|
+
} & RuntimeFlags & JsonFlag) | ({
|
|
45
54
|
command: "login";
|
|
46
55
|
} & RuntimeFlags) | ({
|
|
47
56
|
command: "logout";
|
package/dist/args.js
CHANGED
|
@@ -7,11 +7,15 @@ const runtimeOptions = {
|
|
|
7
7
|
};
|
|
8
8
|
const commandOptions = {
|
|
9
9
|
upload: {
|
|
10
|
+
round: { type: "string" },
|
|
11
|
+
summary: { type: "string" },
|
|
10
12
|
...runtimeOptions,
|
|
11
13
|
project: { type: "string" },
|
|
12
14
|
review: { type: "string" },
|
|
13
15
|
file: { type: "string", multiple: true },
|
|
14
16
|
title: { type: "string" },
|
|
17
|
+
description: { type: "string" },
|
|
18
|
+
group: { type: "string" },
|
|
15
19
|
commit: { type: "string" },
|
|
16
20
|
branch: { type: "string" },
|
|
17
21
|
route: { type: "string" },
|
|
@@ -36,6 +40,8 @@ const commandOptions = {
|
|
|
36
40
|
message: { type: "string" },
|
|
37
41
|
},
|
|
38
42
|
revision: {
|
|
43
|
+
round: { type: "string" },
|
|
44
|
+
summary: { type: "string" },
|
|
39
45
|
...runtimeOptions,
|
|
40
46
|
artifact: { type: "string" },
|
|
41
47
|
file: { type: "string" },
|
|
@@ -45,6 +51,11 @@ const commandOptions = {
|
|
|
45
51
|
...runtimeOptions,
|
|
46
52
|
review: { type: "string" },
|
|
47
53
|
},
|
|
54
|
+
"project:create": {
|
|
55
|
+
...runtimeOptions,
|
|
56
|
+
name: { type: "string" },
|
|
57
|
+
json: { type: "boolean" },
|
|
58
|
+
},
|
|
48
59
|
login: runtimeOptions,
|
|
49
60
|
logout: {
|
|
50
61
|
url: { type: "string" },
|
|
@@ -52,9 +63,13 @@ const commandOptions = {
|
|
|
52
63
|
},
|
|
53
64
|
};
|
|
54
65
|
export function parseCliArgs(argv) {
|
|
55
|
-
|
|
66
|
+
// `project create` is the only two-word command. Joining it into the same
|
|
67
|
+
// `project:create` key the rest of the table uses keeps one lookup rather than
|
|
68
|
+
// a special case threaded through everything below.
|
|
69
|
+
const argv_ = argv[0] === "project" && argv[1] ? [`project:${argv[1]}`, ...argv.slice(2)] : argv;
|
|
70
|
+
const command = argv_[0];
|
|
56
71
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
57
|
-
return { command: "help", ...(
|
|
72
|
+
return { command: "help", ...(argv_[1] ? { topic: argv_[1] } : {}) };
|
|
58
73
|
}
|
|
59
74
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
60
75
|
return { command: "version" };
|
|
@@ -66,7 +81,7 @@ export function parseCliArgs(argv) {
|
|
|
66
81
|
let values;
|
|
67
82
|
try {
|
|
68
83
|
({ values } = parseArgs({
|
|
69
|
-
args:
|
|
84
|
+
args: argv_.slice(1),
|
|
70
85
|
options: commandOptions[name],
|
|
71
86
|
strict: true,
|
|
72
87
|
allowPositionals: false,
|
|
@@ -89,7 +104,11 @@ export function parseCliArgs(argv) {
|
|
|
89
104
|
project: required(values, "project", name),
|
|
90
105
|
review: required(values, "review", name),
|
|
91
106
|
files,
|
|
107
|
+
...optional(values, "round"),
|
|
108
|
+
...optional(values, "summary"),
|
|
92
109
|
...optional(values, "title"),
|
|
110
|
+
...optional(values, "description"),
|
|
111
|
+
...optional(values, "group"),
|
|
93
112
|
...optional(values, "commit"),
|
|
94
113
|
...optional(values, "branch"),
|
|
95
114
|
...optional(values, "route"),
|
|
@@ -128,6 +147,14 @@ export function parseCliArgs(argv) {
|
|
|
128
147
|
...runtime,
|
|
129
148
|
};
|
|
130
149
|
}
|
|
150
|
+
if (name === "project:create") {
|
|
151
|
+
return {
|
|
152
|
+
command: name,
|
|
153
|
+
name: required(values, "name", "project create"),
|
|
154
|
+
...(values.json ? { json: true } : {}),
|
|
155
|
+
...runtime,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
131
158
|
if (name === "login") {
|
|
132
159
|
return { command: name, ...runtime };
|
|
133
160
|
}
|
|
@@ -139,6 +166,8 @@ export function parseCliArgs(argv) {
|
|
|
139
166
|
command: name,
|
|
140
167
|
artifact: required(values, "artifact", name),
|
|
141
168
|
file: required(values, "file", name),
|
|
169
|
+
...optional(values, "round"),
|
|
170
|
+
...optional(values, "summary"),
|
|
142
171
|
...(values.json ? { json: true } : {}),
|
|
143
172
|
...runtime,
|
|
144
173
|
};
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { CliArgs } from "./args.js";
|
|
2
2
|
import { ApiClient, type ApiClientOptions } from "./client.js";
|
|
3
|
+
import { type DeviceLoginDeps } from "./device.js";
|
|
3
4
|
import type { Output } from "./output.js";
|
|
4
5
|
type CreateClient = (options: ApiClientOptions) => ApiClient;
|
|
5
6
|
export declare function login(args: Extract<CliArgs, {
|
|
6
7
|
command: "login";
|
|
7
|
-
}>, environment: NodeJS.ProcessEnv, output: Output, createClient?: CreateClient): Promise<void>;
|
|
8
|
+
}>, environment: NodeJS.ProcessEnv, output: Output, createClient?: CreateClient, device?: DeviceLoginDeps): Promise<void>;
|
|
8
9
|
export declare function logout(args: Extract<CliArgs, {
|
|
9
10
|
command: "logout";
|
|
10
11
|
}>, environment: NodeJS.ProcessEnv, output: Output): Promise<void>;
|
package/dist/auth.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { getCatalog } from "./catalog.js";
|
|
2
2
|
import { ApiClient } from "./client.js";
|
|
3
3
|
import { clearStoredToken, configPath, writeStoredToken } from "./config.js";
|
|
4
|
-
import {
|
|
4
|
+
import { deviceLogin } from "./device.js";
|
|
5
|
+
import { ApiError } from "./errors.js";
|
|
5
6
|
import { resolveUrl } from "./runtime.js";
|
|
6
|
-
export async function login(args, environment, output, createClient = (options) => new ApiClient(options)) {
|
|
7
|
+
export async function login(args, environment, output, createClient = (options) => new ApiClient(options), device = {}) {
|
|
7
8
|
const url = resolveUrl(args.url, environment);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// No token given? Ask a human to approve one in a browser instead. This is the
|
|
10
|
+
// whole point: an agent can get itself signed in without anyone copying a
|
|
11
|
+
// secret between two windows.
|
|
12
|
+
const token = args.token ?? environment.DIT_TOKEN ?? (await deviceLogin(url, output, device));
|
|
12
13
|
// Check the token before writing it. Storing a dead token turns one clear
|
|
13
|
-
// failure here into a confusing one on the next upload.
|
|
14
|
+
// failure here into a confusing one on the next upload. A token that has just
|
|
15
|
+
// come back from the device flow is checked too — it costs one request, and it
|
|
16
|
+
// means "signed in" is never printed over something that does not work.
|
|
14
17
|
try {
|
|
15
18
|
await getCatalog(createClient({ url, token }));
|
|
16
19
|
}
|
package/dist/device.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Output } from "./output.js";
|
|
2
|
+
export type DeviceLoginDeps = {
|
|
3
|
+
fetch?: typeof fetch;
|
|
4
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
};
|
|
7
|
+
export declare function deviceLogin(url: string, output: Output, deps?: DeviceLoginDeps): Promise<string>;
|
|
8
|
+
export type Bootstrapped = {
|
|
9
|
+
token: string;
|
|
10
|
+
project: {
|
|
11
|
+
slug: string;
|
|
12
|
+
};
|
|
13
|
+
claimUrl: string;
|
|
14
|
+
warning: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Make somewhere to upload to for an agent with no credentials at all.
|
|
18
|
+
*
|
|
19
|
+
* This is what lets an agent show its human the product rather than describe it:
|
|
20
|
+
* it uploads the evidence first and hands over a link, and the human claims the
|
|
21
|
+
* result if they want to keep it. The warning and the claim URL are returned by
|
|
22
|
+
* the server rather than composed here, so the retention number a person reads
|
|
23
|
+
* always comes from the plan catalogue and cannot drift.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bootstrapUnclaimed(url: string, projectName: string, doFetch?: typeof fetch): Promise<Bootstrapped>;
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { ApiError, CliError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Signing in without a token to paste.
|
|
4
|
+
*
|
|
5
|
+
* The CLI asks the server for a pair of codes, shows the person a URL and a
|
|
6
|
+
* short code, and polls until they have approved it in a browser. What comes
|
|
7
|
+
* back is a *session* token, which is not what an agent should hold: it belongs
|
|
8
|
+
* to the person, expires on their schedule, and carries their full authority.
|
|
9
|
+
* So the last step trades it immediately for a workspace-scoped `dit_` agent
|
|
10
|
+
* token and forgets the session. The session is a courier.
|
|
11
|
+
*
|
|
12
|
+
* The shape of the exchange is OAuth 2.0 device authorization (RFC 8628), which
|
|
13
|
+
* is the same flow `gh auth login` uses, so the polling error codes below are
|
|
14
|
+
* that specification's rather than this product's.
|
|
15
|
+
*/
|
|
16
|
+
const CLIENT_ID = "dit-cli";
|
|
17
|
+
const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
18
|
+
/**
|
|
19
|
+
* Every capability an agent needs to do the whole loop.
|
|
20
|
+
*
|
|
21
|
+
* `project:create` is in here because `dit project create` is the very next
|
|
22
|
+
* thing an agent runs after signing in, and a token without it gets a 403 on
|
|
23
|
+
* the step the documentation tells it to take.
|
|
24
|
+
*/
|
|
25
|
+
const AGENT_CAPABILITIES = [
|
|
26
|
+
"artifact:write",
|
|
27
|
+
"artifact:read",
|
|
28
|
+
"feedback:read",
|
|
29
|
+
"thread:reply",
|
|
30
|
+
"project:create",
|
|
31
|
+
];
|
|
32
|
+
export async function deviceLogin(url, output, deps = {}) {
|
|
33
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
34
|
+
const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
35
|
+
const now = deps.now ?? (() => Date.now());
|
|
36
|
+
const start = await requestCode(url, doFetch);
|
|
37
|
+
const link = start.verification_uri_complete ?? start.verification_uri;
|
|
38
|
+
output.out(`To finish signing in, open this and approve:\n\n ${link}\n\n` +
|
|
39
|
+
`If it asks for a code, it is: ${start.user_code}\n\nWaiting…\n`);
|
|
40
|
+
const accessToken = await poll(url, start, { doFetch, sleep, now });
|
|
41
|
+
return exchangeForAgentToken(url, accessToken, doFetch);
|
|
42
|
+
}
|
|
43
|
+
async function requestCode(url, doFetch) {
|
|
44
|
+
const response = await doFetch(`${url}/api/auth/device/code`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
body: JSON.stringify({ client_id: CLIENT_ID }),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new ApiError(`Could not start sign-in at ${url} (HTTP ${response.status})`, response.status);
|
|
51
|
+
}
|
|
52
|
+
return (await response.json());
|
|
53
|
+
}
|
|
54
|
+
async function poll(url, start, runtime) {
|
|
55
|
+
// The server states how often it wants to be asked and how long the code is
|
|
56
|
+
// good for. Both are honoured rather than guessed: polling faster than asked
|
|
57
|
+
// earns a `slow_down`, and polling past the expiry just annoys the server.
|
|
58
|
+
let intervalMs = (start.interval ?? 5) * 1000;
|
|
59
|
+
const deadline = runtime.now() + start.expires_in * 1000;
|
|
60
|
+
while (runtime.now() < deadline) {
|
|
61
|
+
await runtime.sleep(intervalMs);
|
|
62
|
+
const response = await runtime.doFetch(`${url}/api/auth/device/token`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/json" },
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
grant_type: GRANT_TYPE,
|
|
67
|
+
device_code: start.device_code,
|
|
68
|
+
client_id: CLIENT_ID,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
const payload = (await response.json().catch(() => ({})));
|
|
72
|
+
if (payload.access_token)
|
|
73
|
+
return payload.access_token;
|
|
74
|
+
switch (payload.error) {
|
|
75
|
+
case "authorization_pending":
|
|
76
|
+
break;
|
|
77
|
+
case "slow_down":
|
|
78
|
+
intervalMs += 5000;
|
|
79
|
+
break;
|
|
80
|
+
case "access_denied":
|
|
81
|
+
throw new CliError("Sign-in was denied in the browser.", 1);
|
|
82
|
+
case "expired_token":
|
|
83
|
+
throw new CliError("The sign-in code expired. Run `dit login` again.", 1);
|
|
84
|
+
default:
|
|
85
|
+
throw new ApiError(payload.error_description ?? payload.error ?? "Sign-in failed", response.status, payload.error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new CliError("Timed out waiting for approval. Run `dit login` again.", 1);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Swap the person's session for an agent token belonging to their workspace.
|
|
92
|
+
*
|
|
93
|
+
* Deliberately workspace-scoped rather than tied to one project, because an
|
|
94
|
+
* agent that cannot create a project would still need a human to make one.
|
|
95
|
+
*/
|
|
96
|
+
async function exchangeForAgentToken(url, accessToken, doFetch) {
|
|
97
|
+
// Which workspace is asked for explicitly. The server refuses to guess when
|
|
98
|
+
// somebody belongs to more than one, and belonging to two is ordinary — it is
|
|
99
|
+
// what happens the moment they claim a workspace an agent made for them, so
|
|
100
|
+
// leaving this out breaks sign-in for exactly the people this feature creates.
|
|
101
|
+
const workspaceId = await currentWorkspaceId(url, accessToken, doFetch);
|
|
102
|
+
const response = await doFetch(`${url}/api/tokens`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: {
|
|
105
|
+
"content-type": "application/json",
|
|
106
|
+
authorization: `Bearer ${accessToken}`,
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
name: agentTokenName(),
|
|
110
|
+
capabilities: [...AGENT_CAPABILITIES],
|
|
111
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const detail = (await response.json().catch(() => ({})));
|
|
116
|
+
throw new ApiError(detail.message ?? `Could not create an agent token (HTTP ${response.status})`, response.status);
|
|
117
|
+
}
|
|
118
|
+
const created = (await response.json());
|
|
119
|
+
if (!created.token) {
|
|
120
|
+
throw new ApiError("The server did not return an agent token", response.status);
|
|
121
|
+
}
|
|
122
|
+
return created.token;
|
|
123
|
+
}
|
|
124
|
+
/** The workspace `/api/me` reports for this person, if it will say. */
|
|
125
|
+
async function currentWorkspaceId(url, accessToken, doFetch) {
|
|
126
|
+
const response = await doFetch(`${url}/api/me`, {
|
|
127
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok)
|
|
130
|
+
return undefined;
|
|
131
|
+
const payload = (await response.json().catch(() => ({})));
|
|
132
|
+
return payload.workspace?.id;
|
|
133
|
+
}
|
|
134
|
+
function agentTokenName() {
|
|
135
|
+
const host = process.env.HOSTNAME?.trim();
|
|
136
|
+
return host ? `dit CLI on ${host}` : "dit CLI";
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Make somewhere to upload to for an agent with no credentials at all.
|
|
140
|
+
*
|
|
141
|
+
* This is what lets an agent show its human the product rather than describe it:
|
|
142
|
+
* it uploads the evidence first and hands over a link, and the human claims the
|
|
143
|
+
* result if they want to keep it. The warning and the claim URL are returned by
|
|
144
|
+
* the server rather than composed here, so the retention number a person reads
|
|
145
|
+
* always comes from the plan catalogue and cannot drift.
|
|
146
|
+
*/
|
|
147
|
+
export async function bootstrapUnclaimed(url, projectName, doFetch = globalThis.fetch) {
|
|
148
|
+
const response = await doFetch(`${url}/v1/bootstrap`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "content-type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ project: projectName }),
|
|
152
|
+
});
|
|
153
|
+
const payload = (await response.json().catch(() => ({})));
|
|
154
|
+
if (!response.ok || !payload.token) {
|
|
155
|
+
throw new ApiError(payload.data?.message ??
|
|
156
|
+
payload.message ??
|
|
157
|
+
`Could not create a workspace at ${url} (HTTP ${response.status})`, response.status);
|
|
158
|
+
}
|
|
159
|
+
return payload;
|
|
160
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,10 +3,11 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { parseCliArgs } from "./args.js";
|
|
4
4
|
import { login, logout } from "./auth.js";
|
|
5
5
|
import { ApiClient } from "./client.js";
|
|
6
|
-
import { DEFAULT_URL } from "./config.js";
|
|
6
|
+
import { DEFAULT_URL, writeStoredToken } from "./config.js";
|
|
7
|
+
import { bootstrapUnclaimed } from "./device.js";
|
|
7
8
|
import { CliError } from "./errors.js";
|
|
8
9
|
import { executeCommand } from "./operations.js";
|
|
9
|
-
import { resolveRuntime } from "./runtime.js";
|
|
10
|
+
import { findToken, resolveRuntime, resolveUrl } from "./runtime.js";
|
|
10
11
|
// Read from the manifest rather than a second copy of the number here: the
|
|
11
12
|
// published version is set by the release tag, and a hard-coded constant is
|
|
12
13
|
// guaranteed to start lying the first time one is cut. `../package.json` from
|
|
@@ -15,14 +16,15 @@ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
|
15
16
|
const HELP = `Usage: dit <command> [options]
|
|
16
17
|
|
|
17
18
|
Commands:
|
|
18
|
-
login --token <token> [--url <url>]
|
|
19
|
+
login [--token <token>] [--url <url>] Sign in; opens a browser approval
|
|
19
20
|
logout [--url <url>]
|
|
20
21
|
upload --project <slug|id> --review <ref|id> --file <path> [--file <path> ...]
|
|
21
22
|
feedback --review <ref|id> [--status open|addressed|resolved|all] [--json]
|
|
22
23
|
reply --thread <id> --message <text>
|
|
23
24
|
address --thread <id> [--message <text>]
|
|
24
|
-
revision --artifact <id> --file <path> [--json]
|
|
25
|
+
revision --artifact <id> --file <path> [--round <key>] [--summary <text>] [--json]
|
|
25
26
|
markdown --review <ref|id>
|
|
27
|
+
project create --name <name> [--json]
|
|
26
28
|
|
|
27
29
|
Global options:
|
|
28
30
|
--url <url> API base URL (default ${DEFAULT_URL}, or DIT_URL)
|
|
@@ -33,6 +35,19 @@ Global options:
|
|
|
33
35
|
Upload options:
|
|
34
36
|
--title <text> --commit <sha> --branch <name> --route <path>
|
|
35
37
|
--viewport <WIDTHxHEIGHT[@SCALE]> --scenario <name> --json
|
|
38
|
+
--description <text> --group <name> Context and alternatives for the same design
|
|
39
|
+
--round <key> --summary <text> Group uploads into a review round with a change summary
|
|
40
|
+
|
|
41
|
+
Review workflow:
|
|
42
|
+
Give screens a clear --title and --description. Upload flags apply to every file.
|
|
43
|
+
Alternatives: upload each option with a distinct title and the same --group name.
|
|
44
|
+
Existing screen: use revision --artifact <id> to retain its history and notes.
|
|
45
|
+
One feedback pass: share a --round key across upload and revision commands.
|
|
46
|
+
Use a new key for the next pass; older rounds cannot be extended once a new one starts.
|
|
47
|
+
The first command sets --summary. Without --round, each command creates a round.
|
|
48
|
+
Reuse --review for the same PR; upload adds screens, revision updates a screen.
|
|
49
|
+
Read feedback --json, fix, upload revisions, reply, then address the threads.
|
|
50
|
+
Humans choose preferred designs, mark updates as seen, and verify reviews.
|
|
36
51
|
`;
|
|
37
52
|
async function main() {
|
|
38
53
|
const args = parseCliArgs(process.argv.slice(2));
|
|
@@ -52,6 +67,27 @@ async function main() {
|
|
|
52
67
|
return login(args, process.env, output);
|
|
53
68
|
if (args.command === "logout")
|
|
54
69
|
return logout(args, process.env, output);
|
|
70
|
+
// An upload with no credentials anywhere does not fail. It creates a temporary
|
|
71
|
+
// workspace nobody owns yet, uploads into that, and hands back a link the
|
|
72
|
+
// human can claim — which is the whole point: an agent can show this product
|
|
73
|
+
// working on its human's own change before that human has an account.
|
|
74
|
+
if (args.command === "upload") {
|
|
75
|
+
const url = resolveUrl(args.url, process.env);
|
|
76
|
+
const existing = await findToken(args, url, process.env);
|
|
77
|
+
if (!existing) {
|
|
78
|
+
const created = await bootstrapUnclaimed(url, args.project);
|
|
79
|
+
// Saved, not held for the one command. Without this the agent cannot come
|
|
80
|
+
// back for the notes: `dit feedback` would find no token and bootstrap a
|
|
81
|
+
// second workspace, so the review it asked about would be one nobody had
|
|
82
|
+
// looked at. Saving it also means a later claim simply makes the token's
|
|
83
|
+
// workspace somebody's, and the agent carries on unaware.
|
|
84
|
+
await writeStoredToken(url, created.token, process.env);
|
|
85
|
+
output.out(`${created.warning}\nClaim it: ${created.claimUrl}\n\n`);
|
|
86
|
+
await executeCommand({ ...args, project: created.project.slug }, new ApiClient({ url, token: created.token }), output);
|
|
87
|
+
output.out(`\nThis review is not claimed yet. Claim it: ${created.claimUrl}\n`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
55
91
|
const runtime = await resolveRuntime(args, process.env);
|
|
56
92
|
await executeCommand(args, new ApiClient(runtime), output);
|
|
57
93
|
}
|
package/dist/operations.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { getCatalog, resolveProject, resolveReview } from "./catalog.js";
|
|
2
3
|
import { ApiError } from "./errors.js";
|
|
3
4
|
import { prepareMedia } from "./media.js";
|
|
@@ -29,6 +30,13 @@ export async function executeCommand(args, client, output) {
|
|
|
29
30
|
output.out(`Addressed ${args.thread}.\n`);
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
33
|
+
if (args.command === "project:create") {
|
|
34
|
+
const created = await client.requestJson("/v1/projects", { method: "POST", idempotent: true, body: { name: args.name } });
|
|
35
|
+
output.out(args.json
|
|
36
|
+
? printJson(created)
|
|
37
|
+
: `Created project ${created.project.name}.\nUse --project ${created.project.slug} when uploading.\n`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
32
40
|
const review = resolveReview(await getCatalog(client), args.review);
|
|
33
41
|
output.out(await client.requestText(`/v1/reviews/${encodeURIComponent(review.id)}/markdown?format=text`));
|
|
34
42
|
}
|
|
@@ -55,6 +63,7 @@ async function upload(args, client, output) {
|
|
|
55
63
|
},
|
|
56
64
|
});
|
|
57
65
|
}
|
|
66
|
+
const roundKey = args.round ?? randomUUID();
|
|
58
67
|
const completions = [];
|
|
59
68
|
for (const file of args.files) {
|
|
60
69
|
const prepared = await prepareMedia(file, output.warn);
|
|
@@ -62,9 +71,13 @@ async function upload(args, client, output) {
|
|
|
62
71
|
completions.push(await declareUploadComplete(client, prepared, {
|
|
63
72
|
path: "/v1/artifacts/uploads",
|
|
64
73
|
declaration: {
|
|
74
|
+
roundKey,
|
|
75
|
+
...(args.summary ? { roundSummary: args.summary } : {}),
|
|
65
76
|
reviewId: reviewPayload.review.id,
|
|
66
77
|
kind: prepared.kind,
|
|
67
78
|
title: args.title ?? stripExtension(prepared.name),
|
|
79
|
+
...(args.description ? { description: args.description } : {}),
|
|
80
|
+
...(args.group ? { variationGroup: args.group } : {}),
|
|
68
81
|
...(args.route ? { route: args.route } : {}),
|
|
69
82
|
...(args.viewport ? { viewport: args.viewport } : {}),
|
|
70
83
|
...(args.commit ? { commit: args.commit } : {}),
|
|
@@ -93,7 +106,10 @@ async function revision(args, client, output) {
|
|
|
93
106
|
try {
|
|
94
107
|
const completion = await declareUploadComplete(client, prepared, {
|
|
95
108
|
path: `/v1/artifacts/${encodeURIComponent(args.artifact)}/revisions`,
|
|
96
|
-
declaration: {
|
|
109
|
+
declaration: {
|
|
110
|
+
roundKey: args.round ?? randomUUID(),
|
|
111
|
+
...(args.summary ? { roundSummary: args.summary } : {}),
|
|
112
|
+
},
|
|
97
113
|
});
|
|
98
114
|
if (args.json) {
|
|
99
115
|
output.out(printJson(completion));
|
package/dist/runtime.d.ts
CHANGED
|
@@ -9,3 +9,8 @@ export declare function resolveRuntime(args: ApiCommandArgs, environment: NodeJS
|
|
|
9
9
|
url: string;
|
|
10
10
|
token: string;
|
|
11
11
|
}>;
|
|
12
|
+
/** The same resolution, without insisting there be an answer. */
|
|
13
|
+
export declare function findToken(args: {
|
|
14
|
+
token?: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
}, url: string, environment: NodeJS.ProcessEnv): Promise<string | undefined>;
|
package/dist/runtime.js
CHANGED
|
@@ -21,9 +21,13 @@ export function resolveUrl(url, environment) {
|
|
|
21
21
|
*/
|
|
22
22
|
export async function resolveRuntime(args, environment) {
|
|
23
23
|
const url = resolveUrl(args.url, environment);
|
|
24
|
-
const token =
|
|
24
|
+
const token = await findToken(args, url, environment);
|
|
25
25
|
if (!token) {
|
|
26
|
-
throw new UsageError(`No agent token for ${url}: run \`dit login
|
|
26
|
+
throw new UsageError(`No agent token for ${url}: run \`dit login\`, pass --token, or set DIT_TOKEN`);
|
|
27
27
|
}
|
|
28
28
|
return { url, token };
|
|
29
29
|
}
|
|
30
|
+
/** The same resolution, without insisting there be an answer. */
|
|
31
|
+
export async function findToken(args, url, environment) {
|
|
32
|
+
return args.token ?? environment.DIT_TOKEN ?? (await readStoredToken(url, environment));
|
|
33
|
+
}
|