stratanodex 0.1.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 +251 -0
- package/dist/api/ApiError.js +10 -0
- package/dist/api/client.js +96 -0
- package/dist/commands/add.js +45 -0
- package/dist/commands/config.js +41 -0
- package/dist/commands/done.js +39 -0
- package/dist/commands/executor.js +457 -0
- package/dist/commands/list.js +86 -0
- package/dist/commands/login.js +1 -0
- package/dist/commands/loginFlow.js +86 -0
- package/dist/commands/logout.js +11 -0
- package/dist/commands/registry.js +351 -0
- package/dist/commands/resolver.js +184 -0
- package/dist/config.js +16 -0
- package/dist/index.js +77 -0
- package/dist/tui/App.js +141 -0
- package/dist/tui/components/AutocompleteOverlay.js +22 -0
- package/dist/tui/components/BottomBar.js +8 -0
- package/dist/tui/components/Breadcrumb.js +6 -0
- package/dist/tui/components/CommandInput.js +111 -0
- package/dist/tui/components/CommandPalette.js +1 -0
- package/dist/tui/components/ErrorBoundary.js +26 -0
- package/dist/tui/components/FocusMode.js +1 -0
- package/dist/tui/components/FolderItem.js +5 -0
- package/dist/tui/components/Header.js +5 -0
- package/dist/tui/components/Keybindings.js +5 -0
- package/dist/tui/components/ListItem.js +5 -0
- package/dist/tui/components/NodeRow.js +14 -0
- package/dist/tui/components/PriorityBadge.js +9 -0
- package/dist/tui/components/SearchOverlay.js +1 -0
- package/dist/tui/components/Spinner.js +23 -0
- package/dist/tui/components/StatusBadge.js +9 -0
- package/dist/tui/components/SuggestionItem.js +8 -0
- package/dist/tui/components/TopBar.js +13 -0
- package/dist/tui/components/TreeConnector.js +17 -0
- package/dist/tui/hooks/useAuth.js +37 -0
- package/dist/tui/hooks/useCommandInput.js +35 -0
- package/dist/tui/hooks/useFolders.js +16 -0
- package/dist/tui/hooks/useKeymap.js +30 -0
- package/dist/tui/hooks/useLists.js +16 -0
- package/dist/tui/hooks/useNavigation.js +15 -0
- package/dist/tui/hooks/useTree.js +93 -0
- package/dist/tui/screens/DailyScreen.js +77 -0
- package/dist/tui/screens/DashboardScreen.js +262 -0
- package/dist/tui/screens/HomeScreen.js +75 -0
- package/dist/tui/screens/ListsScreen.js +73 -0
- package/dist/tui/screens/LoginScreen.js +115 -0
- package/dist/tui/screens/NodeScreen.js +48 -0
- package/dist/tui/screens/TreeScreen.js +182 -0
- package/dist/tui/screens/WelcomeScreen.js +83 -0
- package/dist/tui/types.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/auth.js +11 -0
- package/dist/utils/logger.js +32 -0
- package/dist/utils/numbering.js +38 -0
- package/dist/utils/recents.js +24 -0
- package/dist/utils/scoring.js +29 -0
- package/dist/utils/tree.js +125 -0
- package/package.json +74 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { Spinner } from '@inkjs/ui';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import { createCliSession, pollCliSession } from '../../api/client.js';
|
|
7
|
+
import { saveToken } from '../../utils/auth.js';
|
|
8
|
+
import { ApiError } from '../../api/ApiError.js';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
const AUTH_URL_BASE = process.env['STRATANODEX_AUTH_URL'] ?? 'https://stratanodex-landing-page.vercel.app';
|
|
11
|
+
const POLL_INTERVAL_MS = 2000;
|
|
12
|
+
export function LoginScreen({ replaceScreen, registerActions }) {
|
|
13
|
+
const [state, setState] = useState('creating');
|
|
14
|
+
const [errorMsg, setErrorMsg] = useState('');
|
|
15
|
+
const pollRef = useRef(null);
|
|
16
|
+
// Use a ref for the attempt counter so re-runs are stable
|
|
17
|
+
const attemptRef = useRef(0);
|
|
18
|
+
const stopPolling = useCallback(() => {
|
|
19
|
+
if (pollRef.current) {
|
|
20
|
+
clearInterval(pollRef.current);
|
|
21
|
+
pollRef.current = null;
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
const startLogin = useCallback(async () => {
|
|
25
|
+
stopPolling();
|
|
26
|
+
setState('creating');
|
|
27
|
+
setErrorMsg('');
|
|
28
|
+
const attempt = ++attemptRef.current;
|
|
29
|
+
try {
|
|
30
|
+
// 1. Create CLI session on backend
|
|
31
|
+
const { code } = await createCliSession();
|
|
32
|
+
if (attemptRef.current !== attempt)
|
|
33
|
+
return; // stale attempt
|
|
34
|
+
// 2. Open browser to auth page — hash-routed, ?session= is inside the hash
|
|
35
|
+
const separator = AUTH_URL_BASE.includes('?') ? '&' : '?';
|
|
36
|
+
await open(`${AUTH_URL_BASE}${separator}session=${code}#auth`);
|
|
37
|
+
setState('waiting');
|
|
38
|
+
// 3. Poll until complete or error
|
|
39
|
+
pollRef.current = setInterval(async () => {
|
|
40
|
+
if (attemptRef.current !== attempt) {
|
|
41
|
+
stopPolling();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const result = await pollCliSession(code);
|
|
46
|
+
if (result.status === 'complete' && result.token) {
|
|
47
|
+
stopPolling();
|
|
48
|
+
saveToken(result.token);
|
|
49
|
+
setState('success');
|
|
50
|
+
// Brief flash, then navigate
|
|
51
|
+
setTimeout(() => replaceScreen('home'), 600);
|
|
52
|
+
}
|
|
53
|
+
// status === 'pending' → keep polling, no state change
|
|
54
|
+
}
|
|
55
|
+
catch (_err) {
|
|
56
|
+
stopPolling();
|
|
57
|
+
if (attemptRef.current !== attempt)
|
|
58
|
+
return;
|
|
59
|
+
const statusCode = _err instanceof ApiError ? _err.statusCode : 0;
|
|
60
|
+
if (statusCode === 410) {
|
|
61
|
+
setErrorMsg('Session expired. Press R to retry.');
|
|
62
|
+
}
|
|
63
|
+
else if (statusCode === 404) {
|
|
64
|
+
setErrorMsg('Session not found. Press R to retry.');
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
setErrorMsg('Connection lost. Press R to retry.');
|
|
68
|
+
}
|
|
69
|
+
setState('error');
|
|
70
|
+
}
|
|
71
|
+
}, POLL_INTERVAL_MS);
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (attemptRef.current !== attempt)
|
|
75
|
+
return;
|
|
76
|
+
const status = e instanceof ApiError ? e.statusCode : 0;
|
|
77
|
+
if (status === 429) {
|
|
78
|
+
setErrorMsg('Too many retries. Wait a moment then press R.');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
setErrorMsg('Cannot reach backend. Check your connection and press R.');
|
|
82
|
+
}
|
|
83
|
+
setState('error');
|
|
84
|
+
}
|
|
85
|
+
}, [stopPolling, replaceScreen]);
|
|
86
|
+
// Register navigation actions
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
registerActions({
|
|
89
|
+
onBack: () => {
|
|
90
|
+
stopPolling();
|
|
91
|
+
replaceScreen('welcome');
|
|
92
|
+
},
|
|
93
|
+
onQuit: () => {
|
|
94
|
+
stopPolling();
|
|
95
|
+
replaceScreen('welcome');
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}, [registerActions, stopPolling, replaceScreen]);
|
|
99
|
+
// Mount-only effect — startLogin and stopPolling are stable refs
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
startLogin();
|
|
102
|
+
return stopPolling;
|
|
103
|
+
}, []);
|
|
104
|
+
// Keyboard: R = retry, Q/ESC = cancel
|
|
105
|
+
useInput((input, key) => {
|
|
106
|
+
if (state === 'error' && (input === 'r' || input === 'R')) {
|
|
107
|
+
startLogin();
|
|
108
|
+
}
|
|
109
|
+
if (input === 'q' || input === 'Q' || key.escape) {
|
|
110
|
+
stopPolling();
|
|
111
|
+
replaceScreen('welcome');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 2, gap: 1, children: [_jsx(Text, { bold: true, color: "#00bfff", children: "Authentication" }), state === 'creating' && (_jsx(Box, { flexDirection: "column", gap: 1, children: _jsx(Spinner, { label: chalk.hex('#8b949e')(' Connecting to server (may take ~15s on first launch)...') }) })), state === 'waiting' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: "#00bfff", children: ['>', " Browser opened \u2014 complete login there"] }), _jsx(Spinner, { label: chalk.hex('#8b949e')(' Waiting for authentication...') }), _jsx(Text, { color: "#8b949e", dimColor: true, children: "Q to cancel" })] })), state === 'error' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: "red", children: ["\u2715 ", errorMsg] }), _jsx(Text, { color: "#8b949e", dimColor: true, children: "R to retry \u00B7 Q to quit" })] })), state === 'success' && _jsx(Text, { color: "#00c896", children: "\u2713 Logged in successfully" })] }));
|
|
115
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { getNode } from '../../api/client.js';
|
|
5
|
+
import { Spinner } from '../components/Spinner.js';
|
|
6
|
+
import { Breadcrumb } from '../components/Breadcrumb.js';
|
|
7
|
+
import { Keybindings } from '../components/Keybindings.js';
|
|
8
|
+
const BINDINGS = '[b] back [/] command';
|
|
9
|
+
export function NodeScreen({ nodeId, pop, registerActions }) {
|
|
10
|
+
const [node, setNode] = useState(null);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setLoading(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
getNode(nodeId)
|
|
17
|
+
.then((data) => setNode(data))
|
|
18
|
+
.catch((e) => setError(e.message))
|
|
19
|
+
.finally(() => setLoading(false));
|
|
20
|
+
}, [nodeId]);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
registerActions({
|
|
23
|
+
onBack: () => pop(),
|
|
24
|
+
});
|
|
25
|
+
}, [pop, registerActions]);
|
|
26
|
+
if (loading) {
|
|
27
|
+
return (_jsx(Box, { paddingX: 2, children: _jsx(Spinner, { label: "Loading node details..." }) }));
|
|
28
|
+
}
|
|
29
|
+
if (error) {
|
|
30
|
+
return (_jsxs(Box, { paddingX: 2, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["\u2717 ", error] }), _jsx(Box, { marginTop: 1, children: _jsx(Keybindings, { bindings: BINDINGS }) })] }));
|
|
31
|
+
}
|
|
32
|
+
if (!node) {
|
|
33
|
+
return (_jsxs(Box, { paddingX: 2, flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Node not found." }), _jsx(Box, { marginTop: 1, children: _jsx(Keybindings, { bindings: BINDINGS }) })] }));
|
|
34
|
+
}
|
|
35
|
+
const STATUS_COLORS = {
|
|
36
|
+
TODO: '#8b949e',
|
|
37
|
+
IN_PROGRESS: '#00bfff',
|
|
38
|
+
DONE: '#00c896',
|
|
39
|
+
};
|
|
40
|
+
const PRIORITY_COLORS = {
|
|
41
|
+
LOW: '#00c896',
|
|
42
|
+
MEDIUM: '#f7b955',
|
|
43
|
+
HIGH: '#f85149',
|
|
44
|
+
};
|
|
45
|
+
const statusColor = STATUS_COLORS[node.status] || '#8b949e';
|
|
46
|
+
const priorityColor = node.priority ? PRIORITY_COLORS[node.priority] : '#4A4F57';
|
|
47
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Breadcrumb, { parts: ['Node Details'] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { bold: true, color: "#EDEFF3", wrap: "wrap", children: node.title }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 4, children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: "#7D828B", children: "Status:" }) }), _jsx(Text, { color: statusColor, children: node.status.replace('_', ' ') })] }), _jsxs(Box, { gap: 4, children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: "#7D828B", children: "Priority:" }) }), _jsx(Text, { color: priorityColor, children: node.priority || 'None' })] }), (node.startAt || node.endAt) && (_jsxs(_Fragment, { children: [node.startAt && (_jsxs(Box, { gap: 4, children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: "#7D828B", children: "Start Date:" }) }), _jsx(Text, { children: new Date(node.startAt).toLocaleString() })] })), node.endAt && (_jsxs(Box, { gap: 4, children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: "#7D828B", children: "End Date:" }) }), _jsx(Text, { children: new Date(node.endAt).toLocaleString() })] }))] })), node.reminderAt && (_jsxs(Box, { gap: 4, children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: "#7D828B", children: "Reminder:" }) }), _jsx(Text, { children: new Date(node.reminderAt).toLocaleString() })] })), node.tags && node.tags.length > 0 && (_jsxs(Box, { gap: 4, marginTop: 1, children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: "#7D828B", children: "Tags:" }) }), _jsx(Box, { gap: 1, flexWrap: "wrap", children: node.tags.map((t) => (_jsxs(Text, { color: "#00bfff", children: ["#", t.tag.name] }, t.tag.id))) })] })), node.notes && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "#7D828B", children: "Notes:" }), _jsx(Box, { marginTop: 1, paddingX: 2, borderStyle: "single", borderColor: "#30363d", flexDirection: "column", children: _jsx(Text, { color: "#c9d1d9", wrap: "wrap", children: node.notes }) })] }))] })] }), _jsx(Box, { marginTop: 2, children: _jsx(Keybindings, { bindings: BINDINGS }) })] }));
|
|
48
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Tree screen - core screen
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { useTree } from '../hooks/useTree.js';
|
|
6
|
+
import { NodeRow } from '../components/NodeRow.js';
|
|
7
|
+
import { Breadcrumb } from '../components/Breadcrumb.js';
|
|
8
|
+
import { Keybindings } from '../components/Keybindings.js';
|
|
9
|
+
import { Spinner } from '../components/Spinner.js';
|
|
10
|
+
import { createRootNode, createChildNode, updateNode } from '../../api/client.js';
|
|
11
|
+
const BINDINGS = '[↑↓] navigate [→←] expand/collapse [b] back [/] command';
|
|
12
|
+
/**
|
|
13
|
+
* Flatten the nested tree into a display list with depth, isLast, and parentLines
|
|
14
|
+
* metadata needed for rendering Figma-style connectors.
|
|
15
|
+
*/
|
|
16
|
+
function flattenForDisplay(roots, expandedIds, numberMap) {
|
|
17
|
+
const result = [];
|
|
18
|
+
function walk(children, depth, parentLines) {
|
|
19
|
+
const sorted = [...children].sort((a, b) => a.position - b.position);
|
|
20
|
+
sorted.forEach((node, index) => {
|
|
21
|
+
const isLast = index === sorted.length - 1;
|
|
22
|
+
const entry = {
|
|
23
|
+
node,
|
|
24
|
+
depth,
|
|
25
|
+
isLast,
|
|
26
|
+
parentLines: [...parentLines],
|
|
27
|
+
};
|
|
28
|
+
result.push(entry);
|
|
29
|
+
if (expandedIds.has(node.id) && (node.children ?? []).length > 0) {
|
|
30
|
+
// For children, pass whether this parent's level should draw a continuation line
|
|
31
|
+
const nextParentLines = [...parentLines, !isLast];
|
|
32
|
+
walk(node.children ?? [], depth + 1, nextParentLines);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
walk(roots, 0, []);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
export function TreeScreen({ push, pop, registerActions, listId, listName, folderName, onNodesLoaded, onSelectedNodeChanged, }) {
|
|
40
|
+
const { nodes, flatNodes, expandedIds, toggleExpand, numberMap, loading, error, refetch } = useTree(listId);
|
|
41
|
+
const [cursor, setCursor] = useState(0);
|
|
42
|
+
const [status, setStatus] = useState(null);
|
|
43
|
+
// Build display entries with tree metadata
|
|
44
|
+
const displayEntries = flattenForDisplay(nodes, expandedIds, numberMap);
|
|
45
|
+
// Bubble flat nodes up so CommandInput can resolve node refs for autocomplete
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (onNodesLoaded)
|
|
48
|
+
onNodesLoaded(nodes);
|
|
49
|
+
}, [nodes, onNodesLoaded]);
|
|
50
|
+
// Report selected node to App
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const entry = displayEntries[cursor];
|
|
53
|
+
onSelectedNodeChanged?.(entry?.node.id);
|
|
54
|
+
}, [cursor, displayEntries, onSelectedNodeChanged]);
|
|
55
|
+
const handleCommand = useCallback(async (cmd) => {
|
|
56
|
+
const parts = cmd.trim().split(/\s+/);
|
|
57
|
+
// ── /add node <title> ────────────────────────────────────────────────
|
|
58
|
+
if (parts[0] === '/add' && parts[1] === 'node' && parts.length > 2) {
|
|
59
|
+
const title = parts.slice(2).join(' ');
|
|
60
|
+
setStatus('Adding node...');
|
|
61
|
+
createRootNode(listId, { title })
|
|
62
|
+
.then(() => {
|
|
63
|
+
setStatus(`✓ Added "${title}"`);
|
|
64
|
+
refetch();
|
|
65
|
+
})
|
|
66
|
+
.catch((e) => setStatus(`✗ ${e.message}`));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// ── /add sub-node [index] <title> ────────────────────────────────────
|
|
70
|
+
if (parts[0] === '/add' && parts[1] === 'sub-node' && parts.length > 2) {
|
|
71
|
+
const afterCmd = parts.slice(2);
|
|
72
|
+
// Check if first arg looks like an index (e.g. "1", "1.2", "1.2.1")
|
|
73
|
+
const indexPattern = /^\d+(\.\d+)*$/;
|
|
74
|
+
let parentId = null;
|
|
75
|
+
let title;
|
|
76
|
+
if (indexPattern.test(afterCmd[0])) {
|
|
77
|
+
// /add sub-node <index> <title>
|
|
78
|
+
const idx = afterCmd[0];
|
|
79
|
+
title = afterCmd.slice(1).join(' ');
|
|
80
|
+
// Find node by its number
|
|
81
|
+
for (const [nodeId, num] of numberMap.entries()) {
|
|
82
|
+
if (num === idx) {
|
|
83
|
+
parentId = nodeId;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!parentId) {
|
|
88
|
+
setStatus(`✗ Node "${idx}" not found.`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!title) {
|
|
92
|
+
setStatus('✗ Please provide a title for the sub-node.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// /add sub-node <title> — use currently selected node
|
|
98
|
+
title = afterCmd.join(' ');
|
|
99
|
+
const entry = displayEntries[cursor];
|
|
100
|
+
if (!entry) {
|
|
101
|
+
setStatus('✗ No node selected. Navigate to a node first.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
parentId = entry.node.id;
|
|
105
|
+
}
|
|
106
|
+
setStatus('Adding sub-node...');
|
|
107
|
+
createChildNode(parentId, { title })
|
|
108
|
+
.then(() => {
|
|
109
|
+
setStatus(`✓ Added sub-node "${title}"`);
|
|
110
|
+
refetch();
|
|
111
|
+
})
|
|
112
|
+
.catch((e) => setStatus(`✗ ${e.message}`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// ── /done <index> ────────────────────────────────────────────────────
|
|
116
|
+
if (parts[0] === '/done' && parts[1]) {
|
|
117
|
+
const arg = parts.slice(1).join(' ');
|
|
118
|
+
const node = flatNodes.find((n) => numberMap.get(n.id) === arg);
|
|
119
|
+
if (!node) {
|
|
120
|
+
setStatus(`✗ Node "${arg}" not found.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
setStatus('Marking done...');
|
|
124
|
+
updateNode(node.id, { status: 'DONE' })
|
|
125
|
+
.then(() => {
|
|
126
|
+
setStatus(`✓ Done: ${node.title}`);
|
|
127
|
+
refetch();
|
|
128
|
+
})
|
|
129
|
+
.catch((e) => setStatus(`✗ ${e.message}`));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// ── /back ────────────────────────────────────────────────────────────
|
|
133
|
+
if (parts[0] === '/back') {
|
|
134
|
+
pop();
|
|
135
|
+
}
|
|
136
|
+
}, [flatNodes, displayEntries, cursor, numberMap, listId, refetch, pop]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const len = displayEntries.length;
|
|
139
|
+
registerActions({
|
|
140
|
+
onUp: () => setCursor((c) => Math.max(0, c - 1)),
|
|
141
|
+
onDown: () => setCursor((c) => Math.min(len - 1, c + 1)),
|
|
142
|
+
onRight: () => {
|
|
143
|
+
const entry = displayEntries[cursor];
|
|
144
|
+
if (entry)
|
|
145
|
+
toggleExpand(entry.node.id);
|
|
146
|
+
},
|
|
147
|
+
onLeft: () => {
|
|
148
|
+
const entry = displayEntries[cursor];
|
|
149
|
+
if (entry)
|
|
150
|
+
toggleExpand(entry.node.id);
|
|
151
|
+
},
|
|
152
|
+
onEnter: () => {
|
|
153
|
+
const entry = displayEntries[cursor];
|
|
154
|
+
if (entry) {
|
|
155
|
+
push('node-details', { nodeId: entry.node.id });
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
onBack: () => pop(),
|
|
159
|
+
onCommand: handleCommand,
|
|
160
|
+
onRefetch: refetch,
|
|
161
|
+
});
|
|
162
|
+
}, [displayEntries, cursor, toggleExpand, pop, registerActions, handleCommand, refetch]);
|
|
163
|
+
const breadcrumbParts = [folderName, listName].filter(Boolean);
|
|
164
|
+
if (loading)
|
|
165
|
+
return (_jsx(Box, { paddingX: 2, children: _jsx(Spinner, { label: "Loading tree..." }) }));
|
|
166
|
+
if (error)
|
|
167
|
+
return (_jsx(Box, { paddingX: 2, children: _jsxs(Text, { color: "red", children: ["\u2717 ", error] }) }));
|
|
168
|
+
if (displayEntries.length === 0) {
|
|
169
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [breadcrumbParts.length > 0 && _jsx(Breadcrumb, { parts: breadcrumbParts }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#8b949e", dimColor: true, children: ["No tasks yet. Type", ' '] }), _jsxs(Text, { color: "#00bfff", children: ["/add node ", '<title>'] }), _jsxs(Text, { color: "#8b949e", dimColor: true, children: [' ', "to create one."] })] })] }));
|
|
170
|
+
}
|
|
171
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [breadcrumbParts.length > 0 && _jsx(Breadcrumb, { parts: breadcrumbParts }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: displayEntries.map((entry, i) => {
|
|
172
|
+
const num = numberMap.get(entry.node.id) ?? '';
|
|
173
|
+
// For root nodes: check if this is the last root-level entry
|
|
174
|
+
let isLastRoot = false;
|
|
175
|
+
if (entry.depth === 0) {
|
|
176
|
+
// Look for the next root node after this one
|
|
177
|
+
const nextRootIdx = displayEntries.findIndex((e, j) => j > i && e.depth === 0);
|
|
178
|
+
isLastRoot = nextRootIdx === -1;
|
|
179
|
+
}
|
|
180
|
+
return (_jsx(NodeRow, { node: entry.node, number: num, depth: entry.depth, isSelected: i === cursor, isLast: entry.isLast, isLastRoot: isLastRoot, parentLines: entry.parentLines }, entry.node.id));
|
|
181
|
+
}) }), status && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: status }) })), _jsx(Box, { marginTop: 1, children: _jsx(Keybindings, { bindings: BINDINGS }) })] }));
|
|
182
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState, useRef } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { Spinner } from '@inkjs/ui';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { getToken } from '../../utils/auth.js';
|
|
7
|
+
import { getMe } from '../../api/client.js';
|
|
8
|
+
import { createRequire } from 'module';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { version } = require('../../../package.json');
|
|
11
|
+
const LOGO_LINE_1 = '█▀▀ ▀█▀ █▀█ ▄▀█ ▀█▀ ▄▀█ █▄ █ █▀█ █▀▄ █▀▀ ▀▄▀';
|
|
12
|
+
const LOGO_LINE_2 = '▄██ █ █▀▄ █▀█ █ █▀█ █ ▀█ █▄█ █▄▀ ██▄ █ █';
|
|
13
|
+
export function WelcomeScreen({ replaceScreen, height, width }) {
|
|
14
|
+
const [status, setStatus] = useState('checking');
|
|
15
|
+
const done = useRef(false);
|
|
16
|
+
const transition = (loggedIn) => {
|
|
17
|
+
if (done.current)
|
|
18
|
+
return;
|
|
19
|
+
done.current = true;
|
|
20
|
+
replaceScreen(loggedIn ? 'dashboard' : 'login');
|
|
21
|
+
};
|
|
22
|
+
useInput(() => {
|
|
23
|
+
if (done.current)
|
|
24
|
+
return;
|
|
25
|
+
transition(!!getToken());
|
|
26
|
+
});
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const guestMode = process.env['STRATANODEX_GUEST'] === 'true';
|
|
29
|
+
if (guestMode) {
|
|
30
|
+
transition(true);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const token = getToken();
|
|
34
|
+
if (!token) {
|
|
35
|
+
// No token at all — go to login
|
|
36
|
+
const t = setTimeout(() => transition(false), 1200);
|
|
37
|
+
return () => clearTimeout(t);
|
|
38
|
+
}
|
|
39
|
+
// Token exists — validate with the backend.
|
|
40
|
+
// Retry on network/timeout errors (cold start), only go to login on 401.
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
const MAX_RETRIES = 3;
|
|
43
|
+
const RETRY_DELAY = 5000;
|
|
44
|
+
async function validateToken(attempt) {
|
|
45
|
+
if (cancelled)
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
await getMe();
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return;
|
|
51
|
+
setStatus('done');
|
|
52
|
+
setTimeout(() => transition(true), 600);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (cancelled)
|
|
56
|
+
return;
|
|
57
|
+
const statusCode = err?.response?.status;
|
|
58
|
+
if (statusCode === 401) {
|
|
59
|
+
// Token is genuinely invalid/expired — go to login
|
|
60
|
+
setStatus('error');
|
|
61
|
+
setTimeout(() => transition(false), 1000);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Network error / timeout (cold start) — retry
|
|
65
|
+
if (attempt < MAX_RETRIES) {
|
|
66
|
+
setStatus('checking');
|
|
67
|
+
setTimeout(() => validateToken(attempt + 1), RETRY_DELAY);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// All retries exhausted but token still exists — go home anyway
|
|
71
|
+
// and let the API interceptor handle 401s later
|
|
72
|
+
setStatus('done');
|
|
73
|
+
setTimeout(() => transition(true), 600);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
validateToken(1);
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
};
|
|
81
|
+
}, []);
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: height, width: width, children: [_jsx(Text, { children: chalk.hex('#003355')(LOGO_LINE_1) }), _jsx(Text, { children: chalk.hex('#004477')(LOGO_LINE_1) }), _jsx(Text, { children: chalk.hex('#00bfff').bold(LOGO_LINE_1) }), _jsx(Text, { children: chalk.hex('#00bfff').bold(LOGO_LINE_2) }), _jsx(Text, { children: chalk.hex('#004477')(LOGO_LINE_2) }), _jsx(Text, { children: chalk.hex('#003355')(LOGO_LINE_2) }), _jsx(Text, { children: " " }), _jsx(Text, { children: chalk.hex('#007799')(`CLI ${version}`) }), _jsx(Text, { children: " " }), _jsx(Text, { children: " " }), status === 'checking' && (_jsx(Spinner, { label: chalk.hex('#004477')(' checking connection...') })), status === 'error' && (_jsx(Text, { children: chalk.hex('#440000')('connection failed · redirecting to login') }))] }));
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
const store = new Conf({ projectName: 'stratanodex' });
|
|
3
|
+
export function saveToken(token) {
|
|
4
|
+
store.set('token', token);
|
|
5
|
+
}
|
|
6
|
+
export function getToken() {
|
|
7
|
+
return store.get('token');
|
|
8
|
+
}
|
|
9
|
+
export function clearToken() {
|
|
10
|
+
store.delete('token');
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import Conf from 'conf';
|
|
4
|
+
import { getConfig } from '../config.js';
|
|
5
|
+
const confInstance = new Conf({ projectName: 'stratanodex' });
|
|
6
|
+
const confDir = path.dirname(confInstance.path);
|
|
7
|
+
const logFilePath = path.join(confDir, 'log.txt');
|
|
8
|
+
fs.mkdirSync(confDir, { recursive: true });
|
|
9
|
+
if (fs.existsSync(logFilePath)) {
|
|
10
|
+
const stats = fs.statSync(logFilePath);
|
|
11
|
+
if (stats.size > 10 * 1024 * 1024) {
|
|
12
|
+
fs.renameSync(logFilePath, path.join(confDir, 'log.old.txt'));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function writeLog(level, msg, data) {
|
|
16
|
+
const entry = JSON.stringify({
|
|
17
|
+
time: new Date().toISOString(),
|
|
18
|
+
level,
|
|
19
|
+
msg,
|
|
20
|
+
...(data ?? {}),
|
|
21
|
+
});
|
|
22
|
+
fs.appendFileSync(logFilePath, entry + '\n');
|
|
23
|
+
if (getConfig().verbose) {
|
|
24
|
+
process.stderr.write(entry + '\n');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const logger = {
|
|
28
|
+
info: (msg, data) => writeLog('info', msg, data),
|
|
29
|
+
error: (msg, data) => writeLog('error', msg, data),
|
|
30
|
+
debug: (msg, data) => writeLog('debug', msg, data),
|
|
31
|
+
warn: (msg, data) => writeLog('warn', msg, data),
|
|
32
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given a nested tree of nodes (root nodes with children[] embedded),
|
|
3
|
+
* returns a Map from nodeId → hierarchical number string e.g. "1.2.1".
|
|
4
|
+
* Numbers are never stored — computed at render time only.
|
|
5
|
+
*/
|
|
6
|
+
export function assignNumbers(nodes) {
|
|
7
|
+
const map = new Map();
|
|
8
|
+
function traverse(children, prefix) {
|
|
9
|
+
const sorted = [...children].sort((a, b) => a.position - b.position);
|
|
10
|
+
sorted.forEach((node, index) => {
|
|
11
|
+
const number = prefix ? `${prefix}.${index + 1}` : `${index + 1}`;
|
|
12
|
+
map.set(node.id, number);
|
|
13
|
+
if ((node.children ?? []).length > 0) {
|
|
14
|
+
traverse(node.children, number);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
traverse(nodes, '');
|
|
19
|
+
return map;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* DFS flatten — takes nested node tree, returns flat array in DFS order.
|
|
23
|
+
* Useful for resolving a display number like "1.2.1" to a node ID.
|
|
24
|
+
*/
|
|
25
|
+
export function flattenTree(nodes) {
|
|
26
|
+
const result = [];
|
|
27
|
+
function dfs(children) {
|
|
28
|
+
const sorted = [...children].sort((a, b) => a.position - b.position);
|
|
29
|
+
for (const node of sorted) {
|
|
30
|
+
result.push(node);
|
|
31
|
+
if ((node.children ?? []).length > 0) {
|
|
32
|
+
dfs(node.children);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
dfs(nodes);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// recents.ts — Persisted recent folders/lists store for the CLI.
|
|
2
|
+
// Stores the last 10 opened folders & lists in a JSON file alongside the auth token.
|
|
3
|
+
import Conf from 'conf';
|
|
4
|
+
const MAX_RECENTS = 10;
|
|
5
|
+
const store = new Conf({
|
|
6
|
+
projectName: 'stratanodex-cli',
|
|
7
|
+
defaults: { items: [] },
|
|
8
|
+
});
|
|
9
|
+
/** Record a folder/list being opened. Pushes to front and deduplicates. */
|
|
10
|
+
export function recordRecent(entry) {
|
|
11
|
+
const key = `${entry.type}:${entry.id}`;
|
|
12
|
+
const existing = store.get('items', []);
|
|
13
|
+
const filtered = existing.filter((e) => `${e.type}:${e.id}` !== key);
|
|
14
|
+
const next = [{ ...entry, openedAt: Date.now() }, ...filtered].slice(0, MAX_RECENTS);
|
|
15
|
+
store.set('items', next);
|
|
16
|
+
}
|
|
17
|
+
/** Get the list of recent entries, most recent first. */
|
|
18
|
+
export function getRecents() {
|
|
19
|
+
return store.get('items', []);
|
|
20
|
+
}
|
|
21
|
+
/** Clear all recents. */
|
|
22
|
+
export function clearRecents() {
|
|
23
|
+
store.set('items', []);
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate daily points based on task completion.
|
|
3
|
+
* Must match backend scoring logic exactly.
|
|
4
|
+
*
|
|
5
|
+
* Buckets:
|
|
6
|
+
* total === 0 → 0 (no tasks scheduled, no penalty)
|
|
7
|
+
* done/total >= 0.90 → +3
|
|
8
|
+
* done/total >= 0.60 → +2
|
|
9
|
+
* done/total >= 0.30 → +1
|
|
10
|
+
* done/total > 0.00 → 0
|
|
11
|
+
* done === 0 → -1
|
|
12
|
+
*/
|
|
13
|
+
export function calculatePoints(done, total) {
|
|
14
|
+
done = Math.max(0, done);
|
|
15
|
+
total = Math.max(0, total);
|
|
16
|
+
if (total === 0)
|
|
17
|
+
return 0;
|
|
18
|
+
done = Math.min(done, total);
|
|
19
|
+
const ratio = done / total;
|
|
20
|
+
if (ratio >= 0.9)
|
|
21
|
+
return 3;
|
|
22
|
+
if (ratio >= 0.6)
|
|
23
|
+
return 2;
|
|
24
|
+
if (ratio >= 0.3)
|
|
25
|
+
return 1;
|
|
26
|
+
if (done > 0)
|
|
27
|
+
return 0;
|
|
28
|
+
return -1;
|
|
29
|
+
}
|