itsvertical 0.0.1 → 0.0.3
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 +19 -6
- package/cli/dist/index.js +370 -15
- package/dist/assets/index-C1Wp2kHC.js +177 -0
- package/dist/assets/index-Dq3Strtu.css +1 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +3 -2
- package/package.json +8 -2
- package/dist/assets/index-CgahjK2L.css +0 -1
- package/dist/assets/index-DZzk7eGT.js +0 -177
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
**Tickets pile up, scopes get done. Project work isn't linear, it's Vertical.**
|
|
4
4
|
|
|
5
|
-
Vertical is a file-based project management tool
|
|
5
|
+
Vertical is a file-based project management tool that organizes work into vertical slices that can each be completed independently. No accounts, no cloud, no setup. Just a `.vertical` file and your terminal.
|
|
6
|
+
|
|
7
|
+

|
|
6
8
|
|
|
7
9
|
```
|
|
8
10
|
npx itsvertical new my-project.vertical "My Project"
|
|
@@ -10,13 +12,13 @@ npx itsvertical new my-project.vertical "My Project"
|
|
|
10
12
|
|
|
11
13
|
## Built for AI agents
|
|
12
14
|
|
|
13
|
-
Vertical is designed to be used through AI coding agents
|
|
15
|
+
Vertical is designed to be used through AI coding agents. The CLI is the primary interface — every entity is addressed by ID, every command accepts `--json` for structured output, and errors are machine-readable. An agent can create a project, break work into slices, add tasks, and track progress — all through the command line.
|
|
14
16
|
|
|
15
17
|
The browser UI (`itsvertical open`) is there for when you want to see the board visually, drag things around, or get a quick overview.
|
|
16
18
|
|
|
17
19
|
## How it works
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
Each slice is a box on the board. Add tasks to a box, and split it into layers when the work has distinct phases — design then build, for example. Slices get completed independently. Layers break the work within a slice into steps.
|
|
20
22
|
|
|
21
23
|
Everything is saved to a single `.vertical` file. Version it with git, share it with teammates, or let your agent manage it.
|
|
22
24
|
|
|
@@ -32,6 +34,16 @@ Or run directly with npx:
|
|
|
32
34
|
npx itsvertical new my-project.vertical "My Project"
|
|
33
35
|
```
|
|
34
36
|
|
|
37
|
+
### Agent skill
|
|
38
|
+
|
|
39
|
+
Install the [Vertical skill](https://skills.sh/seasonedcc/vertical/vertical) to teach your AI agent how to use Vertical:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
npx skills add seasonedcc/vertical
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This gives agents like Claude Code, Cursor, and GitHub Copilot procedural knowledge of all Vertical commands and workflows.
|
|
46
|
+
|
|
35
47
|
## Commands
|
|
36
48
|
|
|
37
49
|
All entities are addressed by ID. Use `itsvertical show` to see IDs. Every command accepts `--json` to output the full board state as JSON (useful for agents).
|
|
@@ -44,6 +56,7 @@ itsvertical open <file> # Open in the browser UI
|
|
|
44
56
|
itsvertical show <file> # Print the board to the terminal
|
|
45
57
|
itsvertical show <file> --json # Output the board as JSON
|
|
46
58
|
itsvertical show <file> --box <slice-id> # Show only a specific box
|
|
59
|
+
itsvertical show <file> --visual # Show the board as a visual 3x3 grid with summary
|
|
47
60
|
itsvertical rename <file> <name> # Rename the project
|
|
48
61
|
```
|
|
49
62
|
|
|
@@ -82,9 +95,9 @@ itsvertical layer status <file> <layer-id> none # Clear status
|
|
|
82
95
|
|
|
83
96
|
`itsvertical open` starts a local server and opens the board in your browser. Click **Save** or press **Ctrl+S** / **Cmd+S** to write changes back to the file.
|
|
84
97
|
|
|
85
|
-
## The
|
|
98
|
+
## The board
|
|
86
99
|
|
|
87
|
-
|
|
100
|
+
Each box represents a vertical slice of work.
|
|
88
101
|
|
|
89
102
|
- **Name boxes** by clicking the title area
|
|
90
103
|
- **Drag boxes** to rearrange them in the grid
|
|
@@ -129,7 +142,7 @@ Source at [github.com/seasonedcc/vertical](https://github.com/seasonedcc/vertica
|
|
|
129
142
|
|
|
130
143
|
The package has two parts:
|
|
131
144
|
|
|
132
|
-
- **SPA** (`app/`) — A React app built with Vite.
|
|
145
|
+
- **SPA** (`app/`) — A React app built with Vite. The board UI. Built to `dist/`.
|
|
133
146
|
- **CLI** (`cli/`) — A Node.js CLI built with tsup. Starts a local HTTP server that serves the SPA and provides a read/write API for the `.vertical` file. Built to `cli/dist/`.
|
|
134
147
|
|
|
135
148
|
The CLI and SPA share code: types (`app/state/types.ts`), serialization (`app/file/format.ts`), and project creation (`app/state/initial-state.ts`).
|
package/cli/dist/index.js
CHANGED
|
@@ -1219,8 +1219,8 @@ var Promise2 = getNative_default(root_default, "Promise");
|
|
|
1219
1219
|
var Promise_default = Promise2;
|
|
1220
1220
|
|
|
1221
1221
|
// node_modules/.pnpm/lodash-es@4.17.23/node_modules/lodash-es/_Set.js
|
|
1222
|
-
var
|
|
1223
|
-
var Set_default =
|
|
1222
|
+
var Set2 = getNative_default(root_default, "Set");
|
|
1223
|
+
var Set_default = Set2;
|
|
1224
1224
|
|
|
1225
1225
|
// node_modules/.pnpm/lodash-es@4.17.23/node_modules/lodash-es/_getTag.js
|
|
1226
1226
|
var mapTag2 = "[object Map]";
|
|
@@ -1921,10 +1921,306 @@ function fail(message, json) {
|
|
|
1921
1921
|
process.exit(1);
|
|
1922
1922
|
}
|
|
1923
1923
|
|
|
1924
|
+
// cli/board.ts
|
|
1925
|
+
var MIN_COL_WIDTH = 25;
|
|
1926
|
+
var MAX_COL_WIDTH = 40;
|
|
1927
|
+
var PADDING = 2;
|
|
1928
|
+
var ansi = {
|
|
1929
|
+
reset: "\x1B[0m",
|
|
1930
|
+
bold: "\x1B[1m",
|
|
1931
|
+
dim: "\x1B[2m",
|
|
1932
|
+
green: "\x1B[32m",
|
|
1933
|
+
yellow: "\x1B[33m",
|
|
1934
|
+
cyan: "\x1B[36m"
|
|
1935
|
+
};
|
|
1936
|
+
function style(text, ...codes) {
|
|
1937
|
+
return `${codes.join("")}${text}${ansi.reset}`;
|
|
1938
|
+
}
|
|
1939
|
+
var ANSI_PATTERN = new RegExp(`${"\x1B"}\\[[0-9;]*m`, "g");
|
|
1940
|
+
function stripAnsi(text) {
|
|
1941
|
+
return text.replace(ANSI_PATTERN, "");
|
|
1942
|
+
}
|
|
1943
|
+
function visibleLength(text) {
|
|
1944
|
+
return stripAnsi(text).length;
|
|
1945
|
+
}
|
|
1946
|
+
function truncateStyled(text, maxWidth) {
|
|
1947
|
+
if (visibleLength(text) <= maxWidth)
|
|
1948
|
+
return text;
|
|
1949
|
+
let visible = 0;
|
|
1950
|
+
let i = 0;
|
|
1951
|
+
while (i < text.length && visible < maxWidth - 1) {
|
|
1952
|
+
if (text[i] === "\x1B") {
|
|
1953
|
+
const end = text.indexOf("m", i);
|
|
1954
|
+
i = end + 1;
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
visible++;
|
|
1958
|
+
i++;
|
|
1959
|
+
}
|
|
1960
|
+
return `${text.slice(0, i)}${ansi.reset}\u2026`;
|
|
1961
|
+
}
|
|
1962
|
+
function padRightStyled(text, width) {
|
|
1963
|
+
const padding = Math.max(0, width - visibleLength(text));
|
|
1964
|
+
return text + " ".repeat(padding);
|
|
1965
|
+
}
|
|
1966
|
+
function getSliceLayers(state, slice) {
|
|
1967
|
+
return sortBy_default(
|
|
1968
|
+
state.layers.filter((l) => l.sliceId === slice.id),
|
|
1969
|
+
"sorting"
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
function getLayerTasks(state, layer2) {
|
|
1973
|
+
return sortBy_default(
|
|
1974
|
+
state.tasks.filter((t) => t.layerId === layer2.id),
|
|
1975
|
+
"sorting"
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
function getSliceTasks(state, slice) {
|
|
1979
|
+
const layers = getSliceLayers(state, slice);
|
|
1980
|
+
return layers.flatMap((l) => getLayerTasks(state, l));
|
|
1981
|
+
}
|
|
1982
|
+
function buildCellLines(state, slice, innerWidth) {
|
|
1983
|
+
const lines = [];
|
|
1984
|
+
const layers = getSliceLayers(state, slice);
|
|
1985
|
+
const allTasks = getSliceTasks(state, slice);
|
|
1986
|
+
const hasMultipleLayers = layers.length > 1;
|
|
1987
|
+
const separator = style("\u2504".repeat(innerWidth), ansi.dim);
|
|
1988
|
+
if (slice.name) {
|
|
1989
|
+
lines.push(style(slice.name, ansi.bold, ansi.cyan));
|
|
1990
|
+
}
|
|
1991
|
+
if (allTasks.length === 0) {
|
|
1992
|
+
lines.push(style("(no tasks)", ansi.dim));
|
|
1993
|
+
return lines;
|
|
1994
|
+
}
|
|
1995
|
+
for (let i = 0; i < layers.length; i++) {
|
|
1996
|
+
const layer2 = layers[i];
|
|
1997
|
+
const tasks = getLayerTasks(state, layer2);
|
|
1998
|
+
if (hasMultipleLayers && i > 0) {
|
|
1999
|
+
lines.push(separator);
|
|
2000
|
+
}
|
|
2001
|
+
if (hasMultipleLayers) {
|
|
2002
|
+
const layerName = layer2.name ?? `Layer ${i + 1}`;
|
|
2003
|
+
if (layer2.status === "done") {
|
|
2004
|
+
lines.push(style(`\u2713 ${layerName} (done)`, ansi.green));
|
|
2005
|
+
} else {
|
|
2006
|
+
lines.push(style(layerName, ansi.bold));
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
for (const task2 of tasks) {
|
|
2010
|
+
if (task2.done) {
|
|
2011
|
+
lines.push(` ${style("\u25CF", ansi.green)} ${style(task2.name, ansi.dim)}`);
|
|
2012
|
+
} else {
|
|
2013
|
+
lines.push(` ${style("\u25CB", ansi.yellow)} ${task2.name}`);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
return lines;
|
|
2018
|
+
}
|
|
2019
|
+
function showBoardGrid(state, boxId) {
|
|
2020
|
+
const sortedSlices = sortBy_default(state.slices, "boxNumber");
|
|
2021
|
+
const filteredSlices = boxId ? sortedSlices.filter((s) => s.id === boxId) : sortedSlices;
|
|
2022
|
+
const colWidth = Math.min(
|
|
2023
|
+
MAX_COL_WIDTH,
|
|
2024
|
+
Math.max(
|
|
2025
|
+
MIN_COL_WIDTH,
|
|
2026
|
+
...filteredSlices.flatMap((slice) => {
|
|
2027
|
+
const layers = getSliceLayers(state, slice);
|
|
2028
|
+
const tasks = getSliceTasks(state, slice);
|
|
2029
|
+
const widths = [
|
|
2030
|
+
slice.name?.length ?? 0,
|
|
2031
|
+
...layers.map((l) => (l.name?.length ?? 0) + 10),
|
|
2032
|
+
...tasks.map((t) => t.name.length + 4)
|
|
2033
|
+
];
|
|
2034
|
+
return widths;
|
|
2035
|
+
}).map((w) => w + PADDING * 2)
|
|
2036
|
+
)
|
|
2037
|
+
);
|
|
2038
|
+
const innerWidth = colWidth - PADDING * 2;
|
|
2039
|
+
const numCols = boxId ? 1 : 3;
|
|
2040
|
+
const cellContents = /* @__PURE__ */ new Map();
|
|
2041
|
+
for (const slice of filteredSlices) {
|
|
2042
|
+
cellContents.set(slice.id, buildCellLines(state, slice, innerWidth));
|
|
2043
|
+
}
|
|
2044
|
+
const rows = [];
|
|
2045
|
+
if (boxId) {
|
|
2046
|
+
rows.push(filteredSlices);
|
|
2047
|
+
} else {
|
|
2048
|
+
rows.push(
|
|
2049
|
+
filteredSlices.filter((s) => s.boxNumber >= 1 && s.boxNumber <= 3)
|
|
2050
|
+
);
|
|
2051
|
+
rows.push(
|
|
2052
|
+
filteredSlices.filter((s) => s.boxNumber >= 4 && s.boxNumber <= 6)
|
|
2053
|
+
);
|
|
2054
|
+
rows.push(
|
|
2055
|
+
filteredSlices.filter((s) => s.boxNumber >= 7 && s.boxNumber <= 9)
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
console.log(style(state.project.name, ansi.bold));
|
|
2059
|
+
console.log();
|
|
2060
|
+
const border = (text) => style(text, ansi.dim);
|
|
2061
|
+
const makeBorder = (left, mid2, right) => {
|
|
2062
|
+
const parts = [];
|
|
2063
|
+
for (let c = 0; c < numCols; c++) {
|
|
2064
|
+
parts.push("\u2500".repeat(colWidth));
|
|
2065
|
+
}
|
|
2066
|
+
return border(left + parts.join(mid2) + right);
|
|
2067
|
+
};
|
|
2068
|
+
const top = makeBorder("\u250C", "\u252C", "\u2510");
|
|
2069
|
+
const mid = makeBorder("\u251C", "\u253C", "\u2524");
|
|
2070
|
+
const bot = makeBorder("\u2514", "\u2534", "\u2518");
|
|
2071
|
+
for (let r = 0; r < rows.length; r++) {
|
|
2072
|
+
const row = rows[r];
|
|
2073
|
+
console.log(r === 0 ? top : mid);
|
|
2074
|
+
const cellLines = row.map((slice) => cellContents.get(slice.id) ?? []);
|
|
2075
|
+
const rowHeight = Math.max(3, ...cellLines.map((l) => l.length + 2));
|
|
2076
|
+
for (let line = 0; line < rowHeight; line++) {
|
|
2077
|
+
let output2 = border("\u2502");
|
|
2078
|
+
for (let c = 0; c < numCols; c++) {
|
|
2079
|
+
const lines = cellLines[c] ?? [];
|
|
2080
|
+
const contentLine = line - 1;
|
|
2081
|
+
let content = "";
|
|
2082
|
+
if (contentLine >= 0 && contentLine < lines.length) {
|
|
2083
|
+
content = truncateStyled(lines[contentLine], innerWidth);
|
|
2084
|
+
}
|
|
2085
|
+
output2 += `${" ".repeat(PADDING) + padRightStyled(content, innerWidth) + " ".repeat(PADDING)}${border("\u2502")}`;
|
|
2086
|
+
}
|
|
2087
|
+
console.log(output2);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
console.log(bot);
|
|
2091
|
+
const totalTasks = state.tasks.length;
|
|
2092
|
+
const doneTasks = state.tasks.filter((t) => t.done).length;
|
|
2093
|
+
const progressColor = doneTasks === totalTasks ? ansi.green : ansi.yellow;
|
|
2094
|
+
console.log();
|
|
2095
|
+
console.log(
|
|
2096
|
+
`${style("Progress:", ansi.bold)} ${style(`${doneTasks}/${totalTasks}`, ansi.bold, progressColor)} tasks done`
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
function formatGroupLabel(boxNumbers) {
|
|
2100
|
+
if (boxNumbers.length === 0)
|
|
2101
|
+
return "";
|
|
2102
|
+
if (boxNumbers.length === 1)
|
|
2103
|
+
return `Box ${boxNumbers[0]}`;
|
|
2104
|
+
const ranges = [];
|
|
2105
|
+
let start = boxNumbers[0];
|
|
2106
|
+
let end = boxNumbers[0];
|
|
2107
|
+
for (let i = 1; i < boxNumbers.length; i++) {
|
|
2108
|
+
if (boxNumbers[i] === end + 1) {
|
|
2109
|
+
end = boxNumbers[i];
|
|
2110
|
+
} else {
|
|
2111
|
+
ranges.push(start === end ? `${start}` : `${start}\u2013${end}`);
|
|
2112
|
+
start = boxNumbers[i];
|
|
2113
|
+
end = boxNumbers[i];
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
ranges.push(start === end ? `${start}` : `${start}\u2013${end}`);
|
|
2117
|
+
return `Boxes ${ranges.join(", ")}`;
|
|
2118
|
+
}
|
|
2119
|
+
function buildStatusText(state, slice) {
|
|
2120
|
+
const layers = getSliceLayers(state, slice);
|
|
2121
|
+
const tasks = getSliceTasks(state, slice);
|
|
2122
|
+
if (tasks.length === 0)
|
|
2123
|
+
return style("No tasks yet", ansi.dim);
|
|
2124
|
+
const doneTasks = tasks.filter((t) => t.done).length;
|
|
2125
|
+
const totalTasks = tasks.length;
|
|
2126
|
+
if (doneTasks === totalTasks)
|
|
2127
|
+
return style("All done", ansi.green);
|
|
2128
|
+
if (layers.length > 1) {
|
|
2129
|
+
const doneLayerNames = layers.filter((l) => l.status === "done").map((l) => {
|
|
2130
|
+
const idx = layers.indexOf(l);
|
|
2131
|
+
return l.name ?? `Layer ${idx + 1}`;
|
|
2132
|
+
});
|
|
2133
|
+
const openCount = totalTasks - doneTasks;
|
|
2134
|
+
const parts = [];
|
|
2135
|
+
if (doneLayerNames.length > 0) {
|
|
2136
|
+
parts.push(`${doneLayerNames.join(", ")} marked done`);
|
|
2137
|
+
}
|
|
2138
|
+
if (openCount > 0) {
|
|
2139
|
+
parts.push(`${openCount} task${openCount === 1 ? "" : "s"} still open`);
|
|
2140
|
+
}
|
|
2141
|
+
return parts.join(", ");
|
|
2142
|
+
}
|
|
2143
|
+
return `${doneTasks}/${totalTasks} tasks done`;
|
|
2144
|
+
}
|
|
2145
|
+
function showSummaryTable(state, boxId) {
|
|
2146
|
+
const sortedSlices = sortBy_default(state.slices, "boxNumber");
|
|
2147
|
+
const filteredSlices = boxId ? sortedSlices.filter((s) => s.id === boxId) : sortedSlices;
|
|
2148
|
+
const summaryRows = [];
|
|
2149
|
+
const unnamedEmpty = [];
|
|
2150
|
+
for (const slice of filteredSlices) {
|
|
2151
|
+
const tasks = getSliceTasks(state, slice);
|
|
2152
|
+
if (!slice.name && tasks.length === 0) {
|
|
2153
|
+
unnamedEmpty.push(slice.boxNumber);
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2156
|
+
const doneTasks = tasks.filter((t) => t.done).length;
|
|
2157
|
+
const doneColor = tasks.length > 0 && doneTasks === tasks.length ? ansi.green : ansi.yellow;
|
|
2158
|
+
summaryRows.push({
|
|
2159
|
+
scope: style(slice.name ?? `Box ${slice.boxNumber}`, ansi.bold),
|
|
2160
|
+
done: tasks.length > 0 ? style(`${doneTasks}/${tasks.length}`, doneColor) : style("\u2014", ansi.dim),
|
|
2161
|
+
status: buildStatusText(state, slice)
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
if (unnamedEmpty.length > 0) {
|
|
2165
|
+
summaryRows.push({
|
|
2166
|
+
scope: style(formatGroupLabel(unnamedEmpty), ansi.dim),
|
|
2167
|
+
done: style("\u2014", ansi.dim),
|
|
2168
|
+
status: style("Unnamed & empty", ansi.dim)
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
if (summaryRows.length === 0)
|
|
2172
|
+
return;
|
|
2173
|
+
const headers = {
|
|
2174
|
+
scope: style("Scope", ansi.bold),
|
|
2175
|
+
done: style("Done", ansi.bold),
|
|
2176
|
+
status: style("Status", ansi.bold)
|
|
2177
|
+
};
|
|
2178
|
+
const scopeWidth = Math.max(
|
|
2179
|
+
visibleLength(headers.scope),
|
|
2180
|
+
...summaryRows.map((r) => visibleLength(r.scope))
|
|
2181
|
+
);
|
|
2182
|
+
const doneWidth = Math.max(
|
|
2183
|
+
visibleLength(headers.done),
|
|
2184
|
+
...summaryRows.map((r) => visibleLength(r.done))
|
|
2185
|
+
);
|
|
2186
|
+
const statusWidth = Math.max(
|
|
2187
|
+
visibleLength(headers.status),
|
|
2188
|
+
...summaryRows.map((r) => visibleLength(r.status))
|
|
2189
|
+
);
|
|
2190
|
+
const pad = (text, width) => ` ${padRightStyled(text, width)} `;
|
|
2191
|
+
const centerPad = (text, width) => {
|
|
2192
|
+
const totalPad = width - visibleLength(text);
|
|
2193
|
+
const left = Math.floor(totalPad / 2);
|
|
2194
|
+
const right = totalPad - left;
|
|
2195
|
+
return ` ${" ".repeat(left)}${text}${" ".repeat(right)} `;
|
|
2196
|
+
};
|
|
2197
|
+
const border = (text) => style(text, ansi.dim);
|
|
2198
|
+
const hLine = (left, mid, right) => border(
|
|
2199
|
+
`${left}${"\u2500".repeat(scopeWidth + 2)}${mid}${"\u2500".repeat(doneWidth + 2)}${mid}${"\u2500".repeat(statusWidth + 2)}${right}`
|
|
2200
|
+
);
|
|
2201
|
+
console.log();
|
|
2202
|
+
console.log(hLine("\u250C", "\u252C", "\u2510"));
|
|
2203
|
+
console.log(
|
|
2204
|
+
`${border("\u2502")}${centerPad(headers.scope, scopeWidth)}${border("\u2502")}${centerPad(headers.done, doneWidth)}${border("\u2502")}${centerPad(headers.status, statusWidth)}${border("\u2502")}`
|
|
2205
|
+
);
|
|
2206
|
+
console.log(hLine("\u251C", "\u253C", "\u2524"));
|
|
2207
|
+
for (let i = 0; i < summaryRows.length; i++) {
|
|
2208
|
+
const row = summaryRows[i];
|
|
2209
|
+
console.log(
|
|
2210
|
+
`${border("\u2502")}${pad(row.scope, scopeWidth)}${border("\u2502")}${pad(row.done, doneWidth)}${border("\u2502")}${pad(row.status, statusWidth)}${border("\u2502")}`
|
|
2211
|
+
);
|
|
2212
|
+
if (i < summaryRows.length - 1) {
|
|
2213
|
+
console.log(hLine("\u251C", "\u253C", "\u2524"));
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
console.log(hLine("\u2514", "\u2534", "\u2518"));
|
|
2217
|
+
}
|
|
2218
|
+
|
|
1924
2219
|
// cli/server.ts
|
|
1925
2220
|
import fs2 from "fs";
|
|
1926
2221
|
import http from "http";
|
|
1927
2222
|
import path2 from "path";
|
|
2223
|
+
import readline from "readline";
|
|
1928
2224
|
import { fileURLToPath } from "url";
|
|
1929
2225
|
import getPort from "get-port";
|
|
1930
2226
|
import open from "open";
|
|
@@ -1969,9 +2265,24 @@ function readRequestBody(req) {
|
|
|
1969
2265
|
req.on("error", reject);
|
|
1970
2266
|
});
|
|
1971
2267
|
}
|
|
2268
|
+
function confirm(question) {
|
|
2269
|
+
const rl = readline.createInterface({
|
|
2270
|
+
input: process.stdin,
|
|
2271
|
+
output: process.stdout
|
|
2272
|
+
});
|
|
2273
|
+
rl.on("error", () => {
|
|
2274
|
+
});
|
|
2275
|
+
return new Promise((resolve) => {
|
|
2276
|
+
rl.question(question, (answer) => {
|
|
2277
|
+
rl.close();
|
|
2278
|
+
resolve(answer.toLowerCase() === "y");
|
|
2279
|
+
});
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
1972
2282
|
async function startServer(filePath) {
|
|
1973
2283
|
const absoluteFilePath = path2.resolve(filePath);
|
|
1974
2284
|
const distPath = getDistPath();
|
|
2285
|
+
let browserDirty = false;
|
|
1975
2286
|
if (!fs2.existsSync(distPath)) {
|
|
1976
2287
|
console.error(
|
|
1977
2288
|
"Error: dist/ directory not found. Run `pnpm run build` first."
|
|
@@ -1993,6 +2304,23 @@ async function startServer(filePath) {
|
|
|
1993
2304
|
res.end('{"ok":true}');
|
|
1994
2305
|
return;
|
|
1995
2306
|
}
|
|
2307
|
+
if (url2 === "/api/events" && req.method === "GET") {
|
|
2308
|
+
res.writeHead(200, {
|
|
2309
|
+
"Content-Type": "text/event-stream",
|
|
2310
|
+
"Cache-Control": "no-cache",
|
|
2311
|
+
Connection: "keep-alive"
|
|
2312
|
+
});
|
|
2313
|
+
sseClients.add(res);
|
|
2314
|
+
req.on("close", () => sseClients.delete(res));
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
if (url2 === "/api/dirty" && req.method === "POST") {
|
|
2318
|
+
const body = await readRequestBody(req);
|
|
2319
|
+
browserDirty = JSON.parse(body).dirty;
|
|
2320
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2321
|
+
res.end('{"ok":true}');
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
1996
2324
|
if (serveStaticFile(res, distPath, url2))
|
|
1997
2325
|
return;
|
|
1998
2326
|
const indexPath = path2.join(distPath, "index.html");
|
|
@@ -2006,6 +2334,12 @@ async function startServer(filePath) {
|
|
|
2006
2334
|
});
|
|
2007
2335
|
const port = await getPort();
|
|
2008
2336
|
const url = `http://localhost:${port}`;
|
|
2337
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
2338
|
+
fs2.watch(absoluteFilePath, () => {
|
|
2339
|
+
for (const client of sseClients) {
|
|
2340
|
+
client.write("data: file-changed\n\n");
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2009
2343
|
server.listen(port, () => {
|
|
2010
2344
|
console.log(`
|
|
2011
2345
|
Vertical is running at ${url}`);
|
|
@@ -2013,6 +2347,22 @@ async function startServer(filePath) {
|
|
|
2013
2347
|
console.log(" Press Ctrl+C to stop\n");
|
|
2014
2348
|
open(url);
|
|
2015
2349
|
});
|
|
2350
|
+
process.on("SIGINT", async () => {
|
|
2351
|
+
if (browserDirty) {
|
|
2352
|
+
try {
|
|
2353
|
+
const shouldQuit = await confirm(
|
|
2354
|
+
"\n There are unsaved changes in the browser. Quit anyway? (y/N) "
|
|
2355
|
+
);
|
|
2356
|
+
if (!shouldQuit) {
|
|
2357
|
+
console.log(" Resuming...\n");
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
} catch {
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
server.close();
|
|
2364
|
+
process.exit(0);
|
|
2365
|
+
});
|
|
2016
2366
|
}
|
|
2017
2367
|
|
|
2018
2368
|
// cli/index.ts
|
|
@@ -2051,21 +2401,26 @@ program.command("open").description("Open an existing .vertical file in the brow
|
|
|
2051
2401
|
const filePath = resolveFilePath(file);
|
|
2052
2402
|
await startServer(filePath);
|
|
2053
2403
|
});
|
|
2054
|
-
program.command("show").description("Print the board to the terminal").argument("<file>", "Path to the .vertical file").option("--json", "Output as JSON").option("--box <slice-id>", "Show only a specific box").
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2404
|
+
program.command("show").description("Print the board to the terminal").argument("<file>", "Path to the .vertical file").option("--json", "Output as JSON").option("--box <slice-id>", "Show only a specific box").option("--visual", "Show the board as a visual 3x3 grid with summary").action(
|
|
2405
|
+
(file, options) => {
|
|
2406
|
+
const filePath = resolveFilePath(file, options.json);
|
|
2407
|
+
const state = loadState(filePath);
|
|
2408
|
+
if (options.box) {
|
|
2409
|
+
const slice = state.slices.find((s) => s.id === options.box);
|
|
2410
|
+
if (!slice) {
|
|
2411
|
+
fail(`Box not found: ${options.box}`, options.json);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
if (options.json) {
|
|
2415
|
+
showBoardJson(state);
|
|
2416
|
+
} else if (options.visual) {
|
|
2417
|
+
showBoardGrid(state, options.box);
|
|
2418
|
+
showSummaryTable(state, options.box);
|
|
2419
|
+
} else {
|
|
2420
|
+
showBoard(state, options.box);
|
|
2061
2421
|
}
|
|
2062
2422
|
}
|
|
2063
|
-
|
|
2064
|
-
showBoardJson(state);
|
|
2065
|
-
} else {
|
|
2066
|
-
showBoard(state, options.box);
|
|
2067
|
-
}
|
|
2068
|
-
});
|
|
2423
|
+
);
|
|
2069
2424
|
program.command("rename").description("Rename the project").argument("<file>", "Path to the .vertical file").argument("<name>", "New project name").option("--json", "Output as JSON").action((file, name, options) => {
|
|
2070
2425
|
const filePath = resolveFilePath(file, options.json);
|
|
2071
2426
|
const state = applyAction(filePath, { type: "RENAME_PROJECT", name });
|