step-overflow 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/LICENSE +21 -0
- package/README.ja.md +111 -0
- package/README.md +111 -0
- package/dist/cli.js +28 -0
- package/dist/commands/add.js +188 -0
- package/dist/commands/config.js +92 -0
- package/dist/commands/init.js +193 -0
- package/dist/commands/log.js +41 -0
- package/dist/commands/open.js +78 -0
- package/dist/commands/status.js +55 -0
- package/dist/commands/sync.js +25 -0
- package/dist/lib/achievements.js +322 -0
- package/dist/lib/animation.js +70 -0
- package/dist/lib/calories.js +32 -0
- package/dist/lib/config.js +38 -0
- package/dist/lib/csv.js +55 -0
- package/dist/lib/git.js +97 -0
- package/dist/lib/html.js +437 -0
- package/dist/lib/open-url.js +15 -0
- package/dist/lib/prompt.js +19 -0
- package/dist/lib/routes.js +348 -0
- package/package.json +51 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { configExists, saveConfig, getPagesUrl, getDataDir, } from "../lib/config.js";
|
|
6
|
+
import { ROUTES, formatRouteOption } from "../lib/routes.js";
|
|
7
|
+
import { checkPrerequisites, isGhAuthenticated, getGhUsername, createRepo, gitInit, gitAdd, gitCommit, gitAddRemote, gitPushFirst, enableGitHubPages, } from "../lib/git.js";
|
|
8
|
+
import { initCsv } from "../lib/csv.js";
|
|
9
|
+
import { generateIndexHtml } from "../lib/html.js";
|
|
10
|
+
import { promptText, promptSelect, promptConfirm } from "../lib/prompt.js";
|
|
11
|
+
async function promptRepoName(current) {
|
|
12
|
+
return promptText("Repository name:", {
|
|
13
|
+
placeholder: "my-walking-log",
|
|
14
|
+
default: current ?? "my-walking-log",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async function promptVisibility(current) {
|
|
18
|
+
return promptSelect("Repository visibility:", ["private", "public"], current);
|
|
19
|
+
}
|
|
20
|
+
async function promptSpeed(current) {
|
|
21
|
+
while (true) {
|
|
22
|
+
const val = await promptText("Default walking speed (km/h):", {
|
|
23
|
+
placeholder: "4.0",
|
|
24
|
+
default: String(current ?? 4.0),
|
|
25
|
+
});
|
|
26
|
+
const parsed = parseFloat(val);
|
|
27
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
28
|
+
return parsed;
|
|
29
|
+
consola.warn("Please enter a valid positive number.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function promptWeight(current) {
|
|
33
|
+
while (true) {
|
|
34
|
+
const val = await promptText(current ? `Weight (kg) [optional, current: ${current}]:` : "Weight (kg) [optional]:", { placeholder: "" });
|
|
35
|
+
if (!val)
|
|
36
|
+
return null;
|
|
37
|
+
const parsed = parseFloat(val);
|
|
38
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
39
|
+
return parsed;
|
|
40
|
+
consola.warn("Please enter a valid positive number, or press Enter to skip.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function promptInputs() {
|
|
44
|
+
const inputs = {
|
|
45
|
+
repoName: await promptRepoName(),
|
|
46
|
+
visibility: await promptVisibility(),
|
|
47
|
+
defaultSpeed: await promptSpeed(),
|
|
48
|
+
weightKg: await promptWeight(),
|
|
49
|
+
};
|
|
50
|
+
while (true) {
|
|
51
|
+
const weightDisplay = inputs.weightKg ? `${inputs.weightKg} kg` : "not set";
|
|
52
|
+
consola.box(`Repository: ${inputs.repoName} (${inputs.visibility})\n` +
|
|
53
|
+
`Speed: ${inputs.defaultSpeed} km/h\n` +
|
|
54
|
+
`Weight: ${weightDisplay}`);
|
|
55
|
+
if (await promptConfirm("Confirm settings?", true))
|
|
56
|
+
break;
|
|
57
|
+
const field = await promptSelect("Which setting to change?", [
|
|
58
|
+
"Repository name",
|
|
59
|
+
"Visibility",
|
|
60
|
+
"Speed",
|
|
61
|
+
"Weight",
|
|
62
|
+
]);
|
|
63
|
+
switch (field) {
|
|
64
|
+
case "Repository name":
|
|
65
|
+
inputs.repoName = await promptRepoName(inputs.repoName);
|
|
66
|
+
break;
|
|
67
|
+
case "Visibility":
|
|
68
|
+
inputs.visibility = await promptVisibility(inputs.visibility);
|
|
69
|
+
break;
|
|
70
|
+
case "Speed":
|
|
71
|
+
inputs.defaultSpeed = await promptSpeed(inputs.defaultSpeed);
|
|
72
|
+
break;
|
|
73
|
+
case "Weight":
|
|
74
|
+
inputs.weightKg = await promptWeight(inputs.weightKg);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return inputs;
|
|
79
|
+
}
|
|
80
|
+
export const initCommand = defineCommand({
|
|
81
|
+
meta: { name: "init", description: "Initialize step-overflow" },
|
|
82
|
+
run: async () => {
|
|
83
|
+
if (configExists()) {
|
|
84
|
+
consola.error("step-overflow is already initialized.");
|
|
85
|
+
consola.info("To re-initialize, delete the config and data directories first.");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
// 1. Check prerequisites
|
|
89
|
+
const prereqs = await checkPrerequisites();
|
|
90
|
+
if (!prereqs.git) {
|
|
91
|
+
consola.error("git is not installed. Please install git first.");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
if (!prereqs.gh) {
|
|
95
|
+
consola.error("GitHub CLI (gh) is not installed. Install from https://cli.github.com/");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
consola.start("Checking GitHub CLI authentication...");
|
|
99
|
+
if (!(await isGhAuthenticated())) {
|
|
100
|
+
consola.error("GitHub CLI is not authenticated. Run `gh auth login` first.");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
consola.success("GitHub CLI authenticated.");
|
|
104
|
+
const username = await getGhUsername();
|
|
105
|
+
// 2. Prompt inputs
|
|
106
|
+
const { repoName, visibility, defaultSpeed, weightKg } = await promptInputs();
|
|
107
|
+
const fullName = `${username}/${repoName}`;
|
|
108
|
+
const localPath = join(getDataDir(), repoName);
|
|
109
|
+
// 3. Choose journey route
|
|
110
|
+
const routeOptions = ROUTES.map(formatRouteOption);
|
|
111
|
+
const selectedOption = await promptSelect("Choose your journey:", routeOptions);
|
|
112
|
+
const selectedRoute = ROUTES[routeOptions.indexOf(selectedOption)];
|
|
113
|
+
const journey = {
|
|
114
|
+
route_id: selectedRoute.id,
|
|
115
|
+
started_at: new Date().toISOString().slice(0, 19),
|
|
116
|
+
started_km: 0,
|
|
117
|
+
completed_routes: [],
|
|
118
|
+
};
|
|
119
|
+
// Build config early so we can pass it to HTML generator
|
|
120
|
+
const config = {
|
|
121
|
+
repo: fullName,
|
|
122
|
+
local_path: localPath,
|
|
123
|
+
default_speed: defaultSpeed,
|
|
124
|
+
weight_kg: weightKg,
|
|
125
|
+
username,
|
|
126
|
+
visibility,
|
|
127
|
+
journey,
|
|
128
|
+
achievements: {},
|
|
129
|
+
};
|
|
130
|
+
// 4. Create GitHub repo
|
|
131
|
+
consola.start(`Creating repository ${fullName} (${visibility})...`);
|
|
132
|
+
try {
|
|
133
|
+
await createRepo(repoName, visibility);
|
|
134
|
+
consola.success("Repository created.");
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const err = e;
|
|
138
|
+
if (err.stderr?.includes("already exists")) {
|
|
139
|
+
consola.warn("Repository already exists on GitHub, continuing...");
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
consola.error(`Failed to create repository: ${err.message}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// 5. Create local directory and init git
|
|
147
|
+
consola.start(`Setting up local directory: ${localPath}`);
|
|
148
|
+
await mkdir(localPath, { recursive: true });
|
|
149
|
+
await gitInit(localPath);
|
|
150
|
+
await gitAddRemote(fullName, localPath);
|
|
151
|
+
// 6. Create initial files
|
|
152
|
+
const csvPath = join(localPath, "data", "walking.csv");
|
|
153
|
+
await initCsv(csvPath);
|
|
154
|
+
const docsDir = join(localPath, "docs");
|
|
155
|
+
await mkdir(docsDir, { recursive: true });
|
|
156
|
+
await writeFile(join(docsDir, "index.html"), generateIndexHtml(config));
|
|
157
|
+
// 7. Initial commit and push
|
|
158
|
+
consola.start("Committing and pushing initial files...");
|
|
159
|
+
await gitAdd(["data/walking.csv", "docs/index.html"], localPath);
|
|
160
|
+
await gitCommit("Initial commit by step-overflow", localPath);
|
|
161
|
+
try {
|
|
162
|
+
await gitPushFirst(localPath);
|
|
163
|
+
consola.success("Pushed to GitHub.");
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
const err = e;
|
|
167
|
+
consola.warn(`Push failed: ${err.message}. You can retry with \`stp sync\`.`);
|
|
168
|
+
}
|
|
169
|
+
// 8. Enable GitHub Pages for public repos
|
|
170
|
+
if (visibility === "public") {
|
|
171
|
+
consola.start("Enabling GitHub Pages...");
|
|
172
|
+
const pagesOk = await enableGitHubPages(fullName);
|
|
173
|
+
if (pagesOk) {
|
|
174
|
+
consola.success("GitHub Pages enabled.");
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
consola.warn("Could not enable GitHub Pages automatically. Enable it manually in repo settings.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// 9. Save config and display summary
|
|
181
|
+
await saveConfig(config);
|
|
182
|
+
const pagesUrl = getPagesUrl(config);
|
|
183
|
+
consola.box(`step-overflow initialized!\n\n` +
|
|
184
|
+
`Repo: ${fullName}\n` +
|
|
185
|
+
`Local: ${localPath}\n` +
|
|
186
|
+
`Speed: ${defaultSpeed} km/h\n` +
|
|
187
|
+
(weightKg ? `Weight: ${weightKg} kg\n` : "") +
|
|
188
|
+
`Journey: ${selectedRoute.from} \u2192 ${selectedRoute.to} (${selectedRoute.total_km.toLocaleString()} km)\n` +
|
|
189
|
+
(pagesUrl
|
|
190
|
+
? `Pages: ${pagesUrl}`
|
|
191
|
+
: `Visibility: private (use \`stp open\` to view locally)`));
|
|
192
|
+
},
|
|
193
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { loadConfig, getCsvPath } from "../lib/config.js";
|
|
4
|
+
import { readRecords } from "../lib/csv.js";
|
|
5
|
+
import { calcCalories } from "../lib/calories.js";
|
|
6
|
+
export const logCommand = defineCommand({
|
|
7
|
+
meta: { name: "log", description: "Show recent walking records" },
|
|
8
|
+
args: {
|
|
9
|
+
count: {
|
|
10
|
+
type: "string",
|
|
11
|
+
description: "Number of records to show (default: 10)",
|
|
12
|
+
default: "10",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
run: async ({ args }) => {
|
|
16
|
+
const config = await loadConfig();
|
|
17
|
+
const csvPath = getCsvPath(config);
|
|
18
|
+
const records = await readRecords(csvPath);
|
|
19
|
+
if (records.length === 0) {
|
|
20
|
+
consola.info("No records yet. Run `stp add <minutes>` to start.");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const count = Math.min(parseInt(args.count) || 10, records.length);
|
|
24
|
+
const recent = records.slice(-count).reverse();
|
|
25
|
+
const lines = recent.map((r) => {
|
|
26
|
+
const date = r.datetime.split("T")[0];
|
|
27
|
+
const time = r.datetime.split("T")[1]?.slice(0, 5) || "";
|
|
28
|
+
let line = ` ${date} ${time} ${String(r.time_min).padStart(4)} min ${r.speed_kmh} km/h ${r.distance_km.toFixed(2)} km`;
|
|
29
|
+
if (r.weight_kg) {
|
|
30
|
+
const cal = calcCalories(r.speed_kmh, r.time_min, r.weight_kg);
|
|
31
|
+
line += ` ${cal} kcal`;
|
|
32
|
+
}
|
|
33
|
+
return line;
|
|
34
|
+
});
|
|
35
|
+
consola.log(`\nLast ${count} records:\n`);
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
consola.log(line);
|
|
38
|
+
}
|
|
39
|
+
consola.log("");
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { join, extname, resolve } from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { loadConfig, getPagesUrl } from "../lib/config.js";
|
|
8
|
+
import { openInBrowser } from "../lib/open-url.js";
|
|
9
|
+
const MIME_TYPES = {
|
|
10
|
+
".html": "text/html",
|
|
11
|
+
".css": "text/css",
|
|
12
|
+
".js": "application/javascript",
|
|
13
|
+
".csv": "text/csv",
|
|
14
|
+
".json": "application/json",
|
|
15
|
+
};
|
|
16
|
+
export const openCommand = defineCommand({
|
|
17
|
+
meta: { name: "open", description: "Open walking log in browser" },
|
|
18
|
+
args: {
|
|
19
|
+
remote: {
|
|
20
|
+
type: "boolean",
|
|
21
|
+
description: "Open GitHub Pages URL instead of local file",
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
run: async ({ args }) => {
|
|
26
|
+
const config = await loadConfig();
|
|
27
|
+
if (args.remote) {
|
|
28
|
+
const url = getPagesUrl(config);
|
|
29
|
+
if (!url) {
|
|
30
|
+
consola.error("GitHub Pages is not available for private repositories.");
|
|
31
|
+
consola.info("Use `stp open` without --remote to open locally.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
consola.info(`Opening ${url}`);
|
|
35
|
+
await openInBrowser(url);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const repoRoot = config.local_path;
|
|
39
|
+
const docsDir = join(repoRoot, "docs");
|
|
40
|
+
if (!existsSync(join(docsDir, "index.html"))) {
|
|
41
|
+
consola.error("docs/index.html not found. Run `stp init` first.");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
// Start local HTTP server serving the repo root
|
|
45
|
+
const server = createServer(async (req, res) => {
|
|
46
|
+
const rawUrl = new URL(req.url, "http://localhost").pathname;
|
|
47
|
+
const urlPath = rawUrl === "/" ? "/docs/index.html" : rawUrl;
|
|
48
|
+
const filePath = resolve(repoRoot, urlPath.replace(/^\//, ""));
|
|
49
|
+
// Prevent path traversal
|
|
50
|
+
if (!filePath.startsWith(repoRoot + "/") && filePath !== repoRoot) {
|
|
51
|
+
res.writeHead(403);
|
|
52
|
+
res.end("Forbidden");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const content = await readFile(filePath);
|
|
57
|
+
const ext = extname(filePath);
|
|
58
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream" });
|
|
59
|
+
res.end(content);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
res.writeHead(404);
|
|
63
|
+
res.end("Not found");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
server.listen(0, "127.0.0.1", async () => {
|
|
67
|
+
const addr = server.address();
|
|
68
|
+
if (!addr || typeof addr === "string") {
|
|
69
|
+
consola.error("Failed to start server.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const url = `http://127.0.0.1:${addr.port}`;
|
|
73
|
+
consola.success(`Server running at ${url}`);
|
|
74
|
+
consola.info("Press Ctrl+C to stop.");
|
|
75
|
+
await openInBrowser(url);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { loadConfig, getPagesUrl, getCsvPath } from "../lib/config.js";
|
|
4
|
+
import { isGhAuthenticated, hasUnpushedCommits } from "../lib/git.js";
|
|
5
|
+
import { readRecords } from "../lib/csv.js";
|
|
6
|
+
import { getRoute, getProgress, progressBar } from "../lib/routes.js";
|
|
7
|
+
import { ACHIEVEMENTS } from "../lib/achievements.js";
|
|
8
|
+
export const statusCommand = defineCommand({
|
|
9
|
+
meta: { name: "status", description: "Show current status" },
|
|
10
|
+
run: async () => {
|
|
11
|
+
const config = await loadConfig();
|
|
12
|
+
const ghAuth = await isGhAuthenticated();
|
|
13
|
+
const unpushed = await hasUnpushedCommits(config.local_path);
|
|
14
|
+
const pagesUrl = getPagesUrl(config) ?? "(private repo - use `stp open` for local view)";
|
|
15
|
+
const records = await readRecords(getCsvPath(config));
|
|
16
|
+
const totalKm = records.reduce((s, r) => s + r.distance_km, 0);
|
|
17
|
+
const totalMin = records.reduce((s, r) => s + r.time_min, 0);
|
|
18
|
+
const totalHours = Math.floor(totalMin / 60);
|
|
19
|
+
const remainMin = Math.round(totalMin % 60);
|
|
20
|
+
let statusText = `GitHub repo: ${config.repo}\n` +
|
|
21
|
+
`Local path: ${config.local_path}\n` +
|
|
22
|
+
`Pages URL: ${pagesUrl}\n` +
|
|
23
|
+
`Speed: ${config.default_speed} km/h\n` +
|
|
24
|
+
`Weight: ${config.weight_kg !== null ? config.weight_kg + " kg" : "not set"}\n` +
|
|
25
|
+
`\n` +
|
|
26
|
+
`Total walks: ${records.length}\n` +
|
|
27
|
+
`Distance: ${totalKm.toFixed(2)} km\n` +
|
|
28
|
+
`Time: ${totalHours}h ${remainMin}m\n`;
|
|
29
|
+
// Journey
|
|
30
|
+
const journey = config.journey;
|
|
31
|
+
const route = journey ? getRoute(journey.route_id) : undefined;
|
|
32
|
+
if (journey && route) {
|
|
33
|
+
const journeyKm = Math.max(0, totalKm - journey.started_km);
|
|
34
|
+
const progress = getProgress(route, journeyKm);
|
|
35
|
+
statusText +=
|
|
36
|
+
`\n` +
|
|
37
|
+
`Journey: ${route.from} \u2192 ${route.to}\n` +
|
|
38
|
+
`Progress: ${progressBar(progress.percent)} ${progress.percent.toFixed(1)}%\n` +
|
|
39
|
+
` ${Math.floor(progress.walkedKm)} / ${route.total_km.toLocaleString()} km`;
|
|
40
|
+
if (progress.next) {
|
|
41
|
+
statusText += `\nNext stop: ${progress.next.name} (${Math.ceil(progress.nextDistance)} km)`;
|
|
42
|
+
}
|
|
43
|
+
statusText += "\n";
|
|
44
|
+
}
|
|
45
|
+
// Achievements
|
|
46
|
+
const unlocked = config.achievements ?? {};
|
|
47
|
+
const unlockedCount = Object.keys(unlocked).length;
|
|
48
|
+
statusText += `\nAchievements: ${unlockedCount} / ${ACHIEVEMENTS.length}`;
|
|
49
|
+
statusText +=
|
|
50
|
+
`\n\n` +
|
|
51
|
+
`Unpushed: ${unpushed ? "yes - run \`stp sync\`" : "no"}\n` +
|
|
52
|
+
`gh auth: ${ghAuth ? "authenticated" : "NOT authenticated"}`;
|
|
53
|
+
consola.box(statusText);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { loadConfig } from "../lib/config.js";
|
|
4
|
+
import { gitPush, hasUnpushedCommits } from "../lib/git.js";
|
|
5
|
+
export const syncCommand = defineCommand({
|
|
6
|
+
meta: { name: "sync", description: "Push unpushed commits to GitHub" },
|
|
7
|
+
run: async () => {
|
|
8
|
+
const config = await loadConfig();
|
|
9
|
+
const cwd = config.local_path;
|
|
10
|
+
if (!(await hasUnpushedCommits(cwd))) {
|
|
11
|
+
consola.info("Nothing to push. Already in sync.");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
consola.start("Pushing to GitHub...");
|
|
15
|
+
try {
|
|
16
|
+
await gitPush(cwd);
|
|
17
|
+
consola.success("Pushed successfully.");
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
const err = e;
|
|
21
|
+
consola.error(`Push failed: ${err.message}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
});
|