@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 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 --token dit_…
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
- `--url` defaults to `https://diditthough.app` and `login` remembers the token, so neither
16
- has to be repeated.
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 --token dit_…
25
+ dit login
24
26
  ```
25
27
 
26
- That checks the token against the API before storing it a token saved without checking
27
- turns one clear failure here into a puzzling one on the next upload and writes it to
28
- `~/.config/dit/config.json` with `0600` permissions. `$XDG_CONFIG_HOME` is respected, and
29
- `DIT_CONFIG` overrides the path outright. `dit logout` removes it.
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
- The token is scoped to one project and one workspace, and a human has to create the
32
- project and mint the token first: agents cannot create projects.
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 --token dit_… # the hosted service
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
@@ -42,6 +42,9 @@ export type CliArgs = ({
42
42
  command: "markdown";
43
43
  review: string;
44
44
  } & RuntimeFlags) | ({
45
+ command: "project:create";
46
+ name: string;
47
+ } & RuntimeFlags & JsonFlag) | ({
45
48
  command: "login";
46
49
  } & RuntimeFlags) | ({
47
50
  command: "logout";
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
- 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];
56
65
  if (!command || command === "help" || command === "--help" || command === "-h") {
57
- return { command: "help", ...(argv[1] ? { topic: argv[1] } : {}) };
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: argv.slice(1),
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 { ApiError, UsageError } from "./errors.js";
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
- const token = args.token ?? environment.DIT_TOKEN;
9
- if (!token) {
10
- throw new UsageError("login requires --token (or DIT_TOKEN)");
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
  }
@@ -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>] Save the token for later commands
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
  }
@@ -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 = args.token ?? environment.DIT_TOKEN ?? (await readStoredToken(url, environment));
24
+ const token = await findToken(args, url, environment);
25
25
  if (!token) {
26
- throw new UsageError(`No agent token for ${url}: run \`dit login --token dit_…\`, pass --token, or set DIT_TOKEN`);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelhop/dit",
3
- "version": "0.2.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",