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.
Files changed (59) hide show
  1. package/README.md +251 -0
  2. package/dist/api/ApiError.js +10 -0
  3. package/dist/api/client.js +96 -0
  4. package/dist/commands/add.js +45 -0
  5. package/dist/commands/config.js +41 -0
  6. package/dist/commands/done.js +39 -0
  7. package/dist/commands/executor.js +457 -0
  8. package/dist/commands/list.js +86 -0
  9. package/dist/commands/login.js +1 -0
  10. package/dist/commands/loginFlow.js +86 -0
  11. package/dist/commands/logout.js +11 -0
  12. package/dist/commands/registry.js +351 -0
  13. package/dist/commands/resolver.js +184 -0
  14. package/dist/config.js +16 -0
  15. package/dist/index.js +77 -0
  16. package/dist/tui/App.js +141 -0
  17. package/dist/tui/components/AutocompleteOverlay.js +22 -0
  18. package/dist/tui/components/BottomBar.js +8 -0
  19. package/dist/tui/components/Breadcrumb.js +6 -0
  20. package/dist/tui/components/CommandInput.js +111 -0
  21. package/dist/tui/components/CommandPalette.js +1 -0
  22. package/dist/tui/components/ErrorBoundary.js +26 -0
  23. package/dist/tui/components/FocusMode.js +1 -0
  24. package/dist/tui/components/FolderItem.js +5 -0
  25. package/dist/tui/components/Header.js +5 -0
  26. package/dist/tui/components/Keybindings.js +5 -0
  27. package/dist/tui/components/ListItem.js +5 -0
  28. package/dist/tui/components/NodeRow.js +14 -0
  29. package/dist/tui/components/PriorityBadge.js +9 -0
  30. package/dist/tui/components/SearchOverlay.js +1 -0
  31. package/dist/tui/components/Spinner.js +23 -0
  32. package/dist/tui/components/StatusBadge.js +9 -0
  33. package/dist/tui/components/SuggestionItem.js +8 -0
  34. package/dist/tui/components/TopBar.js +13 -0
  35. package/dist/tui/components/TreeConnector.js +17 -0
  36. package/dist/tui/hooks/useAuth.js +37 -0
  37. package/dist/tui/hooks/useCommandInput.js +35 -0
  38. package/dist/tui/hooks/useFolders.js +16 -0
  39. package/dist/tui/hooks/useKeymap.js +30 -0
  40. package/dist/tui/hooks/useLists.js +16 -0
  41. package/dist/tui/hooks/useNavigation.js +15 -0
  42. package/dist/tui/hooks/useTree.js +93 -0
  43. package/dist/tui/screens/DailyScreen.js +77 -0
  44. package/dist/tui/screens/DashboardScreen.js +262 -0
  45. package/dist/tui/screens/HomeScreen.js +75 -0
  46. package/dist/tui/screens/ListsScreen.js +73 -0
  47. package/dist/tui/screens/LoginScreen.js +115 -0
  48. package/dist/tui/screens/NodeScreen.js +48 -0
  49. package/dist/tui/screens/TreeScreen.js +182 -0
  50. package/dist/tui/screens/WelcomeScreen.js +83 -0
  51. package/dist/tui/types.js +1 -0
  52. package/dist/types/index.js +1 -0
  53. package/dist/utils/auth.js +11 -0
  54. package/dist/utils/logger.js +32 -0
  55. package/dist/utils/numbering.js +38 -0
  56. package/dist/utils/recents.js +24 -0
  57. package/dist/utils/scoring.js +29 -0
  58. package/dist/utils/tree.js +125 -0
  59. 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
+ }