@tenyokjs/devdevian-cli 0.1.0-alpha.1 → 0.2.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,28 @@ 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`, `/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 are kept in memory only.
23
+ - **Ctrl+C** exits. With an unsent draft, confirm leaving or press Ctrl+C again.
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
+
13
29
  ## Everyday commands
14
30
 
15
31
  ```sh
@@ -47,6 +63,6 @@ For local development use `devdevian login --host http://127.0.0.1:4173`. Remote
47
63
 
48
64
  ## Alpha scope
49
65
 
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.
66
+ 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
67
 
52
68
  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" },
@@ -71,7 +73,9 @@ const positive = (value, label = "ID") => {
71
73
  };
72
74
  const help = `Devdevian CLI ${version} · alpha
73
75
 
74
- devdevian Interactive menu
76
+ devdevian Live terminal workspace
77
+ devdevian tui Live workspace in this terminal
78
+ devdevian open Open the workspace in a new terminal window
75
79
  devdevian login Authorize in your browser
76
80
  devdevian logout Revoke this terminal
77
81
  devdevian whoami
@@ -455,59 +459,7 @@ async function execute(args, flags) {
455
459
  }
456
460
  throw Error("Unknown command. Run devdevian --help.");
457
461
  }
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
- }
462
+
511
463
  try {
512
464
  const { values, positionals } = parseArgs({
513
465
  options,
@@ -515,8 +467,19 @@ try {
515
467
  strict: true,
516
468
  });
517
469
  if (!positionals.length && !values.help && !values.version)
518
- await menu(values);
519
- else await execute(positionals, values);
470
+ await (await import("../lib/tui.mjs")).startTui(values);
471
+ else if (positionals[0] === "tui")
472
+ await (await import("../lib/tui.mjs")).startTui(values);
473
+ else if (positionals[0] === "open") {
474
+ const args = [];
475
+ for (const key of ["host", "name"])
476
+ if (values[key]) args.push("--" + key, values[key]);
477
+ if (values["no-open"]) args.push("--no-open");
478
+ await (
479
+ await import("../lib/window.mjs")
480
+ ).openWindow({ entry: fileURLToPath(import.meta.url), args });
481
+ out("Devdevian opened in a separate terminal window.");
482
+ } else await execute(positionals, values);
520
483
  } catch (e) {
521
484
  process.stderr.write("Devdevian: " + clean(e.message) + "\n");
522
485
  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
  );
@@ -0,0 +1,199 @@
1
+ export const navigation = [
2
+ ["inbox", "Inbox", "Replies & mentions"],
3
+ ["feed", "Public feed", "Developer commons"],
4
+ ["open", "Open conversation", "Jump to a post"],
5
+ ["ask", "Ask a question", "Find a way forward"],
6
+ ["review", "Request code review", "Another pair of eyes"],
7
+ ["communities", "Communities", "Your private spaces"],
8
+ ["search", "Search", "Find a conversation"],
9
+ ["devices", "Connected terminals", "Manage CLI access"],
10
+ ["help", "Help", "Keys & commands"],
11
+ ];
12
+ export async function loadPage(api, route, signal) {
13
+ const get = (path) => api(path, { signal }),
14
+ page = route.page || 1;
15
+ if (route.view === "feed")
16
+ return {
17
+ ...(await get("/posts?limit=12&page=" + page)),
18
+ title: "Public feed",
19
+ subtitle: "Questions, ideas and code worth discussing.",
20
+ };
21
+ if (route.view === "inbox")
22
+ return {
23
+ ...(await get("/notifications?page=" + page)),
24
+ title: "Inbox",
25
+ subtitle: "Your replies, mentions and review updates.",
26
+ };
27
+ if (route.view === "communities")
28
+ return {
29
+ ...(await get("/communities?limit=12&page=" + page)),
30
+ title: "Communities",
31
+ subtitle: "Private spaces, shared context. Enter opens a community.",
32
+ };
33
+ if (route.view === "search")
34
+ return {
35
+ ...(await get(
36
+ "/search?type=" +
37
+ (route.type || "posts") +
38
+ "&q=" +
39
+ encodeURIComponent(route.query || "") +
40
+ "&page=" +
41
+ page,
42
+ )),
43
+ title: "Search",
44
+ subtitle: route.query
45
+ ? "Results for “" + route.query + "”"
46
+ : "Press / and type /search followed by your query.",
47
+ };
48
+ if (route.view === "devices")
49
+ return {
50
+ ...(await get("/cli/sessions")),
51
+ title: "Connected terminals",
52
+ subtitle: "Enter to revoke a terminal. Revoking this one signs it out.",
53
+ };
54
+ if (route.view === "community") {
55
+ const community = await get("/communities/" + encodeURIComponent(route.id));
56
+ const posts = community.is_member
57
+ ? await get(
58
+ "/posts?limit=12&community=" +
59
+ encodeURIComponent(route.id) +
60
+ "&page=" +
61
+ page,
62
+ )
63
+ : { items: [] };
64
+ return {
65
+ ...posts,
66
+ community,
67
+ title: community.name,
68
+ subtitle: community.is_member
69
+ ? "Members-only conversations. N creates a conversation here."
70
+ : community.requested
71
+ ? "Your access request is pending."
72
+ : "Private community. J requests access.",
73
+ };
74
+ }
75
+ if (route.view === "post") {
76
+ const post = await get("/posts/" + route.id + "?reply_page=" + page);
77
+ let items = post.replies,
78
+ pagination = post.reply_pagination;
79
+ if (route.tab === "feedback") {
80
+ const feedback = await get(
81
+ "/posts/" + route.id + "/code-reviews?page=" + page,
82
+ );
83
+ items = feedback.items;
84
+ pagination = feedback.pagination;
85
+ }
86
+ return {
87
+ post,
88
+ items,
89
+ pagination,
90
+ title: post.title,
91
+ subtitle:
92
+ (post.community_id ? "Private community" : "Public feed") +
93
+ " · " +
94
+ post.kind +
95
+ " · by " +
96
+ post.name,
97
+ };
98
+ }
99
+ if (route.view === "thread") {
100
+ const post = await get("/posts/" + route.postId),
101
+ replies = await get(
102
+ `/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
103
+ );
104
+ return {
105
+ post,
106
+ ...replies,
107
+ title: "Reply thread",
108
+ subtitle: "Replying to " + route.parent.name,
109
+ };
110
+ }
111
+ if (route.view === "feedback") {
112
+ const [post, notes] = await Promise.all([
113
+ get("/posts/" + route.postId),
114
+ get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
115
+ ]);
116
+ const note = notes.items.find((item) => item.id === Number(route.id));
117
+ if (!note) throw Error("This feedback is no longer available.");
118
+ const replies = await get(
119
+ `/posts/${route.postId}/code-reviews/${route.id}/replies?page=${page}${route.targetReply ? "&target_id=" + route.targetReply : ""}`,
120
+ );
121
+ return {
122
+ post,
123
+ note,
124
+ items: [
125
+ { ...note, entryType: "note" },
126
+ ...replies.items.map((item) => ({
127
+ ...item,
128
+ entryType: "feedbackReply",
129
+ })),
130
+ ],
131
+ pagination: replies.pagination,
132
+ title: "Code feedback",
133
+ subtitle: `Lines ${note.start_line}–${note.end_line} · ${note.resolved ? "resolved" : "open"}`,
134
+ };
135
+ }
136
+ return { items: [], title: "Your workspace", subtitle: "" };
137
+ }
138
+ export function contentLines(route, data) {
139
+ if (route.view === "help")
140
+ return [
141
+ "A developer workspace, in your terminal.",
142
+ "",
143
+ "Tab Switch navigation / content / command bar",
144
+ "↑ ↓ Select a section, conversation or reply",
145
+ "Enter Open the selected item",
146
+ "Esc Back / leave an input",
147
+ "← → Switch conversation tabs",
148
+ "Page Up / Down Scroll a conversation or list",
149
+ "N / A New conversation / ask a question",
150
+ "R Reply in a conversation; refresh a list",
151
+ "C Add feedback on code lines",
152
+ "S Resolve / reopen selected code feedback",
153
+ "E / D Edit / delete your own feedback or reply",
154
+ "O Open this conversation in your browser",
155
+ "/ Focus the command / search bar",
156
+ "Ctrl+S Preview the current form",
157
+ "Ctrl+C Exit and restore the original terminal",
158
+ "",
159
+ "Commands: /feed /inbox /search words /open 142",
160
+ " /ask /review /communities /devices /logout /quit",
161
+ "",
162
+ "Text fields: arrows move the caret; Home/End move within a line.",
163
+ "Multiline fields: Enter adds a line; Tab changes field.",
164
+ "Nothing is published until you confirm the preview.",
165
+ "A -> Z: your browser account and this terminal use the same data.",
166
+ ];
167
+ if (route.view === "community" && !data.community?.is_member)
168
+ return [
169
+ data.community?.description || "",
170
+ "",
171
+ data.community?.requested
172
+ ? "Access requested. The owner or validators will review it."
173
+ : "Press J to request access.",
174
+ ];
175
+ if (route.view === "post" && (!route.tab || route.tab === "overview")) {
176
+ const p = data.post;
177
+ if (!p) return [];
178
+ const lines = [p.body || ""];
179
+ if (p.review_focus) lines.push("", "REVIEW FOCUS", p.review_focus);
180
+ if (p.code)
181
+ lines.push(
182
+ "",
183
+ "CODE SNIPPET",
184
+ ...p.code
185
+ .split("\n")
186
+ .map((line, index) => String(index + 1).padStart(4) + " " + line),
187
+ );
188
+ if (p.links?.length)
189
+ lines.push(
190
+ "",
191
+ "LINKS",
192
+ ...p.links.map((link) => link.label + " · " + link.url),
193
+ );
194
+ if (p.archived_at || p.locked_at)
195
+ lines.push("", "Replies are paused for this conversation.");
196
+ return lines;
197
+ }
198
+ return [];
199
+ }
@@ -0,0 +1,167 @@
1
+ import stringWidth from "string-width";
2
+ import wrapAnsi from "wrap-ansi";
3
+ import { clean } from "./client.mjs";
4
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
+ export const graphemes = (value) =>
6
+ Array.from(segmenter.segment(value), (part) => part.segment);
7
+ export function clip(value, width) {
8
+ const text = clean(value).replace(/\n/g, " ");
9
+ if (width <= 0) return "";
10
+ if (stringWidth(text) <= width) return text;
11
+ let result = "";
12
+ for (const part of graphemes(text)) {
13
+ if (stringWidth(result + part) > width - 1) break;
14
+ result += part;
15
+ }
16
+ return result + "…";
17
+ }
18
+ export function wrap(value, width) {
19
+ return wrapAnsi(clean(value).replace(/\t/g, " "), Math.max(1, width), {
20
+ hard: true,
21
+ trim: false,
22
+ }).split("\n");
23
+ }
24
+ export function editText(
25
+ value,
26
+ cursor,
27
+ input,
28
+ key,
29
+ { multiline = false, max = 15000 } = {},
30
+ ) {
31
+ const chars = graphemes(value),
32
+ at = Math.max(0, Math.min(cursor, chars.length));
33
+ if (multiline && (key.upArrow || key.downArrow)) {
34
+ const start = chars.slice(0, at).lastIndexOf("\n") + 1,
35
+ column = at - start;
36
+ if (key.upArrow) {
37
+ if (!start) return { value, cursor: 0 };
38
+ const previous = chars.slice(0, start - 1).lastIndexOf("\n") + 1;
39
+ return { value, cursor: Math.min(start - 1, previous + column) };
40
+ }
41
+ const end = chars.indexOf("\n", at);
42
+ if (end < 0) return { value, cursor: chars.length };
43
+ const next = chars.indexOf("\n", end + 1);
44
+ return {
45
+ value,
46
+ cursor: Math.min(next < 0 ? chars.length : next, end + 1 + column),
47
+ };
48
+ }
49
+ if (key.leftArrow) return { value, cursor: Math.max(0, at - 1) };
50
+ if (key.rightArrow) return { value, cursor: Math.min(chars.length, at + 1) };
51
+ if (key.home || (key.ctrl && input === "a")) {
52
+ const previous = chars.slice(0, at).lastIndexOf("\n");
53
+ return { value, cursor: previous + 1 };
54
+ }
55
+ if (key.end || (key.ctrl && input === "e")) {
56
+ const next = chars.indexOf("\n", at);
57
+ return { value, cursor: next < 0 ? chars.length : next };
58
+ }
59
+ if (key.ctrl && input === "u") {
60
+ let start = chars.slice(0, at).lastIndexOf("\n") + 1;
61
+ return {
62
+ value: [...chars.slice(0, start), ...chars.slice(at)].join(""),
63
+ cursor: start,
64
+ };
65
+ }
66
+ if (key.ctrl && input === "k") {
67
+ const end = chars.indexOf("\n", at);
68
+ return {
69
+ value: [...chars.slice(0, at), ...(end < 0 ? [] : chars.slice(end))].join(
70
+ "",
71
+ ),
72
+ cursor: at,
73
+ };
74
+ }
75
+ if (key.backspace) {
76
+ if (!at) return { value, cursor: at };
77
+ return {
78
+ value: [...chars.slice(0, at - 1), ...chars.slice(at)].join(""),
79
+ cursor: at - 1,
80
+ };
81
+ }
82
+ if (key.delete)
83
+ return {
84
+ value: [...chars.slice(0, at), ...chars.slice(at + 1)].join(""),
85
+ cursor: at,
86
+ };
87
+ if (key.ctrl || key.meta || key.escape || key.tab)
88
+ return { value, cursor: at };
89
+ let insertion = key.return
90
+ ? multiline
91
+ ? "\n"
92
+ : ""
93
+ : clean(input).replace(/\r/g, "");
94
+ if (!multiline) insertion = insertion.replace(/\n/g, " ");
95
+ if (!insertion) return { value, cursor: at };
96
+ const remaining = Math.max(0, max - value.length),
97
+ parts = [];
98
+ let length = 0;
99
+ for (const part of graphemes(insertion)) {
100
+ if (length + part.length > remaining) break;
101
+ parts.push(part);
102
+ length += part.length;
103
+ }
104
+ return {
105
+ value: [...chars.slice(0, at), ...parts, ...chars.slice(at)].join(""),
106
+ cursor: at + parts.length,
107
+ };
108
+ }
109
+ export function cursorLines(value, cursor, width) {
110
+ const parts = graphemes(value),
111
+ lines = wrap(
112
+ parts.slice(0, cursor).join("") + "▏" + parts.slice(cursor).join(""),
113
+ width,
114
+ );
115
+ const marker = lines.findIndex((line) => line.includes("▏"));
116
+ return { lines, line: Math.max(0, marker) };
117
+ }
118
+ export function parseCommand(text) {
119
+ const [cmd, ...rest] = text.trim().replace(/^\//, "").split(/\s+/);
120
+ const arg = rest.join(" ");
121
+ if (
122
+ [
123
+ "feed",
124
+ "inbox",
125
+ "communities",
126
+ "search",
127
+ "ask",
128
+ "review",
129
+ "help",
130
+ "devices",
131
+ ].includes(cmd)
132
+ )
133
+ return { command: cmd, arg };
134
+ if (
135
+ cmd === "open" &&
136
+ /^[1-9]\d*$/.test(arg) &&
137
+ Number.isSafeInteger(Number(arg))
138
+ )
139
+ return { command: cmd, arg: Number(arg) };
140
+ if (["quit", "logout"].includes(cmd)) return { command: cmd };
141
+ throw Error(
142
+ "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /help or /quit.",
143
+ );
144
+ }
145
+ export class PageCache {
146
+ constructor({ max = 40, ttl = 30000 } = {}) {
147
+ this.rows = new Map();
148
+ this.max = max;
149
+ this.ttl = ttl;
150
+ }
151
+ get(key) {
152
+ const row = this.rows.get(key);
153
+ if (!row || Date.now() - row.time > this.ttl) return null;
154
+ this.rows.delete(key);
155
+ this.rows.set(key, row);
156
+ return row.value;
157
+ }
158
+ set(key, value) {
159
+ this.rows.delete(key);
160
+ this.rows.set(key, { value, time: Date.now() });
161
+ while (this.rows.size > this.max)
162
+ this.rows.delete(this.rows.keys().next().value);
163
+ }
164
+ clear() {
165
+ this.rows.clear();
166
+ }
167
+ }