kalvium-worklog 1.0.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 ADDED
@@ -0,0 +1,124 @@
1
+ # kalvium-worklog
2
+
3
+ Auto-submit your daily [Kalvium](https://kalvium.community) worklog without opening the website.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g kalvium-worklog
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Full setup (login + discover position + schedule + phone webapp)
15
+ kalvium-worklog install
16
+
17
+ # Or step by step:
18
+ kalvium-worklog login # log in via browser (Google + 2FA)
19
+ kalvium-worklog discover # auto-discover your position ID
20
+ kalvium-worklog schedule 14:00 # daily auto-submit at 2 PM
21
+ kalvium-worklog webapp # generate phone web app
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ | Command | Description |
27
+ |---------|-------------|
28
+ | `kalvium-worklog install` | Full setup: login + discover + schedule + webapp |
29
+ | `kalvium-worklog login` | Log in to Kalvium (opens browser, waits for Google + 2FA) |
30
+ | `kalvium-worklog discover` | Auto-discover your internship position ID |
31
+ | `kalvium-worklog submit [text]` | Submit worklog (default: "working") |
32
+ | `kalvium-worklog submit "built a feature"` | Submit with custom text |
33
+ | `kalvium-worklog submit --status remote` | Submit with custom status |
34
+ | `kalvium-worklog submit --dry-run` | Test without submitting |
35
+ | `kalvium-worklog status` | Check recent worklog entries |
36
+ | `kalvium-worklog schedule 14:00` | Set up daily auto-submit at 2 PM |
37
+ | `kalvium-worklog schedule --remove` | Remove daily auto-submit |
38
+ | `kalvium-worklog webapp [path]` | Generate phone web app HTML |
39
+ | `kalvium-worklog daily` | Run daily submit (used by scheduler) |
40
+
41
+ ## Features
42
+
43
+ - **Cross-platform**: macOS (launchd), Windows (Task Scheduler), Linux (cron)
44
+ - **Auto re-login**: If the token expires, automatically opens browser, waits for login, and retries
45
+ - **Token auto-refresh**: Refresh token rotates on each use, lasts 6 days
46
+ - **Phone web app**: Submit from your iPhone/Android (no computer needed after setup)
47
+ - **Auto-discovers position ID**: Works for any user, no hardcoded IDs
48
+ - **Weekend skipping**: Auto-submit skips Saturdays and Sundays
49
+
50
+ ## Work Statuses
51
+
52
+ | Key | Label |
53
+ |-----|-------|
54
+ | `on_site` | Working on-site (Company location) |
55
+ | `remote` | Working Remotely (Not in the Kalvium environment) |
56
+ | `classroom` | Working out of the Kalvium environment (Classroom) |
57
+ | `holiday` | Today was a company Holiday |
58
+ | `leave` | Took an approved leave from work |
59
+
60
+ ```bash
61
+ kalvium-worklog submit "worked from home" --status remote
62
+ ```
63
+
64
+ ## Phone Web App
65
+
66
+ After running `kalvium-worklog webapp`:
67
+
68
+ 1. AirDrop `~/KalviumWorklog.html` to your phone
69
+ 2. Open in **Safari** (iPhone) or **Chrome** (Android)
70
+ 3. **Add to Home Screen**
71
+ 4. Tap the icon to submit worklogs from your phone
72
+
73
+ The phone app works independently — it refreshes its own token and auto-retries if the token expires.
74
+
75
+ ## How It Works
76
+
77
+ ```
78
+ One-time setup:
79
+ Browser login → capture refresh token → discover position ID
80
+
81
+ Daily auto-submit (scheduler):
82
+ Refresh token → PUT worklog API → done
83
+ If token expired → auto open browser → re-login → retry
84
+
85
+ Manual submit:
86
+ kalvium-worklog submit "my update" → refresh token → PUT worklog
87
+ ```
88
+
89
+ ## Token Lifecycle
90
+
91
+ - **Access token**: 3 days
92
+ - **Refresh token**: 6 days, rotates on each use
93
+ - As long as you submit at least once every 6 days, the token never expires
94
+ - If expired: `kalvium-worklog login` (or it auto-triggers)
95
+
96
+ ## Files
97
+
98
+ ```
99
+ ~/.kalvium/
100
+ ├── refresh_token.json # Current token (auto-rotates)
101
+ ├── config.json # Position ID + API URL
102
+ ├── daily_submit.js # Scheduler entry point
103
+ └── worklog.log # Log file
104
+
105
+ ~/.kalvium_profile/ # Browser profile (saved login)
106
+ ~/KalviumWorklog.html # Phone web app
107
+ ```
108
+
109
+ ## Uninstall
110
+
111
+ ```bash
112
+ kalvium-worklog schedule --remove
113
+ npm uninstall -g kalvium-worklog
114
+ rm -rf ~/.kalvium ~/.kalvium_profile ~/KalviumWorklog.html
115
+ ```
116
+
117
+ ## Requirements
118
+
119
+ - Node.js 18+
120
+ - npm
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Try headless mode first (fast — works if session is still valid).
3
+ * If no session, opens a headed browser and waits for manual login.
4
+ */
5
+ export declare function captureToken(): Promise<boolean>;
@@ -0,0 +1,128 @@
1
+ import { chromium } from "playwright";
2
+ import { PROFILE_DIR, TOKEN_FILE, ensureInstallDir, saveToken, } from "./config.js";
3
+ /**
4
+ * Try headless mode first (fast — works if session is still valid).
5
+ * If no session, opens a headed browser and waits for manual login.
6
+ */
7
+ export async function captureToken() {
8
+ ensureInstallDir();
9
+ // Step 1: Try headless (quick — works if session is still valid)
10
+ console.log("Trying headless mode (existing session)...");
11
+ let data = await tryCapture(true);
12
+ if (data) {
13
+ console.log(" Session still valid — token captured!");
14
+ }
15
+ else {
16
+ // Step 2: Need manual login
17
+ console.log(" No valid session. Opening browser for manual login...");
18
+ data = await tryCapture(false);
19
+ }
20
+ if (data) {
21
+ saveToken(data);
22
+ console.log(`\nToken saved to ${TOKEN_FILE}`);
23
+ console.log(` Access token expires in: ${data.expires_in ?? "?"} seconds`);
24
+ console.log(` Refresh token expires in: ${data.refresh_expires_in ?? "?"} seconds`);
25
+ return true;
26
+ }
27
+ console.log("\nERROR: Could not capture token. Please log in and try again.");
28
+ return false;
29
+ }
30
+ async function tryCapture(headless) {
31
+ const tokenData = {};
32
+ const context = await chromium.launchPersistentContext(PROFILE_DIR, {
33
+ headless: false, // Playwright doesn't support --headless=new directly
34
+ args: [
35
+ "--disable-blink-features=AutomationControlled",
36
+ ...(headless ? ["--headless=new"] : []),
37
+ ],
38
+ });
39
+ const page = context.pages()[0] ?? (await context.newPage());
40
+ // Intercept token exchange requests
41
+ context.route("**/openid-connect/token", (route) => {
42
+ if (route.request().method() === "POST") {
43
+ route.fetch().then((response) => {
44
+ response
45
+ .text()
46
+ .then((text) => {
47
+ try {
48
+ const data = JSON.parse(text);
49
+ if (data.refresh_token) {
50
+ if (!headless)
51
+ console.log(" Refresh token captured!");
52
+ tokenData.data = data;
53
+ }
54
+ }
55
+ catch {
56
+ // ignore
57
+ }
58
+ })
59
+ .catch(() => { });
60
+ route.fulfill({ response }).catch(() => { });
61
+ });
62
+ }
63
+ else {
64
+ route.continue().catch(() => { });
65
+ }
66
+ });
67
+ if (headless) {
68
+ console.log("Loading kalvium.community/internships (headless)...");
69
+ try {
70
+ await page.goto("https://kalvium.community/internships", {
71
+ waitUntil: "domcontentloaded",
72
+ timeout: 30000,
73
+ });
74
+ await page
75
+ .waitForLoadState("networkidle", { timeout: 20000 })
76
+ .catch(() => { });
77
+ await page.waitForTimeout(8000);
78
+ }
79
+ catch {
80
+ // ignore
81
+ }
82
+ }
83
+ else {
84
+ console.log("\n ========================================");
85
+ console.log(" Browser opened. Please log in now:");
86
+ console.log(" 1. Click 'Continue with Google'");
87
+ console.log(" 2. Sign in with your Kalvium account");
88
+ console.log(" 3. Complete 2FA if prompted");
89
+ console.log(" 4. Wait — the script continues automatically");
90
+ console.log(" ========================================\n");
91
+ await page.goto("https://kalvium.community/internships", {
92
+ waitUntil: "domcontentloaded",
93
+ timeout: 120000,
94
+ });
95
+ console.log(" Waiting for login to complete (up to 5 minutes)...");
96
+ // Poll up to 5 minutes
97
+ for (let i = 0; i < 300; i++) {
98
+ if (tokenData.data)
99
+ break;
100
+ await page.waitForTimeout(1000);
101
+ if (i > 0 && i % 30 === 0) {
102
+ const url = page.url();
103
+ if (url.includes("kalvium.community/internships") &&
104
+ !url.includes("error")) {
105
+ await page
106
+ .waitForLoadState("networkidle", { timeout: 5000 })
107
+ .catch(() => { });
108
+ }
109
+ }
110
+ }
111
+ // Last resort: reload to trigger token exchange
112
+ if (!tokenData.data) {
113
+ console.log(" Trying page reload to trigger token exchange...");
114
+ try {
115
+ await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 });
116
+ await page
117
+ .waitForLoadState("networkidle", { timeout: 15000 })
118
+ .catch(() => { });
119
+ await page.waitForTimeout(8000);
120
+ }
121
+ catch {
122
+ // ignore
123
+ }
124
+ }
125
+ }
126
+ await context.close();
127
+ return tokenData.data ?? null;
128
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { captureToken } from "./capture-token.js";
4
+ import { discoverPosition } from "./discover-position.js";
5
+ import { submitWorklog, checkStatus, dailySubmit } from "./submit.js";
6
+ import { setupScheduler, removeScheduler } from "./scheduler.js";
7
+ import { generateWebapp } from "./generate-webapp.js";
8
+ import { existsSync } from "fs";
9
+ import { join } from "path";
10
+ import { homedir } from "os";
11
+ const program = new Command();
12
+ program
13
+ .name("kalvium-worklog")
14
+ .description("Auto-submit your daily Kalvium worklog")
15
+ .version("1.0.0");
16
+ // ─── login ─────────────────────────────────────────────────────────────────
17
+ program
18
+ .command("login")
19
+ .description("Log in to Kalvium and capture refresh token (opens browser)")
20
+ .action(async () => {
21
+ const ok = await captureToken();
22
+ if (!ok)
23
+ process.exit(1);
24
+ });
25
+ // ─── discover ──────────────────────────────────────────────────────────────
26
+ program
27
+ .command("discover")
28
+ .description("Auto-discover your internship position ID")
29
+ .action(async () => {
30
+ const ok = await discoverPosition();
31
+ if (!ok)
32
+ process.exit(1);
33
+ });
34
+ // ─── submit ────────────────────────────────────────────────────────────────
35
+ program
36
+ .command("submit [text]")
37
+ .description("Submit worklog (default text: 'working')")
38
+ .option("--status <type>", "Work status", "on_site")
39
+ .option("--dry-run", "Test without submitting")
40
+ .action(async (text, opts) => {
41
+ // Skip weekends (unless dry run)
42
+ const day = new Date().getDay();
43
+ if ((day === 0 || day === 6) && !opts.dryRun) {
44
+ console.log("It's a weekend — skipping.");
45
+ return;
46
+ }
47
+ const result = await submitWorklog({
48
+ text: text ?? "working",
49
+ status: opts.status,
50
+ dryRun: opts.dryRun,
51
+ });
52
+ if (!result.success)
53
+ process.exit(1);
54
+ });
55
+ // ─── status ────────────────────────────────────────────────────────────────
56
+ program
57
+ .command("status")
58
+ .description("Check recent worklog entries")
59
+ .action(async () => {
60
+ await checkStatus();
61
+ });
62
+ // ─── daily ─────────────────────────────────────────────────────────────────
63
+ program
64
+ .command("daily")
65
+ .description("Run daily auto-submit (used by scheduler — skips weekends)")
66
+ .action(async () => {
67
+ const ok = await dailySubmit();
68
+ if (!ok)
69
+ process.exit(1);
70
+ });
71
+ // ─── schedule ──────────────────────────────────────────────────────────────
72
+ program
73
+ .command("schedule [time]")
74
+ .description("Set up daily auto-submit (e.g. '14:00' for 2 PM)")
75
+ .option("--remove", "Remove the scheduled task")
76
+ .action(async (time, opts) => {
77
+ if (opts.remove) {
78
+ await removeScheduler();
79
+ return;
80
+ }
81
+ let hour = 14;
82
+ let minute = 0;
83
+ if (time) {
84
+ const match = time.match(/^(\d{1,2}):(\d{2})$/);
85
+ if (!match) {
86
+ console.log("Invalid time format. Use HH:MM (e.g. 14:00 for 2 PM)");
87
+ process.exit(1);
88
+ }
89
+ hour = parseInt(match[1], 10);
90
+ minute = parseInt(match[2], 10);
91
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
92
+ console.log("Invalid time. Hour: 0-23, Minute: 0-59");
93
+ process.exit(1);
94
+ }
95
+ }
96
+ else {
97
+ // Interactive prompt
98
+ const readline = await import("readline");
99
+ const rl = readline.createInterface({
100
+ input: process.stdin,
101
+ output: process.stdout,
102
+ });
103
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
104
+ const hourInput = await ask("Enter hour (24-hour, e.g. 14 for 2 PM) [14]: ");
105
+ const minuteInput = await ask("Enter minute [0]: ");
106
+ rl.close();
107
+ hour = parseInt(hourInput || "14", 10);
108
+ minute = parseInt(minuteInput || "0", 10);
109
+ if (isNaN(hour) || hour < 0 || hour > 23) {
110
+ console.log("Invalid hour, defaulting to 14 (2 PM)");
111
+ hour = 14;
112
+ }
113
+ if (isNaN(minute) || minute < 0 || minute > 59) {
114
+ console.log("Invalid minute, defaulting to 0");
115
+ minute = 0;
116
+ }
117
+ }
118
+ const ok = await setupScheduler({ hour, minute });
119
+ if (!ok)
120
+ process.exit(1);
121
+ });
122
+ // ─── webapp ────────────────────────────────────────────────────────────────
123
+ program
124
+ .command("webapp [path]")
125
+ .description("Generate phone web app HTML (default: ~/KalviumWorklog.html)")
126
+ .action(async (path) => {
127
+ const ok = await generateWebapp(path);
128
+ if (!ok)
129
+ process.exit(1);
130
+ });
131
+ // ─── install (full setup) ──────────────────────────────────────────────────
132
+ program
133
+ .command("install")
134
+ .description("Full setup: login + discover + schedule + webapp")
135
+ .action(async () => {
136
+ const readline = await import("readline");
137
+ const rl = readline.createInterface({
138
+ input: process.stdin,
139
+ output: process.stdout,
140
+ });
141
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
142
+ console.log("\n🚀 Kalvium Worklog — Full Setup\n");
143
+ // Step 1: Login
144
+ console.log("── Step 1: Log in ──");
145
+ console.log("A browser will open. Log in with Google + 2FA if needed.\n");
146
+ const loginOk = await captureToken();
147
+ if (!loginOk) {
148
+ console.log("Login failed. Try again with `kalvium-worklog login`");
149
+ rl.close();
150
+ process.exit(1);
151
+ }
152
+ // Step 2: Discover position
153
+ console.log("\n── Step 2: Discover position ID ──");
154
+ const discoverOk = await discoverPosition();
155
+ if (!discoverOk) {
156
+ console.log("Discovery failed. Try again with `kalvium-worklog discover`");
157
+ rl.close();
158
+ process.exit(1);
159
+ }
160
+ // Step 3: Schedule
161
+ console.log("\n── Step 3: Set up daily auto-submit ──");
162
+ const timeInput = await ask("What time? (HH:MM, e.g. 14:00 for 2 PM) [14:00]: ");
163
+ rl.close();
164
+ let hour = 14;
165
+ let minute = 0;
166
+ if (timeInput.trim()) {
167
+ const match = timeInput.trim().match(/^(\d{1,2}):(\d{2})$/);
168
+ if (match) {
169
+ hour = parseInt(match[1], 10);
170
+ minute = parseInt(match[2], 10);
171
+ }
172
+ }
173
+ await setupScheduler({ hour, minute });
174
+ // Step 4: Webapp
175
+ console.log("\n── Step 4: Generate phone web app ──");
176
+ await generateWebapp();
177
+ console.log("\n✅ Setup complete!\n");
178
+ console.log("Commands:");
179
+ console.log(" kalvium-worklog submit # submit with 'working'");
180
+ console.log(" kalvium-worklog submit 'my update' # custom text");
181
+ console.log(" kalvium-worklog status # check status");
182
+ console.log(" kalvium-worklog schedule --remove # stop automation");
183
+ console.log("");
184
+ });
185
+ // ─── Default: show help ────────────────────────────────────────────────────
186
+ if (process.argv.length <= 2) {
187
+ // Check if already set up
188
+ const tokenExists = existsSync(join(homedir(), ".kalvium", "refresh_token.json"));
189
+ const configExists = existsSync(join(homedir(), ".kalvium", "config.json"));
190
+ if (!tokenExists || !configExists) {
191
+ console.log("");
192
+ console.log(" Welcome to Kalvium Worklog!");
193
+ console.log("");
194
+ console.log(" You haven't set up yet. Run:");
195
+ console.log("");
196
+ console.log(" kalvium-worklog install");
197
+ console.log("");
198
+ console.log(" This will log you in, discover your position ID,");
199
+ console.log(" set up daily auto-submit, and generate a phone web app.");
200
+ console.log("");
201
+ console.log(" Or run kalvium-worklog --help to see all commands.");
202
+ console.log("");
203
+ }
204
+ else {
205
+ program.help();
206
+ }
207
+ process.exit(0);
208
+ }
209
+ program.parse();
@@ -0,0 +1,29 @@
1
+ export declare const INSTALL_DIR: string;
2
+ export declare const PROFILE_DIR: string;
3
+ export declare const TOKEN_FILE: string;
4
+ export declare const CONFIG_FILE: string;
5
+ export declare const LOG_FILE: string;
6
+ export declare const KEYCLOAK_URL = "https://auth.kalvium.community/auth/realms/kalvium/protocol/openid-connect/token";
7
+ export declare const CLIENT_ID = "login_client";
8
+ export declare const API_BASE = "https://student-api.kalvium.community/api/internships/worklogs";
9
+ export interface TokenData {
10
+ access_token: string;
11
+ refresh_token: string;
12
+ expires_in?: number;
13
+ refresh_expires_in?: number;
14
+ token_type?: string;
15
+ session_state?: string;
16
+ }
17
+ export interface ConfigData {
18
+ position_id: string;
19
+ worklog_api_url: string;
20
+ }
21
+ export declare function ensureInstallDir(): void;
22
+ export declare function loadToken(): TokenData | null;
23
+ export declare function saveToken(data: TokenData): void;
24
+ export declare function loadConfig(): ConfigData | null;
25
+ export declare function saveConfig(config: ConfigData): void;
26
+ export declare function getWorklogApi(): string | null;
27
+ export declare function log(message: string): void;
28
+ export declare function decodeJwt(token: string): Record<string, unknown> | null;
29
+ export declare function getPositionIdFromToken(token: TokenData): string | null;
package/dist/config.js ADDED
@@ -0,0 +1,75 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ export const INSTALL_DIR = join(homedir(), ".kalvium");
5
+ export const PROFILE_DIR = join(homedir(), ".kalvium_profile");
6
+ export const TOKEN_FILE = join(INSTALL_DIR, "refresh_token.json");
7
+ export const CONFIG_FILE = join(INSTALL_DIR, "config.json");
8
+ export const LOG_FILE = join(INSTALL_DIR, "worklog.log");
9
+ export const KEYCLOAK_URL = "https://auth.kalvium.community/auth/realms/kalvium/protocol/openid-connect/token";
10
+ export const CLIENT_ID = "login_client";
11
+ export const API_BASE = "https://student-api.kalvium.community/api/internships/worklogs";
12
+ export function ensureInstallDir() {
13
+ if (!existsSync(INSTALL_DIR)) {
14
+ mkdirSync(INSTALL_DIR, { recursive: true });
15
+ }
16
+ }
17
+ export function loadToken() {
18
+ if (!existsSync(TOKEN_FILE))
19
+ return null;
20
+ try {
21
+ return JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export function saveToken(data) {
28
+ ensureInstallDir();
29
+ writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2));
30
+ }
31
+ export function loadConfig() {
32
+ if (!existsSync(CONFIG_FILE))
33
+ return null;
34
+ try {
35
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ export function saveConfig(config) {
42
+ ensureInstallDir();
43
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
44
+ }
45
+ export function getWorklogApi() {
46
+ const config = loadConfig();
47
+ return config?.worklog_api_url ?? null;
48
+ }
49
+ export function log(message) {
50
+ ensureInstallDir();
51
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
52
+ const line = `${timestamp} ${message}\n`;
53
+ try {
54
+ writeFileSync(LOG_FILE, line, { flag: "a" });
55
+ }
56
+ catch {
57
+ // ignore log errors
58
+ }
59
+ console.log(message);
60
+ }
61
+ // ─── JWT decode ──────────────────────────────────────────────────────────
62
+ export function decodeJwt(token) {
63
+ try {
64
+ const payload = token.split(".")[1];
65
+ const decoded = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
66
+ return JSON.parse(decoded);
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ export function getPositionIdFromToken(token) {
73
+ const payload = decodeJwt(token.access_token);
74
+ return payload?.sub ?? null;
75
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Daily auto-submit entry point (used by launchd/Task Scheduler/cron).
4
+ * Skips weekends. Auto re-logins if token expired.
5
+ */
6
+ import { dailySubmit } from "./submit.js";
7
+ dailySubmit().then((ok) => {
8
+ process.exit(ok ? 0 : 1);
9
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Discover the user's internship position ID by capturing API calls.
3
+ * Tries headless first, falls back to headed browser for manual login.
4
+ */
5
+ export declare function discoverPosition(): Promise<boolean>;