@pixelhop/dit 0.2.0 → 0.3.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 +56 -11
- package/dist/args.d.ts +3 -0
- package/dist/args.js +20 -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 +26 -3
- package/dist/operations.js +7 -0
- 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.
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -45,6 +45,11 @@ const commandOptions = {
|
|
|
45
45
|
...runtimeOptions,
|
|
46
46
|
review: { type: "string" },
|
|
47
47
|
},
|
|
48
|
+
"project:create": {
|
|
49
|
+
...runtimeOptions,
|
|
50
|
+
name: { type: "string" },
|
|
51
|
+
json: { type: "boolean" },
|
|
52
|
+
},
|
|
48
53
|
login: runtimeOptions,
|
|
49
54
|
logout: {
|
|
50
55
|
url: { type: "string" },
|
|
@@ -52,9 +57,13 @@ const commandOptions = {
|
|
|
52
57
|
},
|
|
53
58
|
};
|
|
54
59
|
export function parseCliArgs(argv) {
|
|
55
|
-
|
|
60
|
+
// `project create` is the only two-word command. Joining it into the same
|
|
61
|
+
// `project:create` key the rest of the table uses keeps one lookup rather than
|
|
62
|
+
// a special case threaded through everything below.
|
|
63
|
+
const argv_ = argv[0] === "project" && argv[1] ? [`project:${argv[1]}`, ...argv.slice(2)] : argv;
|
|
64
|
+
const command = argv_[0];
|
|
56
65
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
57
|
-
return { command: "help", ...(
|
|
66
|
+
return { command: "help", ...(argv_[1] ? { topic: argv_[1] } : {}) };
|
|
58
67
|
}
|
|
59
68
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
60
69
|
return { command: "version" };
|
|
@@ -66,7 +75,7 @@ export function parseCliArgs(argv) {
|
|
|
66
75
|
let values;
|
|
67
76
|
try {
|
|
68
77
|
({ values } = parseArgs({
|
|
69
|
-
args:
|
|
78
|
+
args: argv_.slice(1),
|
|
70
79
|
options: commandOptions[name],
|
|
71
80
|
strict: true,
|
|
72
81
|
allowPositionals: false,
|
|
@@ -128,6 +137,14 @@ export function parseCliArgs(argv) {
|
|
|
128
137
|
...runtime,
|
|
129
138
|
};
|
|
130
139
|
}
|
|
140
|
+
if (name === "project:create") {
|
|
141
|
+
return {
|
|
142
|
+
command: name,
|
|
143
|
+
name: required(values, "name", "project create"),
|
|
144
|
+
...(values.json ? { json: true } : {}),
|
|
145
|
+
...runtime,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
131
148
|
if (name === "login") {
|
|
132
149
|
return { command: name, ...runtime };
|
|
133
150
|
}
|
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,7 +16,7 @@ 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]
|
|
@@ -23,6 +24,7 @@ Commands:
|
|
|
23
24
|
address --thread <id> [--message <text>]
|
|
24
25
|
revision --artifact <id> --file <path> [--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)
|
|
@@ -52,6 +54,27 @@ async function main() {
|
|
|
52
54
|
return login(args, process.env, output);
|
|
53
55
|
if (args.command === "logout")
|
|
54
56
|
return logout(args, process.env, output);
|
|
57
|
+
// An upload with no credentials anywhere does not fail. It creates a temporary
|
|
58
|
+
// workspace nobody owns yet, uploads into that, and hands back a link the
|
|
59
|
+
// human can claim — which is the whole point: an agent can show this product
|
|
60
|
+
// working on its human's own change before that human has an account.
|
|
61
|
+
if (args.command === "upload") {
|
|
62
|
+
const url = resolveUrl(args.url, process.env);
|
|
63
|
+
const existing = await findToken(args, url, process.env);
|
|
64
|
+
if (!existing) {
|
|
65
|
+
const created = await bootstrapUnclaimed(url, args.project);
|
|
66
|
+
// Saved, not held for the one command. Without this the agent cannot come
|
|
67
|
+
// back for the notes: `dit feedback` would find no token and bootstrap a
|
|
68
|
+
// second workspace, so the review it asked about would be one nobody had
|
|
69
|
+
// looked at. Saving it also means a later claim simply makes the token's
|
|
70
|
+
// workspace somebody's, and the agent carries on unaware.
|
|
71
|
+
await writeStoredToken(url, created.token, process.env);
|
|
72
|
+
output.out(`${created.warning}\nClaim it: ${created.claimUrl}\n\n`);
|
|
73
|
+
await executeCommand({ ...args, project: created.project.slug }, new ApiClient({ url, token: created.token }), output);
|
|
74
|
+
output.out(`\nThis review is not claimed yet. Claim it: ${created.claimUrl}\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
55
78
|
const runtime = await resolveRuntime(args, process.env);
|
|
56
79
|
await executeCommand(args, new ApiClient(runtime), output);
|
|
57
80
|
}
|
package/dist/operations.js
CHANGED
|
@@ -29,6 +29,13 @@ export async function executeCommand(args, client, output) {
|
|
|
29
29
|
output.out(`Addressed ${args.thread}.\n`);
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
+
if (args.command === "project:create") {
|
|
33
|
+
const created = await client.requestJson("/v1/projects", { method: "POST", idempotent: true, body: { name: args.name } });
|
|
34
|
+
output.out(args.json
|
|
35
|
+
? printJson(created)
|
|
36
|
+
: `Created project ${created.project.name}.\nUse --project ${created.project.slug} when uploading.\n`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
32
39
|
const review = resolveReview(await getCatalog(client), args.review);
|
|
33
40
|
output.out(await client.requestText(`/v1/reviews/${encodeURIComponent(review.id)}/markdown?format=text`));
|
|
34
41
|
}
|
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
|
+
}
|