@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 +39 -14
- package/dist/args.d.ts +9 -1
- package/dist/args.js +11 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +32 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +94 -0
- package/dist/index.js +14 -6
- package/dist/operations.d.ts +2 -4
- package/dist/runtime.d.ts +9 -7
- package/dist/runtime.js +18 -9
- package/package.json +1 -1
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
|
-
|
|
15
|
+
`--url` defaults to `https://diditthough.app` and `login` remembers the token, so neither
|
|
16
|
+
has to be repeated.
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
## Signing in
|
|
18
19
|
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
export DIT_TOKEN=dit_…
|
|
23
|
+
dit login --token dit_…
|
|
29
24
|
```
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
|
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) {
|
package/dist/operations.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
3
|
-
const
|
|
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(
|
|
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
|
|
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
|
}
|