@pixelhop/dit 0.1.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 CHANGED
@@ -7,32 +7,102 @@ PR-ready Markdown, and later reads the humans' annotations as structured JSON.
7
7
  Node 22+. No runtime dependencies.
8
8
 
9
9
  ```bash
10
- npx @pixelhop/dit --help
11
- # or install it once
12
10
  npm install -g @pixelhop/dit
11
+ dit login
12
+ dit project create --name "Marketing site"
13
+ dit upload --project marketing-site --review pr-1234 --file shot.png
13
14
  ```
14
15
 
15
- ## Configuration
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.
16
19
 
17
- Two values, from flags or the environment:
20
+ ## Signing in
18
21
 
19
- | Flag | Environment | Meaning |
20
- | --------------- | ----------- | ----------------------------------------------------------- |
21
- | `--url <url>` | `DIT_URL` | API base URL — `https://diditthough.app` for the hosted app |
22
- | `--token <tok>` | `DIT_TOKEN` | Agent token (`dit_…`), created per project in the web UI |
22
+ Sign in once; after that every command is just the command.
23
23
 
24
- Set `DIT_DEBUG=1` to print stack traces instead of one-line errors.
24
+ ```bash
25
+ dit login
26
+ ```
27
+
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:
25
35
 
26
36
  ```bash
27
- export DIT_URL=https://diditthough.app
28
- export DIT_TOKEN=dit_…
37
+ dit login --token dit_…
29
38
  ```
30
39
 
31
- The token is scoped to one project and one workspace. Agents cannot create projects — a
32
- human makes the project and mints the token first.
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.
44
+
45
+ ### Where each value comes from
46
+
47
+ `--url` defaults to the hosted service, so you only pass it to point at a local
48
+ `wrangler dev`. Tokens are saved per URL, so signing in locally cannot quietly overwrite
49
+ the production one.
50
+
51
+ | Value | Resolution order |
52
+ | ----- | ------------------------------------------------------------------------- |
53
+ | URL | `--url`, then `DIT_URL`, then `https://diditthough.app` |
54
+ | Token | `--token`, then `DIT_TOKEN`, then whatever `dit login` saved for that URL |
55
+
56
+ The saved token comes last on purpose: a one-off `--token`, or `DIT_TOKEN` in CI, wins
57
+ without anyone having to sign out first. Set `DIT_DEBUG=1` to print stack traces instead
58
+ of one-line errors.
33
59
 
34
60
  ## Commands
35
61
 
62
+ ### `dit login` and `dit logout`
63
+
64
+ ```bash
65
+ dit login # browser approval
66
+ dit login --token dit_… # a token you already have
67
+ dit login --token dit_… --url http://localhost:3000 # a local server
68
+ dit logout
69
+ ```
70
+
71
+ `login` also accepts the token from `DIT_TOKEN`, which keeps it out of your shell history.
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
+
36
106
  ### `dit upload`
37
107
 
38
108
  Uploads one or more files to a review, creating the review if it does not exist yet.
package/dist/args.d.ts CHANGED
@@ -41,13 +41,24 @@ export type CliArgs = ({
41
41
  } & RuntimeFlags & JsonFlag) | ({
42
42
  command: "markdown";
43
43
  review: string;
44
- } & RuntimeFlags) | {
44
+ } & RuntimeFlags) | ({
45
+ command: "project:create";
46
+ name: string;
47
+ } & RuntimeFlags & JsonFlag) | ({
48
+ command: "login";
49
+ } & RuntimeFlags) | ({
50
+ command: "logout";
51
+ } & Pick<RuntimeFlags, "url">) | {
45
52
  command: "help";
46
53
  topic?: string;
47
54
  } | {
48
55
  command: "version";
49
56
  };
50
57
  export type FeedbackStatus = "open" | "addressed" | "resolved" | "all";
58
+ /** The commands that talk to the API, so need a resolved URL and token. */
59
+ export type ApiCommandArgs = Exclude<CliArgs, {
60
+ command: "help" | "version" | "login" | "logout";
61
+ }>;
51
62
  export declare function parseCliArgs(argv: string[]): CliArgs;
52
63
  export declare function parseViewport(value: string): Viewport;
53
64
  export {};
package/dist/args.js CHANGED
@@ -45,11 +45,25 @@ 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
+ },
53
+ login: runtimeOptions,
54
+ logout: {
55
+ url: { type: "string" },
56
+ help: { type: "boolean", short: "h" },
57
+ },
48
58
  };
49
59
  export function parseCliArgs(argv) {
50
- const command = argv[0];
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];
51
65
  if (!command || command === "help" || command === "--help" || command === "-h") {
52
- return { command: "help", ...(argv[1] ? { topic: argv[1] } : {}) };
66
+ return { command: "help", ...(argv_[1] ? { topic: argv_[1] } : {}) };
53
67
  }
54
68
  if (command === "--version" || command === "-v" || command === "version") {
55
69
  return { command: "version" };
@@ -61,7 +75,7 @@ export function parseCliArgs(argv) {
61
75
  let values;
62
76
  try {
63
77
  ({ values } = parseArgs({
64
- args: argv.slice(1),
78
+ args: argv_.slice(1),
65
79
  options: commandOptions[name],
66
80
  strict: true,
67
81
  allowPositionals: false,
@@ -123,6 +137,20 @@ export function parseCliArgs(argv) {
123
137
  ...runtime,
124
138
  };
125
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
+ }
148
+ if (name === "login") {
149
+ return { command: name, ...runtime };
150
+ }
151
+ if (name === "logout") {
152
+ return { command: name, ...(runtime.url ? { url: runtime.url } : {}) };
153
+ }
126
154
  if (name === "revision") {
127
155
  return {
128
156
  command: name,
package/dist/auth.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { CliArgs } from "./args.js";
2
+ import { ApiClient, type ApiClientOptions } from "./client.js";
3
+ import { type DeviceLoginDeps } from "./device.js";
4
+ import type { Output } from "./output.js";
5
+ type CreateClient = (options: ApiClientOptions) => ApiClient;
6
+ export declare function login(args: Extract<CliArgs, {
7
+ command: "login";
8
+ }>, environment: NodeJS.ProcessEnv, output: Output, createClient?: CreateClient, device?: DeviceLoginDeps): Promise<void>;
9
+ export declare function logout(args: Extract<CliArgs, {
10
+ command: "logout";
11
+ }>, environment: NodeJS.ProcessEnv, output: Output): Promise<void>;
12
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,35 @@
1
+ import { getCatalog } from "./catalog.js";
2
+ import { ApiClient } from "./client.js";
3
+ import { clearStoredToken, configPath, writeStoredToken } from "./config.js";
4
+ import { deviceLogin } from "./device.js";
5
+ import { ApiError } from "./errors.js";
6
+ import { resolveUrl } from "./runtime.js";
7
+ export async function login(args, environment, output, createClient = (options) => new ApiClient(options), device = {}) {
8
+ const url = resolveUrl(args.url, environment);
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));
13
+ // Check the token before writing it. Storing a dead token turns one clear
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.
17
+ try {
18
+ await getCatalog(createClient({ url, token }));
19
+ }
20
+ catch (error) {
21
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
22
+ throw new ApiError(`That token was rejected by ${url}`, error.status, error.code);
23
+ }
24
+ throw error;
25
+ }
26
+ const path = await writeStoredToken(url, token, environment);
27
+ output.out(`Signed in to ${url}. Token saved to ${path}.\n`);
28
+ }
29
+ export async function logout(args, environment, output) {
30
+ const url = resolveUrl(args.url, environment);
31
+ const removed = await clearStoredToken(url, environment);
32
+ output.out(removed
33
+ ? `Signed out of ${url}.\n`
34
+ : `No saved token for ${url} in ${configPath(environment)}.\n`);
35
+ }
@@ -0,0 +1,12 @@
1
+ /** The hosted service. Overridable, but nobody self-hosts this yet. */
2
+ export declare const DEFAULT_URL = "https://diditthough.app";
3
+ /**
4
+ * Tokens are stored per base URL rather than as one global value, so signing in
5
+ * against a local `wrangler dev` cannot silently overwrite the production token
6
+ * an agent is relying on.
7
+ */
8
+ export declare function configPath(environment: NodeJS.ProcessEnv): string;
9
+ export declare function readStoredToken(url: string, environment: NodeJS.ProcessEnv): Promise<string | undefined>;
10
+ export declare function writeStoredToken(url: string, token: string, environment: NodeJS.ProcessEnv): Promise<string>;
11
+ /** Resolves to false when there was nothing stored for that URL. */
12
+ export declare function clearStoredToken(url: string, environment: NodeJS.ProcessEnv): Promise<boolean>;
package/dist/config.js ADDED
@@ -0,0 +1,94 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { CliError } from "./errors.js";
5
+ /** The hosted service. Overridable, but nobody self-hosts this yet. */
6
+ export const DEFAULT_URL = "https://diditthough.app";
7
+ /**
8
+ * Tokens are stored per base URL rather than as one global value, so signing in
9
+ * against a local `wrangler dev` cannot silently overwrite the production token
10
+ * an agent is relying on.
11
+ */
12
+ export function configPath(environment) {
13
+ const override = environment.DIT_CONFIG?.trim();
14
+ if (override)
15
+ return override;
16
+ const base = environment.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
17
+ return join(base, "dit", "config.json");
18
+ }
19
+ export async function readStoredToken(url, environment) {
20
+ const config = await readConfig(configPath(environment));
21
+ return config?.hosts[url]?.token;
22
+ }
23
+ export async function writeStoredToken(url, token, environment) {
24
+ const path = configPath(environment);
25
+ const config = (await readConfig(path)) ?? { version: 1, hosts: {} };
26
+ config.hosts[url] = { token };
27
+ await writeConfig(path, config);
28
+ return path;
29
+ }
30
+ /** Resolves to false when there was nothing stored for that URL. */
31
+ export async function clearStoredToken(url, environment) {
32
+ const path = configPath(environment);
33
+ const config = await readConfig(path);
34
+ if (!config?.hosts[url])
35
+ return false;
36
+ delete config.hosts[url];
37
+ if (Object.keys(config.hosts).length === 0) {
38
+ await rm(path, { force: true });
39
+ }
40
+ else {
41
+ await writeConfig(path, config);
42
+ }
43
+ return true;
44
+ }
45
+ async function readConfig(path) {
46
+ let contents;
47
+ try {
48
+ contents = await readFile(path, "utf8");
49
+ }
50
+ catch (error) {
51
+ if (isMissing(error))
52
+ return undefined;
53
+ throw new CliError(`Could not read ${path}: ${messageOf(error)}`, 1);
54
+ }
55
+ // A corrupt credential file is worth saying out loud. Falling back to "not
56
+ // signed in" would send someone hunting for a token that is right there.
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(contents);
60
+ }
61
+ catch {
62
+ throw new CliError(`${path} is not valid JSON; delete it and run \`dit login\` again`, 1);
63
+ }
64
+ if (!isStoredConfig(parsed)) {
65
+ throw new CliError(`${path} is not a dit config; delete it and run \`dit login\` again`, 1);
66
+ }
67
+ return parsed;
68
+ }
69
+ async function writeConfig(path, config) {
70
+ // 0700/0600, and written through a temp file so an interrupted write cannot
71
+ // leave a half-JSON credential store behind.
72
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
73
+ const temporary = `${path}.${process.pid}.tmp`;
74
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
75
+ await rename(temporary, path);
76
+ }
77
+ function isStoredConfig(value) {
78
+ if (typeof value !== "object" || value === null)
79
+ return false;
80
+ const candidate = value;
81
+ if (candidate.version !== 1)
82
+ return false;
83
+ if (typeof candidate.hosts !== "object" || candidate.hosts === null)
84
+ return false;
85
+ return Object.values(candidate.hosts).every((host) => typeof host === "object" &&
86
+ host !== null &&
87
+ typeof host.token === "string");
88
+ }
89
+ function isMissing(error) {
90
+ return error?.code === "ENOENT";
91
+ }
92
+ function messageOf(error) {
93
+ return error instanceof Error ? error.message : String(error);
94
+ }
@@ -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
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { parseCliArgs } from "./args.js";
4
+ import { login, logout } from "./auth.js";
4
5
  import { ApiClient } from "./client.js";
6
+ import { DEFAULT_URL, writeStoredToken } from "./config.js";
7
+ import { bootstrapUnclaimed } from "./device.js";
5
8
  import { CliError } from "./errors.js";
6
9
  import { executeCommand } from "./operations.js";
7
- import { resolveRuntime } from "./runtime.js";
10
+ import { findToken, resolveRuntime, resolveUrl } from "./runtime.js";
8
11
  // Read from the manifest rather than a second copy of the number here: the
9
12
  // published version is set by the release tag, and a hard-coded constant is
10
13
  // guaranteed to start lying the first time one is cut. `../package.json` from
@@ -13,16 +16,19 @@ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
13
16
  const HELP = `Usage: dit <command> [options]
14
17
 
15
18
  Commands:
19
+ login [--token <token>] [--url <url>] Sign in; opens a browser approval
20
+ logout [--url <url>]
16
21
  upload --project <slug|id> --review <ref|id> --file <path> [--file <path> ...]
17
22
  feedback --review <ref|id> [--status open|addressed|resolved|all] [--json]
18
23
  reply --thread <id> --message <text>
19
24
  address --thread <id> [--message <text>]
20
25
  revision --artifact <id> --file <path> [--json]
21
26
  markdown --review <ref|id>
27
+ project create --name <name> [--json]
22
28
 
23
29
  Global options:
24
- --url <url> API base URL (or DIT_URL)
25
- --token <token> Agent token (or DIT_TOKEN)
30
+ --url <url> API base URL (default ${DEFAULT_URL}, or DIT_URL)
31
+ --token <token> Agent token (or DIT_TOKEN, or saved by \`dit login\`)
26
32
  -h, --help Show help
27
33
  -v, --version Show version
28
34
 
@@ -40,12 +46,37 @@ async function main() {
40
46
  process.stdout.write(`${VERSION}\n`);
41
47
  return;
42
48
  }
43
- const runtime = resolveRuntime(args, process.env);
44
- const client = new ApiClient(runtime);
45
- await executeCommand(args, client, {
49
+ const output = {
46
50
  out: (value) => process.stdout.write(value),
47
51
  warn: (value) => process.stderr.write(`warning: ${value}\n`),
48
- });
52
+ };
53
+ if (args.command === "login")
54
+ return login(args, process.env, output);
55
+ if (args.command === "logout")
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
+ }
78
+ const runtime = await resolveRuntime(args, process.env);
79
+ await executeCommand(args, new ApiClient(runtime), output);
49
80
  }
50
81
  main().catch((error) => {
51
82
  if (process.env.DIT_DEBUG === "1" && error instanceof Error && error.stack) {
@@ -1,6 +1,4 @@
1
- import type { CliArgs } from "./args.js";
1
+ import type { ApiCommandArgs } from "./args.js";
2
2
  import { ApiClient } from "./client.js";
3
3
  import { type Output } from "./output.js";
4
- export declare function executeCommand(args: Exclude<CliArgs, {
5
- command: "help" | "version";
6
- }>, client: ApiClient, output: Output): Promise<void>;
4
+ export declare function executeCommand(args: ApiCommandArgs, client: ApiClient, output: Output): Promise<void>;
@@ -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
@@ -1,9 +1,16 @@
1
- import type { CliArgs } from "./args.js";
2
- type RuntimeArgs = Exclude<CliArgs, {
3
- command: "help" | "version";
4
- }>;
5
- export declare function resolveRuntime(args: RuntimeArgs, environment: NodeJS.ProcessEnv): {
1
+ import type { ApiCommandArgs } from "./args.js";
2
+ export declare function resolveUrl(url: string | undefined, environment: NodeJS.ProcessEnv): string;
3
+ /**
4
+ * Flag, then environment, then whatever `dit login` saved. The saved token is
5
+ * last so a one-off `--token` or a CI `DIT_TOKEN` still wins without anyone
6
+ * having to sign out first.
7
+ */
8
+ export declare function resolveRuntime(args: ApiCommandArgs, environment: NodeJS.ProcessEnv): Promise<{
6
9
  url: string;
7
10
  token: string;
8
- };
9
- export {};
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
@@ -1,14 +1,10 @@
1
+ import { DEFAULT_URL, readStoredToken } from "./config.js";
1
2
  import { UsageError } from "./errors.js";
2
- export function resolveRuntime(args, environment) {
3
- const url = args.url ?? environment.DIT_URL;
4
- const token = args.token ?? environment.DIT_TOKEN;
5
- if (!url)
6
- throw new UsageError("Missing base URL: pass --url or set DIT_URL");
7
- if (!token)
8
- throw new UsageError("Missing auth token: pass --token or set DIT_TOKEN");
3
+ export function resolveUrl(url, environment) {
4
+ const value = url ?? environment.DIT_URL ?? DEFAULT_URL;
9
5
  let parsed;
10
6
  try {
11
- parsed = new URL(url);
7
+ parsed = new URL(value);
12
8
  }
13
9
  catch {
14
10
  throw new UsageError("DIT_URL/--url must be an absolute HTTP(S) URL");
@@ -16,5 +12,22 @@ export function resolveRuntime(args, environment) {
16
12
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
17
13
  throw new UsageError("DIT_URL/--url must be an absolute HTTP(S) URL");
18
14
  }
19
- return { url: parsed.toString().replace(/\/+$/, ""), token };
15
+ return parsed.toString().replace(/\/+$/, "");
16
+ }
17
+ /**
18
+ * Flag, then environment, then whatever `dit login` saved. The saved token is
19
+ * last so a one-off `--token` or a CI `DIT_TOKEN` still wins without anyone
20
+ * having to sign out first.
21
+ */
22
+ export async function resolveRuntime(args, environment) {
23
+ const url = resolveUrl(args.url, environment);
24
+ const token = await findToken(args, url, environment);
25
+ if (!token) {
26
+ throw new UsageError(`No agent token for ${url}: run \`dit login\`, pass --token, or set DIT_TOKEN`);
27
+ }
28
+ return { url, token };
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));
20
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelhop/dit",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Did It Though? CLI for coding agents — upload PR screenshots and videos, pull structured feedback back",
5
5
  "keywords": [
6
6
  "agents",