taskair-cli 1.0.2 → 1.0.5

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 (3) hide show
  1. package/README.md +7 -15
  2. package/dist/index.js +618 -250
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -18,30 +18,22 @@ npm install -g taskair-cli
18
18
 
19
19
  ## 🪐 Setup & Configuration
20
20
 
21
- Before managing your tasks, you need to point the CLI to your TaskAir Web server and set up your encryption master password.
21
+ Before managing your tasks, you need to configure the CLI to point to your TaskAir Web server and authenticate.
22
22
 
23
- ### 1. Configure the CLI
23
+ ### 1. Configure & Authenticate
24
24
 
25
- Run the configuration command and follow the prompts:
25
+ Run the configuration command and follow the prompt to open the browser for authentication:
26
26
 
27
27
  ```bash
28
- taskair configure
28
+ taskair config
29
29
  ```
30
30
 
31
31
  You will be prompted to enter:
32
- * **API URL**: The URL of your running TaskAir server (e.g., `http://localhost:4321` or your production domain).
33
- * **Email**: Your TaskAir account email address.
34
- * **Master Password**: Your local master password. This password is used client-side to derive the encryption keys for E2E encryption. *It is never sent to the server.*
32
+ * **API URL**: The URL of your running TaskAir server (e.g., `http://localhost:3001` or your production domain).
35
33
 
36
- ### 2. Log In
34
+ After pressing Enter, a web browser window will open automatically. Complete your sign-in or enter your Master Password on the website to link your CLI terminal.
37
35
 
38
- Log in to establish a session with the remote backend:
39
-
40
- ```bash
41
- taskair login
42
- ```
43
-
44
- ### 3. Verify Session
36
+ ### 2. Verify Session
45
37
 
46
38
  You can check your configuration and login status at any time:
47
39
 
package/dist/index.js CHANGED
@@ -3,26 +3,47 @@
3
3
  // src/index.ts
4
4
  import { program } from "commander";
5
5
 
6
- // src/commands/configure.tsx
7
- import React2, { useState as useState2 } from "react";
8
- import { Box as Box3, Text as Text3, useInput, useApp } from "ink";
6
+ // src/commands/config.tsx
7
+ import React3, { useState as useState3, useEffect as useEffect3 } from "react";
8
+ import { Box as Box4, Text as Text4, useInput, useApp } from "ink";
9
+ import { hostname } from "os";
10
+ import http from "http";
11
+ import { parse as parseUrl } from "url";
12
+ import { exec } from "child_process";
9
13
 
10
14
  // src/lib/auth.ts
11
15
  import { homedir } from "os";
12
16
  import { join } from "path";
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, statSync } from "fs";
14
18
  var TASKAIR_DIR = join(homedir(), ".taskair");
15
19
  var CREDENTIALS_FILE = join(TASKAIR_DIR, "credentials");
16
20
  function ensureTaskairDir() {
17
- if (!existsSync(TASKAIR_DIR)) {
21
+ if (existsSync(TASKAIR_DIR)) {
22
+ try {
23
+ const stat = statSync(TASKAIR_DIR);
24
+ if (!stat.isDirectory()) {
25
+ throw new Error(`Path ${TASKAIR_DIR} exists but is not a directory.`);
26
+ }
27
+ } catch (err) {
28
+ if (err.code !== "ENOENT") {
29
+ throw err;
30
+ }
31
+ }
32
+ return;
33
+ }
34
+ try {
18
35
  mkdirSync(TASKAIR_DIR, { recursive: true, mode: 448 });
36
+ } catch (err) {
37
+ if (err.code !== "EEXIST") {
38
+ throw err;
39
+ }
19
40
  }
20
41
  }
21
42
  function parseIni(content) {
22
43
  const result = {};
23
- for (const line of content.split("\n")) {
44
+ for (const line of content.split(/\r?\n/)) {
24
45
  const trimmed = line.trim();
25
- if (trimmed.startsWith("#") || trimmed.startsWith("[") || !trimmed) continue;
46
+ if (trimmed.startsWith("#") || trimmed.startsWith(";") || trimmed.startsWith("[") || !trimmed) continue;
26
47
  const eqIdx = trimmed.indexOf("=");
27
48
  if (eqIdx === -1) continue;
28
49
  const key = trimmed.slice(0, eqIdx).trim();
@@ -46,13 +67,15 @@ function readCredentials() {
46
67
  try {
47
68
  const content = readFileSync(CREDENTIALS_FILE, "utf8");
48
69
  const raw = parseIni(content);
70
+ const rawExpires = raw.expires_at ? parseInt(raw.expires_at, 10) : void 0;
49
71
  return {
50
72
  api_url: raw.api_url || "http://localhost:3001",
51
73
  email: raw.email || "",
52
74
  access_token: raw.access_token || void 0,
53
75
  refresh_token: raw.refresh_token || void 0,
54
- expires_at: raw.expires_at ? parseInt(raw.expires_at) : void 0,
55
- device_id: raw.device_id || void 0
76
+ expires_at: rawExpires && !isNaN(rawExpires) ? rawExpires : void 0,
77
+ device_id: raw.device_id || void 0,
78
+ password: raw.password || void 0
56
79
  };
57
80
  } catch {
58
81
  return null;
@@ -66,7 +89,8 @@ function writeCredentials(creds) {
66
89
  access_token: creds.access_token,
67
90
  refresh_token: creds.refresh_token,
68
91
  expires_at: creds.expires_at,
69
- device_id: creds.device_id
92
+ device_id: creds.device_id,
93
+ password: creds.password
70
94
  };
71
95
  writeFileSync(CREDENTIALS_FILE, toIni(data), { encoding: "utf8" });
72
96
  try {
@@ -98,8 +122,65 @@ function requireAuth() {
98
122
  deviceId: creds.device_id ?? "default",
99
123
  accessToken: creds.access_token,
100
124
  apiUrl: creds.api_url,
101
- expiresAt: creds.expires_at ?? 0
125
+ expiresAt: creds.expires_at ?? 0,
126
+ password: creds.password
127
+ };
128
+ }
129
+
130
+ // src/lib/api.ts
131
+ async function request(apiUrl, path, options = {}) {
132
+ const url = `${apiUrl.replace(/\/$/, "")}${path}`;
133
+ const headers = {
134
+ "Content-Type": "application/json",
135
+ "User-Agent": "taskair-cli/1.0.0"
102
136
  };
137
+ if (options.token) {
138
+ headers["Authorization"] = `Bearer ${options.token}`;
139
+ }
140
+ try {
141
+ const res = await fetch(url, {
142
+ method: options.method ?? "GET",
143
+ headers,
144
+ body: options.body ? JSON.stringify(options.body) : void 0
145
+ });
146
+ const json = await res.json();
147
+ return json;
148
+ } catch (err) {
149
+ const message = err instanceof Error ? err.message : "Network error";
150
+ return {
151
+ success: false,
152
+ error: {
153
+ code: "NETWORK_ERROR",
154
+ message: `Cannot reach API: ${message}`
155
+ }
156
+ };
157
+ }
158
+ }
159
+ async function apiLogin(apiUrl, email, password) {
160
+ return request(apiUrl, "/auth/login", {
161
+ method: "POST",
162
+ body: { email, password }
163
+ });
164
+ }
165
+ async function apiLogout(apiUrl, token) {
166
+ return request(apiUrl, "/auth/logout", {
167
+ method: "POST",
168
+ token
169
+ });
170
+ }
171
+ async function apiRegisterDevice(apiUrl, token, deviceName, platform) {
172
+ return request(apiUrl, "/auth/device", {
173
+ method: "POST",
174
+ token,
175
+ body: { device_name: deviceName, platform }
176
+ });
177
+ }
178
+ async function apiUploadSync(apiUrl, token, encryptedBlob, chk, deviceId) {
179
+ return request(apiUrl, "/sync/upload", {
180
+ method: "POST",
181
+ token,
182
+ body: { encrypted_blob: encryptedBlob, checksum: chk, device_id: deviceId }
183
+ });
103
184
  }
104
185
 
105
186
  // src/components/StarBurst.tsx
@@ -173,235 +254,396 @@ function StatusBadge({
173
254
  ] });
174
255
  }
175
256
 
176
- // src/components/AsciiHeader.tsx
257
+ // src/components/Spinner.tsx
258
+ import { useState as useState2, useEffect as useEffect2 } from "react";
177
259
  import { Box as Box2, Text as Text2 } from "ink";
178
260
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
261
+ var ORBIT_FRAMES = ["\u25D0", "\u25D3", "\u25D1", "\u25D2"];
262
+ var STAR_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
263
+ function Spinner({
264
+ label = "Loading...",
265
+ type = "orbit",
266
+ color = "cyan"
267
+ }) {
268
+ const frames = type === "orbit" ? ORBIT_FRAMES : STAR_FRAMES;
269
+ const [frame, setFrame] = useState2(0);
270
+ useEffect2(() => {
271
+ const timer = setInterval(() => {
272
+ setFrame((f) => (f + 1) % frames.length);
273
+ }, 80);
274
+ return () => clearInterval(timer);
275
+ }, [frames.length]);
276
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
277
+ /* @__PURE__ */ jsxs2(Text2, { color, bold: true, children: [
278
+ frames[frame],
279
+ " "
280
+ ] }),
281
+ /* @__PURE__ */ jsx2(Text2, { color: "white", children: label })
282
+ ] });
283
+ }
284
+
285
+ // src/components/AsciiHeader.tsx
286
+ import { Box as Box3, Text as Text3 } from "ink";
287
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
179
288
  var LOGO = [
180
- "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
181
- "\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2554\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
182
- " \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D",
183
- " \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
184
- " \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551",
185
- " \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D"
289
+ "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
290
+ "\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2554\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
291
+ " \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D",
292
+ " \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
293
+ " \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551",
294
+ " \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D"
186
295
  ];
187
296
  var TAGLINE = " \u2726 Space-themed \xB7 Privacy-first \xB7 AI-native task management \u2726";
188
297
  function AsciiHeader() {
189
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginBottom: 1, children: [
190
- /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: LOGO.map((line, i) => /* @__PURE__ */ jsx2(Text2, { color: "magenta", bold: true, children: line }, i)) }),
191
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", dimColor: true, children: TAGLINE }),
192
- /* @__PURE__ */ jsx2(Box2, { marginTop: 0, children: /* @__PURE__ */ jsx2(Text2, { color: "#7B61FF", children: "\u2500".repeat(60) }) })
298
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginBottom: 1, children: [
299
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: LOGO.map((line, i) => /* @__PURE__ */ jsx3(Text3, { color: "magenta", bold: true, children: line }, i)) }),
300
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", dimColor: true, children: TAGLINE }),
301
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 0, children: /* @__PURE__ */ jsx3(Text3, { color: "#7B61FF", children: "\u2500".repeat(60) }) })
193
302
  ] });
194
303
  }
195
304
  function MiniHeader() {
196
- return /* @__PURE__ */ jsxs2(Box2, { marginBottom: 1, children: [
197
- /* @__PURE__ */ jsxs2(Text2, { color: "magenta", bold: true, children: [
305
+ return /* @__PURE__ */ jsxs3(Box3, { marginBottom: 1, children: [
306
+ /* @__PURE__ */ jsxs3(Text3, { color: "magenta", bold: true, children: [
198
307
  "\u2726",
199
308
  " "
200
309
  ] }),
201
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", bold: true, children: "TaskAir" }),
202
- /* @__PURE__ */ jsx2(Text2, { color: "#7B61FF", children: " \u2014 Space-grade task management" })
310
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", bold: true, children: "TaskAir" }),
311
+ /* @__PURE__ */ jsx3(Text3, { color: "#7B61FF", children: " \u2014 Space-grade task management" })
203
312
  ] });
204
313
  }
205
314
 
206
- // src/commands/configure.tsx
207
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
208
- function ConfigureUI({ initial }) {
315
+ // src/commands/config.tsx
316
+ import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
317
+ function openBrowser(url) {
318
+ const start = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
319
+ const cmd = process.platform === "win32" ? `start "" "${url}"` : `${start} "${url}"`;
320
+ exec(cmd);
321
+ }
322
+ function ConfigUI({ initialApiUrl }) {
209
323
  const { exit } = useApp();
210
- const fields = ["apiUrl", "email", "password"];
211
- const [fieldIndex, setFieldIndex] = useState2(0);
212
- const [values, setValues] = useState2({
213
- apiUrl: initial.apiUrl,
214
- email: initial.email,
215
- password: ""
216
- });
217
- const [currentInput, setCurrentInput] = useState2(
218
- initial.apiUrl
219
- );
220
- const [done, setDone] = useState2(false);
221
- const [error, setError] = useState2("");
222
- const labels = {
223
- apiUrl: "API URL",
224
- email: "Email address",
225
- password: "Master password (used for E2E encryption)"
226
- };
324
+ const [stage, setStage] = useState3("input_api_url");
325
+ const [apiUrl, setApiUrl] = useState3(initialApiUrl);
326
+ const [currentInput, setCurrentInput] = useState3(initialApiUrl);
327
+ const [port, setPort] = useState3(0);
328
+ const [errorMsg, setErrorMsg] = useState3("");
329
+ const [authDetails, setAuthDetails] = useState3(null);
227
330
  useInput((input, key) => {
228
- if (done) return;
229
- if (key.return) {
230
- const field = fields[fieldIndex];
231
- if (!field) return;
232
- if (!currentInput.trim()) {
233
- setError(`${labels[field]} cannot be empty`);
234
- return;
235
- }
236
- setError("");
237
- const updated = { ...values, [field]: currentInput.trim() };
238
- setValues(updated);
239
- if (fieldIndex < fields.length - 1) {
240
- setFieldIndex(fieldIndex + 1);
241
- const nextField = fields[fieldIndex + 1];
242
- setCurrentInput(updated[nextField ?? "password"] ?? "");
331
+ if (stage === "input_api_url") {
332
+ if (key.return) {
333
+ if (!currentInput.trim()) {
334
+ setErrorMsg("API URL cannot be empty");
335
+ return;
336
+ }
337
+ setErrorMsg("");
338
+ setApiUrl(currentInput.trim());
339
+ setStage("waiting_for_browser");
340
+ } else if (key.backspace || key.delete) {
341
+ setCurrentInput((v) => v.slice(0, -1));
342
+ } else if (key.escape) {
343
+ exit();
243
344
  } else {
244
- writeCredentials({
245
- api_url: updated.apiUrl,
246
- email: updated.email
247
- });
248
- setDone(true);
249
- setTimeout(() => exit(), 500);
345
+ setCurrentInput((v) => v + input);
346
+ }
347
+ } else {
348
+ if (key.escape) {
349
+ exit();
250
350
  }
251
- return;
252
351
  }
253
- if (key.backspace || key.delete) {
254
- setCurrentInput((v) => v.slice(0, -1));
255
- return;
352
+ });
353
+ useEffect3(() => {
354
+ if (stage !== "waiting_for_browser") return;
355
+ let server = null;
356
+ let isActive = true;
357
+ async function runServer() {
358
+ try {
359
+ const srv = http.createServer((req, res) => {
360
+ const parsed = parseUrl(req.url || "", true);
361
+ if (parsed.pathname === "/callback") {
362
+ const q = parsed.query;
363
+ const accessToken = q.access_token;
364
+ const refreshToken = q.refresh_token;
365
+ const expiresAt = q.expires_at;
366
+ const email = q.email;
367
+ const password = q.password;
368
+ if (!accessToken || !email) {
369
+ res.writeHead(400, { "Content-Type": "text/html" });
370
+ res.end("<h1>Authentication Failed</h1><p>Missing credentials.</p>");
371
+ if (isActive) {
372
+ setErrorMsg("Authentication failed: Missing credentials from redirect.");
373
+ setStage("error");
374
+ setTimeout(() => exit(new Error("Missing credentials")), 1500);
375
+ }
376
+ return;
377
+ }
378
+ res.writeHead(200, { "Content-Type": "text/html" });
379
+ res.end(`
380
+ <!DOCTYPE html>
381
+ <html>
382
+ <head>
383
+ <title>TaskAir CLI Authenticated</title>
384
+ <style>
385
+ body {
386
+ background:
387
+ radial-gradient(ellipse 80% 60% at 50% -20%, rgba(123, 97, 255, 0.15) 0%, transparent 70%),
388
+ radial-gradient(ellipse 60% 50% at 80% 20%, rgba(0, 112, 243, 0.12) 0%, transparent 60%),
389
+ radial-gradient(ellipse 50% 40% at 20% 30%, rgba(0, 223, 216, 0.1) 0%, transparent 60%),
390
+ radial-gradient(ellipse 70% 40% at 60% 60%, rgba(235, 54, 127, 0.08) 0%, transparent 70%),
391
+ #0D0D11;
392
+ color: #FFFFFF;
393
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
394
+ display: flex;
395
+ align-items: center;
396
+ justify-content: center;
397
+ min-height: 100vh;
398
+ margin: 0;
399
+ }
400
+ .card {
401
+ background: rgba(19, 19, 26, 0.8);
402
+ backdrop-filter: blur(12px);
403
+ -webkit-backdrop-filter: blur(12px);
404
+ border: 1px solid rgba(255, 255, 255, 0.08);
405
+ border-radius: 16px;
406
+ padding: 40px;
407
+ text-align: center;
408
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
409
+ max-width: 400px;
410
+ width: 100%;
411
+ animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
412
+ }
413
+ .logo-container {
414
+ background: #0D0D11;
415
+ border: 1px solid #1F1F2E;
416
+ border-radius: 8px;
417
+ padding: 8px 16px;
418
+ display: inline-flex;
419
+ align-items: center;
420
+ justify-content: center;
421
+ margin-bottom: 24px;
422
+ animation: pulseRing 2s infinite ease-in-out;
423
+ }
424
+ .text-logo {
425
+ display: inline-flex;
426
+ align-items: center;
427
+ gap: 6px;
428
+ text-decoration: none !important;
429
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
430
+ font-size: 16px;
431
+ font-weight: 600;
432
+ letter-spacing: -0.5px;
433
+ color: #FFFFFF;
434
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
435
+ flex-shrink: 0;
436
+ }
437
+ .text-logo-brand {
438
+ transition: letter-spacing 0.3s ease, color 0.3s ease;
439
+ }
440
+ .text-logo-accent {
441
+ background: linear-gradient(135deg, #7B61FF, #0070f3);
442
+ -webkit-background-clip: text;
443
+ -webkit-text-fill-color: transparent;
444
+ }
445
+ .text-logo-spark {
446
+ color: #7B61FF;
447
+ font-size: 11px;
448
+ opacity: 0.7;
449
+ transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease, color 0.3s ease;
450
+ }
451
+ .text-logo:hover {
452
+ opacity: 0.95;
453
+ }
454
+ .text-logo:hover .text-logo-brand {
455
+ letter-spacing: 0.3px;
456
+ }
457
+ .text-logo:hover .text-logo-spark {
458
+ transform: scale(1.3) rotate(180deg);
459
+ opacity: 1;
460
+ color: #00dfd8;
461
+ }
462
+ .success-badge {
463
+ display: inline-flex;
464
+ align-items: center;
465
+ gap: 6px;
466
+ background: rgba(16, 185, 129, 0.1);
467
+ color: #10B981;
468
+ border: 1px solid rgba(16, 185, 129, 0.2);
469
+ padding: 6px 14px;
470
+ border-radius: 99px;
471
+ font-size: 13px;
472
+ font-weight: 500;
473
+ margin-bottom: 20px;
474
+ }
475
+ h1 {
476
+ color: #FFFFFF;
477
+ margin: 0 0 12px 0;
478
+ font-size: 22px;
479
+ font-weight: 600;
480
+ letter-spacing: -0.5px;
481
+ }
482
+ p {
483
+ color: #A1A1B5;
484
+ font-size: 14px;
485
+ line-height: 1.6;
486
+ margin: 0;
487
+ }
488
+ @keyframes fadeUp {
489
+ from { opacity: 0; transform: translateY(20px); }
490
+ to { opacity: 1; transform: translateY(0); }
491
+ }
492
+ @keyframes pulseRing {
493
+ 0% { transform: scale(1); box-shadow: 0 4px 14px rgba(123, 97, 255, 0.15); }
494
+ 50% { transform: scale(1.03); box-shadow: 0 6px 20px rgba(123, 97, 255, 0.35); }
495
+ 100% { transform: scale(1); box-shadow: 0 4px 14px rgba(123, 97, 255, 0.15); }
496
+ }
497
+ </style>
498
+ </head>
499
+ <body>
500
+ <div class="card">
501
+ <div class="logo-container">
502
+ <a href="#" class="text-logo">
503
+ <span class="text-logo-brand" style="color: #FFFFFF;">Task<span class="text-logo-accent">Air</span></span>
504
+ <span class="text-logo-spark">\u2726</span>
505
+ </a>
506
+ </div>
507
+ <div>
508
+ <div class="success-badge">
509
+ <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="margin-top: 1px;">
510
+ <polyline points="20 6 9 17 4 12"/>
511
+ </svg>
512
+ <span>CLI Connected</span>
513
+ </div>
514
+ </div>
515
+ <h1>Authenticated Successfully</h1>
516
+ <p>You can close this tab and return to your terminal.</p>
517
+ </div>
518
+ </body>
519
+ </html>
520
+ `);
521
+ if (isActive) {
522
+ setAuthDetails({
523
+ accessToken,
524
+ refreshToken,
525
+ expiresAt: parseInt(expiresAt, 10),
526
+ email,
527
+ password: password || void 0
528
+ });
529
+ setStage("registering");
530
+ }
531
+ } else {
532
+ res.writeHead(404);
533
+ res.end("Not Found");
534
+ }
535
+ });
536
+ server = srv;
537
+ srv.listen(0, "127.0.0.1", () => {
538
+ const addr = srv.address();
539
+ const p = typeof addr === "string" ? 0 : addr?.port || 0;
540
+ setPort(p);
541
+ const authUrl = `${apiUrl.replace(/\/$/, "")}/cli-auth?callback=http://localhost:${p}/callback`;
542
+ openBrowser(authUrl);
543
+ });
544
+ } catch (err) {
545
+ if (isActive) {
546
+ setErrorMsg(err.message || "Failed to start local server");
547
+ setStage("error");
548
+ setTimeout(() => exit(err), 1500);
549
+ }
550
+ }
256
551
  }
257
- if (key.escape) {
258
- exit();
259
- return;
552
+ runServer();
553
+ return () => {
554
+ isActive = false;
555
+ if (server) {
556
+ server.close();
557
+ }
558
+ };
559
+ }, [stage, apiUrl]);
560
+ useEffect3(() => {
561
+ if (stage !== "registering" || !authDetails) return;
562
+ async function registerAndSave() {
563
+ try {
564
+ const deviceName = `${hostname() || "CLI"} - ${process.platform || "Client"}`;
565
+ const devRes = await apiRegisterDevice(apiUrl, authDetails.accessToken, deviceName, "cli");
566
+ if (devRes.success && devRes.data) {
567
+ const existing = readCredentials();
568
+ writeCredentials({
569
+ ...existing,
570
+ api_url: apiUrl,
571
+ email: authDetails.email,
572
+ access_token: authDetails.accessToken,
573
+ refresh_token: authDetails.refreshToken,
574
+ expires_at: authDetails.expiresAt,
575
+ device_id: devRes.data.id,
576
+ password: authDetails.password
577
+ });
578
+ setStage("success");
579
+ setTimeout(() => exit(), 1200);
580
+ } else {
581
+ setErrorMsg(devRes.error?.message ?? "Device registration failed");
582
+ setStage("error");
583
+ setTimeout(() => exit(new Error(devRes.error?.message)), 1500);
584
+ }
585
+ } catch (err) {
586
+ setErrorMsg(err.message || "Error completing configuration");
587
+ setStage("error");
588
+ setTimeout(() => exit(err), 1500);
589
+ }
260
590
  }
261
- setCurrentInput((v) => v + input);
262
- });
263
- const currentField = fields[fieldIndex];
264
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", padding: 1, children: [
265
- /* @__PURE__ */ jsx3(AsciiHeader, {}),
266
- /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginBottom: 1, children: [
267
- /* @__PURE__ */ jsx3(Text3, { color: "cyan", bold: true, children: "\u2726 Configure TaskAir" }),
268
- /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "Set up your credentials. Press Enter to confirm each field." })
591
+ registerAndSave();
592
+ }, [stage, authDetails, apiUrl]);
593
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", padding: 1, children: [
594
+ /* @__PURE__ */ jsx4(AsciiHeader, {}),
595
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginBottom: 1, children: [
596
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", bold: true, children: "\u2726 Configure TaskAir" }),
597
+ stage === "input_api_url" && /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "Enter the API URL of your TaskAir instance. Press Enter to confirm." })
269
598
  ] }),
270
- fields.map((field, i) => {
271
- const isActive = i === fieldIndex && !done;
272
- const isDone = i < fieldIndex || done;
273
- return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", marginBottom: 0, children: /* @__PURE__ */ jsxs3(Box3, { children: [
274
- /* @__PURE__ */ jsx3(Text3, { color: isActive ? "cyan" : isDone ? "green" : "gray", bold: true, children: isDone ? "\u2713 " : isActive ? "\u2192 " : " " }),
275
- /* @__PURE__ */ jsxs3(Text3, { color: isActive ? "white" : isDone ? "green" : "gray", children: [
276
- labels[field],
277
- ":",
278
- " "
279
- ] }),
280
- isDone && field !== "password" && /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: values[field] }),
281
- isDone && field === "password" && /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "\u2022".repeat(8) }),
282
- isActive && /* @__PURE__ */ jsxs3(Text3, { color: "cyan", children: [
283
- field === "password" ? "\u2022".repeat(currentInput.length) : currentInput,
284
- /* @__PURE__ */ jsx3(Text3, { color: "white", children: "\u258C" })
285
- ] })
286
- ] }) }, field);
287
- }),
288
- error && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(StatusBadge, { type: "error", message: error }) }),
289
- done && /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, flexDirection: "column", children: [
290
- /* @__PURE__ */ jsx3(
291
- StatusBadge,
292
- {
293
- type: "success",
294
- message: "Configuration saved to ~/.taskair/credentials"
295
- }
296
- ),
297
- /* @__PURE__ */ jsxs3(Text3, { color: "gray", dimColor: true, children: [
298
- "Run",
299
- " ",
300
- /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "taskair login" }),
301
- " to authenticate."
599
+ stage === "input_api_url" && /* @__PURE__ */ jsxs4(Box4, { children: [
600
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", bold: true, children: "\u2192 " }),
601
+ /* @__PURE__ */ jsx4(Text4, { color: "white", children: "API URL: " }),
602
+ /* @__PURE__ */ jsxs4(Text4, { color: "cyan", children: [
603
+ currentInput,
604
+ /* @__PURE__ */ jsx4(Text4, { color: "white", children: "\u258C" })
302
605
  ] })
303
606
  ] }),
304
- /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "gray", dimColor: true, children: "Press Esc to cancel" }) })
607
+ stage === "waiting_for_browser" && /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 0.5, children: [
608
+ /* @__PURE__ */ jsxs4(Text4, { color: "cyan", children: [
609
+ "\u2713 API URL set to: ",
610
+ apiUrl
611
+ ] }),
612
+ port > 0 ? /* @__PURE__ */ jsxs4(Fragment, { children: [
613
+ /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
614
+ "\u2726 Started local server on port ",
615
+ port
616
+ ] }),
617
+ /* @__PURE__ */ jsx4(Spinner, { label: "Opening browser to authenticate\u2026", type: "orbit", color: "magenta" }),
618
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", dimColor: true, children: "If the page does not load, open this URL manually:" }),
619
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", underline: true, children: `${apiUrl.replace(/\/$/, "")}/cli-auth?callback=http://localhost:${port}/callback` })
620
+ ] }) : /* @__PURE__ */ jsx4(Spinner, { label: "Starting local server\u2026", type: "orbit", color: "magenta" })
621
+ ] }),
622
+ stage === "registering" && /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
623
+ /* @__PURE__ */ jsx4(Text4, { color: "green", children: "\u2713 Received credentials from browser" }),
624
+ /* @__PURE__ */ jsx4(Spinner, { label: "Registering device with backend\u2026", type: "orbit", color: "magenta" })
625
+ ] }),
626
+ stage === "success" && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(StatusBadge, { type: "success", message: "Configuration saved successfully! E2E Encryption Active." }) }),
627
+ stage === "error" && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(StatusBadge, { type: "error", message: errorMsg }) }),
628
+ (stage === "input_api_url" || stage === "waiting_for_browser") && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { color: "gray", dimColor: true, children: "Press Esc to cancel" }) })
305
629
  ] });
306
630
  }
307
- function registerConfigure(program2) {
308
- program2.command("configure").description("Set up API URL, email, and master password").action(async () => {
631
+ function registerConfig(program2) {
632
+ program2.command("config").alias("configure").description("Authenticate and configure CLI via browser").action(async () => {
309
633
  const existing = readCredentials();
310
634
  const { render } = await import("ink");
311
635
  render(
312
- React2.createElement(ConfigureUI, {
313
- initial: {
314
- apiUrl: existing?.api_url ?? "http://localhost:3001",
315
- email: existing?.email ?? ""
316
- }
636
+ React3.createElement(ConfigUI, {
637
+ initialApiUrl: existing?.api_url ?? "http://localhost:3001"
317
638
  })
318
639
  );
319
640
  });
320
641
  }
321
642
 
322
643
  // src/commands/login.tsx
323
- import React4, { useState as useState4, useEffect as useEffect3 } from "react";
644
+ import React4, { useState as useState4, useEffect as useEffect4 } from "react";
324
645
  import { Box as Box5, Text as Text5, useInput as useInput2, useApp as useApp2 } from "ink";
325
- import { v4 as uuidv4 } from "uuid";
326
-
327
- // src/lib/api.ts
328
- async function request(apiUrl, path, options = {}) {
329
- const url = `${apiUrl.replace(/\/$/, "")}${path}`;
330
- const headers = {
331
- "Content-Type": "application/json",
332
- "User-Agent": "taskair-cli/1.0.0"
333
- };
334
- if (options.token) {
335
- headers["Authorization"] = `Bearer ${options.token}`;
336
- }
337
- try {
338
- const res = await fetch(url, {
339
- method: options.method ?? "GET",
340
- headers,
341
- body: options.body ? JSON.stringify(options.body) : void 0
342
- });
343
- const json = await res.json();
344
- return json;
345
- } catch (err) {
346
- const message = err instanceof Error ? err.message : "Network error";
347
- return {
348
- success: false,
349
- error: {
350
- code: "NETWORK_ERROR",
351
- message: `Cannot reach API: ${message}`
352
- }
353
- };
354
- }
355
- }
356
- async function apiLogin(apiUrl, email, password) {
357
- return request(apiUrl, "/auth/login", {
358
- method: "POST",
359
- body: { email, password }
360
- });
361
- }
362
- async function apiLogout(apiUrl, token) {
363
- return request(apiUrl, "/auth/logout", {
364
- method: "POST",
365
- token
366
- });
367
- }
368
- async function apiUploadSync(apiUrl, token, encryptedBlob, chk, deviceId) {
369
- return request(apiUrl, "/sync/upload", {
370
- method: "POST",
371
- token,
372
- body: { encrypted_blob: encryptedBlob, checksum: chk, device_id: deviceId }
373
- });
374
- }
375
-
376
- // src/components/Spinner.tsx
377
- import { useState as useState3, useEffect as useEffect2 } from "react";
378
- import { Box as Box4, Text as Text4 } from "ink";
379
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
380
- var ORBIT_FRAMES = ["\u25D0", "\u25D3", "\u25D1", "\u25D2"];
381
- var STAR_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
382
- function Spinner({
383
- label = "Loading...",
384
- type = "orbit",
385
- color = "cyan"
386
- }) {
387
- const frames = type === "orbit" ? ORBIT_FRAMES : STAR_FRAMES;
388
- const [frame, setFrame] = useState3(0);
389
- useEffect2(() => {
390
- const timer = setInterval(() => {
391
- setFrame((f) => (f + 1) % frames.length);
392
- }, 80);
393
- return () => clearInterval(timer);
394
- }, [frames.length]);
395
- return /* @__PURE__ */ jsxs4(Box4, { children: [
396
- /* @__PURE__ */ jsxs4(Text4, { color, bold: true, children: [
397
- frames[frame],
398
- " "
399
- ] }),
400
- /* @__PURE__ */ jsx4(Text4, { color: "white", children: label })
401
- ] });
402
- }
403
-
404
- // src/commands/login.tsx
646
+ import { hostname as hostname2 } from "os";
405
647
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
406
648
  function LoginUI() {
407
649
  const { exit } = useApp2();
@@ -411,22 +653,32 @@ function LoginUI() {
411
653
  const [stage, setStage] = useState4("input_email");
412
654
  const [currentInput, setCurrentInput] = useState4(creds?.email ?? "");
413
655
  const [errorMsg, setErrorMsg] = useState4("");
414
- useEffect3(() => {
656
+ useEffect4(() => {
415
657
  if (stage === "loading") {
416
658
  const apiUrl = creds?.api_url ?? "http://localhost:3001";
417
- apiLogin(apiUrl, email, password).then((res) => {
659
+ apiLogin(apiUrl, email, password).then(async (res) => {
418
660
  if (res.success && res.data) {
419
- const deviceId = creds?.device_id ?? uuidv4();
420
- writeCredentials({
421
- api_url: apiUrl,
422
- email,
423
- access_token: res.data.access_token,
424
- refresh_token: res.data.refresh_token,
425
- expires_at: res.data.expires_at,
426
- device_id: deviceId
427
- });
428
- setStage("success");
429
- setTimeout(() => exit(), 1200);
661
+ const accessToken = res.data.access_token;
662
+ const deviceName = `${hostname2() || "CLI"} - ${process.platform || "Client"}`;
663
+ const devRes = await apiRegisterDevice(apiUrl, accessToken, deviceName, "cli");
664
+ if (devRes.success && devRes.data) {
665
+ const existing = readCredentials();
666
+ writeCredentials({
667
+ ...existing,
668
+ api_url: apiUrl,
669
+ email,
670
+ access_token: accessToken,
671
+ refresh_token: res.data.refresh_token,
672
+ expires_at: res.data.expires_at,
673
+ device_id: devRes.data.id
674
+ });
675
+ setStage("success");
676
+ setTimeout(() => exit(), 1200);
677
+ } else {
678
+ setErrorMsg(devRes.error?.message ?? "Device registration failed");
679
+ setStage("error");
680
+ setTimeout(() => exit(new Error(devRes.error?.message)), 1500);
681
+ }
430
682
  } else {
431
683
  setErrorMsg(res.error?.message ?? "Login failed");
432
684
  setStage("error");
@@ -503,14 +755,14 @@ function registerLogin(program2) {
503
755
  }
504
756
 
505
757
  // src/commands/logout.tsx
506
- import React5, { useEffect as useEffect4, useState as useState5 } from "react";
758
+ import React5, { useEffect as useEffect5, useState as useState5 } from "react";
507
759
  import { Box as Box6, useApp as useApp3 } from "ink";
508
760
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
509
761
  function LogoutUI() {
510
762
  const { exit } = useApp3();
511
763
  const [status, setStatus] = useState5("loading");
512
764
  const [message, setMessage] = useState5("");
513
- useEffect4(() => {
765
+ useEffect5(() => {
514
766
  const creds = readCredentials();
515
767
  if (!creds?.access_token) {
516
768
  setMessage("Not logged in.");
@@ -607,14 +859,14 @@ function registerWhoami(program2) {
607
859
  }
608
860
 
609
861
  // src/commands/add.tsx
610
- import React7, { useEffect as useEffect5, useState as useState6 } from "react";
862
+ import React7, { useEffect as useEffect6, useState as useState6 } from "react";
611
863
  import { Box as Box8, Text as Text8, useApp as useApp4 } from "ink";
612
- import { v4 as uuidv43 } from "uuid";
864
+ import { v4 as uuidv42 } from "uuid";
613
865
 
614
866
  // src/lib/storage.ts
615
867
  import { join as join2 } from "path";
616
868
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
617
- import { v4 as uuidv42 } from "uuid";
869
+ import { v4 as uuidv4 } from "uuid";
618
870
  var STORE_FILE = join2(TASKAIR_DIR, "tasks.json");
619
871
  function defaultStore() {
620
872
  return { tasks: [], sync_queue: [] };
@@ -677,7 +929,7 @@ function deleteTask(id) {
677
929
  }
678
930
  function queueOperation(store, operation, taskId) {
679
931
  store.sync_queue.push({
680
- id: uuidv42(),
932
+ id: uuidv4(),
681
933
  operation,
682
934
  task_id: taskId,
683
935
  created_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -741,11 +993,11 @@ function AddUI({
741
993
  const [status, setStatus] = useState6("working");
742
994
  const [taskId, setTaskId] = useState6("");
743
995
  const [errorMsg, setErrorMsg] = useState6("");
744
- useEffect5(() => {
996
+ useEffect6(() => {
745
997
  try {
746
998
  const auth = requireAuth();
747
999
  const task = {
748
- id: uuidv43(),
1000
+ id: uuidv42(),
749
1001
  description,
750
1002
  priority,
751
1003
  status: "pending",
@@ -832,7 +1084,7 @@ function registerAdd(program2) {
832
1084
  }
833
1085
 
834
1086
  // src/commands/list.tsx
835
- import React9, { useEffect as useEffect6, useState as useState7 } from "react";
1087
+ import React9, { useEffect as useEffect7, useState as useState7 } from "react";
836
1088
  import { Box as Box10, Text as Text10, useApp as useApp5 } from "ink";
837
1089
 
838
1090
  // src/components/TaskTable.tsx
@@ -996,7 +1248,7 @@ import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
996
1248
  function ListUI({ filter, format }) {
997
1249
  const { exit } = useApp5();
998
1250
  const [tasks, setTasks] = useState7([]);
999
- useEffect6(() => {
1251
+ useEffect7(() => {
1000
1252
  const results = filterTasks(filter);
1001
1253
  results.sort((a, b) => {
1002
1254
  const pOrder = { high: 0, medium: 1, low: 2 };
@@ -1064,15 +1316,15 @@ function registerList(program2) {
1064
1316
  }
1065
1317
 
1066
1318
  // src/commands/done.tsx
1067
- import React10, { useEffect as useEffect7, useState as useState8 } from "react";
1319
+ import React10, { useEffect as useEffect8, useState as useState8 } from "react";
1068
1320
  import { Box as Box11, Text as Text11, useApp as useApp6 } from "ink";
1069
- import { Fragment, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
1321
+ import { Fragment as Fragment2, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
1070
1322
  function DoneUI({ id, note }) {
1071
1323
  const { exit } = useApp6();
1072
1324
  const [status, setStatus] = useState8("success");
1073
1325
  const [message, setMessage] = useState8("");
1074
1326
  const [description, setDescription] = useState8("");
1075
- useEffect7(() => {
1327
+ useEffect8(() => {
1076
1328
  const task = getTask(id);
1077
1329
  if (!task) {
1078
1330
  setMessage(`No task found with ID starting with "${id}"`);
@@ -1100,7 +1352,7 @@ function DoneUI({ id, note }) {
1100
1352
  }, []);
1101
1353
  return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", padding: 1, children: [
1102
1354
  /* @__PURE__ */ jsx11(MiniHeader, {}),
1103
- /* @__PURE__ */ jsx11(Box11, { marginTop: 1, flexDirection: "column", children: status === "success" ? /* @__PURE__ */ jsxs11(Fragment, { children: [
1355
+ /* @__PURE__ */ jsx11(Box11, { marginTop: 1, flexDirection: "column", children: status === "success" ? /* @__PURE__ */ jsxs11(Fragment2, { children: [
1104
1356
  /* @__PURE__ */ jsx11(StarBurst, { label: message, color: "green" }),
1105
1357
  description && /* @__PURE__ */ jsxs11(Box11, { marginTop: 1, children: [
1106
1358
  /* @__PURE__ */ jsx11(Text11, { color: "gray", children: " " }),
@@ -1122,7 +1374,7 @@ function registerDone(program2) {
1122
1374
  }
1123
1375
 
1124
1376
  // src/commands/remove.tsx
1125
- import React11, { useEffect as useEffect8, useState as useState9 } from "react";
1377
+ import React11, { useEffect as useEffect9, useState as useState9 } from "react";
1126
1378
  import { Box as Box12, Text as Text12, useApp as useApp7, useInput as useInput3 } from "ink";
1127
1379
  import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
1128
1380
  function RemoveUI({ id, force }) {
@@ -1130,7 +1382,7 @@ function RemoveUI({ id, force }) {
1130
1382
  const [stage, setStage] = useState9("confirm");
1131
1383
  const [message, setMessage] = useState9("");
1132
1384
  const task = getTask(id);
1133
- useEffect8(() => {
1385
+ useEffect9(() => {
1134
1386
  if (!task) {
1135
1387
  setMessage(`No task found with ID starting with "${id}"`);
1136
1388
  setStage("error");
@@ -1203,15 +1455,15 @@ function registerRemove(program2) {
1203
1455
  }
1204
1456
 
1205
1457
  // src/commands/edit.tsx
1206
- import React12, { useState as useState10, useEffect as useEffect9 } from "react";
1458
+ import React12, { useState as useState10, useEffect as useEffect10 } from "react";
1207
1459
  import { Box as Box13, Text as Text13, useApp as useApp8 } from "ink";
1208
- import { Fragment as Fragment2, jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
1460
+ import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
1209
1461
  function EditUI({ id, updates }) {
1210
1462
  const { exit } = useApp8();
1211
1463
  const [status, setStatus] = useState10("success");
1212
1464
  const [message, setMessage] = useState10("");
1213
1465
  const [changes, setChanges] = useState10([]);
1214
- useEffect9(() => {
1466
+ useEffect10(() => {
1215
1467
  const task = getTask(id);
1216
1468
  if (!task) {
1217
1469
  setMessage(`No task found with ID starting with "${id}"`);
@@ -1264,7 +1516,7 @@ function EditUI({ id, updates }) {
1264
1516
  }, []);
1265
1517
  return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", padding: 1, children: [
1266
1518
  /* @__PURE__ */ jsx13(MiniHeader, {}),
1267
- /* @__PURE__ */ jsx13(Box13, { marginTop: 1, flexDirection: "column", children: status === "success" ? /* @__PURE__ */ jsxs13(Fragment2, { children: [
1519
+ /* @__PURE__ */ jsx13(Box13, { marginTop: 1, flexDirection: "column", children: status === "success" ? /* @__PURE__ */ jsxs13(Fragment3, { children: [
1268
1520
  /* @__PURE__ */ jsx13(StarBurst, { label: message, color: "magenta" }),
1269
1521
  changes.map((c) => /* @__PURE__ */ jsxs13(Box13, { marginTop: 0, children: [
1270
1522
  /* @__PURE__ */ jsx13(Text13, { color: "gray", children: " \u21B3 " }),
@@ -1281,9 +1533,9 @@ function registerEdit(program2) {
1281
1533
  }
1282
1534
 
1283
1535
  // src/commands/stats.tsx
1284
- import React13, { useEffect as useEffect10 } from "react";
1536
+ import React13, { useEffect as useEffect11 } from "react";
1285
1537
  import { Box as Box14, Text as Text14, useApp as useApp9 } from "ink";
1286
- import { Fragment as Fragment3, jsx as jsx14, jsxs as jsxs14 } from "react/jsx-runtime";
1538
+ import { Fragment as Fragment4, jsx as jsx14, jsxs as jsxs14 } from "react/jsx-runtime";
1287
1539
  function ProgressBar({
1288
1540
  value,
1289
1541
  max,
@@ -1302,7 +1554,7 @@ function ProgressBar({
1302
1554
  function StatsUI() {
1303
1555
  const { exit } = useApp9();
1304
1556
  const stats = computeStats();
1305
- useEffect10(() => {
1557
+ useEffect11(() => {
1306
1558
  setTimeout(() => exit(), 100);
1307
1559
  }, []);
1308
1560
  const rows = [
@@ -1338,7 +1590,7 @@ function StatsUI() {
1338
1590
  row.label.padEnd(16)
1339
1591
  ] }),
1340
1592
  /* @__PURE__ */ jsx14(Text14, { color: row.color, bold: true, children: String(row.value).padStart(4) }),
1341
- stats.total > 0 && /* @__PURE__ */ jsxs14(Fragment3, { children: [
1593
+ stats.total > 0 && /* @__PURE__ */ jsxs14(Fragment4, { children: [
1342
1594
  /* @__PURE__ */ jsx14(Text14, { color: "gray", children: " " }),
1343
1595
  /* @__PURE__ */ jsx14(
1344
1596
  ProgressBar,
@@ -1360,7 +1612,7 @@ function StatsUI() {
1360
1612
  row.label.padEnd(20)
1361
1613
  ] }),
1362
1614
  /* @__PURE__ */ jsx14(Text14, { color: row.color, bold: true, children: String(row.value).padStart(4) }),
1363
- stats.total > 0 && /* @__PURE__ */ jsxs14(Fragment3, { children: [
1615
+ stats.total > 0 && /* @__PURE__ */ jsxs14(Fragment4, { children: [
1364
1616
  /* @__PURE__ */ jsx14(Text14, { color: "gray", children: " " }),
1365
1617
  /* @__PURE__ */ jsx14(
1366
1618
  ProgressBar,
@@ -1398,7 +1650,7 @@ function registerStats(program2) {
1398
1650
  }
1399
1651
 
1400
1652
  // src/commands/sync.tsx
1401
- import React14, { useState as useState11, useEffect as useEffect11 } from "react";
1653
+ import React14, { useState as useState11, useEffect as useEffect12 } from "react";
1402
1654
  import { Box as Box15, Text as Text15, useApp as useApp10 } from "ink";
1403
1655
 
1404
1656
  // src/lib/crypto.ts
@@ -1446,13 +1698,13 @@ function checksum(plaintext) {
1446
1698
  }
1447
1699
 
1448
1700
  // src/commands/sync.tsx
1449
- import { Fragment as Fragment4, jsx as jsx15, jsxs as jsxs15 } from "react/jsx-runtime";
1701
+ import { Fragment as Fragment5, jsx as jsx15, jsxs as jsxs15 } from "react/jsx-runtime";
1450
1702
  function SyncUI({ dryRun, password }) {
1451
1703
  const { exit } = useApp10();
1452
1704
  const [status, setStatus] = useState11("loading");
1453
1705
  const [message, setMessage] = useState11("");
1454
1706
  const [details, setDetails] = useState11([]);
1455
- useEffect11(() => {
1707
+ useEffect12(() => {
1456
1708
  async function doSync() {
1457
1709
  try {
1458
1710
  const auth = requireAuth();
@@ -1477,13 +1729,14 @@ function SyncUI({ dryRun, password }) {
1477
1729
  setTimeout(() => exit(), 100);
1478
1730
  return;
1479
1731
  }
1480
- if (!password) {
1481
- setMessage("Encryption password required. Use --password flag.");
1732
+ const syncPassword = password || auth.password;
1733
+ if (!syncPassword) {
1734
+ setMessage("Encryption password required. Use --password flag or configure it.");
1482
1735
  setStatus("error");
1483
1736
  setTimeout(() => exit(new Error("Password required")), 1200);
1484
1737
  return;
1485
1738
  }
1486
- const blob = encrypt(plaintext, password);
1739
+ const blob = encrypt(plaintext, syncPassword);
1487
1740
  const res = await apiUploadSync(
1488
1741
  auth.apiUrl,
1489
1742
  auth.accessToken,
@@ -1500,6 +1753,15 @@ function SyncUI({ dryRun, password }) {
1500
1753
  ]);
1501
1754
  setStatus("success");
1502
1755
  setTimeout(() => exit(), 1500);
1756
+ } else if (res.error?.code === "NETWORK_ERROR") {
1757
+ setMessage("System is offline. Sync is queued!");
1758
+ setDetails([
1759
+ "Tasks stored locally in cache (tasks.json) at zero cost",
1760
+ `Sync queue contains ${tasks.length} pending task(s)`,
1761
+ "Will upload to cloud & Google Drive once internet is restored."
1762
+ ]);
1763
+ setStatus("offline");
1764
+ setTimeout(() => exit(), 3e3);
1503
1765
  } else {
1504
1766
  setMessage(res.error?.message ?? "Sync failed");
1505
1767
  setStatus("error");
@@ -1517,7 +1779,7 @@ function SyncUI({ dryRun, password }) {
1517
1779
  /* @__PURE__ */ jsx15(MiniHeader, {}),
1518
1780
  /* @__PURE__ */ jsxs15(Box15, { marginTop: 1, flexDirection: "column", children: [
1519
1781
  status === "loading" && /* @__PURE__ */ jsx15(Spinner, { label: "Syncing to cloud (E2E encrypted)\u2026", type: "orbit", color: "magenta" }),
1520
- status === "success" && /* @__PURE__ */ jsxs15(Fragment4, { children: [
1782
+ status === "success" && /* @__PURE__ */ jsxs15(Fragment5, { children: [
1521
1783
  /* @__PURE__ */ jsx15(StarBurst, { label: message, color: "cyan" }),
1522
1784
  details.map((d) => /* @__PURE__ */ jsxs15(Box15, { children: [
1523
1785
  /* @__PURE__ */ jsx15(Text15, { color: "gray", children: " \xB7 " }),
@@ -1525,6 +1787,13 @@ function SyncUI({ dryRun, password }) {
1525
1787
  ] }, d))
1526
1788
  ] }),
1527
1789
  status === "error" && /* @__PURE__ */ jsx15(StatusBadge, { type: "error", message }),
1790
+ status === "offline" && /* @__PURE__ */ jsxs15(Box15, { flexDirection: "column", children: [
1791
+ /* @__PURE__ */ jsx15(StatusBadge, { type: "warn", message }),
1792
+ details.map((d) => /* @__PURE__ */ jsxs15(Box15, { children: [
1793
+ /* @__PURE__ */ jsx15(Text15, { color: "gray", children: " \xB7 " }),
1794
+ /* @__PURE__ */ jsx15(Text15, { color: "yellow", dimColor: true, children: d })
1795
+ ] }, d))
1796
+ ] }),
1528
1797
  status === "dry-run" && /* @__PURE__ */ jsxs15(Box15, { flexDirection: "column", children: [
1529
1798
  /* @__PURE__ */ jsx15(StatusBadge, { type: "info", message: "Dry run \u2014 sync preview:" }),
1530
1799
  details.map((d) => /* @__PURE__ */ jsxs15(Box15, { children: [
@@ -1603,14 +1872,113 @@ function registerExport(program2) {
1603
1872
  });
1604
1873
  }
1605
1874
 
1875
+ // src/commands/upgrade.tsx
1876
+ import React15, { useState as useState12, useEffect as useEffect13 } from "react";
1877
+ import { Box as Box16, Text as Text16 } from "ink";
1878
+ import { exec as exec2 } from "child_process";
1879
+
1880
+ // src/lib/version.ts
1881
+ var CLI_VERSION = "1.0.5";
1882
+
1883
+ // src/commands/upgrade.tsx
1884
+ import { jsx as jsx16, jsxs as jsxs16 } from "react/jsx-runtime";
1885
+ function isOutdated(current, latest) {
1886
+ const cParts = current.split(".").map(Number);
1887
+ const lParts = latest.split(".").map(Number);
1888
+ for (let i = 0; i < 3; i++) {
1889
+ const c = cParts[i] ?? 0;
1890
+ const l = lParts[i] ?? 0;
1891
+ if (l > c) return true;
1892
+ if (c > l) return false;
1893
+ }
1894
+ return false;
1895
+ }
1896
+ function UpgradeUI() {
1897
+ const [status, setStatus] = useState12("checking");
1898
+ const [latestVersion, setLatestVersion] = useState12("");
1899
+ const [errorMsg, setErrorMsg] = useState12("");
1900
+ useEffect13(() => {
1901
+ async function checkAndUpgrade() {
1902
+ try {
1903
+ const res = await fetch("https://registry.npmjs.org/taskair-cli/latest", {
1904
+ signal: AbortSignal.timeout(5e3)
1905
+ // 5s timeout
1906
+ });
1907
+ if (!res.ok) {
1908
+ throw new Error(`Failed to fetch latest version from npm registry. Status: ${res.status}`);
1909
+ }
1910
+ const data = await res.json();
1911
+ const latest = data.version || "1.0.4";
1912
+ setLatestVersion(latest);
1913
+ if (isOutdated(CLI_VERSION, latest)) {
1914
+ setStatus("upgrading");
1915
+ exec2("npm install -g taskair-cli", (error, stdout, stderr) => {
1916
+ if (error) {
1917
+ setErrorMsg(error.message || stderr || "Failed to install update");
1918
+ setStatus("error");
1919
+ } else {
1920
+ setStatus("success");
1921
+ }
1922
+ });
1923
+ } else {
1924
+ setStatus("up-to-date");
1925
+ }
1926
+ } catch (err) {
1927
+ setErrorMsg(err.message || "Network error checking for updates.");
1928
+ setStatus("error");
1929
+ }
1930
+ }
1931
+ checkAndUpgrade();
1932
+ }, []);
1933
+ return /* @__PURE__ */ jsxs16(Box16, { flexDirection: "column", padding: 1, children: [
1934
+ /* @__PURE__ */ jsx16(MiniHeader, {}),
1935
+ /* @__PURE__ */ jsxs16(Box16, { flexDirection: "column", borderStyle: "round", borderColor: "#7B61FF", paddingX: 2, paddingY: 1, marginTop: 1, children: [
1936
+ /* @__PURE__ */ jsx16(Box16, { marginBottom: 1, children: /* @__PURE__ */ jsx16(Text16, { color: "cyan", bold: true, children: "\u2726 TaskAir Upgrade" }) }),
1937
+ status === "checking" && /* @__PURE__ */ jsx16(Spinner, { label: `Checking npm registry for updates... (Current: v${CLI_VERSION})`, type: "orbit" }),
1938
+ status === "upgrading" && /* @__PURE__ */ jsxs16(Box16, { flexDirection: "column", children: [
1939
+ /* @__PURE__ */ jsxs16(Text16, { color: "yellow", bold: true, children: [
1940
+ "\u2726 New version detected: v",
1941
+ CLI_VERSION,
1942
+ " \u2192 v",
1943
+ latestVersion
1944
+ ] }),
1945
+ /* @__PURE__ */ jsx16(Box16, { marginTop: 1, children: /* @__PURE__ */ jsx16(Spinner, { label: "Upgrading globally via npm install -g taskair-cli...", type: "star", color: "yellow" }) })
1946
+ ] }),
1947
+ status === "success" && /* @__PURE__ */ jsxs16(Box16, { flexDirection: "column", children: [
1948
+ /* @__PURE__ */ jsx16(StatusBadge, { type: "success", message: `TaskAir CLI successfully upgraded to v${latestVersion}!` }),
1949
+ /* @__PURE__ */ jsx16(Box16, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text16, { color: "gray", children: "You are now running the latest space-grade build." }) })
1950
+ ] }),
1951
+ status === "up-to-date" && /* @__PURE__ */ jsxs16(Box16, { flexDirection: "column", children: [
1952
+ /* @__PURE__ */ jsx16(StatusBadge, { type: "success", message: `TaskAir CLI is already up to date (v${CLI_VERSION}).` }),
1953
+ /* @__PURE__ */ jsx16(Box16, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text16, { color: "gray", children: "No upgrade required at this time. Blast off! \u{1F680}" }) })
1954
+ ] }),
1955
+ status === "error" && /* @__PURE__ */ jsxs16(Box16, { flexDirection: "column", children: [
1956
+ /* @__PURE__ */ jsx16(StatusBadge, { type: "error", message: `Upgrade failed: ${errorMsg}` }),
1957
+ /* @__PURE__ */ jsxs16(Box16, { marginTop: 1, flexDirection: "column", children: [
1958
+ /* @__PURE__ */ jsx16(Text16, { color: "gray", children: "You can try upgrading manually by running:" }),
1959
+ /* @__PURE__ */ jsx16(Text16, { color: "cyan", bold: true, children: " npm install -g taskair-cli" }),
1960
+ /* @__PURE__ */ jsx16(Text16, { color: "gray", marginTop: 1, children: "If permission errors occur, run with administrative rights (sudo)." })
1961
+ ] })
1962
+ ] })
1963
+ ] })
1964
+ ] });
1965
+ }
1966
+ function registerUpgrade(program2) {
1967
+ program2.command("upgrade").description("Upgrade TaskAir CLI to the latest version").action(async () => {
1968
+ const { render } = await import("ink");
1969
+ render(React15.createElement(UpgradeUI));
1970
+ });
1971
+ }
1972
+
1606
1973
  // src/index.ts
1607
1974
  program.name("taskair").description(
1608
1975
  "\u2726 Space-themed task management with E2E encryption \xB7 AI-native \xB7 Privacy-first"
1609
- ).version("1.0.2", "-v, --version", "Output the current version").helpOption("-h, --help", "Display help information");
1610
- registerConfigure(program);
1976
+ ).version(CLI_VERSION, "-v, --version", "Output the current version").helpOption("-h, --help", "Display help information");
1977
+ registerConfig(program);
1611
1978
  registerLogin(program);
1612
1979
  registerLogout(program);
1613
1980
  registerWhoami(program);
1981
+ registerUpgrade(program);
1614
1982
  registerAdd(program);
1615
1983
  registerList(program);
1616
1984
  registerDone(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskair-cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
4
4
  "description": "Space-themed, privacy-first task management CLI with E2E encryption",
5
5
  "type": "module",
6
6
  "bin": {