@tenyokjs/devdevian-cli 0.1.0-alpha.1 → 0.3.0-alpha.1

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
@@ -4,12 +4,52 @@ An official terminal client for [Devdevian](https://devdevian.vercel.app), the o
4
4
 
5
5
  ```sh
6
6
  npm install -g @tenyokjs/devdevian-cli@alpha
7
- devdevian login
8
- devdevian
7
+ devdevian open # a separate terminal window
8
+ # or: devdevian tui (the current terminal)
9
9
  ```
10
10
 
11
11
  Login opens a browser. Compare the code shown in the terminal, sign in to Devdevian, then authorize the terminal. Google, GitHub and email login happen on the website; no provider credentials are collected by this package. Use `--no-open` to open the printed address yourself, including when connecting over SSH.
12
12
 
13
+ ## Live workspace
14
+
15
+ The default `devdevian` command and `devdevian tui` open an alternate terminal screen. Navigation, conversations, inline forms, previews and search update in that screen; exiting restores your normal terminal. The blue and purple layout adapts to the terminal size, with compact navigation below 90 columns. Minimum size: 60 columns × 18 rows.
16
+
17
+ - **Tab** switches between navigation, content and the command bar. **↑/↓** select; **Enter** opens; **Esc** goes back.
18
+ - **/** focuses search and commands: `/feed`, `/inbox`, `/search query`, `/open 142`, `/ask`, `/review`, `/drafts`, `/communities`, `/devices`, `/help`, `/logout`, `/quit`.
19
+ - **←/→** switch conversation tabs; **Space** reads the selected reply/remark in full. **[ / ]** paginate; **Page Up/Down** scroll.
20
+ - **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
21
+ - **C** comments on snippet lines; **E/D** edit/delete your own feedback; **S** resolves/reopens a code remark as the publication author; **!** reports selected code feedback.
22
+ - In forms, **Tab/Shift+Tab** change fields, arrows choose options or move the text cursor, **Enter** adds a line, **Ctrl+S** opens the preview. Publication requires a separate confirmation; Escape asks before discarding a draft. Drafts auto-save locally after a short pause and can be restored from **Local drafts** or `/drafts`.
23
+ - **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
24
+
25
+ `devdevian open` launches Terminal on macOS, Windows Terminal on Windows, or GNOME Terminal/Konsole/xterm on a Linux desktop. A graphical desktop and an installed supported terminal are required; over SSH and inside an editor, use `devdevian tui`. Window launchers still need platform-specific user testing; terminal rendering and input are covered by macOS/Linux pseudo-terminal checks.
26
+
27
+ Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R. Network errors stay inside the workspace; failed writes are never automatically retried. Existing web permissions and server validation apply to every operation.
28
+
29
+ ## Project files and Git changes
30
+
31
+ Start inside your project (`cd PROJECT1 && devdevian tui`) or use `devdevian tui --project /path/to/PROJECT1`. `devdevian open --project /path/to/PROJECT1` carries the same project into a new terminal window. The project root stays fixed for the session.
32
+
33
+ In a **Code review** form:
34
+
35
+ 1. Press **Ctrl+O** to browse project files, or **Ctrl+G** to browse Git changes. These shortcuts work from any review field. Enter on an empty Code file field also opens the explorer.
36
+ 2. **↑/↓** select entries; **Enter** opens a directory or previews a file; **←/Backspace** goes to the parent directory. **/** filters names in the current directory, **H** shows hidden files and **G** switches Files/Git changes. Wide terminals show the file list next to the preview; compact terminals use the available width for code.
37
+ 3. In a file preview, use **↑/↓** or **Page Up/Down** to move. **Space** starts a line selection; move to extend it, or press Space again to reset. **←/→** scroll long lines horizontally. **Enter** uses the file/range; **Esc** returns to the list.
38
+ 4. In Git changes, **T** switches **unstaged/staged**. The preview initially includes all hunks for one file; **[ / ]** choose the whole patch or an individual hunk. **Enter** uses that selection. Untracked files are selected through Files; binary diffs/submodules are excluded.
39
+ 5. Fill in title, focus and context. **Ctrl+S** shows the exact code and audience to publish. Publication requires a separate confirmation.
40
+
41
+ The explorer excludes `.git`, dependency/build directories, symlinks, common environment/credential/key filenames and (inside Git repositories) ignored paths. No content scanner can identify every secret: inspect the publication preview before confirming. Git is required for changes and `.gitignore` detection; ordinary file selection also works without Git. Git reads disable external diff/textconv/fsmonitor helpers and never stage, commit, reset or execute project code.
42
+
43
+ File previews accept UTF-8 text up to **512 KiB**; a published snippet/patch is **10–20,000 characters**. Large files can be narrowed to a range and large patches to one hunk. Git patches appear as ordinary review snippets on the website; review line numbers count the **published patch including headers**, not the original source file. Source paths are relative to the project. If the selected patch changes before the publication preview, selection must be repeated. Publication uses the confirmed preview snapshot.
44
+
45
+ ## Local drafts
46
+
47
+ Publication, reply, feedback, edit and community forms save locally after a **450 ms** pause. Explicit draft discard removes that draft; successful publication removes it too. `/drafts` lists saved drafts for the current **server, account and project directory**. Nothing is uploaded by autosave. Switching accounts or projects does not expose another scope's drafts.
48
+
49
+ Draft files live under the CLI credentials directory in `drafts/<scope>/`, with Unix file permissions `0600` and new directories `0700`. They contain your text and file/diff selection descriptors, **not copies of selected source files or patches**. They are local JSON files, not encrypted; protect your OS account and do not commit/share this directory. The history retains up to **20 drafts per scope for 30 days**. An abrupt termination may lose the most recent input before autosave; these drafts are not a backup service.
50
+
51
+ Restoring a review rereads the local source at the publication preview. Changed Git selections require reselection. A private audience that is no longer accessible is preserved as unavailable until you explicitly choose an available audience; it never silently becomes public.
52
+
13
53
  ## Everyday commands
14
54
 
15
55
  ```sh
@@ -47,6 +87,6 @@ For local development use `devdevian login --host http://127.0.0.1:4173`. Remote
47
87
 
48
88
  ## Alpha scope
49
89
 
50
- This first release covers login/logout, inbox, paginated feeds and search, discussions and replies, questions, code review creation, line feedback and feedback threads/status, and community discovery/private publication. Profile/media editing, events administration and moderation remain available in the browser. There is no offline mode or automatic GitHub repository synchronization.
90
+ This alpha covers login/logout, inbox, paginated feeds and search, discussions and replies, questions, code review creation, line feedback and feedback threads/status, and community discovery, creation and private publication. Profile/media editing, events administration and moderation remain available in the browser. There is no offline mode or automatic GitHub repository synchronization.
51
91
 
52
92
  Source, contributions and issue reporting: [Tenyokj/devdevian](https://github.com/Tenyokj/devdevian). Report security issues through the repository's [security policy](https://github.com/Tenyokj/devdevian/blob/main/SECURITY.md), not a public issue containing private data. Licensed GPL-3.0-only.
package/bin/devdevian.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { fileURLToPath } from "node:url";
3
+ import packageInfo from "../package.json" with { type: "json" };
2
4
  import { parseArgs } from "node:util";
3
5
  import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
4
6
  import { hostname, tmpdir } from "node:os";
@@ -14,7 +16,7 @@ import {
14
16
  removeConfig,
15
17
  clean,
16
18
  } from "../lib/client.mjs";
17
- const version = "0.1.0-alpha.1";
19
+ const version = packageInfo.version;
18
20
  const options = {
19
21
  host: { type: "string" },
20
22
  page: { type: "string" },
@@ -32,6 +34,7 @@ const options = {
32
34
  revision: { type: "string" },
33
35
  "reply-id": { type: "string" },
34
36
  name: { type: "string" },
37
+ project: { type: "string" },
35
38
  yes: { type: "boolean" },
36
39
  json: { type: "boolean" },
37
40
  editor: { type: "boolean" },
@@ -71,7 +74,9 @@ const positive = (value, label = "ID") => {
71
74
  };
72
75
  const help = `Devdevian CLI ${version} · alpha
73
76
 
74
- devdevian Interactive menu
77
+ devdevian Live terminal workspace
78
+ devdevian tui Live workspace in this terminal
79
+ devdevian open Open the workspace in a new terminal window
75
80
  devdevian login Authorize in your browser
76
81
  devdevian logout Revoke this terminal
77
82
  devdevian whoami
@@ -90,6 +95,7 @@ const help = `Devdevian CLI ${version} · alpha
90
95
  devdevian review reopen POST_ID REVIEW_ID
91
96
  devdevian communities [--page 2]
92
97
 
98
+ --project PATH Root directory for the live file/diff picker (default: current directory)
93
99
  --community ID Publish to an approved private community
94
100
  --editor Write text in $VISUAL / $EDITOR
95
101
  --json Machine-readable output
@@ -455,59 +461,7 @@ async function execute(args, flags) {
455
461
  }
456
462
  throw Error("Unknown command. Run devdevian --help.");
457
463
  }
458
- async function menu(flags) {
459
- const config = await loadConfig();
460
- if (!config) {
461
- out("Devdevian · developer commons");
462
- if (
463
- (await prompt("Connect your account? (yes/no)", "yes")).toLowerCase() !==
464
- "yes"
465
- )
466
- return;
467
- await login(flags);
468
- }
469
- const identityConfig = await loadConfig(),
470
- identity = await client(identityConfig.origin, identityConfig.token)("/me");
471
- while (true) {
472
- out(
473
- `\nDEVDEVIAN · @${identity.user.username}\n 1 Inbox\n 2 Public feed\n 3 Open conversation\n 4 Ask a question\n 5 Request code review\n 6 Communities\n 7 Review feedback\n 8 Search\n 9 Reply to a conversation\n 0 Exit`,
474
- );
475
- const choice = await prompt("Choose", "0");
476
- if (choice === "0") return;
477
- try {
478
- if (choice === "1") await execute(["inbox"], flags);
479
- else if (choice === "2") await execute(["feed"], flags);
480
- else if (choice === "3")
481
- await execute(["conversation", "open", await prompt("Post ID")], flags);
482
- else if (choice === "4") await execute(["ask"], flags);
483
- else if (choice === "5") await execute(["review", "create"], flags);
484
- else if (choice === "6") await execute(["communities"], flags);
485
- else if (choice === "7") {
486
- const post = await prompt("Review post ID");
487
- await execute(["review", "list", post], flags);
488
- const action = await prompt(
489
- "Action: thread / comment / reply / resolve / reopen / back",
490
- "back",
491
- );
492
- if (action !== "back") {
493
- const id = action === "comment" ? null : await prompt("Feedback ID");
494
- const next = { ...flags };
495
- if (action === "comment") {
496
- next["start-line"] = await prompt("Start line");
497
- next["end-line"] = await prompt("End line", next["start-line"]);
498
- }
499
- await execute(["review", action, post, ...(id ? [id] : [])], next);
500
- }
501
- } else if (choice === "9")
502
- await execute(["reply", await prompt("Post ID")], flags);
503
- else if (choice === "8")
504
- await execute(["search", await prompt("Search")], flags);
505
- else out("Choose one of the listed actions.");
506
- } catch (e) {
507
- out("Error: " + e.message);
508
- }
509
- }
510
- }
464
+
511
465
  try {
512
466
  const { values, positionals } = parseArgs({
513
467
  options,
@@ -515,8 +469,19 @@ try {
515
469
  strict: true,
516
470
  });
517
471
  if (!positionals.length && !values.help && !values.version)
518
- await menu(values);
519
- else await execute(positionals, values);
472
+ await (await import("../lib/tui.mjs")).startTui(values);
473
+ else if (positionals[0] === "tui")
474
+ await (await import("../lib/tui.mjs")).startTui(values);
475
+ else if (positionals[0] === "open") {
476
+ const args = [];
477
+ for (const key of ["host", "name", "project"])
478
+ if (values[key]) args.push("--" + key, values[key]);
479
+ if (values["no-open"]) args.push("--no-open");
480
+ await (
481
+ await import("../lib/window.mjs")
482
+ ).openWindow({ entry: fileURLToPath(import.meta.url), args });
483
+ out("Devdevian opened in a separate terminal window.");
484
+ } else await execute(positionals, values);
520
485
  } catch (e) {
521
486
  process.stderr.write("Devdevian: " + clean(e.message) + "\n");
522
487
  process.exitCode = 1;
package/lib/client.mjs CHANGED
@@ -90,7 +90,7 @@ export async function removeConfig() {
90
90
  }
91
91
  export function client(origin, token = null) {
92
92
  origin = originOf(origin);
93
- return async (path, { method = "GET", body } = {}) => {
93
+ return async (path, { method = "GET", body, signal } = {}) => {
94
94
  if (!path.startsWith("/") || path.startsWith("//"))
95
95
  throw Error("Invalid API path.");
96
96
  let response;
@@ -104,9 +104,12 @@ export function client(origin, token = null) {
104
104
  ...(token ? { Authorization: "Bearer " + token } : {}),
105
105
  },
106
106
  body: body ? JSON.stringify(body) : undefined,
107
- signal: AbortSignal.timeout(20000),
107
+ signal: signal
108
+ ? AbortSignal.any([signal, AbortSignal.timeout(20000)])
109
+ : AbortSignal.timeout(20000),
108
110
  });
109
- } catch {
111
+ } catch (error) {
112
+ if (signal?.aborted) throw error;
110
113
  throw Error(
111
114
  "Could not reach Devdevian. Check your connection and server address.",
112
115
  );
package/lib/drafts.mjs ADDED
@@ -0,0 +1,119 @@
1
+ import {
2
+ mkdir,
3
+ lstat,
4
+ readdir,
5
+ readFile,
6
+ writeFile,
7
+ rename,
8
+ unlink,
9
+ } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+ import { createHash, randomBytes } from "node:crypto";
12
+ import { configFile } from "./client.mjs";
13
+ const digest = (value) =>
14
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
+ const MAX = 128 * 1024,
16
+ TTL = 30 * 86400000;
17
+ export function draftIdentity(form) {
18
+ return /^[a-f0-9]{64}$/.test(form.draftId || "")
19
+ ? form.draftId
20
+ : digest([
21
+ form.kind,
22
+ form.postId || "",
23
+ form.url || "",
24
+ form.parentId || "",
25
+ ]);
26
+ }
27
+ export class DraftStore {
28
+ constructor({ origin, userId, root, env = process.env }) {
29
+ this.scope = digest([origin, userId, root]);
30
+ this.directory = join(dirname(configFile(env)), "drafts", this.scope);
31
+ this.root = root;
32
+ }
33
+ async ensure() {
34
+ const base = dirname(dirname(this.directory));
35
+ await mkdir(base, { recursive: true, mode: 0o700 });
36
+ for (const directory of [base, dirname(this.directory), this.directory]) {
37
+ await mkdir(directory, { recursive: true, mode: 0o700 });
38
+ const info = await lstat(directory);
39
+ if (!info.isDirectory() || info.isSymbolicLink())
40
+ throw Error("The draft directory must be a real directory.");
41
+ }
42
+ }
43
+ async read(id) {
44
+ if (!/^[a-f0-9]{64}$/.test(id)) throw Error("Invalid draft ID.");
45
+ const file = join(this.directory, id + ".json");
46
+ const info = await lstat(file);
47
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX)
48
+ throw Error("Invalid draft file.");
49
+ if (process.platform !== "win32" && info.mode & 0o077)
50
+ throw Error("Draft file permissions must be 600.");
51
+ const record = JSON.parse(await readFile(file, "utf8"));
52
+ if (
53
+ !Number.isFinite(record.savedAt) ||
54
+ record.version !== 1 ||
55
+ record.scope !== this.scope ||
56
+ record.id !== id ||
57
+ Date.now() - record.savedAt > TTL ||
58
+ record.savedAt > Date.now() + 60000 ||
59
+ !["publication", "reply", "line", "edit", "community"].includes(
60
+ record.form?.kind,
61
+ ) ||
62
+ !record.form?.values ||
63
+ typeof record.form.values !== "object" ||
64
+ Array.isArray(record.form.values)
65
+ )
66
+ throw Error("Draft expired or is invalid.");
67
+ return record;
68
+ }
69
+ async list() {
70
+ await this.ensure();
71
+ const names = await readdir(this.directory),
72
+ rows = [];
73
+ for (const name of names) {
74
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
75
+ try {
76
+ rows.push(await this.read(name.slice(0, -5)));
77
+ } catch {
78
+ /* A corrupt or expired local draft never prevents opening the workspace. */
79
+ }
80
+ }
81
+ return rows.sort((a, b) => b.savedAt - a.savedAt).slice(0, 20);
82
+ }
83
+ async save(form) {
84
+ if (
85
+ ["open", "report"].includes(form.kind) ||
86
+ !["title", "body", "name", "description", "file", "review_focus"].some(
87
+ (key) => String(form.values[key] || "").trim(),
88
+ )
89
+ )
90
+ return;
91
+ await this.ensure();
92
+ const id = draftIdentity(form),
93
+ record = { version: 1, id, scope: this.scope, savedAt: Date.now(), form };
94
+ const body = JSON.stringify(record);
95
+ if (Buffer.byteLength(body) > MAX)
96
+ throw Error("This draft is too large to save locally.");
97
+ const temp = join(this.directory, "." + randomBytes(12).toString("hex"));
98
+ try {
99
+ await writeFile(temp, body, { mode: 0o600, flag: "wx" });
100
+ await rename(temp, join(this.directory, id + ".json"));
101
+ } finally {
102
+ await unlink(temp).catch((e) => {
103
+ if (e.code !== "ENOENT") throw e;
104
+ });
105
+ }
106
+ const rows = await this.list();
107
+ const keep = new Set(rows.map((row) => row.id));
108
+ for (const file of await readdir(this.directory))
109
+ if (/^[a-f0-9]{64}\.json$/.test(file) && !keep.has(file.slice(0, -5)))
110
+ await this.remove(file.slice(0, -5));
111
+ return id;
112
+ }
113
+ async remove(id) {
114
+ if (!/^[a-f0-9]{64}$/.test(id)) throw Error("Invalid draft ID.");
115
+ await unlink(join(this.directory, id + ".json")).catch((e) => {
116
+ if (e.code !== "ENOENT") throw e;
117
+ });
118
+ }
119
+ }