@pixelhop/dit 0.1.0 → 0.2.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,57 @@ 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 --token dit_…
12
+ dit upload --project marketing-site --review pr-1234 --file shot.png
13
13
  ```
14
14
 
15
- ## Configuration
15
+ `--url` defaults to `https://diditthough.app` and `login` remembers the token, so neither
16
+ has to be repeated.
16
17
 
17
- Two values, from flags or the environment:
18
+ ## Signing in
18
19
 
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 |
23
-
24
- Set `DIT_DEBUG=1` to print stack traces instead of one-line errors.
20
+ Sign in once; after that every command is just the command.
25
21
 
26
22
  ```bash
27
- export DIT_URL=https://diditthough.app
28
- export DIT_TOKEN=dit_…
23
+ dit login --token dit_…
29
24
  ```
30
25
 
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.
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.
30
+
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.
33
+
34
+ ### Where each value comes from
35
+
36
+ `--url` defaults to the hosted service, so you only pass it to point at a local
37
+ `wrangler dev`. Tokens are saved per URL, so signing in locally cannot quietly overwrite
38
+ the production one.
39
+
40
+ | Value | Resolution order |
41
+ | ----- | ------------------------------------------------------------------------- |
42
+ | URL | `--url`, then `DIT_URL`, then `https://diditthough.app` |
43
+ | Token | `--token`, then `DIT_TOKEN`, then whatever `dit login` saved for that URL |
44
+
45
+ The saved token comes last on purpose: a one-off `--token`, or `DIT_TOKEN` in CI, wins
46
+ without anyone having to sign out first. Set `DIT_DEBUG=1` to print stack traces instead
47
+ of one-line errors.
33
48
 
34
49
  ## Commands
35
50
 
51
+ ### `dit login` and `dit logout`
52
+
53
+ ```bash
54
+ dit login --token dit_… # the hosted service
55
+ dit login --token dit_… --url http://localhost:3000 # a local server
56
+ dit logout
57
+ ```
58
+
59
+ `login` also accepts the token from `DIT_TOKEN`, which keeps it out of your shell history.
60
+
36
61
  ### `dit upload`
37
62
 
38
63
  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,21 @@ export type CliArgs = ({
41
41
  } & RuntimeFlags & JsonFlag) | ({
42
42
  command: "markdown";
43
43
  review: string;
44
- } & RuntimeFlags) | {
44
+ } & RuntimeFlags) | ({
45
+ command: "login";
46
+ } & RuntimeFlags) | ({
47
+ command: "logout";
48
+ } & Pick<RuntimeFlags, "url">) | {
45
49
  command: "help";
46
50
  topic?: string;
47
51
  } | {
48
52
  command: "version";
49
53
  };
50
54
  export type FeedbackStatus = "open" | "addressed" | "resolved" | "all";
55
+ /** The commands that talk to the API, so need a resolved URL and token. */
56
+ export type ApiCommandArgs = Exclude<CliArgs, {
57
+ command: "help" | "version" | "login" | "logout";
58
+ }>;
51
59
  export declare function parseCliArgs(argv: string[]): CliArgs;
52
60
  export declare function parseViewport(value: string): Viewport;
53
61
  export {};
package/dist/args.js CHANGED
@@ -45,6 +45,11 @@ const commandOptions = {
45
45
  ...runtimeOptions,
46
46
  review: { type: "string" },
47
47
  },
48
+ login: runtimeOptions,
49
+ logout: {
50
+ url: { type: "string" },
51
+ help: { type: "boolean", short: "h" },
52
+ },
48
53
  };
49
54
  export function parseCliArgs(argv) {
50
55
  const command = argv[0];
@@ -123,6 +128,12 @@ export function parseCliArgs(argv) {
123
128
  ...runtime,
124
129
  };
125
130
  }
131
+ if (name === "login") {
132
+ return { command: name, ...runtime };
133
+ }
134
+ if (name === "logout") {
135
+ return { command: name, ...(runtime.url ? { url: runtime.url } : {}) };
136
+ }
126
137
  if (name === "revision") {
127
138
  return {
128
139
  command: name,
package/dist/auth.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { CliArgs } from "./args.js";
2
+ import { ApiClient, type ApiClientOptions } from "./client.js";
3
+ import type { Output } from "./output.js";
4
+ type CreateClient = (options: ApiClientOptions) => ApiClient;
5
+ export declare function login(args: Extract<CliArgs, {
6
+ command: "login";
7
+ }>, environment: NodeJS.ProcessEnv, output: Output, createClient?: CreateClient): Promise<void>;
8
+ export declare function logout(args: Extract<CliArgs, {
9
+ command: "logout";
10
+ }>, environment: NodeJS.ProcessEnv, output: Output): Promise<void>;
11
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,32 @@
1
+ import { getCatalog } from "./catalog.js";
2
+ import { ApiClient } from "./client.js";
3
+ import { clearStoredToken, configPath, writeStoredToken } from "./config.js";
4
+ import { ApiError, UsageError } from "./errors.js";
5
+ import { resolveUrl } from "./runtime.js";
6
+ export async function login(args, environment, output, createClient = (options) => new ApiClient(options)) {
7
+ 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
+ }
12
+ // 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
+ try {
15
+ await getCatalog(createClient({ url, token }));
16
+ }
17
+ catch (error) {
18
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
19
+ throw new ApiError(`That token was rejected by ${url}`, error.status, error.code);
20
+ }
21
+ throw error;
22
+ }
23
+ const path = await writeStoredToken(url, token, environment);
24
+ output.out(`Signed in to ${url}. Token saved to ${path}.\n`);
25
+ }
26
+ export async function logout(args, environment, output) {
27
+ const url = resolveUrl(args.url, environment);
28
+ const removed = await clearStoredToken(url, environment);
29
+ output.out(removed
30
+ ? `Signed out of ${url}.\n`
31
+ : `No saved token for ${url} in ${configPath(environment)}.\n`);
32
+ }
@@ -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
+ }
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
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 } from "./config.js";
5
7
  import { CliError } from "./errors.js";
6
8
  import { executeCommand } from "./operations.js";
7
9
  import { resolveRuntime } from "./runtime.js";
@@ -13,6 +15,8 @@ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
13
15
  const HELP = `Usage: dit <command> [options]
14
16
 
15
17
  Commands:
18
+ login --token <token> [--url <url>] Save the token for later commands
19
+ logout [--url <url>]
16
20
  upload --project <slug|id> --review <ref|id> --file <path> [--file <path> ...]
17
21
  feedback --review <ref|id> [--status open|addressed|resolved|all] [--json]
18
22
  reply --thread <id> --message <text>
@@ -21,8 +25,8 @@ Commands:
21
25
  markdown --review <ref|id>
22
26
 
23
27
  Global options:
24
- --url <url> API base URL (or DIT_URL)
25
- --token <token> Agent token (or DIT_TOKEN)
28
+ --url <url> API base URL (default ${DEFAULT_URL}, or DIT_URL)
29
+ --token <token> Agent token (or DIT_TOKEN, or saved by \`dit login\`)
26
30
  -h, --help Show help
27
31
  -v, --version Show version
28
32
 
@@ -40,12 +44,16 @@ async function main() {
40
44
  process.stdout.write(`${VERSION}\n`);
41
45
  return;
42
46
  }
43
- const runtime = resolveRuntime(args, process.env);
44
- const client = new ApiClient(runtime);
45
- await executeCommand(args, client, {
47
+ const output = {
46
48
  out: (value) => process.stdout.write(value),
47
49
  warn: (value) => process.stderr.write(`warning: ${value}\n`),
48
- });
50
+ };
51
+ if (args.command === "login")
52
+ return login(args, process.env, output);
53
+ if (args.command === "logout")
54
+ return logout(args, process.env, output);
55
+ const runtime = await resolveRuntime(args, process.env);
56
+ await executeCommand(args, new ApiClient(runtime), output);
49
57
  }
50
58
  main().catch((error) => {
51
59
  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>;
package/dist/runtime.d.ts CHANGED
@@ -1,9 +1,11 @@
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
+ }>;
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,18 @@ 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 = args.token ?? environment.DIT_TOKEN ?? (await readStoredToken(url, environment));
25
+ if (!token) {
26
+ throw new UsageError(`No agent token for ${url}: run \`dit login --token dit_…\`, pass --token, or set DIT_TOKEN`);
27
+ }
28
+ return { url, token };
20
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelhop/dit",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",