@blastin-dev/clocktopus-cli 0.1.2 → 0.1.4

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 CHANGED
@@ -38,23 +38,38 @@ Clear stored credentials.
38
38
 
39
39
  Display the current authenticated user.
40
40
 
41
- ### `clocktopus clock in`
41
+ ### `clocktopus clock (in|out)`
42
42
 
43
- Record a clock-in signal.
43
+ ```
44
+ clocktopus clock (in|out) [--ago <duration> | --at <time>]
45
+ ```
44
46
 
45
- ### `clocktopus clock out`
47
+ Record the start or end of a work session. Defaults to "now" when no
48
+ flag is passed.
46
49
 
47
- Record a clock-out signal.
50
+ | Option | Description |
51
+ | --- | --- |
52
+ | `--ago <duration>` | Backdate by a duration from now (`15m`, `1h`, `1h30m`). Mutually exclusive with `--at`. |
53
+ | `--at <time>` | Backdate to an absolute wall-clock time today (`HH:mm` or `HH:mm:ss`). |
48
54
 
49
55
  ### `clocktopus clock status`
50
56
 
51
- Show signals for a given date.
57
+ ```
58
+ clocktopus clock status [-d <YYYY-MM-DD>]
59
+ ```
60
+
61
+ Show clock signals for a given date.
62
+
63
+ | Option | Description |
64
+ | --- | --- |
65
+ | `-d, --date <date>` | Show signals for a specific date in `YYYY-MM-DD` format. Defaults to today. |
52
66
 
53
67
  ## Token Storage
54
68
 
55
69
  Credentials are stored in a platform-specific config directory:
56
70
 
57
- - **macOS**: `~/Library/Preferences/clocktopus-cli-nodejs/config.json`
71
+ - **macOS**: `~/Library/Preferences/clocktopus-cli-nodejs/conf
72
+ ig.json`
58
73
  - **Linux**: `~/.config/clocktopus-cli-nodejs/config.json`
59
74
  - **Windows**: `%APPDATA%/clocktopus-cli-nodejs/config.json`
60
75
 
@@ -1,11 +1,33 @@
1
1
  /**
2
- * Record a clock-in signal for today
2
+ * Record a clock-in signal for today.
3
+ *
4
+ * Accepts the same backdate flags as `clock out`:
5
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
6
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
7
+ *
8
+ * The server silently clamps the requested time if it would fall outside
9
+ * the allowed window (before start-of-today, before the day's last
10
+ * clock-out, or after the first existing time entry on the day).
3
11
  */
4
- export declare function clockInCommand(): Promise<void>;
12
+ export declare function clockInCommand(options?: {
13
+ ago?: string;
14
+ at?: string;
15
+ }): Promise<void>;
5
16
  /**
6
- * Record a clock-out signal for today
17
+ * Record a clock-out signal for today.
18
+ *
19
+ * Accepts optional backdate flags:
20
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
21
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
22
+ *
23
+ * The two flags are mutually exclusive. The server silently clamps the
24
+ * requested time if it would predate the session's clock-in or the
25
+ * session's last time entry.
7
26
  */
8
- export declare function clockOutCommand(): Promise<void>;
27
+ export declare function clockOutCommand(options?: {
28
+ ago?: string;
29
+ at?: string;
30
+ }): Promise<void>;
9
31
  /**
10
32
  * Show clock signals status for a given date (default: today)
11
33
  */
@@ -1 +1 @@
1
- {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../../../src/commands/clock.ts"],"names":[],"mappings":"AAuDA;;GAEG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAqCpD;AAED;;GAEG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAuCrD;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,IAAI,CAAC,CAiDhB"}
1
+ {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../../../src/commands/clock.ts"],"names":[],"mappings":"AAgFA;;;;;;;;;;GAUG;AACH,wBAAsB,cAAc,CAClC,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,OAAO,CAAC,IAAI,CAAC,CA0Ef;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,OAAO,CAAC,IAAI,CAAC,CAsEf;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,IAAI,CAAC,CAiDhB"}
@@ -16,8 +16,30 @@ const ClockSignalResponseSchema = z.object({
16
16
  signalTimestamp: z.string(),
17
17
  alreadyExists: z.boolean(),
18
18
  blockedReason: z.enum(["already_clocked_in", "not_clocked_in"]).nullable(),
19
+ clamped: z.boolean().optional(),
20
+ requestedTime: z.string().nullable().optional(),
21
+ reopened: z.boolean().optional(),
19
22
  }),
20
23
  });
24
+ /**
25
+ * Parses "15m", "1h", "1h30m" → whole minutes. Mirrors
26
+ * `packages/core/src/utils/duration.ts` (the CLI is a standalone package
27
+ * and doesn't depend on @repo/core).
28
+ */
29
+ function parseDurationToMinutes(input) {
30
+ const trimmed = input.trim().toLowerCase();
31
+ const match = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(trimmed);
32
+ if (!match || (!match[1] && !match[2])) {
33
+ throw new Error(`Invalid duration "${input}". Use formats like 15m, 1h, or 1h30m.`);
34
+ }
35
+ const hours = match[1] ? Number(match[1]) : 0;
36
+ const minutes = match[2] ? Number(match[2]) : 0;
37
+ const total = hours * 60 + minutes;
38
+ if (total <= 0) {
39
+ throw new Error(`Duration must be greater than zero: "${input}".`);
40
+ }
41
+ return total;
42
+ }
21
43
  /**
22
44
  * Schema for clock signals list response
23
45
  */
@@ -44,14 +66,43 @@ function formatSignalType(type) {
44
66
  return type === "clock_in" ? "Clock In" : "Clock Out";
45
67
  }
46
68
  /**
47
- * Record a clock-in signal for today
69
+ * Record a clock-in signal for today.
70
+ *
71
+ * Accepts the same backdate flags as `clock out`:
72
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
73
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
74
+ *
75
+ * The server silently clamps the requested time if it would fall outside
76
+ * the allowed window (before start-of-today, before the day's last
77
+ * clock-out, or after the first existing time entry on the day).
48
78
  */
49
- export async function clockInCommand() {
79
+ export async function clockInCommand(options = {}) {
50
80
  if (!checkAuth()) {
51
81
  process.exit(1);
52
82
  }
83
+ if (options.ago && options.at) {
84
+ console.error("Cannot use --ago and --at together. Pick one.");
85
+ process.exit(1);
86
+ }
87
+ const body = { signalType: "clock_in" };
88
+ if (options.ago) {
89
+ try {
90
+ body.minutesAgo = parseDurationToMinutes(options.ago);
91
+ }
92
+ catch (err) {
93
+ console.error(err instanceof Error ? err.message : String(err));
94
+ process.exit(1);
95
+ }
96
+ }
97
+ if (options.at) {
98
+ if (!/^\d{2}:\d{2}(:\d{2})?$/.test(options.at)) {
99
+ console.error(`Invalid time "${options.at}". Use HH:mm or HH:mm:ss format.`);
100
+ process.exit(1);
101
+ }
102
+ body.effectiveTime = options.at;
103
+ }
53
104
  try {
54
- const data = await post("/api/clock-signal", { signalType: "clock_in" });
105
+ const data = await post("/api/clock-signal", body);
55
106
  const response = ClockSignalResponseSchema.parse(data);
56
107
  if (response.signal.alreadyExists) {
57
108
  console.log(`\nAlready clocked in - must clock out first.`);
@@ -60,11 +111,21 @@ export async function clockInCommand() {
60
111
  console.log(` Time: ${response.signal.effectiveTime}`);
61
112
  console.log(` Timezone: ${response.signal.timezone}`);
62
113
  }
114
+ else if (response.signal.reopened) {
115
+ console.log(`\nPrevious session reopened — clock-out removed.`);
116
+ console.log(` Clocked in since:`);
117
+ console.log(` Date: ${response.signal.workDate}`);
118
+ console.log(` Time: ${response.signal.effectiveTime}`);
119
+ console.log(` Timezone: ${response.signal.timezone}`);
120
+ }
63
121
  else {
64
122
  console.log(`\nClock In recorded!`);
65
123
  console.log(` Date: ${response.signal.workDate}`);
66
124
  console.log(` Time: ${response.signal.effectiveTime}`);
67
125
  console.log(` Timezone: ${response.signal.timezone}`);
126
+ if (response.signal.clamped && response.signal.requestedTime) {
127
+ console.log(` Adjusted: you asked for ${response.signal.requestedTime}, but that's outside the allowed window for this day.`);
128
+ }
68
129
  }
69
130
  }
70
131
  catch (error) {
@@ -83,14 +144,43 @@ export async function clockInCommand() {
83
144
  }
84
145
  }
85
146
  /**
86
- * Record a clock-out signal for today
147
+ * Record a clock-out signal for today.
148
+ *
149
+ * Accepts optional backdate flags:
150
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
151
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
152
+ *
153
+ * The two flags are mutually exclusive. The server silently clamps the
154
+ * requested time if it would predate the session's clock-in or the
155
+ * session's last time entry.
87
156
  */
88
- export async function clockOutCommand() {
157
+ export async function clockOutCommand(options = {}) {
89
158
  if (!checkAuth()) {
90
159
  process.exit(1);
91
160
  }
161
+ if (options.ago && options.at) {
162
+ console.error("Cannot use --ago and --at together. Pick one.");
163
+ process.exit(1);
164
+ }
165
+ const body = { signalType: "clock_out" };
166
+ if (options.ago) {
167
+ try {
168
+ body.minutesAgo = parseDurationToMinutes(options.ago);
169
+ }
170
+ catch (err) {
171
+ console.error(err instanceof Error ? err.message : String(err));
172
+ process.exit(1);
173
+ }
174
+ }
175
+ if (options.at) {
176
+ if (!/^\d{2}:\d{2}(:\d{2})?$/.test(options.at)) {
177
+ console.error(`Invalid time "${options.at}". Use HH:mm or HH:mm:ss format.`);
178
+ process.exit(1);
179
+ }
180
+ body.effectiveTime = options.at;
181
+ }
92
182
  try {
93
- const data = await post("/api/clock-signal", { signalType: "clock_out" });
183
+ const data = await post("/api/clock-signal", body);
94
184
  const response = ClockSignalResponseSchema.parse(data);
95
185
  if (response.signal.alreadyExists) {
96
186
  console.log(`\nNot clocked in - must clock in first.`);
@@ -106,6 +196,9 @@ export async function clockOutCommand() {
106
196
  console.log(` Date: ${response.signal.workDate}`);
107
197
  console.log(` Time: ${response.signal.effectiveTime}`);
108
198
  console.log(` Timezone: ${response.signal.timezone}`);
199
+ if (response.signal.clamped && response.signal.requestedTime) {
200
+ console.log(` Adjusted: you asked for ${response.signal.requestedTime}, but that's before this session's earliest allowed time.`);
201
+ }
109
202
  }
110
203
  }
111
204
  catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../../src/commands/login.ts"],"names":[],"mappings":"AAKA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAsFlD"}
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../../src/commands/login.ts"],"names":[],"mappings":"AAKA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAmFlD"}
@@ -1,12 +1,12 @@
1
1
  import { get } from "../lib/api.js";
2
2
  import { pollForToken, requestDeviceCode, sleep } from "../lib/auth.js";
3
- import { isLoggedIn, setToken } from "../lib/config.js";
3
+ import { setToken } from "../lib/config.js";
4
4
  import { UserSchema } from "../lib/validators.js";
5
5
  export async function loginCommand() {
6
- if (isLoggedIn()) {
7
- console.log("You are already logged in. Use 'clocktopus logout' to log out first.");
8
- return;
9
- }
6
+ // If the user is already logged in we still start a fresh device flow
7
+ // `setToken` on success overwrites the existing token. We don't
8
+ // clear the old token up-front, so a failed/cancelled/timed-out login
9
+ // leaves the previously-valid session intact.
10
10
  console.log("Starting device authorization...\n");
11
11
  try {
12
12
  const deviceCode = await requestDeviceCode();
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAqEA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAgGA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
package/dist/src/index.js CHANGED
@@ -1,14 +1,21 @@
1
+ import { createRequire } from "node:module";
1
2
  import { Command } from "commander";
3
+ import { z } from "zod";
2
4
  import { clockInCommand, clockOutCommand, clockStatusCommand, } from "./commands/clock.js";
3
5
  import { loginCommand } from "./commands/login.js";
4
6
  import { logoutCommand } from "./commands/logout.js";
5
7
  import { whoamiCommand } from "./commands/whoami.js";
6
- import { ENVIRONMENTS, setRuntimeEnvironment, } from "./lib/config.js";
8
+ import { ENVIRONMENTS, setRuntimeEnvironment } from "./lib/config.js";
9
+ const require = createRequire(import.meta.url);
10
+ const parsedJson = z
11
+ .object({ version: z.string() })
12
+ .safeParse(require("../../package.json"));
13
+ const version = parsedJson.success ? parsedJson.data.version : "0.0.0";
7
14
  const program = new Command();
8
15
  program
9
16
  .name("clocktopus")
10
17
  .description("CLI for Clocktopus time tracking")
11
- .version("0.1.0")
18
+ .version(version)
12
19
  .option("-e, --env <environment>", "Use environment (dev or prod)")
13
20
  .hook("preAction", (thisCommand) => {
14
21
  const opts = thisCommand.opts();
@@ -38,12 +45,16 @@ const clock = program
38
45
  .description("Record clock-in and clock-out signals");
39
46
  clock
40
47
  .command("in")
41
- .description("Record clock-in for today")
42
- .action(clockInCommand);
48
+ .description("Record clock-in for today (optionally backdated)")
49
+ .option("--ago <duration>", "Backdate by a duration relative to now (e.g. 45m, 1h, 1h30m)")
50
+ .option("--at <time>", "Backdate to an absolute time in your timezone (HH:mm or HH:mm:ss)")
51
+ .action((options) => clockInCommand(options));
43
52
  clock
44
53
  .command("out")
45
- .description("Record clock-out for today")
46
- .action(clockOutCommand);
54
+ .description("Record clock-out for today (optionally backdated)")
55
+ .option("--ago <duration>", "Backdate by a duration relative to now (e.g. 45m, 1h, 1h30m)")
56
+ .option("--at <time>", "Backdate to an absolute time in your timezone (HH:mm or HH:mm:ss)")
57
+ .action((options) => clockOutCommand(options));
47
58
  clock
48
59
  .command("status")
49
60
  .description("Show clock signals for a specific date")
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/lib/api.ts"],"names":[],"mappings":"AAEA,KAAK,cAAc,GAAG;IACpB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,QAAS,SAAQ,KAAK;IAExB,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,MAAM;gBADlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;CAKnB;AAED,wBAAsB,OAAO,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,CAAC,CAAC,CA6BZ;AAED,wBAAsB,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,UAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAE3E;AAED,wBAAsB,IAAI,CAAC,CAAC,EAC1B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,aAAa,UAAO,GACnB,OAAO,CAAC,CAAC,CAAC,CAEZ"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/lib/api.ts"],"names":[],"mappings":"AAEA,KAAK,cAAc,GAAG;IACpB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,QAAS,SAAQ,KAAK;IAExB,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,MAAM;gBADlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;CAKnB;AAED,wBAAsB,OAAO,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,CAAC,CAAC,CAqCZ;AAuBD,wBAAsB,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,UAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAE3E;AAED,wBAAsB,IAAI,CAAC,CAAC,EAC1B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,aAAa,UAAO,GACnB,OAAO,CAAC,CAAC,CAAC,CAEZ"}
@@ -29,10 +29,37 @@ export async function request(path, options = {}) {
29
29
  });
30
30
  if (!response.ok) {
31
31
  const errorText = await response.text();
32
- throw new ApiError(response.status, response.statusText, errorText);
32
+ // The frontend's `handleApiError` serializes errors as
33
+ // `{ error: { message, stack? }, timestamp, path }`. Pull the
34
+ // `message` out so the user sees the actual reason (e.g. "Signal
35
+ // time cannot be in the future…") rather than a raw JSON blob.
36
+ throw new ApiError(response.status, response.statusText, extractErrorMessage(errorText) ?? errorText);
33
37
  }
34
38
  return response.json();
35
39
  }
40
+ function extractErrorMessage(body) {
41
+ if (!body)
42
+ return null;
43
+ try {
44
+ const parsed = JSON.parse(body);
45
+ if (parsed && typeof parsed === "object") {
46
+ const record = parsed;
47
+ const err = record.error;
48
+ if (err && typeof err === "object") {
49
+ const msg = err.message;
50
+ if (typeof msg === "string" && msg.length > 0)
51
+ return msg;
52
+ }
53
+ if (typeof record.message === "string" && record.message.length > 0) {
54
+ return record.message;
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // Not JSON — fall through and let the caller show the raw text.
60
+ }
61
+ return null;
62
+ }
36
63
  export async function get(path, authenticated = true) {
37
64
  return request(path, { method: "GET", authenticated });
38
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blastin-dev/clocktopus-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clocktopus": "./dist/bin/clocktopus.js"
@@ -11,16 +11,17 @@
11
11
  "dependencies": {
12
12
  "commander": "^13.0.0",
13
13
  "conf": "^13.0.0",
14
- "zod": "4.1.12",
15
- "date-fns": "4.1.0"
14
+ "date-fns": "4.1.0",
15
+ "zod": "4.1.12"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/node": "22.15.3",
19
- "eslint": "^9.28.0",
19
+ "eslint": "9.37.0",
20
+ "eslint-plugin-import-x": "^4.16.1",
20
21
  "typescript": "5.9.2",
21
22
  "@repo/eslint-config": "0.0.0",
22
- "@repo/typescript-config": "0.0.0",
23
- "@repo/prettier-config": "0.1.0"
23
+ "@repo/prettier-config": "0.1.0",
24
+ "@repo/typescript-config": "0.0.0"
24
25
  },
25
26
  "prettier": "@repo/prettier-config",
26
27
  "scripts": {