kalvium-worklog 1.3.2 → 1.4.1

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.
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Try headless mode first (fast — works if session is still valid).
3
3
  * If no session, opens a headed browser and waits for manual login.
4
+ * On platforms without Playwright (e.g. Android/Termux), uses the
5
+ * phone's built-in browser + a local HTTP server to capture the token.
4
6
  */
5
7
  export declare function captureToken(): Promise<boolean>;
@@ -1,21 +1,45 @@
1
- import { chromium } from "playwright";
2
1
  import { PROFILE_DIR, TOKEN_FILE, ensureInstallDir, saveToken, } from "./config.js";
2
+ import * as readline from "readline";
3
+ import { createServer } from "http";
4
+ import { execSync } from "child_process";
5
+ /**
6
+ * Check if Playwright is available on this platform.
7
+ * Playwright doesn't support Android/Termux.
8
+ */
9
+ function isPlaywrightAvailable() {
10
+ try {
11
+ if (process.platform === "android")
12
+ return false;
13
+ require.resolve("playwright");
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
3
20
  /**
4
21
  * Try headless mode first (fast — works if session is still valid).
5
22
  * If no session, opens a headed browser and waits for manual login.
23
+ * On platforms without Playwright (e.g. Android/Termux), uses the
24
+ * phone's built-in browser + a local HTTP server to capture the token.
6
25
  */
7
26
  export async function captureToken() {
8
27
  ensureInstallDir();
28
+ if (!isPlaywrightAvailable()) {
29
+ return captureTokenViaBrowser();
30
+ }
31
+ // Lazy import — only load Playwright when available
32
+ const { chromium } = await import("playwright");
9
33
  // Step 1: Try headless (quick — works if session is still valid)
10
34
  console.log("Trying headless mode (existing session)...");
11
- let data = await tryCapture(true);
35
+ let data = await tryCapture(chromium, true);
12
36
  if (data) {
13
37
  console.log(" Session still valid — token captured!");
14
38
  }
15
39
  else {
16
40
  // Step 2: Need manual login
17
41
  console.log(" No valid session. Opening browser for manual login...");
18
- data = await tryCapture(false);
42
+ data = await tryCapture(chromium, false);
19
43
  }
20
44
  if (data) {
21
45
  saveToken(data);
@@ -27,17 +51,173 @@ export async function captureToken() {
27
51
  console.log("\nERROR: Could not capture token. Please log in and try again.");
28
52
  return false;
29
53
  }
30
- async function tryCapture(headless) {
54
+ /**
55
+ * Capture token on platforms without Playwright (Android/Termux).
56
+ * Starts a local HTTP server, opens the phone's browser to kalvium.community,
57
+ * and after login the user pastes a JS snippet that sends the token back.
58
+ */
59
+ async function captureTokenViaBrowser() {
60
+ const PORT = 4567;
61
+ console.log("\n ========================================");
62
+ console.log(" Browser-based token capture (no Playwright)");
63
+ console.log(" ========================================\n");
64
+ // Start local HTTP server to receive the token
65
+ const tokenPromise = new Promise((resolve) => {
66
+ const server = createServer((req, res) => {
67
+ // Handle CORS + token capture
68
+ res.setHeader("Access-Control-Allow-Origin", "*");
69
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
70
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
71
+ if (req.method === "OPTIONS") {
72
+ res.writeHead(204);
73
+ res.end();
74
+ return;
75
+ }
76
+ let body = "";
77
+ req.on("data", (chunk) => {
78
+ body += chunk;
79
+ });
80
+ req.on("end", () => {
81
+ if (req.url?.startsWith("/capture")) {
82
+ try {
83
+ // Try parsing as JSON first (POST body)
84
+ let data;
85
+ if (body) {
86
+ data = JSON.parse(body);
87
+ }
88
+ else {
89
+ // Try query param
90
+ const url = new URL(req.url, `http://localhost:${PORT}`);
91
+ const tokenParam = url.searchParams.get("token");
92
+ if (!tokenParam) {
93
+ res.writeHead(400);
94
+ res.end("Missing token");
95
+ return;
96
+ }
97
+ data = JSON.parse(tokenParam);
98
+ }
99
+ if (data.refresh_token) {
100
+ res.writeHead(200, { "Content-Type": "text/html" });
101
+ res.end("<html><body><h2>Token captured! You can close this tab.</h2></body></html>");
102
+ resolve(data);
103
+ }
104
+ else {
105
+ res.writeHead(400);
106
+ res.end("Invalid token — missing refresh_token");
107
+ resolve(null);
108
+ }
109
+ }
110
+ catch {
111
+ res.writeHead(400);
112
+ res.end("Invalid JSON");
113
+ resolve(null);
114
+ }
115
+ }
116
+ else {
117
+ res.writeHead(200, { "Content-Type": "text/html" });
118
+ res.end(`
119
+ <html><body>
120
+ <h2>Kalvium Worklog — Token Capture</h2>
121
+ <p>If you're seeing this, the local server is running.</p>
122
+ <p>Go back to the Kalvium tab and paste the JavaScript snippet.</p>
123
+ </body></html>
124
+ `);
125
+ }
126
+ });
127
+ });
128
+ server.listen(PORT, () => {
129
+ console.log(` Local server running on http://localhost:${PORT}`);
130
+ });
131
+ // Timeout after 5 minutes
132
+ setTimeout(() => {
133
+ server.close();
134
+ resolve(null);
135
+ }, 300000);
136
+ });
137
+ // Open kalvium.community in the phone's default browser
138
+ const url = "https://kalvium.community/internships";
139
+ try {
140
+ if (process.platform === "android") {
141
+ // Termux: use termux-open-url
142
+ execSync(`termux-open-url ${url}`, { stdio: "ignore" });
143
+ }
144
+ else {
145
+ // Other platforms: try xdg-open or open
146
+ execSync(`xdg-open ${url} || open ${url}`, { stdio: "ignore" });
147
+ }
148
+ console.log(` Opened ${url} in your browser.`);
149
+ }
150
+ catch {
151
+ console.log(` Could not auto-open browser. Please open: ${url}`);
152
+ }
153
+ console.log("\n ────────────────────────────────────────────");
154
+ console.log(" 1. Log in with Google (complete 2FA if needed)");
155
+ console.log(" 2. After you're logged in, copy this line:");
156
+ console.log(" ────────────────────────────────────────────\n");
157
+ // The JS snippet extracts tokens from the page's network requests
158
+ // and sends them to our local server
159
+ const jsSnippet = `javascript:void(function(){var t=localStorage.getItem('kc-token')||localStorage.getItem('keycloak-token');if(!t){for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i),v=localStorage.getItem(k);if(v&&v.includes('refresh_token')){t=v;break}}}if(!t){for(var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i),v=sessionStorage.getItem(k);if(v&&v.includes('refresh_token')){t=v;break}}}if(t){fetch('http://localhost:${PORT}/capture',{method:'POST',headers:{'Content-Type':'text/plain'},body:t}).then(function(r){if(r.ok)alert('Token sent! You can close this tab.');else alert('Failed: '+r.status)}).catch(function(e){alert('Error: '+e)})}else{alert('No token found in storage. Make sure you are logged in.')}})()`;
160
+ console.log(` ${jsSnippet}\n`);
161
+ console.log(" 3. Paste it in the browser ADDRESS BAR (where you type URLs)");
162
+ console.log(" and press Enter. (Remove any leading 'javascript:' that");
163
+ console.log(" the browser strips, then type it manually if needed.)");
164
+ console.log("\n Waiting for token (up to 5 minutes)...\n");
165
+ const data = await tokenPromise;
166
+ if (data) {
167
+ saveToken(data);
168
+ console.log(`\n✓ Token captured and saved to ${TOKEN_FILE}`);
169
+ console.log(` Access token expires in: ${data.expires_in ?? "?"} seconds`);
170
+ console.log(` Refresh token expires in: ${data.refresh_expires_in ?? "?"} seconds`);
171
+ return true;
172
+ }
173
+ // Fallback: manual paste
174
+ console.log("\n Automatic capture failed. Let's try manual paste.");
175
+ return captureTokenManual();
176
+ }
177
+ /**
178
+ * Manual token capture — fallback for platforms without Playwright.
179
+ * Prompts the user to paste a refresh token JSON obtained from a desktop.
180
+ */
181
+ async function captureTokenManual() {
182
+ console.log("\n ========================================");
183
+ console.log(" Manual token setup");
184
+ console.log(" ========================================\n");
185
+ console.log(" To get your token:");
186
+ console.log(" 1. Run `kalvium-worklog login` on a desktop/laptop");
187
+ console.log(" 2. Copy the contents of ~/.kalvium/refresh_token.json");
188
+ console.log(" 3. Paste it below\n");
189
+ const rl = readline.createInterface({
190
+ input: process.stdin,
191
+ output: process.stdout,
192
+ });
193
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
194
+ const input = await ask("Paste token JSON here: ");
195
+ rl.close();
196
+ try {
197
+ const data = JSON.parse(input.trim());
198
+ if (!data.refresh_token) {
199
+ console.log("ERROR: Invalid token JSON — missing refresh_token field.");
200
+ return false;
201
+ }
202
+ saveToken(data);
203
+ console.log(`\nToken saved to ${TOKEN_FILE}`);
204
+ return true;
205
+ }
206
+ catch {
207
+ console.log("ERROR: Invalid JSON. Please paste the full token JSON.");
208
+ return false;
209
+ }
210
+ }
211
+ async function tryCapture(chromium, headless) {
31
212
  const tokenData = {};
32
213
  const context = await chromium.launchPersistentContext(PROFILE_DIR, {
33
- headless: false, // Playwright doesn't support --headless=new directly
214
+ headless: false,
34
215
  args: [
35
216
  "--disable-blink-features=AutomationControlled",
36
217
  ...(headless ? ["--headless=new"] : []),
37
218
  ],
38
219
  });
39
220
  const page = context.pages()[0] ?? (await context.newPage());
40
- // Intercept token exchange requests
41
221
  context.route("**/openid-connect/token", (route) => {
42
222
  if (route.request().method() === "POST") {
43
223
  route.fetch().then((response) => {
@@ -74,23 +254,17 @@ async function tryCapture(headless) {
74
254
  await page
75
255
  .waitForLoadState("networkidle", { timeout: 10000 })
76
256
  .catch(() => { });
77
- // Auto-click "Continue with Google" in headless mode too.
78
- // If the Google session is still valid in the persistent profile,
79
- // this completes the entire login invisibly — no browser window,
80
- // no user interaction needed.
81
257
  const googleBtn = page.locator("text=Continue with Google").first();
82
258
  const isVisible = await googleBtn.isVisible().catch(() => false);
83
259
  if (isVisible) {
84
260
  await googleBtn.click();
85
261
  console.log(" Clicked 'Continue with Google' (headless)...");
86
- // Wait for token exchange after auto-click
87
262
  for (let i = 0; i < 15; i++) {
88
263
  if (tokenData.data)
89
264
  break;
90
265
  await page.waitForTimeout(1000);
91
266
  }
92
267
  }
93
- // If still no token, wait a bit more for any redirects
94
268
  if (!tokenData.data) {
95
269
  await page.waitForTimeout(5000);
96
270
  }
@@ -108,21 +282,14 @@ async function tryCapture(headless) {
108
282
  waitUntil: "domcontentloaded",
109
283
  timeout: 120000,
110
284
  });
111
- // Auto-click "Continue with Google" button
112
285
  try {
113
- // Try common selectors for the Google login button
114
- const googleBtn = await page
115
- .locator("text=Continue with Google")
116
- .first();
117
- const isVisible = await googleBtn
118
- .isVisible()
119
- .catch(() => false);
286
+ const googleBtn = page.locator("text=Continue with Google").first();
287
+ const isVisible = await googleBtn.isVisible().catch(() => false);
120
288
  if (isVisible) {
121
289
  await googleBtn.click();
122
290
  console.log(" Clicked 'Continue with Google'.");
123
291
  }
124
292
  else {
125
- // Fallback: try other patterns
126
293
  await page
127
294
  .locator("button:has-text('Google'), a:has-text('Google')")
128
295
  .first()
@@ -135,7 +302,6 @@ async function tryCapture(headless) {
135
302
  console.log(" Could not auto-click. Please click 'Continue with Google' manually.");
136
303
  }
137
304
  console.log(" Waiting for login to complete (up to 5 minutes)...");
138
- // Poll up to 5 minutes
139
305
  for (let i = 0; i < 300; i++) {
140
306
  if (tokenData.data)
141
307
  break;
@@ -150,7 +316,6 @@ async function tryCapture(headless) {
150
316
  }
151
317
  }
152
318
  }
153
- // Last resort: reload to trigger token exchange
154
319
  if (!tokenData.data) {
155
320
  console.log(" Trying page reload to trigger token exchange...");
156
321
  try {
@@ -1,5 +1,7 @@
1
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.
2
+ * Discover the user's internship position ID.
3
+ * Tries JWT extraction first (no browser needed).
4
+ * Falls back to browser-based discovery via Playwright if available.
5
+ * On Android, uses JWT extraction only.
4
6
  */
5
7
  export declare function discoverPosition(): Promise<boolean>;
@@ -1,31 +1,71 @@
1
- import { chromium } from "playwright";
2
- import { PROFILE_DIR, CONFIG_FILE, ensureInstallDir, saveConfig, } from "./config.js";
1
+ import { PROFILE_DIR, CONFIG_FILE, ensureInstallDir, saveConfig, loadToken, getPositionIdFromToken, } from "./config.js";
3
2
  /**
4
- * Discover the user's internship position ID by capturing API calls.
5
- * Tries headless first, falls back to headed browser for manual login.
3
+ * Check if Playwright is available on this platform.
4
+ */
5
+ function isPlaywrightAvailable() {
6
+ try {
7
+ if (process.platform === "android")
8
+ return false;
9
+ require.resolve("playwright");
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Discover the user's internship position ID.
18
+ * Tries JWT extraction first (no browser needed).
19
+ * Falls back to browser-based discovery via Playwright if available.
20
+ * On Android, uses JWT extraction only.
6
21
  */
7
22
  export async function discoverPosition() {
8
23
  ensureInstallDir();
9
24
  console.log("=== Discovering internship position ID ===\n");
10
- // Step 1: Try headless first
11
- console.log("1. Trying headless mode (existing session)...");
12
- let captured = await discoverViaBrowser(true);
25
+ // Step 1: Try extracting from saved token (no browser needed)
26
+ const token = loadToken();
27
+ if (token) {
28
+ const positionId = getPositionIdFromToken(token);
29
+ if (positionId) {
30
+ const worklogUrl = `https://student-api.kalvium.community/api/internships/worklogs/${positionId}`;
31
+ saveConfig({ position_id: positionId, worklog_api_url: worklogUrl });
32
+ console.log(`\n=== SUCCESS ===`);
33
+ console.log(`Position ID: ${positionId}`);
34
+ console.log(`Worklog API: ${worklogUrl}`);
35
+ console.log(`Saved to: ${CONFIG_FILE}`);
36
+ return true;
37
+ }
38
+ console.log(" Could not extract position ID from token.");
39
+ }
40
+ else {
41
+ console.log(" No token found. Run `kalvium-worklog login` first.");
42
+ }
43
+ // Step 2: Fall back to browser-based discovery (if Playwright available)
44
+ if (!isPlaywrightAvailable()) {
45
+ console.log("\nERROR: Could not discover position ID.");
46
+ console.log("Playwright is not available on this platform.");
47
+ console.log("Make sure you have a valid token (run `kalvium-worklog login`).");
48
+ return false;
49
+ }
50
+ const { chromium } = await import("playwright");
51
+ console.log("\n1. Trying headless mode (existing session)...");
52
+ let captured = await discoverViaBrowser(chromium, true);
13
53
  if (!captured.token) {
14
54
  console.log(" No valid session. Opening browser for manual login...");
15
- captured = await discoverViaBrowser(false);
55
+ captured = await discoverViaBrowser(chromium, false);
16
56
  }
17
57
  if (!captured.token) {
18
58
  console.log("\nERROR: Could not capture token. Please log in and try again.");
19
59
  return false;
20
60
  }
21
61
  console.log(` Token captured: ${captured.token.slice(0, 40)}...`);
22
- // Step 2: Extract position ID from captured API URLs
62
+ // Step 3: Extract position ID from captured API URLs
23
63
  const positionId = extractPositionId(captured.api_url ?? "");
24
64
  if (!positionId) {
25
65
  console.log("\nERROR: Could not determine position ID from API calls");
26
66
  return false;
27
67
  }
28
- // Step 3: Save config
68
+ // Step 4: Save config
29
69
  const worklogUrl = `https://student-api.kalvium.community/api/internships/worklogs/${positionId}`;
30
70
  saveConfig({ position_id: positionId, worklog_api_url: worklogUrl });
31
71
  console.log(`\n=== SUCCESS ===`);
@@ -34,7 +74,7 @@ export async function discoverPosition() {
34
74
  console.log(`Saved to: ${CONFIG_FILE}`);
35
75
  return true;
36
76
  }
37
- async function discoverViaBrowser(headless) {
77
+ async function discoverViaBrowser(chromium, headless) {
38
78
  const captured = {};
39
79
  const context = await chromium.launchPersistentContext(PROFILE_DIR, {
40
80
  headless: false,
@@ -75,16 +115,25 @@ async function discoverViaBrowser(headless) {
75
115
  }
76
116
  else {
77
117
  console.log("\n ========================================");
78
- console.log(" Browser opened. Please log in now:");
79
- console.log(" 1. Click 'Continue with Google'");
80
- console.log(" 2. Sign in with your Kalvium account");
81
- console.log(" 3. Complete 2FA if prompted");
82
- console.log(" 4. Wait — the script continues automatically");
118
+ console.log(" Browser opened. Auto-clicking 'Continue with Google'...");
119
+ console.log(" Please complete 2FA if prompted.");
83
120
  console.log(" ========================================\n");
84
121
  await page.goto("https://kalvium.community/internships", {
85
122
  waitUntil: "domcontentloaded",
86
123
  timeout: 120000,
87
124
  });
125
+ // Auto-click "Continue with Google"
126
+ try {
127
+ const googleBtn = page.locator("text=Continue with Google").first();
128
+ const isVisible = await googleBtn.isVisible().catch(() => false);
129
+ if (isVisible) {
130
+ await googleBtn.click();
131
+ console.log(" Clicked 'Continue with Google'.");
132
+ }
133
+ }
134
+ catch {
135
+ // ignore
136
+ }
88
137
  console.log(" Waiting for login to complete (up to 5 minutes)...");
89
138
  for (let i = 0; i < 300; i++) {
90
139
  if (captured.token && captured.api_url)
@@ -112,7 +161,6 @@ function extractPositionId(apiUrl) {
112
161
  if (internshipsIdx === -1)
113
162
  return null;
114
163
  for (const part of parts.slice(internshipsIdx + 1)) {
115
- // Position IDs are UUIDs (36 chars, 4 dashes)
116
164
  if (part.length === 36 && part.split("-").length === 5) {
117
165
  return part;
118
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalvium-worklog",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "Auto-submit your daily Kalvium worklog without opening the website",
5
5
  "keywords": [
6
6
  "kalvium",
@@ -37,7 +37,9 @@
37
37
  "postinstall": "node dist/postinstall.js"
38
38
  },
39
39
  "dependencies": {
40
- "commander": "^12.1.0",
40
+ "commander": "^12.1.0"
41
+ },
42
+ "optionalDependencies": {
41
43
  "playwright": "^1.48.0"
42
44
  },
43
45
  "devDependencies": {